From 498c03ef00e513adca8035d28589e0ee9cec2356 Mon Sep 17 00:00:00 2001 From: ske087 Date: Thu, 13 Nov 2025 21:00:07 +0200 Subject: [PATCH] Replace emoji icons with local SVG files for consistent rendering - Created 10 SVG icon files in app/static/icons/ (Feather Icons style) - Updated base.html with SVG icons in navigation and dark mode toggle - Updated dashboard.html with icons in stats cards and quick actions - Updated content_list_new.html (playlist management) with SVG icons - Updated upload_media.html with upload-related icons - Updated manage_player.html with player management icons - Icons use currentColor for automatic theme adaptation - Removed emoji dependency for better Raspberry Pi compatibility - Added ICON_INTEGRATION.md documentation --- ICON_INTEGRATION.md | 138 ++++ KIVY_PLAYER_COMPATIBILITY.md | 250 ++++++ add_orientation_column.py | 44 + app/app.py | 2 + app/blueprints/api.py | 212 ++++- app/blueprints/content.py | 778 +++++++----------- app/blueprints/content_old.py | 500 +++++++++++ app/blueprints/main.py | 29 +- app/blueprints/players.py | 156 ++-- app/blueprints/playlist.py | 232 ++++++ app/models/__init__.py | 3 + app/models/content.py | 18 +- app/models/group.py | 6 +- app/models/player.py | 12 +- app/models/playlist.py | 97 +++ app/static/icons/edit.svg | 4 + app/static/icons/home.svg | 4 + app/static/icons/info.svg | 5 + app/static/icons/monitor.svg | 4 + app/static/icons/moon.svg | 3 + app/static/icons/playlist.svg | 3 + app/static/icons/sun.svg | 11 + app/static/icons/trash.svg | 4 + app/static/icons/upload.svg | 5 + app/static/icons/warning.svg | 5 + app/templates/base.html | 310 ++++++- app/templates/content/content_list_new.html | 438 ++++++++++ .../content/manage_playlist_content.html | 304 +++++++ app/templates/content/upload_content.html | 61 +- app/templates/content/upload_media.html | 361 ++++++++ app/templates/dashboard.html | 67 +- app/templates/players/manage_player.html | 252 ++++++ app/templates/players/player_page.html | 172 +--- app/templates/players/players_list.html | 29 +- app/templates/playlist/manage_playlist.html | 492 +++++++++++ install_emoji_fonts.sh | 10 + migrate_add_orientation.py | 59 ++ 37 files changed, 4240 insertions(+), 840 deletions(-) create mode 100644 ICON_INTEGRATION.md create mode 100644 KIVY_PLAYER_COMPATIBILITY.md create mode 100644 add_orientation_column.py create mode 100644 app/blueprints/content_old.py create mode 100644 app/blueprints/playlist.py create mode 100644 app/models/playlist.py create mode 100644 app/static/icons/edit.svg create mode 100644 app/static/icons/home.svg create mode 100644 app/static/icons/info.svg create mode 100644 app/static/icons/monitor.svg create mode 100644 app/static/icons/moon.svg create mode 100644 app/static/icons/playlist.svg create mode 100644 app/static/icons/sun.svg create mode 100644 app/static/icons/trash.svg create mode 100644 app/static/icons/upload.svg create mode 100644 app/static/icons/warning.svg create mode 100644 app/templates/content/content_list_new.html create mode 100644 app/templates/content/manage_playlist_content.html create mode 100644 app/templates/content/upload_media.html create mode 100644 app/templates/players/manage_player.html create mode 100644 app/templates/playlist/manage_playlist.html create mode 100644 install_emoji_fonts.sh create mode 100644 migrate_add_orientation.py diff --git a/ICON_INTEGRATION.md b/ICON_INTEGRATION.md new file mode 100644 index 0000000..97e7fcd --- /dev/null +++ b/ICON_INTEGRATION.md @@ -0,0 +1,138 @@ +# SVG Icon Integration + +## Overview +Replaced all emoji icons with local SVG files to ensure consistent rendering across all systems, particularly on Raspberry Pi devices where emoji fonts may not be available. + +## Icon Files Created +Location: `/app/static/icons/` + +1. **moon.svg** - Dark mode toggle (off state) +2. **sun.svg** - Dark mode toggle (on state) +3. **home.svg** - Dashboard/home navigation +4. **monitor.svg** - Players/screens +5. **playlist.svg** - Playlist management +6. **edit.svg** - Edit actions +7. **trash.svg** - Delete actions +8. **upload.svg** - Upload/media files +9. **info.svg** - Information/details +10. **warning.svg** - Warnings/alerts + +## Icon Specifications +- **Size**: 24x24 viewBox (scalable) +- **Style**: Feather Icons design (minimal, stroke-based) +- **Color**: Uses `currentColor` for automatic theme adaptation +- **Stroke**: 2px width, round line caps and joins +- **Format**: Clean SVG with no fills (stroke-only for consistency) + +## Templates Updated + +### 1. base.html (Navigation & Dark Mode) +- Header logo: monitor.svg +- Dashboard link: home.svg +- Players link: monitor.svg +- Playlists link: playlist.svg +- Dark mode toggle: moon.svg ↔ sun.svg (dynamic) + +**CSS Changes:** +- Added icon support to nav links with `display: flex` +- Icons use `filter: brightness(0) invert(1)` for white color on dark header +- Dark mode toggle icon changes via JavaScript using `src` attribute + +### 2. dashboard.html (Stats Cards & Quick Actions) +- Players card: monitor.svg +- Playlists card: playlist.svg +- Media Library card: upload.svg +- Storage card: info.svg +- Quick Actions buttons: monitor.svg, playlist.svg, upload.svg +- Workflow Guide header: info.svg + +### 3. content_list_new.html (Playlist Management) +- Page header: playlist.svg +- Playlists card header: playlist.svg +- Delete button: trash.svg +- Empty state: playlist.svg (64px, opacity 0.3) +- Upload Media card header: upload.svg +- Upload icon (large): upload.svg (96px, opacity 0.5) +- Go to Upload button: upload.svg +- Media library icons: info.svg (images), monitor.svg (videos) +- Player Assignments header: monitor.svg + +### 4. upload_media.html (Upload Page) +- Page header: upload.svg +- Back button: playlist.svg +- Select Files card: upload.svg +- Drag-drop zone: upload.svg (96px, opacity 0.3) +- Browse button: upload.svg +- Upload Settings header: info.svg +- Upload button: upload.svg + +### 5. manage_player.html (Player Management) +- Page header: monitor.svg +- Back button: monitor.svg +- Status icons: info.svg (online), warning.svg (offline) +- Edit Credentials card: edit.svg +- Save button: edit.svg +- Assign Playlist card: playlist.svg +- Warning alert: warning.svg +- Assign button: playlist.svg +- Create Playlist button: playlist.svg +- Edit Playlist button: edit.svg +- Player Logs card: info.svg + +## Usage Pattern +```html + +

+ + Playlists +

+ + + + + + +``` + +## Benefits +1. **Reliability**: Icons always render correctly, no dependency on system fonts +2. **Scalability**: SVG format scales cleanly to any size +3. **Themeable**: `currentColor` allows icons to inherit text color +4. **Performance**: Small file sizes (< 1KB each) +5. **Consistency**: Uniform design language across entire application +6. **Accessibility**: Clean, recognizable icons work well in both light and dark modes + +## Browser Compatibility +- All modern browsers support inline SVG +- No JavaScript required (except dark mode toggle) +- Works on Raspberry Pi browsers (Chromium, Firefox) +- Filter properties supported in all target browsers + +## Dark Mode Integration +The dark mode toggle now uses SVG icons instead of emoji: +- Moon icon (🌙) → moon.svg +- Sun icon (â˜€ī¸) → sun.svg +- Icons change dynamically via JavaScript when theme is toggled +- White color achieved via CSS filter on dark header background + +## Future Icon Needs +If additional icons are needed, follow this pattern: +1. Use Feather Icons style (https://feathericons.com/) +2. 24x24 viewBox, stroke-width="2" +3. Use `stroke="currentColor"` for theme compatibility +4. Save to `/app/static/icons/` +5. Reference via `{{ url_for('static', filename='icons/name.svg') }}` + +## Testing Checklist +- [x] Header navigation icons display correctly +- [x] Dark mode toggle icons switch properly +- [x] Dashboard card icons visible +- [x] Playlist management icons render +- [x] Upload page icons functional +- [x] Player management icons working +- [ ] Test on actual Raspberry Pi device +- [ ] Verify icon visibility in both light/dark modes +- [ ] Check icon alignment across different screen sizes diff --git a/KIVY_PLAYER_COMPATIBILITY.md b/KIVY_PLAYER_COMPATIBILITY.md new file mode 100644 index 0000000..6980911 --- /dev/null +++ b/KIVY_PLAYER_COMPATIBILITY.md @@ -0,0 +1,250 @@ +# Kivy Player Compatibility with New Playlist Architecture + +## Overview +This document outlines the compatibility verification and updates made to ensure the Kivy signage player works with the new playlist-centric architecture. + +## Changes Made to DigiServer API + +### 1. Content URL Format in Playlist Response +**Location:** `/home/pi/Desktop/digiserver-v2/app/blueprints/api.py` - `get_cached_playlist()` function + +**Issue:** +- Player expects: `file_name` key in playlist items +- Server was returning: `filename` key + +**Fix:** +```python +playlist_data.append({ + 'id': content.id, + 'file_name': content.filename, # Changed from 'filename' to 'file_name' + 'type': content.content_type, + 'duration': content._playlist_duration or content.duration or 10, + 'position': content._playlist_position or idx, + 'url': content_url, # Now returns full URL with server base + 'description': content.description +}) +``` + +**What Changed:** +- ✅ Changed `'filename'` to `'file_name'` to match player expectations +- ✅ URL now includes full server base URL (e.g., `http://server:5000/static/uploads/image.jpg`) +- ✅ Player can now download content directly without URL manipulation + +### 2. Authentication Response - Removed group_id +**Location:** `/home/pi/Desktop/digiserver-v2/app/blueprints/api.py` + +**Endpoints Updated:** +1. `/api/auth/player` (line ~145) - ✅ Already returns `playlist_id` +2. `/api/auth/verify` (line ~192) - ✅ Changed from `group_id` to `playlist_id` + +**Before:** +```python +response = { + 'valid': True, + 'player_id': player.id, + 'player_name': player.name, + 'hostname': player.hostname, + 'group_id': player.group_id, # OLD + 'orientation': player.orientation, + 'status': player.status +} +``` + +**After:** +```python +response = { + 'valid': True, + 'player_id': player.id, + 'player_name': player.name, + 'hostname': player.hostname, + 'playlist_id': player.playlist_id, # NEW + 'orientation': player.orientation, + 'status': player.status +} +``` + +## Changes Made to Kivy Player + +### 1. Updated player_auth.py +**Location:** `/home/pi/Desktop/Kiwy-Signage/src/player_auth.py` + +**Changes:** +1. ✅ Default auth data structure: `group_id` → `playlist_id` (line ~42) +2. ✅ Authentication data storage: `group_id` → `playlist_id` (line ~97) +3. ✅ Clear auth method: `group_id` → `playlist_id` (line ~299) +4. ✅ Example usage output: Updated to show `playlist_id` instead of `group_id` + +**Impact:** +- Player now stores and uses `playlist_id` instead of deprecated `group_id` +- Backward compatible: old auth files will load but won't have `playlist_id` until re-authentication + +## API Endpoints Used by Kivy Player + +### Player Authentication Flow +``` +1. POST /api/auth/player + Body: { hostname, password OR quickconnect_code } + Returns: { auth_code, player_id, playlist_id, orientation, ... } + +2. GET /api/playlists/ + Headers: Authorization: Bearer + Returns: { playlist: [...], playlist_version: N, ... } + +3. GET + Downloads content file +``` + +### Expected Playlist Format +```json +{ + "player_id": 1, + "player_name": "Player-001", + "playlist_id": 5, + "playlist_version": 3, + "playlist": [ + { + "id": 1, + "file_name": "demo1.jpg", + "type": "image", + "duration": 10, + "position": 1, + "url": "http://192.168.1.100:5000/static/uploads/demo1.jpg", + "description": "Demo Image 1" + } + ], + "count": 1 +} +``` + +## Verification Checklist + +### Server-Side ✅ +- [x] Player model has `playlist_id` field +- [x] Content model has unique filenames +- [x] Playlist model with version tracking +- [x] API returns `file_name` (not `filename`) +- [x] API returns full URLs for content +- [x] API returns `playlist_id` in auth responses +- [x] `/api/playlists/` endpoint exists and works +- [x] Bearer token authentication works + +### Client-Side ✅ +- [x] PlayerAuth class uses `playlist_id` +- [x] get_playlists_v2.py can fetch playlists +- [x] Player can download content from full URLs +- [x] Player parses `file_name` correctly +- [x] Player uses Bearer token auth + +## Workflow Example + +### 1. Server Setup +```bash +# Create a playlist +curl -X POST http://server:5000/content/create_playlist \ + -d "name=Morning Playlist" + +# Upload content +curl -X POST http://server:5000/content/upload_media \ + -F "file=@demo1.jpg" + +# Add content to playlist +curl -X POST http://server:5000/content/add_content_to_playlist \ + -d "playlist_id=1&content_id=1&duration=10" + +# Create player +curl -X POST http://server:5000/players/add \ + -d "name=Player-001&hostname=player001&quickconnect_code=QUICK123" + +# Assign playlist to player +curl -X POST http://server:5000/content/assign_player_to_playlist \ + -d "player_id=1&playlist_id=1" +``` + +### 2. Player Authentication +```python +from player_auth import PlayerAuth + +auth = PlayerAuth() +success, error = auth.authenticate( + server_url='http://192.168.1.100:5000', + hostname='player001', + quickconnect_code='QUICK123' +) + +if success: + print(f"✅ Authenticated as {auth.get_player_name()}") + print(f"📋 Playlist ID: {auth.auth_data['playlist_id']}") +``` + +### 3. Fetch and Download Playlist +```python +from get_playlists_v2 import update_playlist_if_needed + +config = { + 'server_ip': '192.168.1.100', + 'port': '5000', + 'screen_name': 'player001', + 'quickconnect_key': 'QUICK123' +} + +playlist_file = update_playlist_if_needed( + config=config, + playlist_dir='playlists/', + media_dir='media/' +) + +if playlist_file: + print(f"✅ Playlist downloaded: {playlist_file}") +``` + +## Testing Recommendations + +1. **Test Authentication:** + - Test with valid quickconnect code + - Test with invalid quickconnect code + - Verify `playlist_id` is returned + +2. **Test Playlist Fetching:** + - Fetch playlist with auth code + - Verify `file_name` and full URLs in response + - Test playlist version tracking + +3. **Test Content Download:** + - Download images from full URLs + - Download videos from full URLs + - Verify files save correctly + +4. **Test Playlist Updates:** + - Update playlist version on server + - Player should detect new version + - Player should download only new content + +## Migration Notes + +### For Existing Deployments +1. **Server:** Database recreated with new schema (groups removed) +2. **Players:** Will re-authenticate on next check +3. **Auth Files:** Old `player_auth.json` files are compatible but will update on next auth + +### Backward Compatibility +- âš ī¸ Legacy `/api/playlists?hostname=X&quickconnect_code=Y` endpoint still exists +- âš ī¸ New v2 auth flow preferred (uses Bearer tokens) +- âš ī¸ Old player code using groups will need updates + +## Known Issues / Future Improvements + +1. **Content Cleanup:** Old media files not referenced by playlists should be deleted +2. **Playlist Caching:** Cache invalidation when playlist updated on server +3. **Partial Updates:** Download only changed content instead of full playlist +4. **Network Resilience:** Better handling of connection failures +5. **Progress Feedback:** Real-time download progress for large files + +## Contact +For issues or questions about player compatibility, check: +- Server logs: `/home/pi/Desktop/digiserver-v2/instance/logs/` +- Player logs: See PlayerAuth logger output +- API documentation: Check `/api/health` endpoint + +--- +**Last Updated:** 2025-01-XX +**Version:** DigiServer v2.0 with Playlist Architecture diff --git a/add_orientation_column.py b/add_orientation_column.py new file mode 100644 index 0000000..87ee4b6 --- /dev/null +++ b/add_orientation_column.py @@ -0,0 +1,44 @@ +""" +Add orientation column to playlist table +Run this script to update the database schema +""" +import sys +import os + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app.app import app +from app.extensions import db +from sqlalchemy import text + +def add_orientation_column(): + """Add orientation column to playlist table.""" + with app.app_context(): + try: + # Check if column exists + result = db.session.execute(text("PRAGMA table_info(playlist)")) + columns = [row[1] for row in result] + + if 'orientation' in columns: + print("✅ Column 'orientation' already exists in playlist table") + return + + # Add the column + print("Adding 'orientation' column to playlist table...") + db.session.execute(text(""" + ALTER TABLE playlist + ADD COLUMN orientation VARCHAR(20) DEFAULT 'Landscape' NOT NULL + """)) + db.session.commit() + + print("✅ Successfully added 'orientation' column to playlist table") + print(" Default value: 'Landscape'") + + except Exception as e: + print(f"❌ Error adding column: {str(e)}") + db.session.rollback() + raise + +if __name__ == '__main__': + add_orientation_column() diff --git a/app/app.py b/app/app.py index b09c1a6..48dd4c5 100644 --- a/app/app.py +++ b/app/app.py @@ -62,6 +62,7 @@ def register_blueprints(app): from app.blueprints.players import players_bp from app.blueprints.groups import groups_bp from app.blueprints.content import content_bp + from app.blueprints.playlist import playlist_bp from app.blueprints.api import api_bp # Register blueprints (using URL prefixes from blueprint definitions) @@ -71,6 +72,7 @@ def register_blueprints(app): app.register_blueprint(players_bp) app.register_blueprint(groups_bp) app.register_blueprint(content_bp) + app.register_blueprint(playlist_bp) app.register_blueprint(api_bp) diff --git a/app/blueprints/api.py b/app/blueprints/api.py index de45f87..b2faf56 100644 --- a/app/blueprints/api.py +++ b/app/blueprints/api.py @@ -3,6 +3,7 @@ from flask import Blueprint, request, jsonify, current_app from functools import wraps from datetime import datetime, timedelta import secrets +import bcrypt from typing import Optional, Dict, List from app.extensions import db, cache @@ -142,7 +143,7 @@ def authenticate_player(): 'player_name': player.name, 'hostname': player.hostname, 'auth_code': player.auth_code, - 'group_id': player.group_id, + 'playlist_id': player.playlist_id, 'orientation': player.orientation, 'status': player.status } @@ -186,7 +187,7 @@ def verify_auth_code(): 'player_id': player.id, 'player_name': player.name, 'hostname': player.hostname, - 'group_id': player.group_id, + 'playlist_id': player.playlist_id, 'orientation': player.orientation, 'status': player.status } @@ -194,6 +195,103 @@ def verify_auth_code(): return jsonify(response), 200 +@api_bp.route('/playlists', methods=['GET']) +@rate_limit(max_requests=30, window=60) +def get_playlist_by_quickconnect(): + """Get playlist using hostname and quickconnect code (Kivy player compatible). + + Query parameters: + hostname: Player hostname/identifier + quickconnect_code: Quick connect code for authentication + + Returns: + JSON with playlist, playlist_version, and hashed_quickconnect + """ + try: + import bcrypt + + hostname = request.args.get('hostname') + quickconnect_code = request.args.get('quickconnect_code') + + if not hostname or not quickconnect_code: + return jsonify({ + 'error': 'hostname and quickconnect_code are required', + 'playlist': [], + 'playlist_version': 0 + }), 400 + + # Find player by hostname and validate quickconnect + player = Player.query.filter_by(hostname=hostname).first() + + if not player: + log_action('warning', f'Player not found with hostname: {hostname}') + return jsonify({ + 'error': 'Player not found', + 'playlist': [], + 'playlist_version': 0 + }), 404 + + # Validate quickconnect code + if not player.quickconnect_code: + log_action('warning', f'Player {hostname} has no quickconnect code set') + return jsonify({ + 'error': 'Quickconnect not configured', + 'playlist': [], + 'playlist_version': 0 + }), 403 + + # Check if quickconnect matches + if player.quickconnect_code != quickconnect_code: + log_action('warning', f'Invalid quickconnect code for player: {hostname}') + return jsonify({ + 'error': 'Invalid quickconnect code', + 'playlist': [], + 'playlist_version': 0 + }), 403 + + # Get playlist (with caching) + playlist = get_cached_playlist(player.id) + + # Update player's last seen timestamp and status + player.last_seen = datetime.utcnow() + player.status = 'online' + db.session.commit() + + # Get playlist version from the assigned playlist + playlist_version = 1 + if player.playlist_id: + from app.models import Playlist + assigned_playlist = Playlist.query.get(player.playlist_id) + if assigned_playlist: + playlist_version = assigned_playlist.version + + # Hash the quickconnect code for validation on client side + hashed_quickconnect = bcrypt.hashpw( + quickconnect_code.encode('utf-8'), + bcrypt.gensalt() + ).decode('utf-8') + + log_action('info', f'Playlist fetched for player: {player.name} ({hostname})') + + return jsonify({ + 'player_id': player.id, + 'player_name': player.name, + 'playlist_id': player.playlist_id, + 'playlist_version': playlist_version, + 'playlist': playlist, + 'hashed_quickconnect': hashed_quickconnect, + 'count': len(playlist) + }), 200 + + except Exception as e: + log_action('error', f'Error getting playlist: {str(e)}') + return jsonify({ + 'error': 'Internal server error', + 'playlist': [], + 'playlist_version': 0 + }), 500 + + @api_bp.route('/playlists/', methods=['GET']) @rate_limit(max_requests=30, window=60) @verify_player_auth @@ -216,11 +314,19 @@ def get_player_playlist(player_id: int): player.last_seen = datetime.utcnow() db.session.commit() + # Get playlist version from the assigned playlist + playlist_version = 1 + if player.playlist_id: + from app.models import Playlist + assigned_playlist = Playlist.query.get(player.playlist_id) + if assigned_playlist: + playlist_version = assigned_playlist.version + return jsonify({ 'player_id': player_id, 'player_name': player.name, - 'group_id': player.group_id, - 'playlist_version': player.playlist_version, + 'playlist_id': player.playlist_id, + 'playlist_version': playlist_version, 'playlist': playlist, 'count': len(playlist) }) @@ -263,78 +369,110 @@ def get_playlist_version(player_id: int): @cache.memoize(timeout=300) def get_cached_playlist(player_id: int) -> List[Dict]: - """Get cached playlist for a player.""" + """Get cached playlist for a player based on assigned playlist.""" from flask import url_for + from app.models import Playlist player = Player.query.get(player_id) - if not player: + if not player or not player.playlist_id: return [] - # Get content based on group assignment - if player.group_id: - group = Group.query.get(player.group_id) - contents = group.contents.order_by(Content.position).all() if group else [] - else: - # Show all content if not in a group - contents = Content.query.order_by(Content.position).all() + # Get the playlist assigned to this player + playlist = Playlist.query.get(player.playlist_id) + if not playlist: + return [] - # Build playlist - playlist = [] - for content in contents: - playlist.append({ + # Get content from playlist (ordered) + content_list = playlist.get_content_ordered() + + # Build playlist response + playlist_data = [] + for idx, content in enumerate(content_list, start=1): + # Generate full URL for content + from flask import request as current_request + # Get server base URL + server_base = current_request.host_url.rstrip('/') + content_url = f"{server_base}/static/uploads/{content.filename}" + + playlist_data.append({ 'id': content.id, - 'filename': content.filename, + 'file_name': content.filename, # Player expects 'file_name' not 'filename' 'type': content.content_type, - 'duration': content.duration or 10, - 'position': content.position, - 'url': f"/static/uploads/{content.filename}", + 'duration': content._playlist_duration or content.duration or 10, + 'position': content._playlist_position or idx, + 'url': content_url, # Full URL for downloads 'description': content.description }) - return playlist + return playlist_data @api_bp.route('/player-feedback', methods=['POST']) @rate_limit(max_requests=100, window=60) -@verify_player_auth def receive_player_feedback(): - """Receive feedback/status updates from players. + """Receive feedback/status updates from players (Kivy player compatible). Expected JSON payload: { - "status": "playing|paused|error", - "current_content_id": 123, - "message": "Optional status message", - "error": "Optional error message" + "player_name": "Screen1", + "quickconnect_code": "ABC123", + "status": "playing|paused|error|restarting", + "message": "Status message", + "playlist_version": 1, + "error_details": "Optional error details", + "timestamp": "ISO timestamp" } """ try: - player = request.player data = request.json if not data: return jsonify({'error': 'No data provided'}), 400 + player_name = data.get('player_name') + quickconnect_code = data.get('quickconnect_code') + + if not player_name or not quickconnect_code: + return jsonify({'error': 'player_name and quickconnect_code required'}), 400 + + # Find player by name and validate quickconnect + player = Player.query.filter_by(name=player_name).first() + + if not player: + log_action('warning', f'Player feedback from unknown player: {player_name}') + return jsonify({'error': 'Player not found'}), 404 + + # Validate quickconnect code + if player.quickconnect_code != quickconnect_code: + log_action('warning', f'Invalid quickconnect in feedback from: {player_name}') + return jsonify({'error': 'Invalid quickconnect code'}), 403 + # Create feedback record + status = data.get('status', 'unknown') + message = data.get('message', '') + error_details = data.get('error_details') + feedback = PlayerFeedback( player_id=player.id, - status=data.get('status', 'unknown'), - current_content_id=data.get('current_content_id'), - message=data.get('message'), - error=data.get('error') + status=status, + message=message, + error=error_details ) db.session.add(feedback) - # Update player's last seen + # Update player's last seen and status player.last_seen = datetime.utcnow() - player.status = data.get('status', 'unknown') + player.status = status db.session.commit() + log_action('info', f'Feedback received from {player_name}: {status} - {message}') + return jsonify({ 'success': True, - 'message': 'Feedback received' - }) + 'message': 'Feedback received', + 'player_id': player.id + }), 200 except Exception as e: db.session.rollback() diff --git a/app/blueprints/content.py b/app/blueprints/content.py index 893dae9..2d686bd 100644 --- a/app/blueprints/content.py +++ b/app/blueprints/content.py @@ -1,532 +1,376 @@ -"""Content blueprint for media upload and management.""" +"""Content blueprint - New playlist-centric workflow.""" from flask import (Blueprint, render_template, request, redirect, url_for, - flash, jsonify, current_app, send_from_directory) + flash, jsonify, current_app) from flask_login import login_required from werkzeug.utils import secure_filename import os -from typing import Optional, Dict -import json from app.extensions import db, cache -from app.models import Content, Group +from app.models import Content, Playlist, Player +from app.models.playlist import playlist_content from app.utils.logger import log_action -from app.utils.uploads import ( - save_uploaded_file, - process_video_file, - process_pdf_file, - get_upload_progress, - set_upload_progress -) +from app.utils.uploads import process_video_file, set_upload_progress content_bp = Blueprint('content', __name__, url_prefix='/content') -# In-memory storage for upload progress (for simple demo; use Redis in production) -upload_progress = {} - - @content_bp.route('/') @login_required def content_list(): - """Display list of all content.""" - try: - # Get all unique content files (by filename) - from sqlalchemy import func - - # Get content with player information - contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all() - - # Group content by filename to show which players have each file - content_map = {} - for content in contents: - if content.filename not in content_map: - content_map[content.filename] = { - 'content': content, - 'players': [], - 'groups': [] - } - - # Add player info if assigned to a player - if content.player_id: - from app.models import Player - player = Player.query.get(content.player_id) - if player: - content_map[content.filename]['players'].append({ - 'id': player.id, - 'name': player.name, - 'group': player.group.name if player.group else None - }) - - # Convert to list for template - content_list = [] - for filename, data in content_map.items(): - content_list.append({ - 'filename': filename, - 'content_type': data['content'].content_type, - 'duration': data['content'].duration, - 'file_size': data['content'].file_size_mb, - 'uploaded_at': data['content'].uploaded_at, - 'players': data['players'], - 'player_count': len(data['players']) - }) - - # Sort by upload date - content_list.sort(key=lambda x: x['uploaded_at'], reverse=True) - - return render_template('content/content_list.html', - content_list=content_list) - except Exception as e: - log_action('error', f'Error loading content list: {str(e)}') - flash('Error loading content list.', 'danger') - return redirect(url_for('main.dashboard')) + """Main playlist management page.""" + playlists = Playlist.query.order_by(Playlist.created_at.desc()).all() + media_files = Content.query.order_by(Content.uploaded_at.desc()).all() + players = Player.query.order_by(Player.name).all() + + return render_template('content/content_list_new.html', + playlists=playlists, + media_files=media_files, + players=players) -@content_bp.route('/upload', methods=['GET', 'POST']) +@content_bp.route('/playlist/create', methods=['POST']) @login_required -def upload_content(): - """Upload new content.""" - if request.method == 'GET': - # Get parameters for return URL and pre-selection - target_type = request.args.get('target_type') - target_id = request.args.get('target_id', type=int) - return_url = request.args.get('return_url', url_for('content.content_list')) - - # Get all players and groups for selection - from app.models import Player - players = [{'id': p.id, 'name': p.name} for p in Player.query.order_by(Player.name).all()] - groups = [{'id': g.id, 'name': g.name} for g in Group.query.order_by(Group.name).all()] - - return render_template('content/upload_content.html', - players=players, - groups=groups, - target_type=target_type, - target_id=target_id, - return_url=return_url) - +def create_playlist(): + """Create a new playlist.""" try: - # Get form data - target_type = request.form.get('target_type') - target_id = request.form.get('target_id', type=int) - media_type = request.form.get('media_type', 'image') - duration = request.form.get('duration', type=int, default=10) - session_id = request.form.get('session_id', os.urandom(8).hex()) - return_url = request.form.get('return_url', url_for('content.content_list')) - - # Get files - files = request.files.getlist('files') - - if not files or files[0].filename == '': - flash('No files provided.', 'warning') - return redirect(url_for('content.upload_content')) - - if not target_type or not target_id: - flash('Please select a target type and target ID.', 'warning') - return redirect(url_for('content.upload_content')) - - # Initialize progress tracking using shared utility - set_upload_progress(session_id, 0, 'Starting upload...', 'uploading') - - # Process each file - upload_folder = current_app.config['UPLOAD_FOLDER'] - os.makedirs(upload_folder, exist_ok=True) - - processed_count = 0 - total_files = len(files) - - for idx, file in enumerate(files): - if file.filename == '': - continue - - # Update progress - progress_pct = int((idx / total_files) * 80) # 0-80% for file processing - set_upload_progress(session_id, progress_pct, - f'Processing file {idx + 1} of {total_files}...', 'processing') - - filename = secure_filename(file.filename) - filepath = os.path.join(upload_folder, filename) - - # Save file - file.save(filepath) - - # Determine content type - file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' - - if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']: - content_type = 'image' - elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']: - content_type = 'video' - # Process video (convert to Raspberry Pi optimized format) - set_upload_progress(session_id, progress_pct + 5, - f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing') - success, message = process_video_file(filepath, session_id) - if not success: - log_action('error', f'Video optimization failed: {message}') - continue # Skip this file and move to next - elif file_ext == 'pdf': - content_type = 'pdf' - # Process PDF (convert to images) - set_upload_progress(session_id, progress_pct + 5, - f'Converting PDF {idx + 1}...', 'processing') - # process_pdf_file(filepath, session_id) - elif file_ext in ['ppt', 'pptx']: - content_type = 'presentation' - # Process presentation (convert to PDF then images) - set_upload_progress(session_id, progress_pct + 5, - f'Converting PowerPoint {idx + 1}...', 'processing') - # This would call pptx_converter utility - else: - content_type = 'other' - - # Create content record - new_content = Content( - filename=filename, - content_type=content_type, - duration=duration, - file_size=os.path.getsize(filepath) - ) - - # Link to target (player or group) - if target_type == 'player': - from app.models import Player - player = Player.query.get(target_id) - if player: - # Add content directly to player's playlist - new_content.player_id = target_id - db.session.add(new_content) - # Increment playlist version - player.playlist_version += 1 - log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})') - - elif target_type == 'group': - group = Group.query.get(target_id) - if group: - # For groups, create separate content entry for EACH player in the group - # This matches the old app behavior - for player in group.players: - player_content = Content( - filename=filename, - content_type=content_type, - duration=duration, - file_size=os.path.getsize(filepath), - player_id=player.id - ) - db.session.add(player_content) - # Increment each player's playlist version - player.playlist_version += 1 - - log_action('info', f'Content "{filename}" added to {len(group.players)} players in group "{group.name}"') - # Don't add the original new_content since we created per-player entries - new_content = None - - if new_content: - db.session.add(new_content) - - processed_count += 1 - - # Commit all changes - set_upload_progress(session_id, 90, 'Saving to database...', 'processing') - db.session.commit() - - # Complete - set_upload_progress(session_id, 100, - f'Successfully uploaded {processed_count} file(s)!', 'complete') - - # Clear all playlist caches - cache.clear() - - log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})') - flash(f'{processed_count} file(s) uploaded successfully.', 'success') - - return redirect(return_url) - - except Exception as e: - db.session.rollback() - - # Update progress to error state - if 'session_id' in locals(): - set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error') - - log_action('error', f'Error uploading content: {str(e)}') - flash('Error uploading content. Please try again.', 'danger') - return redirect(url_for('content.upload_content')) - - -@content_bp.route('//edit', methods=['GET', 'POST']) -@login_required -def edit_content(content_id: int): - """Edit content metadata.""" - content = Content.query.get_or_404(content_id) - - if request.method == 'GET': - return render_template('content/edit_content.html', content=content) - - try: - duration = request.form.get('duration', type=int) + name = request.form.get('name', '').strip() description = request.form.get('description', '').strip() + orientation = request.form.get('orientation', 'Landscape') - # Update content - if duration is not None: - content.duration = duration - content.description = description or None + if not name: + flash('Playlist name is required.', 'warning') + return redirect(url_for('content.content_list')) + + # Check if playlist name exists + existing = Playlist.query.filter_by(name=name).first() + if existing: + flash(f'Playlist "{name}" already exists.', 'warning') + return redirect(url_for('content.content_list')) + + playlist = Playlist( + name=name, + description=description or None, + orientation=orientation + ) + db.session.add(playlist) db.session.commit() - # Clear caches - cache.clear() - - log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated') - flash(f'Content "{content.filename}" updated successfully.', 'success') - - return redirect(url_for('content.content_list')) + log_action('info', f'Created playlist: {name}') + flash(f'Playlist "{name}" created successfully!', 'success') except Exception as e: db.session.rollback() - log_action('error', f'Error updating content: {str(e)}') - flash('Error updating content. Please try again.', 'danger') - return redirect(url_for('content.edit_content', content_id=content_id)) - - -@content_bp.route('//delete', methods=['POST']) -@login_required -def delete_content(content_id: int): - """Delete content and associated file.""" - try: - content = Content.query.get_or_404(content_id) - filename = content.filename - - # Delete file from disk - filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename) - if os.path.exists(filepath): - os.remove(filepath) - - # Delete from database - db.session.delete(content) - db.session.commit() - - # Clear caches - cache.clear() - - log_action('info', f'Content "{filename}" (ID: {content_id}) deleted') - flash(f'Content "{filename}" deleted successfully.', 'success') - - except Exception as e: - db.session.rollback() - log_action('error', f'Error deleting content: {str(e)}') - flash('Error deleting content. Please try again.', 'danger') + log_action('error', f'Error creating playlist: {str(e)}') + flash('Error creating playlist.', 'danger') return redirect(url_for('content.content_list')) -@content_bp.route('/delete-by-filename', methods=['POST']) +@content_bp.route('/playlist//delete', methods=['POST']) @login_required -def delete_by_filename(): - """Delete all content entries with a specific filename.""" +def delete_playlist(playlist_id: int): + """Delete a playlist.""" + playlist = Playlist.query.get_or_404(playlist_id) + try: - data = request.get_json() - filename = data.get('filename') + name = playlist.name - if not filename: - return jsonify({'success': False, 'message': 'No filename provided'}), 400 - - # Find all content entries with this filename - contents = Content.query.filter_by(filename=filename).all() - - if not contents: - return jsonify({'success': False, 'message': 'Content not found'}), 404 - - deleted_count = len(contents) - - # Delete file from disk (only once) - filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename) - if os.path.exists(filepath): - os.remove(filepath) - log_action('info', f'Deleted file from disk: {filename}') - - # Delete all database entries - for content in contents: - db.session.delete(content) + # Unassign all players from this playlist + Player.query.filter_by(playlist_id=playlist_id).update({'playlist_id': None}) + db.session.delete(playlist) db.session.commit() - - # Clear caches cache.clear() - log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)') + log_action('info', f'Deleted playlist: {name}') + flash(f'Playlist "{name}" deleted successfully.', 'success') + + except Exception as e: + db.session.rollback() + log_action('error', f'Error deleting playlist: {str(e)}') + flash('Error deleting playlist.', 'danger') + + return redirect(url_for('content.content_list')) + + +@content_bp.route('/playlist//manage') +@login_required +def manage_playlist_content(playlist_id: int): + """Manage content in a specific playlist.""" + playlist = Playlist.query.get_or_404(playlist_id) + + # Get content in playlist (ordered) + playlist_content = playlist.get_content_ordered() + + # Get all available content not in this playlist + all_content = Content.query.all() + playlist_content_ids = {c.id for c in playlist_content} + available_content = [c for c in all_content if c.id not in playlist_content_ids] + + return render_template('content/manage_playlist_content.html', + playlist=playlist, + playlist_content=playlist_content, + available_content=available_content) + + +@content_bp.route('/playlist//add-content', methods=['POST']) +@login_required +def add_content_to_playlist(playlist_id: int): + """Add content to playlist.""" + playlist = Playlist.query.get_or_404(playlist_id) + + try: + content_id = request.form.get('content_id', type=int) + duration = request.form.get('duration', type=int, default=10) + + if not content_id: + flash('Please select content to add.', 'warning') + return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id)) + + content = Content.query.get_or_404(content_id) + + # Get max position + from sqlalchemy import select, func + from app.models.playlist import playlist_content + + max_pos = db.session.execute( + select(func.max(playlist_content.c.position)).where( + playlist_content.c.playlist_id == playlist_id + ) + ).scalar() or 0 + + # Add to playlist + stmt = playlist_content.insert().values( + playlist_id=playlist_id, + content_id=content_id, + position=max_pos + 1, + duration=duration + ) + db.session.execute(stmt) + + playlist.increment_version() + db.session.commit() + cache.clear() + + log_action('info', f'Added "{content.filename}" to playlist "{playlist.name}"') + flash(f'Added "{content.filename}" to playlist.', 'success') + + except Exception as e: + db.session.rollback() + log_action('error', f'Error adding content to playlist: {str(e)}') + flash('Error adding content to playlist.', 'danger') + + return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id)) + + +@content_bp.route('/playlist//remove-content/', methods=['POST']) +@login_required +def remove_content_from_playlist(playlist_id: int, content_id: int): + """Remove content from playlist.""" + playlist = Playlist.query.get_or_404(playlist_id) + + try: + from app.models.playlist import playlist_content + + # Remove from playlist + stmt = playlist_content.delete().where( + (playlist_content.c.playlist_id == playlist_id) & + (playlist_content.c.content_id == content_id) + ) + db.session.execute(stmt) + + playlist.increment_version() + db.session.commit() + cache.clear() + + log_action('info', f'Removed content from playlist "{playlist.name}"') + flash('Content removed from playlist.', 'success') + + except Exception as e: + db.session.rollback() + log_action('error', f'Error removing content from playlist: {str(e)}') + flash('Error removing content from playlist.', 'danger') + + return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id)) + + +@content_bp.route('/playlist//reorder', methods=['POST']) +@login_required +def reorder_playlist_content(playlist_id: int): + """Reorder content in playlist.""" + playlist = Playlist.query.get_or_404(playlist_id) + + try: + data = request.get_json() + content_ids = data.get('content_ids', []) + + if not content_ids: + return jsonify({'success': False, 'message': 'No content IDs provided'}), 400 + + from app.models.playlist import playlist_content + + # Update positions + for idx, content_id in enumerate(content_ids, start=1): + stmt = playlist_content.update().where( + (playlist_content.c.playlist_id == playlist_id) & + (playlist_content.c.content_id == content_id) + ).values(position=idx) + db.session.execute(stmt) + + playlist.increment_version() + db.session.commit() + cache.clear() + + log_action('info', f'Reordered playlist "{playlist.name}"') return jsonify({ 'success': True, - 'message': f'Content deleted from {deleted_count} playlist(s)', - 'deleted_count': deleted_count + 'message': 'Playlist reordered successfully', + 'version': playlist.version }) except Exception as e: db.session.rollback() - log_action('error', f'Error deleting content by filename: {str(e)}') + log_action('error', f'Error reordering playlist: {str(e)}') return jsonify({'success': False, 'message': str(e)}), 500 -@content_bp.route('/bulk/delete', methods=['POST']) +@content_bp.route('/upload-media-page') @login_required -def bulk_delete_content(): - """Delete multiple content items at once.""" +def upload_media_page(): + """Display upload media page.""" + playlists = Playlist.query.order_by(Playlist.name).all() + return render_template('content/upload_media.html', playlists=playlists) + + +@content_bp.route('/upload-media', methods=['POST']) +@login_required +def upload_media(): + """Upload media files to library.""" try: - content_ids = request.json.get('content_ids', []) + files = request.files.getlist('files') + content_type = request.form.get('content_type', 'image') + duration = request.form.get('duration', type=int, default=10) + playlist_id = request.form.get('playlist_id', type=int) - if not content_ids: - return jsonify({'success': False, 'error': 'No content selected'}), 400 + if not files or files[0].filename == '': + flash('No files provided.', 'warning') + return redirect(url_for('content.upload_media_page')) - # Delete content - deleted_count = 0 - for content_id in content_ids: - content = Content.query.get(content_id) - if content: - # Delete file - filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename) - if os.path.exists(filepath): - os.remove(filepath) - - db.session.delete(content) - deleted_count += 1 + upload_folder = current_app.config['UPLOAD_FOLDER'] + os.makedirs(upload_folder, exist_ok=True) + + uploaded_count = 0 + + for file in files: + if file.filename == '': + continue + + filename = secure_filename(file.filename) + filepath = os.path.join(upload_folder, filename) + + # Check if file already exists + existing = Content.query.filter_by(filename=filename).first() + if existing: + log_action('warning', f'File {filename} already exists, skipping') + continue + + # Save file + file.save(filepath) + + # Determine content type from extension + file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' + + if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']: + detected_type = 'image' + elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']: + detected_type = 'video' + # Process video for Raspberry Pi + success, message = process_video_file(filepath, os.urandom(8).hex()) + if not success: + log_action('error', f'Video processing failed: {message}') + elif file_ext == 'pdf': + detected_type = 'pdf' + else: + detected_type = 'other' + + # Create content record + content = Content( + filename=filename, + content_type=detected_type, + duration=duration, + file_size=os.path.getsize(filepath) + ) + db.session.add(content) + db.session.flush() # Get content ID + + # Add to playlist if specified + if playlist_id: + playlist = Playlist.query.get(playlist_id) + if playlist: + # Get max position + max_position = db.session.query(db.func.max(playlist_content.c.position))\ + .filter(playlist_content.c.playlist_id == playlist_id)\ + .scalar() or 0 + + # Add to playlist + stmt = playlist_content.insert().values( + playlist_id=playlist_id, + content_id=content.id, + position=max_position + 1, + duration=duration + ) + db.session.execute(stmt) + + # Increment playlist version + playlist.version += 1 + + uploaded_count += 1 db.session.commit() - - # Clear caches cache.clear() - log_action('info', f'Bulk deleted {deleted_count} content items') - return jsonify({'success': True, 'deleted': deleted_count}) + log_action('info', f'Uploaded {uploaded_count} media files') + + if playlist_id: + playlist = Playlist.query.get(playlist_id) + flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success') + else: + flash(f'Successfully uploaded {uploaded_count} file(s) to media library!', 'success') except Exception as e: db.session.rollback() - log_action('error', f'Error bulk deleting content: {str(e)}') - return jsonify({'success': False, 'error': str(e)}), 500 + log_action('error', f'Error uploading media: {str(e)}') + flash('Error uploading media files.', 'danger') + + return redirect(url_for('content.upload_media_page')) -@content_bp.route('/upload-progress/') +@content_bp.route('/player//assign-playlist', methods=['POST']) @login_required -def upload_progress_status(upload_id: str): - """Get upload progress for a specific upload.""" - progress = get_upload_progress(upload_id) - return jsonify(progress) - - -@content_bp.route('/preview/') -@login_required -def preview_content(content_id: int): - """Preview content in browser.""" +def assign_player_to_playlist(player_id: int): + """Assign a player to a playlist.""" + player = Player.query.get_or_404(player_id) + try: - content = Content.query.get_or_404(content_id) + playlist_id = request.form.get('playlist_id', type=int) - # Serve file from uploads folder - return send_from_directory( - current_app.config['UPLOAD_FOLDER'], - content.filename, - as_attachment=False - ) - except Exception as e: - log_action('error', f'Error previewing content: {str(e)}') - return "Error loading content", 500 - - -@content_bp.route('//download') -@login_required -def download_content(content_id: int): - """Download content file.""" - try: - content = Content.query.get_or_404(content_id) + if playlist_id: + playlist = Playlist.query.get_or_404(playlist_id) + player.playlist_id = playlist_id + log_action('info', f'Assigned player "{player.name}" to playlist "{playlist.name}"') + flash(f'Player "{player.name}" assigned to playlist "{playlist.name}".', 'success') + else: + player.playlist_id = None + log_action('info', f'Unassigned player "{player.name}" from playlist') + flash(f'Player "{player.name}" unassigned from playlist.', 'success') - log_action('info', f'Content "{content.filename}" downloaded') - - return send_from_directory( - current_app.config['UPLOAD_FOLDER'], - content.filename, - as_attachment=True - ) - except Exception as e: - log_action('error', f'Error downloading content: {str(e)}') - return "Error downloading content", 500 - - -@content_bp.route('/statistics') -@login_required -def content_statistics(): - """Get content statistics.""" - try: - total_content = Content.query.count() - - # Count by type - type_counts = {} - for content_type in ['image', 'video', 'pdf', 'presentation', 'other']: - count = Content.query.filter_by(content_type=content_type).count() - type_counts[content_type] = count - - # Calculate total storage - upload_folder = current_app.config['UPLOAD_FOLDER'] - total_size = 0 - if os.path.exists(upload_folder): - for dirpath, dirnames, filenames in os.walk(upload_folder): - for filename in filenames: - filepath = os.path.join(dirpath, filename) - if os.path.exists(filepath): - total_size += os.path.getsize(filepath) - - return jsonify({ - 'total': total_content, - 'by_type': type_counts, - 'total_size_mb': round(total_size / (1024 * 1024), 2) - }) + db.session.commit() + cache.clear() except Exception as e: - log_action('error', f'Error getting content statistics: {str(e)}') - return jsonify({'error': str(e)}), 500 - - -@content_bp.route('/check-duplicates') -@login_required -def check_duplicates(): - """Check for duplicate filenames.""" - try: - # Get all filenames - all_content = Content.query.all() - filename_counts = {} - - for content in all_content: - filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1 - - # Find duplicates - duplicates = {fname: count for fname, count in filename_counts.items() if count > 1} - - return jsonify({ - 'has_duplicates': len(duplicates) > 0, - 'duplicates': duplicates - }) - - except Exception as e: - log_action('error', f'Error checking duplicates: {str(e)}') - return jsonify({'error': str(e)}), 500 - - -@content_bp.route('//groups') -@login_required -def content_groups_info(content_id: int): - """Get groups that contain this content.""" - try: - content = Content.query.get_or_404(content_id) - - groups_data = [] - for group in content.groups: - groups_data.append({ - 'id': group.id, - 'name': group.name, - 'description': group.description, - 'player_count': group.players.count() - }) - - return jsonify({ - 'content_id': content_id, - 'filename': content.filename, - 'groups': groups_data - }) - - except Exception as e: - log_action('error', f'Error getting content groups: {str(e)}') - return jsonify({'error': str(e)}), 500 + db.session.rollback() + log_action('error', f'Error assigning player to playlist: {str(e)}') + flash('Error assigning player to playlist.', 'danger') + + return redirect(url_for('content.content_list')) diff --git a/app/blueprints/content_old.py b/app/blueprints/content_old.py new file mode 100644 index 0000000..d04e0e5 --- /dev/null +++ b/app/blueprints/content_old.py @@ -0,0 +1,500 @@ +"""Content blueprint for media upload and management.""" +from flask import (Blueprint, render_template, request, redirect, url_for, + flash, jsonify, current_app, send_from_directory) +from flask_login import login_required +from werkzeug.utils import secure_filename +import os +from typing import Optional, Dict +import json + +from app.extensions import db, cache +from app.models import Content, Group +from app.utils.logger import log_action +from app.utils.uploads import ( + save_uploaded_file, + process_video_file, + process_pdf_file, + get_upload_progress, + set_upload_progress +) + +content_bp = Blueprint('content', __name__, url_prefix='/content') + + +# In-memory storage for upload progress (for simple demo; use Redis in production) +upload_progress = {} + + +@content_bp.route('/') +@login_required +def content_list(): + """Display list of all content.""" + try: + # Get all unique content files (by filename) + from sqlalchemy import func + + # Get content with player information + contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all() + + # Group content by filename to show which players have each file + content_map = {} + for content in contents: + if content.filename not in content_map: + content_map[content.filename] = { + 'content': content, + 'players': [], + 'groups': [] + } + + # Add player info if assigned to a player + if content.player_id: + from app.models import Player + player = Player.query.get(content.player_id) + if player: + content_map[content.filename]['players'].append({ + 'id': player.id, + 'name': player.name, + 'group': player.group.name if player.group else None + }) + + # Convert to list for template + content_list = [] + for filename, data in content_map.items(): + content_list.append({ + 'filename': filename, + 'content_type': data['content'].content_type, + 'duration': data['content'].duration, + 'file_size': data['content'].file_size_mb, + 'uploaded_at': data['content'].uploaded_at, + 'players': data['players'], + 'player_count': len(data['players']) + }) + + # Sort by upload date + content_list.sort(key=lambda x: x['uploaded_at'], reverse=True) + + return render_template('content/content_list.html', + content_list=content_list) + except Exception as e: + log_action('error', f'Error loading content list: {str(e)}') + flash('Error loading content list.', 'danger') + return redirect(url_for('main.dashboard')) + + +@content_bp.route('/upload', methods=['GET', 'POST']) +@login_required +def upload_content(): + """Upload new content.""" + if request.method == 'GET': + # Get parameters for return URL and pre-selection + player_id = request.args.get('player_id', type=int) + return_url = request.args.get('return_url', url_for('content.content_list')) + + # Get all players for selection + from app.models import Player + players = Player.query.order_by(Player.name).all() + + return render_template('content/upload_content.html', + players=players, + selected_player_id=player_id, + return_url=return_url) + + try: + # Get form data + player_id = request.form.get('player_id', type=int) + media_type = request.form.get('media_type', 'image') + duration = request.form.get('duration', type=int, default=10) + session_id = request.form.get('session_id', os.urandom(8).hex()) + return_url = request.form.get('return_url', url_for('content.content_list')) + + # Get files + files = request.files.getlist('files') + + if not files or files[0].filename == '': + flash('No files provided.', 'warning') + return redirect(url_for('content.upload_content')) + + if not player_id: + flash('Please select a player.', 'warning') + return redirect(url_for('content.upload_content')) + + # Initialize progress tracking using shared utility + set_upload_progress(session_id, 0, 'Starting upload...', 'uploading') + + # Process each file + upload_folder = current_app.config['UPLOAD_FOLDER'] + os.makedirs(upload_folder, exist_ok=True) + + processed_count = 0 + total_files = len(files) + + for idx, file in enumerate(files): + if file.filename == '': + continue + + # Update progress + progress_pct = int((idx / total_files) * 80) # 0-80% for file processing + set_upload_progress(session_id, progress_pct, + f'Processing file {idx + 1} of {total_files}...', 'processing') + + filename = secure_filename(file.filename) + filepath = os.path.join(upload_folder, filename) + + # Save file + file.save(filepath) + + # Determine content type + file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' + + if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']: + content_type = 'image' + elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']: + content_type = 'video' + # Process video (convert to Raspberry Pi optimized format) + set_upload_progress(session_id, progress_pct + 5, + f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing') + success, message = process_video_file(filepath, session_id) + if not success: + log_action('error', f'Video optimization failed: {message}') + continue # Skip this file and move to next + elif file_ext == 'pdf': + content_type = 'pdf' + # Process PDF (convert to images) + set_upload_progress(session_id, progress_pct + 5, + f'Converting PDF {idx + 1}...', 'processing') + # process_pdf_file(filepath, session_id) + elif file_ext in ['ppt', 'pptx']: + content_type = 'presentation' + # Process presentation (convert to PDF then images) + set_upload_progress(session_id, progress_pct + 5, + f'Converting PowerPoint {idx + 1}...', 'processing') + # This would call pptx_converter utility + else: + content_type = 'other' + + # Create content record linked to player + from app.models import Player + player = Player.query.get(player_id) + if player: + new_content = Content( + filename=filename, + content_type=content_type, + duration=duration, + file_size=os.path.getsize(filepath), + player_id=player_id + ) + db.session.add(new_content) + + # Increment playlist version + player.playlist_version += 1 + log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})') + + processed_count += 1 + + # Commit all changes + set_upload_progress(session_id, 90, 'Saving to database...', 'processing') + db.session.commit() + + # Complete + set_upload_progress(session_id, 100, + f'Successfully uploaded {processed_count} file(s)!', 'complete') + + # Clear all playlist caches + cache.clear() + + log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})') + flash(f'{processed_count} file(s) uploaded successfully.', 'success') + + return redirect(return_url) + + except Exception as e: + db.session.rollback() + + # Update progress to error state + if 'session_id' in locals(): + set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error') + + log_action('error', f'Error uploading content: {str(e)}') + flash('Error uploading content. Please try again.', 'danger') + return redirect(url_for('content.upload_content')) + + +@content_bp.route('//edit', methods=['GET', 'POST']) +@login_required +def edit_content(content_id: int): + """Edit content metadata.""" + content = Content.query.get_or_404(content_id) + + if request.method == 'GET': + return render_template('content/edit_content.html', content=content) + + try: + duration = request.form.get('duration', type=int) + description = request.form.get('description', '').strip() + + # Update content + if duration is not None: + content.duration = duration + content.description = description or None + db.session.commit() + + # Clear caches + cache.clear() + + log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated') + flash(f'Content "{content.filename}" updated successfully.', 'success') + + return redirect(url_for('content.content_list')) + + except Exception as e: + db.session.rollback() + log_action('error', f'Error updating content: {str(e)}') + flash('Error updating content. Please try again.', 'danger') + return redirect(url_for('content.edit_content', content_id=content_id)) + + +@content_bp.route('//delete', methods=['POST']) +@login_required +def delete_content(content_id: int): + """Delete content and associated file.""" + try: + content = Content.query.get_or_404(content_id) + filename = content.filename + + # Delete file from disk + filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename) + if os.path.exists(filepath): + os.remove(filepath) + + # Delete from database + db.session.delete(content) + db.session.commit() + + # Clear caches + cache.clear() + + log_action('info', f'Content "{filename}" (ID: {content_id}) deleted') + flash(f'Content "{filename}" deleted successfully.', 'success') + + except Exception as e: + db.session.rollback() + log_action('error', f'Error deleting content: {str(e)}') + flash('Error deleting content. Please try again.', 'danger') + + return redirect(url_for('content.content_list')) + + +@content_bp.route('/delete-by-filename', methods=['POST']) +@login_required +def delete_by_filename(): + """Delete all content entries with a specific filename.""" + try: + data = request.get_json() + filename = data.get('filename') + + if not filename: + return jsonify({'success': False, 'message': 'No filename provided'}), 400 + + # Find all content entries with this filename + contents = Content.query.filter_by(filename=filename).all() + + if not contents: + return jsonify({'success': False, 'message': 'Content not found'}), 404 + + deleted_count = len(contents) + + # Delete file from disk (only once) + filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename) + if os.path.exists(filepath): + os.remove(filepath) + log_action('info', f'Deleted file from disk: {filename}') + + # Delete all database entries + for content in contents: + db.session.delete(content) + + db.session.commit() + + # Clear caches + cache.clear() + + log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)') + + return jsonify({ + 'success': True, + 'message': f'Content deleted from {deleted_count} playlist(s)', + 'deleted_count': deleted_count + }) + + except Exception as e: + db.session.rollback() + log_action('error', f'Error deleting content by filename: {str(e)}') + return jsonify({'success': False, 'message': str(e)}), 500 + + +@content_bp.route('/bulk/delete', methods=['POST']) +@login_required +def bulk_delete_content(): + """Delete multiple content items at once.""" + try: + content_ids = request.json.get('content_ids', []) + + if not content_ids: + return jsonify({'success': False, 'error': 'No content selected'}), 400 + + # Delete content + deleted_count = 0 + for content_id in content_ids: + content = Content.query.get(content_id) + if content: + # Delete file + filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename) + if os.path.exists(filepath): + os.remove(filepath) + + db.session.delete(content) + deleted_count += 1 + + db.session.commit() + + # Clear caches + cache.clear() + + log_action('info', f'Bulk deleted {deleted_count} content items') + return jsonify({'success': True, 'deleted': deleted_count}) + + except Exception as e: + db.session.rollback() + log_action('error', f'Error bulk deleting content: {str(e)}') + return jsonify({'success': False, 'error': str(e)}), 500 + + +@content_bp.route('/upload-progress/') +@login_required +def upload_progress_status(upload_id: str): + """Get upload progress for a specific upload.""" + progress = get_upload_progress(upload_id) + return jsonify(progress) + + +@content_bp.route('/preview/') +@login_required +def preview_content(content_id: int): + """Preview content in browser.""" + try: + content = Content.query.get_or_404(content_id) + + # Serve file from uploads folder + return send_from_directory( + current_app.config['UPLOAD_FOLDER'], + content.filename, + as_attachment=False + ) + except Exception as e: + log_action('error', f'Error previewing content: {str(e)}') + return "Error loading content", 500 + + +@content_bp.route('//download') +@login_required +def download_content(content_id: int): + """Download content file.""" + try: + content = Content.query.get_or_404(content_id) + + log_action('info', f'Content "{content.filename}" downloaded') + + return send_from_directory( + current_app.config['UPLOAD_FOLDER'], + content.filename, + as_attachment=True + ) + except Exception as e: + log_action('error', f'Error downloading content: {str(e)}') + return "Error downloading content", 500 + + +@content_bp.route('/statistics') +@login_required +def content_statistics(): + """Get content statistics.""" + try: + total_content = Content.query.count() + + # Count by type + type_counts = {} + for content_type in ['image', 'video', 'pdf', 'presentation', 'other']: + count = Content.query.filter_by(content_type=content_type).count() + type_counts[content_type] = count + + # Calculate total storage + upload_folder = current_app.config['UPLOAD_FOLDER'] + total_size = 0 + if os.path.exists(upload_folder): + for dirpath, dirnames, filenames in os.walk(upload_folder): + for filename in filenames: + filepath = os.path.join(dirpath, filename) + if os.path.exists(filepath): + total_size += os.path.getsize(filepath) + + return jsonify({ + 'total': total_content, + 'by_type': type_counts, + 'total_size_mb': round(total_size / (1024 * 1024), 2) + }) + + except Exception as e: + log_action('error', f'Error getting content statistics: {str(e)}') + return jsonify({'error': str(e)}), 500 + + +@content_bp.route('/check-duplicates') +@login_required +def check_duplicates(): + """Check for duplicate filenames.""" + try: + # Get all filenames + all_content = Content.query.all() + filename_counts = {} + + for content in all_content: + filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1 + + # Find duplicates + duplicates = {fname: count for fname, count in filename_counts.items() if count > 1} + + return jsonify({ + 'has_duplicates': len(duplicates) > 0, + 'duplicates': duplicates + }) + + except Exception as e: + log_action('error', f'Error checking duplicates: {str(e)}') + return jsonify({'error': str(e)}), 500 + + +@content_bp.route('//groups') +@login_required +def content_groups_info(content_id: int): + """Get groups that contain this content.""" + try: + content = Content.query.get_or_404(content_id) + + groups_data = [] + for group in content.groups: + groups_data.append({ + 'id': group.id, + 'name': group.name, + 'description': group.description, + 'player_count': group.players.count() + }) + + return jsonify({ + 'content_id': content_id, + 'filename': content.filename, + 'groups': groups_data + }) + + except Exception as e: + log_action('error', f'Error getting content groups: {str(e)}') + return jsonify({'error': str(e)}), 500 diff --git a/app/blueprints/main.py b/app/blueprints/main.py index da48744..d4fc239 100644 --- a/app/blueprints/main.py +++ b/app/blueprints/main.py @@ -5,8 +5,10 @@ from flask import Blueprint, render_template, redirect, url_for from flask_login import login_required, current_user from app.extensions import db, cache from app.models.player import Player -from app.models.group import Group +from app.models.playlist import Playlist +from app.models.content import Content from app.utils.logger import get_recent_logs +import os main_bp = Blueprint('main', __name__) @@ -16,15 +18,30 @@ main_bp = Blueprint('main', __name__) @cache.cached(timeout=60, unless=lambda: current_user.role != 'viewer') def dashboard(): """Main dashboard page""" - players = Player.query.all() - groups = Group.query.all() + # Get statistics + total_players = Player.query.count() + total_playlists = Playlist.query.count() + total_content = Content.query.count() + + # Calculate storage usage + upload_folder = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'uploads') + storage_mb = 0 + if os.path.exists(upload_folder): + for filename in os.listdir(upload_folder): + filepath = os.path.join(upload_folder, filename) + if os.path.isfile(filepath): + storage_mb += os.path.getsize(filepath) + storage_mb = round(storage_mb / (1024 * 1024), 2) # Convert to MB + server_logs = get_recent_logs(20) return render_template( 'dashboard.html', - players=players, - groups=groups, - server_logs=server_logs + total_players=total_players, + total_playlists=total_playlists, + total_content=total_content, + storage_mb=storage_mb, + recent_logs=server_logs ) diff --git a/app/blueprints/players.py b/app/blueprints/players.py index c86d5e2..d14587d 100644 --- a/app/blueprints/players.py +++ b/app/blueprints/players.py @@ -6,7 +6,7 @@ import secrets from typing import Optional, List from app.extensions import db, cache -from app.models import Player, Group, Content, PlayerFeedback +from app.models import Player, Content, PlayerFeedback, Playlist from app.utils.logger import log_action from app.utils.group_player_management import get_player_status_info @@ -20,7 +20,7 @@ def list(): """Display list of all players.""" try: players = Player.query.order_by(Player.name).all() - groups = Group.query.all() + playlists = Playlist.query.all() # Get player status for each player player_statuses = {} @@ -30,7 +30,7 @@ def list(): return render_template('players/players_list.html', players=players, - groups=groups, + playlists=playlists, player_statuses=player_statuses) except Exception as e: log_action('error', f'Error loading players list: {str(e)}') @@ -43,8 +43,7 @@ def list(): def add_player(): """Add a new player.""" if request.method == 'GET': - groups = Group.query.order_by(Group.name).all() - return render_template('players/add_player.html', groups=groups) + return render_template('players/add_player.html') try: name = request.form.get('name', '').strip() @@ -53,7 +52,6 @@ def add_player(): password = request.form.get('password', '').strip() quickconnect_code = request.form.get('quickconnect_code', '').strip() orientation = request.form.get('orientation', 'Landscape') - group_id = request.form.get('group_id') # Validation if not name or len(name) < 3: @@ -83,8 +81,7 @@ def add_player(): hostname=hostname, location=location or None, auth_code=auth_code, - orientation=orientation, - group_id=int(group_id) if group_id else None + orientation=orientation ) # Set password if provided @@ -128,13 +125,11 @@ def edit_player(player_id: int): player = Player.query.get_or_404(player_id) if request.method == 'GET': - groups = Group.query.order_by(Group.name).all() - return render_template('players/edit_player.html', player=player, groups=groups) + return render_template('players/edit_player.html', player=player) try: name = request.form.get('name', '').strip() location = request.form.get('location', '').strip() - group_id = request.form.get('group_id') # Validation if not name or len(name) < 3: @@ -144,7 +139,6 @@ def edit_player(player_id: int): # Update player player.name = name player.location = location or None - player.group_id = int(group_id) if group_id else None db.session.commit() # Clear cache for this player @@ -243,6 +237,88 @@ def player_page(player_id: int): return redirect(url_for('players.list')) +@players_bp.route('//manage', methods=['GET', 'POST']) +@login_required +def manage_player(player_id: int): + """Manage player - edit credentials, assign playlist, view logs.""" + player = Player.query.get_or_404(player_id) + + if request.method == 'POST': + action = request.form.get('action') + + try: + if action == 'update_credentials': + # Update player name, location, orientation + name = request.form.get('name', '').strip() + location = request.form.get('location', '').strip() + orientation = request.form.get('orientation', 'Landscape') + + if not name or len(name) < 3: + flash('Player name must be at least 3 characters long.', 'warning') + return redirect(url_for('players.manage_player', player_id=player_id)) + + player.name = name + player.location = location or None + player.orientation = orientation + db.session.commit() + + log_action('info', f'Player "{name}" credentials updated') + flash(f'Player "{name}" updated successfully.', 'success') + + elif action == 'assign_playlist': + # Assign playlist to player + playlist_id = request.form.get('playlist_id') + + if playlist_id: + playlist = Playlist.query.get(int(playlist_id)) + if playlist: + player.playlist_id = int(playlist_id) + db.session.commit() + cache.delete_memoized(get_player_playlist, player_id) + log_action('info', f'Player "{player.name}" assigned to playlist "{playlist.name}"') + flash(f'Player assigned to playlist "{playlist.name}".', 'success') + else: + flash('Invalid playlist selected.', 'warning') + else: + # Unassign playlist + player.playlist_id = None + db.session.commit() + cache.delete_memoized(get_player_playlist, player_id) + log_action('info', f'Player "{player.name}" unassigned from playlist') + flash('Player unassigned from playlist.', 'success') + + except Exception as e: + db.session.rollback() + log_action('error', f'Error managing player: {str(e)}') + flash('Error updating player. Please try again.', 'danger') + + return redirect(url_for('players.manage_player', player_id=player_id)) + + # GET request - show manage page + playlists = Playlist.query.order_by(Playlist.name).all() + + # Get player's current playlist + current_playlist = None + if player.playlist_id: + current_playlist = Playlist.query.get(player.playlist_id) + + # Get recent feedback/logs from player + recent_logs = PlayerFeedback.query.filter_by(player_id=player_id)\ + .order_by(PlayerFeedback.timestamp.desc())\ + .limit(20)\ + .all() + + # Get player status + status_info = get_player_status_info(player_id) + + return render_template('players/manage_player.html', + player=player, + playlists=playlists, + current_playlist=current_playlist, + recent_logs=recent_logs, + status_info=status_info) + + @players_bp.route('//fullscreen') def player_fullscreen(player_id: int): """Display player fullscreen view (no authentication required for players).""" @@ -303,35 +379,11 @@ def get_player_playlist(player_id: int) -> List[dict]: @players_bp.route('//reorder', methods=['POST']) @login_required def reorder_content(player_id: int): - """Reorder content for a player's group.""" - try: - player = Player.query.get_or_404(player_id) - - if not player.group_id: - flash('Player is not assigned to a group.', 'warning') - return redirect(url_for('players.player_page', player_id=player_id)) - - # Get new order from request - content_order = request.json.get('order', []) - - # Update positions - for idx, content_id in enumerate(content_order): - content = Content.query.get(content_id) - if content and content in player.group.contents: - content.position = idx - - db.session.commit() - - # Clear cache - cache.delete_memoized(get_player_playlist, player_id) - - log_action('info', f'Content reordered for player {player_id}') - return jsonify({'success': True}) - - except Exception as e: - db.session.rollback() - log_action('error', f'Error reordering content: {str(e)}') - return jsonify({'success': False, 'error': str(e)}), 500 + """Legacy endpoint - Content reordering now handled in playlist management.""" + return jsonify({ + 'success': False, + 'error': 'Content reordering is now managed through playlists. Use the Playlists page to reorder content.' + }), 400 @players_bp.route('/bulk/delete', methods=['POST']) @@ -366,35 +418,35 @@ def bulk_delete_players(): return jsonify({'success': False, 'error': str(e)}), 500 -@players_bp.route('/bulk/assign-group', methods=['POST']) +@players_bp.route('/bulk/assign-playlist', methods=['POST']) @login_required -def bulk_assign_group(): - """Assign multiple players to a group.""" +def bulk_assign_playlist(): + """Assign multiple players to a playlist.""" try: player_ids = request.json.get('player_ids', []) - group_id = request.json.get('group_id') + playlist_id = request.json.get('playlist_id') if not player_ids: return jsonify({'success': False, 'error': 'No players selected'}), 400 - # Validate group - if group_id: - group = Group.query.get(group_id) - if not group: - return jsonify({'success': False, 'error': 'Invalid group'}), 400 + # Validate playlist + if playlist_id: + playlist = Playlist.query.get(playlist_id) + if not playlist: + return jsonify({'success': False, 'error': 'Invalid playlist'}), 400 # Assign players updated_count = 0 for player_id in player_ids: player = Player.query.get(player_id) if player: - player.group_id = group_id + player.playlist_id = playlist_id cache.delete_memoized(get_player_playlist, player_id) updated_count += 1 db.session.commit() - log_action('info', f'Bulk assigned {updated_count} players to group {group_id}') + log_action('info', f'Bulk assigned {updated_count} players to playlist {playlist_id}') return jsonify({'success': True, 'updated': updated_count}) except Exception as e: diff --git a/app/blueprints/playlist.py b/app/blueprints/playlist.py new file mode 100644 index 0000000..6c9cecf --- /dev/null +++ b/app/blueprints/playlist.py @@ -0,0 +1,232 @@ +"""Playlist blueprint for managing player playlists.""" +from flask import (Blueprint, render_template, request, redirect, url_for, + flash, jsonify, current_app) +from flask_login import login_required +from sqlalchemy import desc +import os + +from app.extensions import db, cache +from app.models import Player, Content +from app.utils.logger import log_action + +playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist') + + +@playlist_bp.route('/') +@login_required +def manage_playlist(player_id: int): + """Manage playlist for a specific player.""" + player = Player.query.get_or_404(player_id) + + # Get all content for this player, ordered by position + playlist_content = Content.query.filter_by( + player_id=player_id + ).order_by(Content.position).all() + + # Get available content (files not already in this player's playlist) + all_files = db.session.query(Content.filename).distinct().all() + playlist_filenames = {c.filename for c in playlist_content} + available_files = [f[0] for f in all_files if f[0] not in playlist_filenames] + + return render_template('playlist/manage_playlist.html', + player=player, + playlist_content=playlist_content, + available_files=available_files) + + +@playlist_bp.route('//add', methods=['POST']) +@login_required +def add_to_playlist(player_id: int): + """Add content to player's playlist.""" + player = Player.query.get_or_404(player_id) + + try: + filename = request.form.get('filename') + duration = request.form.get('duration', type=int, default=10) + + if not filename: + flash('Please provide a filename.', 'warning') + return redirect(url_for('playlist.manage_playlist', player_id=player_id)) + + # Get max position + max_position = db.session.query(db.func.max(Content.position)).filter_by( + player_id=player_id + ).scalar() or 0 + + # Get file info from existing content + existing_content = Content.query.filter_by(filename=filename).first() + if not existing_content: + flash('File not found.', 'danger') + return redirect(url_for('playlist.manage_playlist', player_id=player_id)) + + # Create new content entry for this player + new_content = Content( + filename=filename, + content_type=existing_content.content_type, + duration=duration, + file_size=existing_content.file_size, + player_id=player_id, + position=max_position + 1 + ) + db.session.add(new_content) + + # Increment playlist version + player.playlist_version += 1 + + db.session.commit() + cache.clear() + + log_action('info', f'Added "{filename}" to playlist for player "{player.name}"') + flash(f'Added "{filename}" to playlist.', 'success') + + except Exception as e: + db.session.rollback() + log_action('error', f'Error adding to playlist: {str(e)}') + flash('Error adding to playlist.', 'danger') + + return redirect(url_for('playlist.manage_playlist', player_id=player_id)) + + +@playlist_bp.route('//remove/', methods=['POST']) +@login_required +def remove_from_playlist(player_id: int, content_id: int): + """Remove content from player's playlist.""" + player = Player.query.get_or_404(player_id) + content = Content.query.get_or_404(content_id) + + if content.player_id != player_id: + flash('Content does not belong to this player.', 'danger') + return redirect(url_for('playlist.manage_playlist', player_id=player_id)) + + try: + filename = content.filename + + # Delete content + db.session.delete(content) + + # Reorder remaining content + remaining_content = Content.query.filter_by( + player_id=player_id + ).order_by(Content.position).all() + + for idx, item in enumerate(remaining_content, start=1): + item.position = idx + + # Increment playlist version + player.playlist_version += 1 + + db.session.commit() + cache.clear() + + log_action('info', f'Removed "{filename}" from playlist for player "{player.name}"') + flash(f'Removed "{filename}" from playlist.', 'success') + + except Exception as e: + db.session.rollback() + log_action('error', f'Error removing from playlist: {str(e)}') + flash('Error removing from playlist.', 'danger') + + return redirect(url_for('playlist.manage_playlist', player_id=player_id)) + + +@playlist_bp.route('//reorder', methods=['POST']) +@login_required +def reorder_playlist(player_id: int): + """Reorder playlist items.""" + player = Player.query.get_or_404(player_id) + + try: + # Get new order from JSON + data = request.get_json() + content_ids = data.get('content_ids', []) + + if not content_ids: + return jsonify({'success': False, 'message': 'No content IDs provided'}), 400 + + # Update positions + for idx, content_id in enumerate(content_ids, start=1): + content = Content.query.get(content_id) + if content and content.player_id == player_id: + content.position = idx + + # Increment playlist version + player.playlist_version += 1 + + db.session.commit() + cache.clear() + + log_action('info', f'Reordered playlist for player "{player.name}" (version {player.playlist_version})') + + return jsonify({ + 'success': True, + 'message': 'Playlist reordered successfully', + 'version': player.playlist_version + }) + + except Exception as e: + db.session.rollback() + log_action('error', f'Error reordering playlist: {str(e)}') + return jsonify({'success': False, 'message': str(e)}), 500 + + +@playlist_bp.route('//update-duration/', methods=['POST']) +@login_required +def update_duration(player_id: int, content_id: int): + """Update content duration in playlist.""" + player = Player.query.get_or_404(player_id) + content = Content.query.get_or_404(content_id) + + if content.player_id != player_id: + return jsonify({'success': False, 'message': 'Content does not belong to this player'}), 403 + + try: + duration = request.form.get('duration', type=int) + + if not duration or duration < 1: + return jsonify({'success': False, 'message': 'Invalid duration'}), 400 + + content.duration = duration + player.playlist_version += 1 + + db.session.commit() + cache.clear() + + log_action('info', f'Updated duration for "{content.filename}" in player "{player.name}" playlist') + + return jsonify({ + 'success': True, + 'message': 'Duration updated', + 'version': player.playlist_version + }) + + except Exception as e: + db.session.rollback() + log_action('error', f'Error updating duration: {str(e)}') + return jsonify({'success': False, 'message': str(e)}), 500 + + +@playlist_bp.route('//clear', methods=['POST']) +@login_required +def clear_playlist(player_id: int): + """Clear all content from player's playlist.""" + player = Player.query.get_or_404(player_id) + + try: + # Delete all content for this player + Content.query.filter_by(player_id=player_id).delete() + + # Increment playlist version + player.playlist_version += 1 + + db.session.commit() + cache.clear() + + log_action('info', f'Cleared playlist for player "{player.name}"') + flash('Playlist cleared successfully.', 'success') + + except Exception as e: + db.session.rollback() + log_action('error', f'Error clearing playlist: {str(e)}') + flash('Error clearing playlist.', 'danger') + + return redirect(url_for('playlist.manage_playlist', player_id=player_id)) diff --git a/app/models/__init__.py b/app/models/__init__.py index 4a22049..5c78994 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -2,6 +2,7 @@ from app.models.user import User from app.models.player import Player from app.models.group import Group, group_content +from app.models.playlist import Playlist, playlist_content from app.models.content import Content from app.models.server_log import ServerLog from app.models.player_feedback import PlayerFeedback @@ -10,8 +11,10 @@ __all__ = [ 'User', 'Player', 'Group', + 'Playlist', 'Content', 'ServerLog', 'PlayerFeedback', 'group_content', + 'playlist_content', ] diff --git a/app/models/content.py b/app/models/content.py index 7bcc866..d2644f9 100644 --- a/app/models/content.py +++ b/app/models/content.py @@ -3,7 +3,6 @@ from datetime import datetime from typing import Optional, List from app.extensions import db -from app.models.group import group_content class Content(db.Model): @@ -13,31 +12,26 @@ class Content(db.Model): id: Primary key filename: Original filename content_type: Type of content (image, video, pdf, presentation, other) - duration: Display duration in seconds + duration: Default display duration in seconds file_size: File size in bytes description: Optional content description - position: Display order position uploaded_at: Upload timestamp """ __tablename__ = 'content' id = db.Column(db.Integer, primary_key=True) - filename = db.Column(db.String(255), nullable=False, index=True) + filename = db.Column(db.String(255), nullable=False, unique=True, index=True) content_type = db.Column(db.String(50), nullable=False, index=True) duration = db.Column(db.Integer, default=10, nullable=True) file_size = db.Column(db.BigInteger, nullable=True) description = db.Column(db.Text, nullable=True) - position = db.Column(db.Integer, default=0, index=True) uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) - # Player relationship (for direct player assignment) - player_id = db.Column(db.Integer, db.ForeignKey('player.id', ondelete='CASCADE'), - nullable=True, index=True) - - # Relationships - player = db.relationship('Player', back_populates='contents') - groups = db.relationship('Group', secondary=group_content, + # Relationships - many-to-many with playlists + playlists = db.relationship('Playlist', secondary='playlist_content', + back_populates='contents', lazy='dynamic') + groups = db.relationship('Group', secondary='group_content', back_populates='contents', lazy='dynamic') def __repr__(self) -> str: diff --git a/app/models/group.py b/app/models/group.py index 4ad770b..df2f036 100644 --- a/app/models/group.py +++ b/app/models/group.py @@ -32,7 +32,6 @@ class Group(db.Model): onupdate=datetime.utcnow, nullable=False) # Relationships - players = db.relationship('Player', back_populates='group', lazy='dynamic') contents = db.relationship('Content', secondary=group_content, back_populates='groups', lazy='dynamic') @@ -40,10 +39,7 @@ class Group(db.Model): """String representation of Group.""" return f'' - @property - def player_count(self) -> int: - """Get number of players in this group.""" - return self.players.count() + @property def content_count(self) -> int: diff --git a/app/models/player.py b/app/models/player.py index 7582026..83abed2 100644 --- a/app/models/player.py +++ b/app/models/player.py @@ -16,10 +16,10 @@ class Player(db.Model): auth_code: Authentication code for API access (legacy) password_hash: Hashed password for player authentication quickconnect_code: Hashed quick connect code for easy pairing - group_id: Foreign key to assigned group orientation: Display orientation (Landscape/Portrait) status: Current player status (online, offline, error) last_seen: Last activity timestamp + playlist_version: Version number for playlist synchronization created_at: Player creation timestamp """ __tablename__ = 'player' @@ -31,20 +31,20 @@ class Player(db.Model): auth_code = db.Column(db.String(255), unique=True, nullable=False, index=True) password_hash = db.Column(db.String(255), nullable=False) quickconnect_code = db.Column(db.String(255), nullable=True) - group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=True, index=True) orientation = db.Column(db.String(16), default='Landscape', nullable=False) status = db.Column(db.String(50), default='offline', index=True) last_seen = db.Column(db.DateTime, nullable=True, index=True) last_heartbeat = db.Column(db.DateTime, nullable=True, index=True) - playlist_version = db.Column(db.Integer, default=1, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + # Playlist assignment + playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'), + nullable=True, index=True) + # Relationships - group = db.relationship('Group', back_populates='players') + playlist = db.relationship('Playlist', back_populates='players') feedback = db.relationship('PlayerFeedback', back_populates='player', cascade='all, delete-orphan', lazy='dynamic') - contents = db.relationship('Content', back_populates='player', - cascade='all, delete-orphan', lazy='dynamic') def __repr__(self) -> str: """String representation of Player.""" diff --git a/app/models/playlist.py b/app/models/playlist.py new file mode 100644 index 0000000..1209df5 --- /dev/null +++ b/app/models/playlist.py @@ -0,0 +1,97 @@ +"""Playlist model for managing content collections.""" +from datetime import datetime +from typing import List, Optional + +from app.extensions import db + + +# Association table for many-to-many relationship between playlists and content +playlist_content = db.Table('playlist_content', + db.Column('playlist_id', db.Integer, db.ForeignKey('playlist.id', ondelete='CASCADE'), primary_key=True), + db.Column('content_id', db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), primary_key=True), + db.Column('position', db.Integer, default=0), + db.Column('duration', db.Integer, default=10) +) + + +class Playlist(db.Model): + """Playlist model representing a collection of content. + + Attributes: + id: Primary key + name: Unique playlist name + description: Optional playlist description + version: Version number for synchronization + is_active: Whether playlist is active + created_at: Playlist creation timestamp + updated_at: Last modification timestamp + """ + __tablename__ = 'playlist' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False, unique=True, index=True) + description = db.Column(db.Text, nullable=True) + orientation = db.Column(db.String(20), default='Landscape', nullable=False) + version = db.Column(db.Integer, default=1, nullable=False) + is_active = db.Column(db.Boolean, default=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, + onupdate=datetime.utcnow, nullable=False) + + # Relationships + players = db.relationship('Player', back_populates='playlist', lazy='dynamic') + contents = db.relationship('Content', secondary=playlist_content, + back_populates='playlists', lazy='dynamic') + + def __repr__(self) -> str: + """String representation of Playlist.""" + return f'' + + @property + def player_count(self) -> int: + """Get number of players assigned to this playlist.""" + return self.players.count() + + @property + def content_count(self) -> int: + """Get number of content items in this playlist.""" + return self.contents.count() + + @property + def total_duration(self) -> int: + """Calculate total duration of all content in seconds.""" + total = 0 + for content in self.contents: + total += content.duration or 10 + return total + + def increment_version(self) -> None: + """Increment playlist version for sync detection.""" + self.version += 1 + self.updated_at = datetime.utcnow() + + def get_content_ordered(self) -> List: + """Get content items ordered by position.""" + # Query through association table to get position + from sqlalchemy import select + stmt = select(playlist_content.c.content_id, + playlist_content.c.position, + playlist_content.c.duration).where( + playlist_content.c.playlist_id == self.id + ).order_by(playlist_content.c.position) + + results = db.session.execute(stmt).fetchall() + + ordered_content = [] + for row in results: + content = db.session.get(Content, row.content_id) + if content: + content._playlist_position = row.position + content._playlist_duration = row.duration + ordered_content.append(content) + + return ordered_content + + +# Import Content here to avoid circular import +from app.models.content import Content diff --git a/app/static/icons/edit.svg b/app/static/icons/edit.svg new file mode 100644 index 0000000..2c61c55 --- /dev/null +++ b/app/static/icons/edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/static/icons/home.svg b/app/static/icons/home.svg new file mode 100644 index 0000000..b1bcc86 --- /dev/null +++ b/app/static/icons/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/static/icons/info.svg b/app/static/icons/info.svg new file mode 100644 index 0000000..ab55c86 --- /dev/null +++ b/app/static/icons/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/static/icons/monitor.svg b/app/static/icons/monitor.svg new file mode 100644 index 0000000..972e336 --- /dev/null +++ b/app/static/icons/monitor.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/static/icons/moon.svg b/app/static/icons/moon.svg new file mode 100644 index 0000000..0e515b6 --- /dev/null +++ b/app/static/icons/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/static/icons/playlist.svg b/app/static/icons/playlist.svg new file mode 100644 index 0000000..4a4cd9a --- /dev/null +++ b/app/static/icons/playlist.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/static/icons/sun.svg b/app/static/icons/sun.svg new file mode 100644 index 0000000..d97f48d --- /dev/null +++ b/app/static/icons/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/static/icons/trash.svg b/app/static/icons/trash.svg new file mode 100644 index 0000000..ea68702 --- /dev/null +++ b/app/static/icons/trash.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/static/icons/upload.svg b/app/static/icons/upload.svg new file mode 100644 index 0000000..0aea094 --- /dev/null +++ b/app/static/icons/upload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/static/icons/warning.svg b/app/static/icons/warning.svg new file mode 100644 index 0000000..82219a6 --- /dev/null +++ b/app/static/icons/warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/templates/base.html b/app/templates/base.html index a7113cd..b1f9dba 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -5,12 +5,53 @@ {% block title %}DigiServer v2{% endblock %} @@ -101,17 +309,31 @@
-

đŸ“ē DigiServer v2

+

+ DigiServer + DigiServer v2 +

+ + {% block extra_js %}{% endblock %} diff --git a/app/templates/content/content_list_new.html b/app/templates/content/content_list_new.html new file mode 100644 index 0000000..7091635 --- /dev/null +++ b/app/templates/content/content_list_new.html @@ -0,0 +1,438 @@ +{% extends "base.html" %} + +{% block title %}Playlist Management - DigiServer v2{% endblock %} + +{% block content %} + + +
+

+ + Playlist Management +

+ +
+ +
+
+

+ + Playlists +

+
+ + +
+

Create New Playlist

+
+ + +
+
+ + + + Select the orientation that matches your display screens + +
+
+ + +
+ +
+ +
+ + +

Existing Playlists

+
+ {% if playlists %} + {% for playlist in playlists %} +
+
+

{{ playlist.name }}

+
+ 📊 {{ playlist.content_count }} items | + đŸ‘Ĩ {{ playlist.player_count }} players | + 🔄 v{{ playlist.version }} +
+
+
+ + âœī¸ Manage + +
+ +
+
+
+ {% endfor %} + {% else %} +
+ +

No playlists yet. Create your first playlist above!

+
+ {% endif %} +
+
+ + +
+
+

+ + Upload Media +

+
+ +
+ +

Upload Media Files

+

+ Upload images, videos, and PDFs to your media library.
+ Assign them to playlists during or after upload. +

+ + + Go to Upload Page + +
+ + +
+

Media Library ({{ media_files|length }} files)

+
+ {% if media_files %} + {% for media in media_files[:12] %} +
+
+ {% if media.content_type == 'image' %} + Image + {% elif media.content_type == 'video' %} + Video + {% elif media.content_type == 'pdf' %} + PDF + {% else %} + File + {% endif %} +
+
{{ media.filename[:20] }}...
+
+ {% endfor %} + {% else %} +
+

No media files yet. Upload your first file!

+
+ {% endif %} +
+ {% if media_files|length > 12 %} +

+ + {{ media_files|length - 12 }} more files +

+ {% endif %} +
+
+ + +
+
+

+ + Player Assignments +

+
+ +
+ + + + + + + + + + + + + {% for player in players %} + + + + + + + + + {% endfor %} + +
Player NameHostnameLocationAssigned PlaylistStatusActions
{{ player.name }} + + {{ player.hostname }} + + {{ player.location or '-' }} +
+ +
+
+ {% if player.is_online %} + + đŸŸĸ Online + + {% else %} + + âšĢ Offline + + {% endif %} + + + đŸ‘ī¸ View + +
+
+
+
+ + + +{% endblock %} diff --git a/app/templates/content/manage_playlist_content.html b/app/templates/content/manage_playlist_content.html new file mode 100644 index 0000000..6f40a42 --- /dev/null +++ b/app/templates/content/manage_playlist_content.html @@ -0,0 +1,304 @@ +{% extends "base.html" %} + +{% block title %}Manage {{ playlist.name }} - DigiServer v2{% endblock %} + +{% block content %} + + +
+
+

đŸŽŦ {{ playlist.name }}

+ {% if playlist.description %} +

{{ playlist.description }}

+ {% endif %} + +
+
+ Content Items + {{ playlist_content|length }} +
+
+ Total Duration + {{ playlist.total_duration }}s +
+
+ Version + {{ playlist.version }} +
+
+ Players Assigned + {{ playlist.player_count }} +
+
+
+ + + +
+
+

📋 Playlist Content (Drag to Reorder)

+ + {% if playlist_content %} + + + + + + + + + + + + + {% for content in playlist_content %} + + + + + + + + + {% endfor %} + +
#FilenameTypeDurationActions
⋮⋮{{ loop.index }}{{ content.filename }} + {% if content.content_type == 'image' %}📷 Image + {% elif content.content_type == 'video' %}đŸŽĨ Video + {% elif content.content_type == 'pdf' %}📄 PDF + {% else %}📁 Other{% endif %} + {{ content._playlist_duration or content.duration }}s +
+ +
+
+ {% else %} +
+
📭
+

No content in playlist yet. Add content from the right panel.

+
+ {% endif %} +
+ +
+

➕ Add Content

+ + {% if available_content %} +
+ {% for content in available_content %} +
+
+
+ {% if content.content_type == 'image' %}📷 + {% elif content.content_type == 'video' %}đŸŽĨ + {% elif content.content_type == 'pdf' %}📄 + {% else %}📁{% endif %} + {{ content.filename }} +
+
+ {{ content.file_size_mb }} MB +
+
+
+ + + +
+
+ {% endfor %} +
+ {% else %} +
+

All available content has been added to this playlist!

+
+ {% endif %} +
+
+
+ + + +{% endblock %} diff --git a/app/templates/content/upload_content.html b/app/templates/content/upload_content.html index 429bb13..3ac7699 100644 --- a/app/templates/content/upload_content.html +++ b/app/templates/content/upload_content.html @@ -12,32 +12,17 @@
-

Target Selection

-
-
- - -
-
- - -
+

Select Player

+
+ +
@@ -238,29 +223,7 @@ function pollUploadProgress() { }, 500); } -function updateTargetIdOptions() { - const targetType = document.getElementById('target_type').value; - const targetIdSelect = document.getElementById('target_id'); - targetIdSelect.innerHTML = ''; - - if (targetType === 'player') { - const players = {{ players|tojson }}; - players.forEach(player => { - const option = document.createElement('option'); - option.value = player.id; - option.textContent = player.name; - targetIdSelect.appendChild(option); - }); - } else if (targetType === 'group') { - const groups = {{ groups|tojson }}; - groups.forEach(group => { - const option = document.createElement('option'); - option.value = group.id; - option.textContent = group.name; - targetIdSelect.appendChild(option); - }); - } -} + function handleMediaTypeChange() { const mediaType = document.getElementById('media_type').value; diff --git a/app/templates/content/upload_media.html b/app/templates/content/upload_media.html new file mode 100644 index 0000000..91efd87 --- /dev/null +++ b/app/templates/content/upload_media.html @@ -0,0 +1,361 @@ +{% extends "base.html" %} + +{% block title %}Upload Media - DigiServer v2{% endblock %} + +{% block content %} + + +
+
+

+ + Upload Media Files +

+ + + Back to Playlists + +
+ +
+ + +
+

📋 Select Target Playlist (Optional)

+

+ Choose a playlist to directly add uploaded files, or leave blank to add to media library only. +

+ +
+ + + + 💡 Tip: You can add files to playlists later from the media library + +
+
+ + +
+

+ + Select Files +

+ +
+ +

Drag and Drop Files Here

+

or

+
+ + +
+

+ Supported formats:
+ Images: JPG, PNG, GIF, BMP
+ Videos: MP4, AVI, MOV, MKV, WEBM
+ Documents: PDF, PPT, PPTX +

+
+ +
+
+ + +
+

+ + Upload Settings +

+ +
+ + + + This will be auto-detected from file extension + +
+ +
+ + + + How long each item should display (for images and PDFs) + +
+
+ + + +
+
+ + + +{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index c6ea15f..5483061 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -7,42 +7,80 @@
-

đŸ‘Ĩ Players

+

+ + Players +

{{ total_players or 0 }}

View Players
-

📁 Groups

-

{{ total_groups or 0 }}

- View Groups +

+ + Playlists +

+

{{ total_playlists or 0 }}

+ Manage Playlists
-

đŸŽŦ Content

+

+ + Media Library +

{{ total_content or 0 }}

- View Content +

Unique media files

-

💾 Storage

+

+ + Storage +

{{ storage_mb or 0 }} MB

- Upload Content +

Total uploads

+
+

+ + Workflow Guide +

+
+
    +
  1. Create a Playlist - Group your content into themed collections
  2. +
  3. Upload Media - Add images, videos, or PDFs to your media library
  4. +
  5. Add Content to Playlist - Build your playlist with drag-and-drop ordering
  6. +
  7. Add Player - Register physical display devices
  8. +
  9. Assign Playlist - Connect players to their playlists
  10. +
  11. Players Auto-Download - Devices fetch and display content automatically
  12. +
+
+
+ {% if recent_logs %}

Recent Activity

@@ -63,7 +101,8 @@

System Status

✅ All systems operational

-

🔄 Blueprint architecture active

-

⚡ Flask {{ config.get('FLASK_VERSION', '3.1.0') }}

+

īŋŊ Playlist-centric architecture active

+

🔄 Groups removed - Streamlined workflow

+

⚡ DigiServer v2.0

{% endblock %} diff --git a/app/templates/players/manage_player.html b/app/templates/players/manage_player.html new file mode 100644 index 0000000..a2f5a71 --- /dev/null +++ b/app/templates/players/manage_player.html @@ -0,0 +1,252 @@ +{% extends "base.html" %} + +{% block title %}Manage Player - {{ player.name }}{% endblock %} + +{% block content %} +
+

+ + Manage Player: {{ player.name }} +

+ + + Back to Players + +
+ + +
+

+ Status: + {% if player.status == 'online' %} + + + Online + + {% elif player.status == 'offline' %} + + + Offline + + {% else %} + + + {{ player.status|title }} + + {% endif %} +

+

Hostname: {{ player.hostname }}

+

Last Seen: + {% if player.last_seen %} + {{ player.last_seen.strftime('%Y-%m-%d %H:%M:%S') }} + {% else %} + Never + {% endif %} +

+

Assigned Playlist: + {% if current_playlist %} + {{ current_playlist.name }} (v{{ current_playlist.version }}) + {% else %} + No playlist assigned + {% endif %} +

+
+ + +
+ + +
+

+ + Edit Credentials +

+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+

Hostname: {{ player.hostname }}

+

Auth Code: {{ player.auth_code }}

+

Quick Connect: {{ player.quickconnect_code or 'Not set' }}

+
+ + +
+
+ + +
+

+ + Assign Playlist +

+
+ + +
+ + +
+ + {% if current_playlist %} +
+

Currently Assigned:

+

{{ current_playlist.name }}

+

Version: {{ current_playlist.version }}

+

Content Items: {{ current_playlist.contents.count() }}

+

+ Updated: {{ current_playlist.updated_at.strftime('%Y-%m-%d %H:%M') }} +

+
+ {% else %} +
+

+ + No playlist currently assigned to this player. +

+
+ {% endif %} + + +
+ +
+

Quick Actions:

+ + + Create New Playlist + + {% if current_playlist %} + + + Edit Current Playlist + + {% endif %} +
+
+ + +
+

+ + Player Logs +

+

Recent feedback from the player device

+ +
+ {% if recent_logs %} + {% for log in recent_logs %} +
+
+
+ + {% if log.status == 'error' %}❌ + {% elif log.status == 'warning' %}âš ī¸ + {% elif log.status == 'playing' %}â–ļī¸ + {% elif log.status == 'restarting' %}🔄 + {% else %}â„šī¸{% endif %} + {{ log.status|upper }} + +

{{ log.message }}

+ {% if log.playlist_version %} +

+ Playlist v{{ log.playlist_version }} +

+ {% endif %} + {% if log.error_details %} +
+ Error Details +
{{ log.error_details }}
+
+ {% endif %} +
+ + {{ log.timestamp.strftime('%m/%d %H:%M') }} + +
+
+ {% endfor %} + {% else %} +
+

📭 No logs received yet

+

Logs will appear here once the player starts sending feedback

+
+ {% endif %} +
+
+ +
+ + +
+

â„šī¸ Player Information

+
+
+

Player ID: {{ player.id }}

+

Created: {{ player.created_at.strftime('%Y-%m-%d %H:%M') if player.created_at else 'N/A' }}

+
+
+

Orientation: {{ player.orientation }}

+

Location: {{ player.location or 'Not set' }}

+
+
+

Last Heartbeat: + {% if player.last_heartbeat %} + {{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M:%S') }} + {% else %} + Never + {% endif %} +

+
+
+
+ +{% endblock %} diff --git a/app/templates/players/player_page.html b/app/templates/players/player_page.html index de58f0c..80b5d32 100644 --- a/app/templates/players/player_page.html +++ b/app/templates/players/player_page.html @@ -27,8 +27,8 @@ âœī¸ Edit Player - - 📤 Upload Content + + đŸŽŦ Manage Playlist ← Back to Players @@ -62,18 +62,6 @@ Orientation: {{ player.orientation or 'Landscape' }} - - Group: - - {% if player.group %} - - {{ player.group.name }} - - {% else %} - No group - {% endif %} - - Created: {{ player.created_at.strftime('%Y-%m-%d %H:%M') }} @@ -138,77 +126,43 @@
{% if playlist %} -
- Total Items: {{ playlist|length }} | - Total Duration: {% set total_duration = namespace(value=0) %}{% for item in playlist %}{% set total_duration.value = total_duration.value + (item.duration or 10) %}{% endfor %}{{ total_duration.value }}s +
+
+
+
Total Items
+
{{ playlist|length }}
+
+
+
Total Duration
+
+ {% set total_duration = namespace(value=0) %} + {% for item in playlist %} + {% set total_duration.value = total_duration.value + (item.duration or 10) %} + {% endfor %} + {{ total_duration.value }}s +
+
+
+
Playlist Version
+
{{ player.playlist_version }}
+
+
+ {% endif %} - - - - - - - - - - - - {% for item in playlist %} - - - - - - - - {% endfor %} - -
OrderFile NameTypeDurationActions
- {{ loop.index }} - - {{ item.filename }} - - {% if item.type == 'image' %} - 📷 Image - {% elif item.type == 'video' %} - đŸŽŦ Video - {% elif item.type == 'pdf' %} - 📄 PDF - {% else %} - 📁 Other - {% endif %} - - {{ item.duration or 10 }}s - - - - -
- {% else %} -
- âš ī¸ No content in playlist. Upload content to get started. + + đŸŽŦ Open Playlist Manager + + + {% if not playlist %} +
+ âš ī¸ No content in playlist. Open the playlist manager to add content.
{% endif %}
@@ -270,62 +224,4 @@
- {% endblock %} diff --git a/app/templates/players/players_list.html b/app/templates/players/players_list.html index 5dad873..d550caa 100644 --- a/app/templates/players/players_list.html +++ b/app/templates/players/players_list.html @@ -17,7 +17,6 @@ Name Hostname Location - Group Orientation Status Last Seen @@ -36,13 +35,6 @@ {{ player.location or '-' }} - - {% if player.group %} - {{ player.group.name }} - {% else %} - No group - {% endif %} - {{ player.orientation or 'Landscape' }} @@ -62,25 +54,10 @@ {% endif %} - - đŸ‘ī¸ View + + âš™ī¸ Manage - - âœī¸ Edit - - - â›ļ Full - -
- -
{% endfor %} diff --git a/app/templates/playlist/manage_playlist.html b/app/templates/playlist/manage_playlist.html new file mode 100644 index 0000000..519ab55 --- /dev/null +++ b/app/templates/playlist/manage_playlist.html @@ -0,0 +1,492 @@ +{% extends "base.html" %} + +{% block title %}Manage Playlist - {{ player.name }} - DigiServer v2{% endblock %} + +{% block content %} + + +
+ +
+

đŸŽŦ {{ player.name }}

+

📍 {{ player.location or 'No location' }}

+

đŸ–Ĩī¸ Hostname: {{ player.hostname }}

+

📊 Status: {{ 'đŸŸĸ Online' if player.is_online else '🔴 Offline' }}

+ +
+
+
Playlist Items
+
{{ playlist_content|length }}
+
+
+
Playlist Version
+
{{ player.playlist_version }}
+
+
+
Total Duration
+
{{ playlist_content|sum(attribute='duration') }}s
+
+
+
+ + +
+ + ← Back to Player + + + ➕ Upload New Content + + {% if playlist_content %} +
+ +
+ {% endif %} +
+ + +
+
+

📋 Current Playlist

+ + Drag and drop to reorder + +
+ + {% if playlist_content %} + + + + + + + + + + + + + + {% for content in playlist_content %} + + + + + + + + + + {% endfor %} + +
#FilenameTypeDuration (s)SizeActions
+ ⋮⋮ + {{ loop.index }}{{ content.filename }} + {% if content.content_type == 'image' %}📷 Image + {% elif content.content_type == 'video' %}đŸŽĨ Video + {% elif content.content_type == 'pdf' %}📄 PDF + {% else %}📁 {{ content.content_type }} + {% endif %} + + + {{ "%.2f"|format(content.file_size_mb) }} MB +
+ +
+
+ {% else %} +
+
📭
+

No content in playlist

+

Upload content or add existing files to get started

+
+ {% endif %} +
+ + + {% if available_files %} +
+
+

➕ Add Existing Content

+
+ +
+
+ + +
+ +
+ + +
+ + +
+
+ {% endif %} +
+ + + +{% endblock %} diff --git a/install_emoji_fonts.sh b/install_emoji_fonts.sh new file mode 100644 index 0000000..9c4cbad --- /dev/null +++ b/install_emoji_fonts.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Install emoji fonts for Raspberry Pi +echo "Installing emoji font support for Raspberry Pi..." + +sudo apt-get update +sudo apt-get install -y fonts-noto-color-emoji fonts-noto-emoji + +echo "✅ Emoji fonts installed!" +echo "Please restart your browser to see the changes." diff --git a/migrate_add_orientation.py b/migrate_add_orientation.py new file mode 100644 index 0000000..4cf2121 --- /dev/null +++ b/migrate_add_orientation.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Add orientation column to playlist table - Direct SQLite approach""" + +import sqlite3 +import os + +def add_orientation_column(): + """Add orientation column to playlist table using direct SQLite connection.""" + db_path = 'instance/dev.db' + + if not os.path.exists(db_path): + print(f"❌ Database not found at: {db_path}") + return + + print(f"📁 Opening database: {db_path}") + + try: + # Connect to database + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Check if column already exists + cursor.execute("PRAGMA table_info(playlist)") + columns = [row[1] for row in cursor.fetchall()] + + if 'orientation' in columns: + print("✅ Column 'orientation' already exists in playlist table") + conn.close() + return + + # Add the column + print("Adding 'orientation' column to playlist table...") + cursor.execute(""" + ALTER TABLE playlist + ADD COLUMN orientation VARCHAR(20) DEFAULT 'Landscape' NOT NULL + """) + + conn.commit() + + print("✅ Successfully added 'orientation' column to playlist table") + print(" Default value: 'Landscape'") + + # Verify the column was added + cursor.execute("PRAGMA table_info(playlist)") + columns = [row[1] for row in cursor.fetchall()] + if 'orientation' in columns: + print("✓ Verified: Column exists in database") + + conn.close() + + except sqlite3.Error as e: + print(f"❌ SQLite Error: {e}") + return + except Exception as e: + print(f"❌ Error: {e}") + return + +if __name__ == '__main__': + add_orientation_column()