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 @@
+
+
{% block extra_js %}{% endblock %}