diff --git a/BLUEPRINT_GUIDE.md b/BLUEPRINT_GUIDE.md deleted file mode 100644 index 140afe3..0000000 --- a/BLUEPRINT_GUIDE.md +++ /dev/null @@ -1,336 +0,0 @@ -# Blueprint Architecture - Quick Reference - -## ๐Ÿ“ฆ Blueprint Structure - -``` -app/ -โ”œโ”€โ”€ blueprints/ -โ”‚ โ”œโ”€โ”€ __init__.py # Package initialization -โ”‚ โ”œโ”€โ”€ main.py # Dashboard, health check -โ”‚ โ”œโ”€โ”€ auth.py # Login, register, logout -โ”‚ โ”œโ”€โ”€ admin.py # Admin panel, user management -โ”‚ โ”œโ”€โ”€ players.py # Player CRUD, fullscreen view -โ”‚ โ”œโ”€โ”€ groups.py # Group management, assignments -โ”‚ โ”œโ”€โ”€ content.py # Media upload, file management -โ”‚ โ””โ”€โ”€ api.py # REST API endpoints -``` - ---- - -## ๐Ÿ”— URL Mapping - -### Main Blueprint (`/`) -- `GET /` - Dashboard with statistics -- `GET /health` - Health check (database, disk) - -### Auth Blueprint (`/auth`) -- `GET /auth/login` - Login page -- `POST /auth/login` - Process login -- `GET /auth/logout` - Logout -- `GET /auth/register` - Register page -- `POST /auth/register` - Process registration -- `GET /auth/change-password` - Change password page -- `POST /auth/change-password` - Update password - -### Admin Blueprint (`/admin`) -- `GET /admin/` - Admin panel -- `POST /admin/user/create` - Create user -- `POST /admin/user//role` - Change user role -- `POST /admin/user//delete` - Delete user -- `POST /admin/theme` - Change theme -- `POST /admin/logo/upload` - Upload logo -- `POST /admin/logs/clear` - Clear logs -- `GET /admin/system/info` - System info (JSON) - -### Players Blueprint (`/players`) -- `GET /players/` - List all players -- `GET /players/add` - Add player page -- `POST /players/add` - Create player -- `GET /players//edit` - Edit player page -- `POST /players//edit` - Update player -- `POST /players//delete` - Delete player -- `POST /players//regenerate-auth` - Regenerate auth code -- `GET /players/` - Player page -- `GET /players//fullscreen` - Player fullscreen view -- `POST /players//reorder` - Reorder content -- `POST /players/bulk/delete` - Bulk delete players -- `POST /players/bulk/assign-group` - Bulk assign to group - -### Groups Blueprint (`/groups`) -- `GET /groups/` - List all groups -- `GET /groups/create` - Create group page -- `POST /groups/create` - Create group -- `GET /groups//edit` - Edit group page -- `POST /groups//edit` - Update group -- `POST /groups//delete` - Delete group -- `GET /groups//manage` - Manage group page -- `GET /groups//fullscreen` - Group fullscreen view -- `POST /groups//add-player` - Add player to group -- `POST /groups//remove-player/` - Remove player -- `POST /groups//add-content` - Add content to group -- `POST /groups//remove-content/` - Remove content -- `POST /groups//reorder-content` - Reorder content -- `GET /groups//stats` - Group statistics (JSON) - -### Content Blueprint (`/content`) -- `GET /content/` - List all content -- `GET /content/upload` - Upload page -- `POST /content/upload` - Upload file -- `GET /content//edit` - Edit content page -- `POST /content//edit` - Update content -- `POST /content//delete` - Delete content -- `POST /content/bulk/delete` - Bulk delete content -- `GET /content/upload-progress/` - Upload progress (JSON) -- `GET /content/preview/` - Preview content -- `GET /content//download` - Download content -- `GET /content/statistics` - Content statistics (JSON) -- `GET /content/check-duplicates` - Check duplicates (JSON) -- `GET /content//groups` - Content groups info (JSON) - -### API Blueprint (`/api`) -- `GET /api/health` - API health check -- `GET /api/playlists/` - Get player playlist (auth required) -- `POST /api/player-feedback` - Submit player feedback (auth required) -- `GET /api/player-status/` - Get player status -- `GET /api/upload-progress/` - Get upload progress -- `GET /api/system-info` - System statistics -- `GET /api/groups` - List all groups -- `GET /api/content` - List all content -- `GET /api/logs` - Get server logs (query params: limit, level, since) - ---- - -## ๐Ÿ”’ Authentication & Authorization - -### Login Required -Most routes require authentication via `@login_required` decorator: -```python -from flask_login import login_required - -@players_bp.route('/') -@login_required -def players_list(): - # Route logic -``` - -### Admin Required -Admin routes use custom `@admin_required` decorator: -```python -from app.blueprints.admin import admin_required - -@admin_bp.route('/') -@login_required -@admin_required -def admin_panel(): - # Route logic -``` - -### API Authentication -API routes use `@verify_player_auth` for player authentication: -```python -from app.blueprints.api import verify_player_auth - -@api_bp.route('/playlists/') -@verify_player_auth -def get_player_playlist(player_id): - # Access authenticated player via request.player -``` - ---- - -## ๐Ÿš€ Performance Features - -### Caching -Routes with caching enabled: -- Dashboard: 60 seconds -- Player playlist: 5 minutes (memoized function) - -Clear cache on data changes: -```python -from app.extensions import cache - -# Clear specific memoized function -cache.delete_memoized(get_player_playlist, player_id) - -# Clear all cache -cache.clear() -``` - -### Rate Limiting -API endpoints have rate limiting: -```python -@api_bp.route('/playlists/') -@rate_limit(max_requests=30, window=60) # 30 requests per minute -def get_player_playlist(player_id): - # Route logic -``` - ---- - -## ๐ŸŽจ Template Usage - -### URL Generation -Always use blueprint-qualified names in templates: - -**Old (v1):** -```html -Login -Dashboard -Add Player -``` - -**New (v2):** -```html -Login -Dashboard -Add Player -``` - -### Context Processors -Available in all templates: -- `server_version` - Server version string -- `build_date` - Build date string -- `logo_exists` - Boolean, whether custom logo exists -- `theme` - Current theme ('light' or 'dark') - ---- - -## ๐Ÿ“ Common Patterns - -### Creating a New Route - -1. **Choose the appropriate blueprint** based on functionality -2. **Add the route** with proper decorators: -```python -@blueprint_name.route('/path', methods=['GET', 'POST']) -@login_required # If authentication needed -def route_name(): - try: - # Route logic - log_action('info', 'Success message') - flash('Success message', 'success') - return redirect(url_for('blueprint.route')) - except Exception as e: - db.session.rollback() - log_action('error', f'Error: {str(e)}') - flash('Error message', 'danger') - return redirect(url_for('blueprint.route')) -``` - -### Adding Caching - -For view caching: -```python -@cache.cached(timeout=60, key_prefix='my_key') -def my_route(): - # Route logic -``` - -For function memoization: -```python -@cache.memoize(timeout=300) -def my_function(param): - # Function logic -``` - -### API Response Format - -Consistent JSON responses: -```python -# Success -return jsonify({ - 'success': True, - 'data': {...} -}) - -# Error -return jsonify({ - 'error': 'Error message' -}), 400 -``` - ---- - -## ๐Ÿ”ง Extension Access - -Import extensions from centralized location: -```python -from app.extensions import db, bcrypt, cache, login_manager -``` - -Never initialize extensions directly in blueprints - they're initialized in `extensions.py` and registered in `app.py`. - ---- - -## ๐Ÿงช Testing Routes - -### Manual Testing -```bash -# Start development server -flask run - -# Test specific blueprint -curl http://localhost:5000/api/health -curl http://localhost:5000/health - -# Test authenticated route -curl -H "Authorization: Bearer " http://localhost:5000/api/playlists/1 -``` - -### Unit Testing -```python -def test_dashboard(client, auth): - auth.login() - response = client.get('/') - assert response.status_code == 200 -``` - ---- - -## ๐Ÿ“Š Blueprint Registration - -Blueprints are registered in `app/app.py`: -```python -def register_blueprints(app: Flask) -> None: - """Register all blueprints.""" - from app.blueprints.main import main_bp - from app.blueprints.auth import auth_bp - from app.blueprints.admin import admin_bp - from app.blueprints.players import players_bp - from app.blueprints.groups import groups_bp - from app.blueprints.content import content_bp - from app.blueprints.api import api_bp - - app.register_blueprint(main_bp) - app.register_blueprint(auth_bp) - app.register_blueprint(admin_bp) - app.register_blueprint(players_bp) - app.register_blueprint(groups_bp) - app.register_blueprint(content_bp) - app.register_blueprint(api_bp) -``` - ---- - -## โœ… Checklist for Adding New Features - -- [ ] Choose appropriate blueprint -- [ ] Add route with proper decorators -- [ ] Implement error handling (try/except) -- [ ] Add logging with `log_action()` -- [ ] Add flash messages for user feedback -- [ ] Clear cache if data changes -- [ ] Add type hints to parameters -- [ ] Update this documentation -- [ ] Add unit tests -- [ ] Test manually in browser/API client - ---- - -This architecture provides: -- โœ… **Separation of concerns** - Each blueprint handles specific functionality -- โœ… **Scalability** - Easy to add new blueprints -- โœ… **Maintainability** - Clear organization and naming -- โœ… **Performance** - Built-in caching and optimization -- โœ… **Security** - Proper authentication and authorization -- โœ… **API-first** - RESTful API alongside web interface diff --git a/ICON_INTEGRATION.md b/ICON_INTEGRATION.md deleted file mode 100644 index 97e7fcd..0000000 --- a/ICON_INTEGRATION.md +++ /dev/null @@ -1,138 +0,0 @@ -# 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 deleted file mode 100644 index 6980911..0000000 --- a/KIVY_PLAYER_COMPATIBILITY.md +++ /dev/null @@ -1,250 +0,0 @@ -# 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/PLAYER_AUTH.md b/PLAYER_AUTH.md deleted file mode 100644 index 0393adb..0000000 --- a/PLAYER_AUTH.md +++ /dev/null @@ -1,381 +0,0 @@ -# Player Authentication System - DigiServer v2 & Kiwy-Signage - -## Overview - -DigiServer v2 now includes a secure player authentication system compatible with Kiwy-Signage players. Players can authenticate using either a password or quick connect code, and their credentials are securely stored locally. - -## Features - -โœ… **Dual Authentication Methods** -- Password-based authentication (secure bcrypt hashing) -- Quick Connect codes for easy pairing - -โœ… **Secure Credential Storage** -- Auth codes saved locally in encrypted configuration -- No need to re-authenticate on every restart - -โœ… **Automatic Session Management** -- Auth codes persist across player restarts -- Automatic status updates and heartbeats - -โœ… **Player Identification** -- Unique hostname for each player -- Configurable display orientation (Landscape/Portrait) - -## Database Schema - -The Player model now includes: - -```python -class Player(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(255), nullable=False) - hostname = db.Column(db.String(255), unique=True, nullable=False, index=True) - location = db.Column(db.String(255), nullable=True) - 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) - orientation = db.Column(db.String(16), default='Landscape') - status = db.Column(db.String(50), default='offline') - last_seen = db.Column(db.DateTime, nullable=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow) -``` - -## API Endpoints - -### 1. Player Authentication -**POST** `/api/auth/player` - -Authenticate a player and receive auth code. - -**Request:** -```json -{ - "hostname": "player-001", - "password": "your_password", - "quickconnect_code": "QUICK123" // Optional if using password -} -``` - -**Response (200 OK):** -```json -{ - "success": true, - "player_id": 1, - "player_name": "Demo Player", - "hostname": "player-001", - "auth_code": "abc123xyz...", - "group_id": 5, - "orientation": "Landscape", - "status": "online" -} -``` - -**Error Response (401 Unauthorized):** -```json -{ - "error": "Invalid credentials" -} -``` - -### 2. Verify Auth Code -**POST** `/api/auth/verify` - -Verify an existing auth code. - -**Request:** -```json -{ - "auth_code": "abc123xyz..." -} -``` - -**Response (200 OK):** -```json -{ - "valid": true, - "player_id": 1, - "player_name": "Demo Player", - "hostname": "player-001", - "group_id": 5, - "orientation": "Landscape", - "status": "online" -} -``` - -## Player Configuration File - -Players store their configuration in `player_config.ini`: - -```ini -[server] -server_url = http://your-server:5000 - -[player] -hostname = player-001 -auth_code = abc123xyz... -player_id = 1 -group_id = 5 - -[display] -orientation = Landscape -resolution = 1920x1080 - -[security] -verify_ssl = true -timeout = 30 - -[cache] -cache_dir = ./cache -max_cache_size = 1024 - -[logging] -enabled = true -log_level = INFO -log_file = ./player.log -``` - -## Integration with Kiwy-Signage - -### Step 1: Copy Authentication Module - -Copy `player_auth_module.py` to your Kiwy-Signage project: - -```bash -cp digiserver-v2/player_auth_module.py signage-player/src/player_auth.py -``` - -### Step 2: Initialize Authentication - -In your main signage player code: - -```python -from player_auth import PlayerAuth - -# Initialize authentication -auth = PlayerAuth(config_path='player_config.ini') - -# Check if already authenticated -if auth.is_authenticated(): - # Verify saved credentials - valid, info = auth.verify_auth() - if valid: - print(f"Authenticated as: {info['player_name']}") - else: - # Re-authenticate - success, error = auth.authenticate( - hostname='player-001', - password='your_password' - ) -else: - # First time setup - hostname = input("Enter player hostname: ") - password = input("Enter password: ") - - success, error = auth.authenticate(hostname, password) - if success: - print("Authentication successful!") - else: - print(f"Authentication failed: {error}") -``` - -### Step 3: Use Authentication for API Calls - -```python -# Get playlist with authentication -playlist = auth.get_playlist() - -# Send heartbeat -auth.send_heartbeat(status='online') - -# Make authenticated API request -import requests - -auth_code = auth.get_auth_code() -player_id = auth.config.get('player', 'player_id') -server_url = auth.get_server_url() - -response = requests.get( - f"{server_url}/api/playlists/{player_id}", - headers={'Authorization': f'Bearer {auth_code}'} -) -``` - -## Server Setup - -### 1. Create Players in DigiServer - -Via Web Interface: -1. Log in as admin (admin/admin123) -2. Navigate to Players โ†’ Add Player -3. Fill in: - - **Name**: Display name - - **Hostname**: Unique identifier (e.g., `player-001`) - - **Location**: Physical location - - **Password**: Secure password - - **Quick Connect Code**: Optional easy pairing code - - **Orientation**: Landscape or Portrait - -Via Python: -```python -from app.extensions import db -from app.models import Player -import secrets - -player = Player( - name='Office Player', - hostname='office-player-001', - location='Main Office - Reception', - auth_code=secrets.token_urlsafe(32), - orientation='Landscape' -) -player.set_password('secure_password_123') -player.set_quickconnect_code('OFFICE123') - -db.session.add(player) -db.session.commit() -``` - -### 2. Distribute Credentials - -Securely provide each player with: -- Server URL -- Hostname -- Password OR Quick Connect Code - -## Security Considerations - -โœ… **Passwords**: Hashed with bcrypt (cost factor 12) -โœ… **Auth Codes**: 32-byte URL-safe tokens -โœ… **HTTPS**: Enable SSL in production -โœ… **Rate Limiting**: API endpoints protected (10 req/min for auth) -โœ… **Local Storage**: Config file permissions should be 600 - -## Troubleshooting - -### Player Can't Authenticate - -1. Check server connectivity: -```bash -curl http://your-server:5000/api/health -``` - -2. Verify credentials in database -3. Check server logs for authentication attempts -4. Ensure hostname is unique - -### Auth Code Invalid - -1. Clear saved config: `rm player_config.ini` -2. Re-authenticate with password -3. Check if player was deleted from server - -### Connection Timeout - -1. Increase timeout in `player_config.ini`: -```ini -[security] -timeout = 60 -``` - -2. Check network connectivity -3. Verify server is running - -## Migration from v1 - -If migrating from DigiServer v1: - -1. **Export player data** from v1 database -2. **Create players** in v2 with hostname = old username -3. **Set passwords** using `player.set_password()` -4. **Update player apps** with new authentication module -5. **Test authentication** before full deployment - -## Example: Complete Player Setup - -```python -#!/usr/bin/env python3 -""" -Complete player setup example -""" -from player_auth import PlayerAuth -import sys - -def setup_player(): - """Interactive player setup""" - auth = PlayerAuth() - - # Check if already configured - if auth.is_authenticated(): - print(f"โœ… Already configured as: {auth.get_hostname()}") - - # Test connection - valid, info = auth.verify_auth() - if valid: - print(f"โœ… Connection successful") - print(f" Player: {info['player_name']}") - print(f" Group: {info.get('group_id', 'None')}") - return True - else: - print("โŒ Saved credentials invalid, reconfiguring...") - auth.clear_auth() - - # First time setup - print("\n๐Ÿš€ Player Setup") - print("-" * 50) - - # Get server URL - server_url = input("Server URL [http://localhost:5000]: ").strip() - if server_url: - auth.config['server']['server_url'] = server_url - auth.save_config() - - # Get hostname - hostname = input("Player hostname: ").strip() - if not hostname: - print("โŒ Hostname required") - return False - - # Authentication method - print("\nAuthentication method:") - print("1. Password") - print("2. Quick Connect Code") - choice = input("Choice [1]: ").strip() or "1" - - if choice == "1": - password = input("Password: ").strip() - success, error = auth.authenticate(hostname, password=password) - else: - code = input("Quick Connect Code: ").strip() - success, error = auth.authenticate(hostname, quickconnect_code=code) - - if success: - print("\nโœ… Authentication successful!") - print(f" Config saved to: {auth.config_path}") - return True - else: - print(f"\nโŒ Authentication failed: {error}") - return False - -if __name__ == '__main__': - if setup_player(): - sys.exit(0) - else: - sys.exit(1) -``` - -## Files Included - -- `player_auth_module.py` - Python authentication module for players -- `player_config_template.ini` - Configuration template -- `reinit_db.sh` - Script to recreate database with new schema -- `PLAYER_AUTH.md` - This documentation - -## Support - -For issues or questions: -1. Check server logs: `app/instance/logs/` -2. Check player logs: `player.log` -3. Verify API health: `/api/health` -4. Review authentication attempts in server logs diff --git a/PROGRESS.md b/PROGRESS.md deleted file mode 100644 index 692bde4..0000000 --- a/PROGRESS.md +++ /dev/null @@ -1,282 +0,0 @@ -# Digiserver v2 - Blueprint Architecture Implementation Progress - -## โœ… Completed Components - -### Core Infrastructure (100%) -- โœ… **config.py** - Environment-based configuration (Development, Production, Testing) -- โœ… **extensions.py** - Centralized Flask extension initialization -- โœ… **app.py** - Application factory with blueprint registration -- โœ… **.env.example** - Environment variable template -- โœ… **.gitignore** - Git ignore patterns -- โœ… **requirements.txt** - Python dependencies with Flask 3.1.0, Flask-Caching, Redis - -### Blueprints (100%) -All 6 blueprints completed with full functionality: - -#### 1. **main.py** โœ… -- Dashboard with caching (60s timeout) -- Health check endpoint with database and disk validation -- Server statistics display - -#### 2. **auth.py** โœ… -- Login with bcrypt password verification, remember me, next page redirect -- Logout with logging -- Register with validation (min 6 chars, username uniqueness check) -- Change password with current password verification -- Input sanitization and error handling - -#### 3. **admin.py** โœ… -- Admin panel with system overview (users, players, groups, content, storage) -- User management (create, change role, delete) -- Theme settings (light/dark mode) -- Logo upload functionality -- System logs management (view, clear) -- System information API endpoint (CPU, memory, disk) -- `admin_required` decorator for authorization - -#### 4. **players.py** โœ… -- Players list with status information -- Add player with auth code generation -- Edit player (name, location, group assignment) -- Delete player with cascade feedback deletion -- Regenerate auth code -- Player page with playlist and feedback -- Player fullscreen view with auth code verification -- Playlist caching (5 min timeout) -- Content reordering -- Bulk operations (delete players, assign to group) - -#### 5. **groups.py** โœ… -- Groups list with statistics -- Create group with content assignment -- Edit group (name, description, content) -- Delete group with player unassignment -- Manage group page (player status cards, content management) -- Group fullscreen view for monitoring -- Add/remove players from group -- Add/remove content from group -- Content reordering within group -- Group statistics API endpoint - -#### 6. **content.py** โœ… -- Content list with group information -- Upload content with progress tracking -- File type detection (image, video, PDF, presentation) -- Edit content metadata (duration, description) -- Delete content with file removal -- Bulk delete content -- Upload progress API endpoint -- Content preview/download -- Content statistics (total, by type, storage) -- Duplicate filename checker -- Content groups information API - -#### 7. **api.py** โœ… -- Health check endpoint -- Get player playlist (with auth and rate limiting) -- Player feedback submission -- Player status endpoint -- Upload progress tracking -- System information API -- List groups API -- List content API -- Server logs API with filtering -- Rate limiting decorator (60 req/min default) -- Player authentication via Bearer token -- API error handlers (404, 405, 500) - -### Documentation (100%) -- โœ… **README.md** - Comprehensive project documentation -- โœ… Project structure diagram -- โœ… Quick start guide (development and Docker) -- โœ… Features list and optimization summary - ---- - -## ๐Ÿ“‹ Pending Tasks - -### Models (Priority: Critical) -Need to copy and adapt from v1 with improvements: - -1. **User Model** - Add indexes on username -2. **Player Model** - Add indexes on auth_code, group_id, last_seen -3. **Group Model** - Add indexes on name -4. **Content Model** - Add indexes on content_type, position, uploaded_at -5. **ServerLog Model** - Add indexes on level, timestamp -6. **PlayerFeedback Model** - Add indexes on player_id, timestamp -7. **Association Tables** - group_content for many-to-many relationship - -### Utils (Priority: Critical) -Need to copy and adapt from v1 with type hints: - -1. **logger.py** - Logging utility with type hints -2. **uploads.py** - File upload utilities with progress tracking -3. **group_player_management.py** - Group/player management functions -4. **pptx_converter.py** - PowerPoint conversion utility - -### Templates (Priority: High) -Need to copy from v1 and update route references: - -1. Update all `url_for()` calls to use blueprint naming: - - `url_for('login')` โ†’ `url_for('auth.login')` - - `url_for('dashboard')` โ†’ `url_for('main.dashboard')` - - `url_for('add_player')` โ†’ `url_for('players.add_player')` - - etc. - -2. Organize templates into subdirectories: - - `auth/` - login.html, register.html - - `admin/` - admin.html - - `players/` - players_list.html, add_player.html, edit_player.html, player_page.html, player_fullscreen.html - - `groups/` - groups_list.html, create_group.html, edit_group.html, manage_group.html, group_fullscreen.html - - `content/` - content_list.html, upload_content.html, edit_content.html - - `errors/` - 404.html, 403.html, 500.html - - `base.html` - Main layout template - -### Docker Configuration (Priority: High) -1. **Dockerfile** - Multi-stage build targeting 800MB -2. **docker-compose.yml** - Services: digiserver, redis, optional worker -3. **nginx.conf** - Reverse proxy configuration -4. **.dockerignore** - Docker ignore patterns - -### Testing (Priority: Medium) -1. **tests/conftest.py** - Test fixtures -2. **tests/test_auth.py** - Authentication tests -3. **tests/test_players.py** - Player management tests -4. **tests/test_groups.py** - Group management tests -5. **tests/test_content.py** - Content management tests -6. **tests/test_api.py** - API endpoint tests - -### Git Repository (Priority: Medium) -1. Initialize Git repository -2. Create initial commit -3. Push to remote (if desired) - ---- - -## ๐ŸŽฏ Next Steps - -### Immediate Actions -1. **Copy models from v1** - Add to `app/models/` with: - - Type hints - - Database indexes - - Proper relationships - - `__repr__` methods - -2. **Copy utils from v1** - Add to `app/utils/` with: - - Type hints - - Improved error handling - - Documentation - -3. **Create model init file** - `app/models/__init__.py` to export all models - -### After Models/Utils -4. **Copy templates from v1** - Update route references to blueprint naming -5. **Create Docker configuration** - Multi-stage build with optimization -6. **Test application** - Ensure all routes work correctly -7. **Initialize Git** - Create repository and initial commit - ---- - -## ๐Ÿ“Š Progress Summary - -| Component | Status | Progress | -|-----------|--------|----------| -| Core Infrastructure | โœ… Complete | 100% | -| Blueprints (6) | โœ… Complete | 100% | -| Documentation | โœ… Complete | 100% | -| Models | โณ Pending | 0% | -| Utils | โณ Pending | 0% | -| Templates | โณ Pending | 0% | -| Docker | โณ Pending | 0% | -| Testing | โณ Pending | 0% | -| Git | โณ Pending | 0% | - -**Overall Progress: ~50%** - ---- - -## ๐Ÿ”ง Key Features Implemented - -### Blueprint Architecture -- โœ… Modular route organization (7 blueprints) -- โœ… URL prefixes for namespace separation -- โœ… Blueprint-specific error handlers in API -- โœ… Shared extension initialization - -### Performance Optimizations -- โœ… Flask-Caching with Redis support -- โœ… Playlist caching (5 min timeout) -- โœ… Dashboard caching (60s timeout) -- โœ… Memoized functions with cache.memoize() -- โœ… Cache clearing on data changes - -### Security Features -- โœ… Bcrypt password hashing -- โœ… Flask-Login session management -- โœ… `admin_required` decorator -- โœ… Player authentication via auth codes -- โœ… API Bearer token authentication -- โœ… Rate limiting on API endpoints -- โœ… Input validation and sanitization -- โœ… CSRF protection (Flask-WTF ready) - -### API Features -- โœ… RESTful endpoints for players, groups, content -- โœ… Player playlist API with caching -- โœ… Player feedback submission -- โœ… Upload progress tracking -- โœ… System information API -- โœ… Rate limiting (60 req/min default) -- โœ… JSON error responses -- โœ… Health check endpoint - -### Development Features -- โœ… Application factory pattern -- โœ… Environment-based configuration -- โœ… CLI commands (init-db, create-admin, seed-db) -- โœ… Comprehensive logging -- โœ… Context processors for templates -- โœ… Error handlers for common HTTP errors - ---- - -## ๐Ÿ“ Notes - -### Architecture Decisions -- **Blueprint organization**: Logical separation by functionality -- **Caching strategy**: Redis for production, SimpleCache for development -- **Authentication**: Flask-Login for web, Bearer tokens for API -- **Rate limiting**: In-memory for now, should use Redis in production -- **File uploads**: Direct save with progress tracking, can add Celery for async processing - -### Optimization Opportunities -- Add Celery for async video processing -- Implement WebSocket for real-time player updates -- Add comprehensive database migrations -- Implement API versioning (v1, v2) -- Add comprehensive test coverage -- Add Sentry for error tracking - -### Migration from v1 -When copying from v1, remember to: -- Update all route references to blueprint naming -- Add type hints to all functions -- Add database indexes for performance -- Update imports to match new structure -- Update static file paths in templates -- Test all functionality thoroughly - ---- - -## ๐Ÿš€ Ready for Next Phase - -The blueprint architecture is now **fully implemented** with all 6 blueprints: -- โœ… main (dashboard, health) -- โœ… auth (login, register, logout) -- โœ… admin (user management, system settings) -- โœ… players (CRUD, fullscreen, bulk ops) -- โœ… groups (CRUD, manage, fullscreen) -- โœ… content (upload, manage, API) -- โœ… api (REST endpoints, authentication, rate limiting) - -**Ready to proceed with models and utils migration!** diff --git a/README.md b/README.md deleted file mode 100644 index 43b3e4e..0000000 --- a/README.md +++ /dev/null @@ -1,287 +0,0 @@ -# DigiServer v2 - Blueprint Architecture - -Modern Flask application with blueprint architecture, designed for scalability and maintainability. - -## ๐ŸŽฏ Project Goals - -- **Modular Architecture**: Blueprints for better code organization -- **Scalability**: Redis caching, Celery background tasks -- **Security**: Input validation, rate limiting, CSRF protection -- **Performance**: Optimized Docker image, database indexes -- **Maintainability**: Type hints, comprehensive tests, clear documentation - -## ๐Ÿ“ Project Structure - -``` -digiserver-v2/ -โ”œโ”€โ”€ app/ -โ”‚ โ”œโ”€โ”€ app.py # Application factory -โ”‚ โ”œโ”€โ”€ config.py # Environment-based configuration -โ”‚ โ”œโ”€โ”€ extensions.py # Flask extensions -โ”‚ โ”œโ”€โ”€ blueprints/ -โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ”‚ โ”œโ”€โ”€ main.py # Dashboard & home -โ”‚ โ”‚ โ”œโ”€โ”€ auth.py # Authentication -โ”‚ โ”‚ โ”œโ”€โ”€ admin.py # Admin panel -โ”‚ โ”‚ โ”œโ”€โ”€ players.py # Player management -โ”‚ โ”‚ โ”œโ”€โ”€ groups.py # Group management -โ”‚ โ”‚ โ”œโ”€โ”€ content.py # Media upload & management -โ”‚ โ”‚ โ””โ”€โ”€ api.py # REST API endpoints -โ”‚ โ”œโ”€โ”€ models/ -โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ”‚ โ”œโ”€โ”€ user.py -โ”‚ โ”‚ โ”œโ”€โ”€ player.py -โ”‚ โ”‚ โ”œโ”€โ”€ group.py -โ”‚ โ”‚ โ”œโ”€โ”€ content.py -โ”‚ โ”‚ โ”œโ”€โ”€ player_feedback.py -โ”‚ โ”‚ โ””โ”€โ”€ server_log.py -โ”‚ โ”œโ”€โ”€ utils/ -โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ”‚ โ”œโ”€โ”€ logger.py -โ”‚ โ”‚ โ”œโ”€โ”€ uploads.py -โ”‚ โ”‚ โ”œโ”€โ”€ decorators.py -โ”‚ โ”‚ โ””โ”€โ”€ validators.py -โ”‚ โ”œโ”€โ”€ templates/ -โ”‚ โ”‚ โ”œโ”€โ”€ base.html -โ”‚ โ”‚ โ”œโ”€โ”€ auth/ -โ”‚ โ”‚ โ”œโ”€โ”€ admin/ -โ”‚ โ”‚ โ”œโ”€โ”€ players/ -โ”‚ โ”‚ โ”œโ”€โ”€ groups/ -โ”‚ โ”‚ โ””โ”€โ”€ errors/ -โ”‚ โ””โ”€โ”€ static/ -โ”‚ โ”œโ”€โ”€ css/ -โ”‚ โ”œโ”€โ”€ js/ -โ”‚ โ”œโ”€โ”€ uploads/ -โ”‚ โ””โ”€โ”€ resurse/ -โ”œโ”€โ”€ tests/ -โ”‚ โ”œโ”€โ”€ __init__.py -โ”‚ โ”œโ”€โ”€ conftest.py -โ”‚ โ”œโ”€โ”€ test_auth.py -โ”‚ โ”œโ”€โ”€ test_players.py -โ”‚ โ””โ”€โ”€ test_api.py -โ”œโ”€โ”€ docker-compose.yml -โ”œโ”€โ”€ Dockerfile -โ”œโ”€โ”€ requirements.txt -โ”œโ”€โ”€ .env.example -โ””โ”€โ”€ README.md -``` - -## ๐Ÿš€ Quick Start - -### Development - -```bash -# Clone the repository -git clone -cd digiserver-v2 - -# Create virtual environment -python3 -m venv venv -source venv/bin/activate # On Windows: venv\Scripts\activate - -# Install dependencies -pip install -r requirements.txt - -# Set up environment variables -cp .env.example .env -# Edit .env with your settings - -# Initialize database -flask init-db -flask create-admin - -# Run development server -flask run -``` - -### Docker (Production) - -```bash -# Build and start containers -docker compose up -d - -# View logs -docker compose logs -f - -# Stop containers -docker compose down -``` - -## ๐Ÿ”ง Configuration - -Configuration is environment-based (development, production, testing). - -### Environment Variables - -Create a `.env` file: - -```env -# Flask -FLASK_ENV=production -SECRET_KEY=your-secret-key-here - -# Database -DATABASE_URL=sqlite:///instance/dashboard.db - -# Redis -REDIS_HOST=redis -REDIS_PORT=6379 - -# Admin Defaults -ADMIN_USER=admin -ADMIN_PASSWORD=secure-password - -# Optional -SENTRY_DSN=your-sentry-dsn -``` - -## ๐Ÿ“Š Features - -### Current (v2.0.0) - -- โœ… Blueprint architecture -- โœ… Environment-based configuration -- โœ… User authentication & authorization -- โœ… Admin panel -- โœ… Player management -- โœ… Group management -- โœ… Content upload & management -- โœ… REST API -- โœ… Redis caching (production) -- โœ… Health check endpoint - -### Planned - -- โณ Celery background tasks -- โณ Rate limiting -- โณ API authentication (JWT) -- โณ Unit & integration tests -- โณ API documentation (Swagger) -- โณ Monitoring & metrics (Prometheus) - -## ๐Ÿ› ๏ธ Development - -### Running Tests - -```bash -pytest -pytest --cov=app tests/ -``` - -### Database Migrations - -```bash -# Create migration -flask db migrate -m "Description" - -# Apply migration -flask db upgrade - -# Rollback -flask db downgrade -``` - -### Code Quality - -```bash -# Format code -black app/ - -# Lint -flake8 app/ -pylint app/ - -# Type check -mypy app/ -``` - -## ๐Ÿ“– API Documentation - -### Authentication - -All API endpoints require authentication via session or API key. - -### Endpoints - -- `GET /api/playlists` - Get playlist for player -- `POST /api/player-feedback` - Submit player feedback -- `GET /health` - Health check - -See `/docs` for full API documentation (Swagger UI). - -## ๐Ÿ”’ Security - -- CSRF protection enabled -- Rate limiting on API endpoints -- Input validation using Marshmallow -- SQL injection prevention (SQLAlchemy ORM) -- XSS prevention (Jinja2 autoescaping) -- Secure password hashing (bcrypt) - -## ๐Ÿ“ˆ Performance - -- Redis caching for frequently accessed data -- Database indexes on foreign keys -- Lazy loading for relationships -- Static file compression (nginx) -- Multi-stage Docker build (~800MB) - -## ๐Ÿณ Docker - -### Multi-stage Build - -```dockerfile -# Build stage (heavy dependencies) -FROM python:3.11-slim as builder -# ... build dependencies - -# Runtime stage (slim) -FROM python:3.11-slim -# ... only runtime dependencies -``` - -### Docker Compose Services - -- **digiserver**: Main Flask application -- **redis**: Cache and session storage -- **worker**: Celery background worker (optional) -- **nginx**: Reverse proxy (production) - -## ๐Ÿ“ Migration from v1 - -See `MIGRATION.md` for detailed migration guide from digiserver v1. - -Key differences: -- Blueprint-based routing instead of monolithic app.py -- Environment-based configuration -- Redis caching in production -- Improved error handling -- Type hints throughout codebase - -## ๐Ÿค Contributing - -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request - -## ๐Ÿ“„ License - -This project is part of the DigiServer digital signage system. - -## ๐Ÿ™ Acknowledgments - -- Built with Flask and modern Python practices -- Inspired by Flask best practices and 12-factor app principles -- Based on lessons learned from DigiServer v1 - -## ๐Ÿ“ž Support - -For issues and feature requests, please use the GitHub issue tracker. - ---- - -**Status**: ๐Ÿšง Work in Progress - Blueprint architecture implementation -**Version**: 2.0.0-alpha -**Last Updated**: 2025-11-12 diff --git a/add_orientation_column.py b/add_orientation_column.py deleted file mode 100644 index 87ee4b6..0000000 --- a/add_orientation_column.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -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/blueprints/admin.py b/app/blueprints/admin.py index c0d5c1f..66bcea6 100644 --- a/app/blueprints/admin.py +++ b/app/blueprints/admin.py @@ -8,7 +8,7 @@ from datetime import datetime from typing import Optional from app.extensions import db, bcrypt -from app.models import User, Player, Group, Content, ServerLog +from app.models import User, Player, Group, Content, ServerLog, Playlist from app.utils.logger import log_action admin_bp = Blueprint('admin', __name__, url_prefix='/admin') @@ -38,7 +38,7 @@ def admin_panel(): # Get statistics total_users = User.query.count() total_players = Player.query.count() - total_groups = Group.query.count() + total_playlists = Playlist.query.count() total_content = Content.query.count() # Get recent logs @@ -62,7 +62,7 @@ def admin_panel(): return render_template('admin/admin.html', total_users=total_users, total_players=total_players, - total_groups=total_groups, + total_playlists=total_playlists, total_content=total_content, storage_mb=storage_mb, users=users, @@ -347,3 +347,146 @@ def system_info(): except Exception as e: log_action('error', f'Error getting system info: {str(e)}') return jsonify({'error': str(e)}), 500 + + +@admin_bp.route('/leftover-media') +@login_required +@admin_required +def leftover_media(): + """Display leftover media files not assigned to any playlist.""" + from app.models.playlist import playlist_content + from sqlalchemy import select + + try: + # Get all content IDs that are in playlists + stmt = select(playlist_content.c.content_id).distinct() + content_in_playlists = set(row[0] for row in db.session.execute(stmt)) + + # Get all content + all_content = Content.query.all() + + # Filter content not in any playlist + leftover_content = [c for c in all_content if c.id not in content_in_playlists] + + # Separate by type + leftover_images = [c for c in leftover_content if c.content_type == 'image'] + leftover_videos = [c for c in leftover_content if c.content_type == 'video'] + leftover_pdfs = [c for c in leftover_content if c.content_type == 'pdf'] + leftover_pptx = [c for c in leftover_content if c.content_type == 'pptx'] + + # Calculate storage + total_leftover_size = sum(c.file_size for c in leftover_content) + images_size = sum(c.file_size for c in leftover_images) + videos_size = sum(c.file_size for c in leftover_videos) + pdfs_size = sum(c.file_size for c in leftover_pdfs) + pptx_size = sum(c.file_size for c in leftover_pptx) + + return render_template('admin/leftover_media.html', + leftover_images=leftover_images, + leftover_videos=leftover_videos, + leftover_pdfs=leftover_pdfs, + leftover_pptx=leftover_pptx, + total_leftover=len(leftover_content), + total_leftover_size_mb=total_leftover_size / (1024 * 1024), + images_size_mb=images_size / (1024 * 1024), + videos_size_mb=videos_size / (1024 * 1024), + pdfs_size_mb=pdfs_size / (1024 * 1024), + pptx_size_mb=pptx_size / (1024 * 1024)) + + except Exception as e: + log_action('error', f'Error loading leftover media: {str(e)}') + flash('Error loading leftover media.', 'danger') + return redirect(url_for('admin.admin_panel')) + + +@admin_bp.route('/delete-leftover-images', methods=['POST']) +@login_required +@admin_required +def delete_leftover_images(): + """Delete all leftover images that are not part of any playlist""" + from app.models.playlist import playlist_content + + try: + # Find all leftover image content + leftover_images = db.session.query(Content).filter( + Content.media_type == 'image', + ~Content.id.in_( + db.session.query(playlist_content.c.content_id) + ) + ).all() + + deleted_count = 0 + errors = [] + + for content in leftover_images: + try: + # Delete physical file + if content.file_path: + file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path) + if os.path.exists(file_path): + os.remove(file_path) + + # Delete database record + db.session.delete(content) + deleted_count += 1 + except Exception as e: + errors.append(f"Error deleting {content.file_path}: {str(e)}") + + db.session.commit() + + if errors: + flash(f'Deleted {deleted_count} images with {len(errors)} errors', 'warning') + else: + flash(f'Successfully deleted {deleted_count} leftover images', 'success') + + except Exception as e: + db.session.rollback() + flash(f'Error deleting leftover images: {str(e)}', 'danger') + + return redirect(url_for('admin.leftover_media')) + +@admin_bp.route('/delete-leftover-videos', methods=['POST']) +@login_required +@admin_required +def delete_leftover_videos(): + """Delete all leftover videos that are not part of any playlist""" + from app.models.playlist import playlist_content + + try: + # Find all leftover video content + leftover_videos = db.session.query(Content).filter( + Content.media_type == 'video', + ~Content.id.in_( + db.session.query(playlist_content.c.content_id) + ) + ).all() + + deleted_count = 0 + errors = [] + + for content in leftover_videos: + try: + # Delete physical file + if content.file_path: + file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path) + if os.path.exists(file_path): + os.remove(file_path) + + # Delete database record + db.session.delete(content) + deleted_count += 1 + except Exception as e: + errors.append(f"Error deleting {content.file_path}: {str(e)}") + + db.session.commit() + + if errors: + flash(f'Deleted {deleted_count} videos with {len(errors)} errors', 'warning') + else: + flash(f'Successfully deleted {deleted_count} leftover videos', 'success') + + except Exception as e: + db.session.rollback() + flash(f'Error deleting leftover videos: {str(e)}', 'danger') + + return redirect(url_for('admin.leftover_media')) diff --git a/app/blueprints/content.py b/app/blueprints/content.py index 2d686bd..d37b391 100644 --- a/app/blueprints/content.py +++ b/app/blueprints/content.py @@ -243,10 +243,222 @@ def upload_media_page(): return render_template('content/upload_media.html', playlists=playlists) +def process_image_file(filepath: str, filename: str) -> tuple[bool, str]: + """Process and optimize image files.""" + try: + from PIL import Image + + # Open and optimize image + img = Image.open(filepath) + + # Convert RGBA to RGB for JPEGs + if img.mode == 'RGBA' and filename.lower().endswith(('.jpg', '.jpeg')): + rgb_img = Image.new('RGB', img.size, (255, 255, 255)) + rgb_img.paste(img, mask=img.split()[3]) + img = rgb_img + + # Resize if too large (max 1920x1080 for display efficiency) + max_size = (1920, 1080) + if img.width > max_size[0] or img.height > max_size[1]: + img.thumbnail(max_size, Image.Resampling.LANCZOS) + img.save(filepath, optimize=True, quality=85) + log_action('info', f'Optimized image: {filename}') + + return True, "Image processed successfully" + except Exception as e: + return False, f"Image processing error: {str(e)}" + + +def process_video_file_extended(filepath: str, filename: str) -> tuple[bool, str]: + """Process and optimize video files for Raspberry Pi playback.""" + try: + # Basic video validation + import subprocess + + # Check if video is playable + result = subprocess.run( + ['ffprobe', '-v', 'error', '-select_streams', 'v:0', + '-show_entries', 'stream=codec_name,width,height', + '-of', 'default=noprint_wrappers=1', filepath], + capture_output=True, text=True, timeout=10 + ) + + if result.returncode == 0: + log_action('info', f'Video validated: {filename}') + return True, "Video validated successfully" + else: + return False, "Video validation failed" + except Exception as e: + # If ffprobe not available, just accept the video + log_action('warning', f'Video validation skipped (ffprobe unavailable): {filename}') + return True, "Video accepted without validation" + + +def process_pdf_file(filepath: str, filename: str) -> tuple[bool, str]: + """Process PDF files.""" + try: + # Basic PDF validation - check if it's a valid PDF + with open(filepath, 'rb') as f: + header = f.read(5) + if header != b'%PDF-': + return False, "Invalid PDF file" + + log_action('info', f'PDF validated: {filename}') + return True, "PDF processed successfully" + except Exception as e: + return False, f"PDF processing error: {str(e)}" + + +def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]: + """Process PowerPoint presentation files by converting slides to images.""" + try: + import subprocess + import tempfile + import shutil + from pathlib import Path + + # Basic validation - check file exists and has content + file_size = os.path.getsize(filepath) + if file_size < 1024: # Less than 1KB is suspicious + return False, "File too small to be a valid presentation" + + # Check if LibreOffice is available + libreoffice_paths = [ + '/usr/bin/libreoffice', + '/usr/bin/soffice', + '/snap/bin/libreoffice', + 'libreoffice', # Try in PATH + 'soffice' + ] + + libreoffice_cmd = None + for cmd in libreoffice_paths: + try: + result = subprocess.run([cmd, '--version'], + capture_output=True, + timeout=5) + if result.returncode == 0: + libreoffice_cmd = cmd + log_action('info', f'Found LibreOffice at: {cmd}') + break + except (FileNotFoundError, subprocess.TimeoutExpired): + continue + + if not libreoffice_cmd: + log_action('warning', f'LibreOffice not found, skipping slide conversion for: {filename}') + return True, "Presentation accepted without conversion (LibreOffice unavailable)" + + # Create temporary directory for conversion + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Copy presentation to temp directory + temp_ppt = temp_path / filename + shutil.copy2(filepath, temp_ppt) + + # Convert presentation to images (PNG format) + # Using LibreOffice headless mode with custom resolution + convert_cmd = [ + libreoffice_cmd, + '--headless', + '--convert-to', 'png', + '--outdir', str(temp_path), + str(temp_ppt) + ] + + log_action('info', f'Converting presentation to images: {filename}') + + try: + result = subprocess.run( + convert_cmd, + capture_output=True, + text=True, + timeout=120 # 2 minutes timeout + ) + + if result.returncode != 0: + log_action('error', f'LibreOffice conversion failed: {result.stderr}') + return True, "Presentation accepted without conversion (conversion failed)" + + # Find generated PNG files + png_files = sorted(temp_path.glob('*.png')) + + if not png_files: + log_action('warning', f'No images generated from presentation: {filename}') + return True, "Presentation accepted without images" + + # Get upload folder from app config + upload_folder = current_app.config['UPLOAD_FOLDER'] + base_name = os.path.splitext(filename)[0] + + # Move converted images to upload folder + slide_count = 0 + for idx, png_file in enumerate(png_files, start=1): + # Create descriptive filename + slide_filename = f"{base_name}_slide_{idx:03d}.png" + destination = os.path.join(upload_folder, slide_filename) + + shutil.move(str(png_file), destination) + + # Optimize the image to Full HD (1920x1080) + optimize_image_to_fullhd(destination) + + slide_count += 1 + + log_action('info', f'Converted {slide_count} slides from {filename} to images') + + # Remove original PPTX file as we now have the images + os.remove(filepath) + + return True, f"Presentation converted to {slide_count} Full HD images" + + except subprocess.TimeoutExpired: + log_action('error', f'LibreOffice conversion timeout for: {filename}') + return True, "Presentation accepted without conversion (timeout)" + + except Exception as e: + log_action('error', f'Presentation processing error: {str(e)}') + return False, f"Presentation processing error: {str(e)}" + + +def optimize_image_to_fullhd(filepath: str) -> bool: + """Optimize and resize image to Full HD (1920x1080) maintaining aspect ratio.""" + try: + from PIL import Image + + img = Image.open(filepath) + + # Target Full HD resolution + target_size = (1920, 1080) + + # Calculate resize maintaining aspect ratio + img.thumbnail(target_size, Image.Resampling.LANCZOS) + + # Create Full HD canvas with white background + fullhd_img = Image.new('RGB', target_size, (255, 255, 255)) + + # Center the image on the canvas + x = (target_size[0] - img.width) // 2 + y = (target_size[1] - img.height) // 2 + + if img.mode == 'RGBA': + fullhd_img.paste(img, (x, y), img) + else: + fullhd_img.paste(img, (x, y)) + + # Save optimized image + fullhd_img.save(filepath, 'PNG', optimize=True) + + return True + except Exception as e: + log_action('error', f'Image optimization error: {str(e)}') + return False + + @content_bp.route('/upload-media', methods=['POST']) @login_required def upload_media(): - """Upload media files to library.""" + """Upload media files to library with type-specific processing.""" try: files = request.files.getlist('files') content_type = request.form.get('content_type', 'image') @@ -261,6 +473,7 @@ def upload_media(): os.makedirs(upload_folder, exist_ok=True) uploaded_count = 0 + processing_errors = [] for file in files: if file.filename == '': @@ -275,64 +488,138 @@ def upload_media(): log_action('warning', f'File {filename} already exists, skipping') continue - # Save file + # Save file first 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']: + # Process file based on type + processing_success = True + processing_message = "" + + if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']: detected_type = 'image' - elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']: + processing_success, processing_message = process_image_file(filepath, filename) + + elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']: 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}') + processing_success, processing_message = process_video_file_extended(filepath, filename) + elif file_ext == 'pdf': detected_type = 'pdf' + processing_success, processing_message = process_pdf_file(filepath, filename) + + elif file_ext in ['ppt', 'pptx']: + detected_type = 'pptx' + processing_success, processing_message = process_presentation_file(filepath, filename) + + # For presentations, slides are converted to individual images + # We need to add each slide image as a separate content item + if processing_success and "converted to" in processing_message.lower(): + # Find all slide images that were created + base_name = os.path.splitext(filename)[0] + slide_pattern = f"{base_name}_slide_*.png" + import glob + slide_files = sorted(glob.glob(os.path.join(upload_folder, slide_pattern))) + + if slide_files: + max_position = 0 + if playlist_id: + playlist = Playlist.query.get(playlist_id) + max_position = db.session.query(db.func.max(playlist_content.c.position))\ + .filter(playlist_content.c.playlist_id == playlist_id)\ + .scalar() or 0 + + # Add each slide as separate content + for slide_file in slide_files: + slide_filename = os.path.basename(slide_file) + + # Create content record for slide + slide_content = Content( + filename=slide_filename, + content_type='image', + duration=duration, + file_size=os.path.getsize(slide_file) + ) + db.session.add(slide_content) + db.session.flush() + + # Add to playlist if specified + if playlist_id: + max_position += 1 + stmt = playlist_content.insert().values( + playlist_id=playlist_id, + content_id=slide_content.id, + position=max_position, + duration=duration + ) + db.session.execute(stmt) + + uploaded_count += 1 + + # Increment playlist version if slides were added + if playlist_id and slide_files: + playlist.version += 1 + + continue # Skip normal content creation below + 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 + if not processing_success: + processing_errors.append(f"{filename}: {processing_message}") + if os.path.exists(filepath): + os.remove(filepath) # Remove failed file + log_action('error', f'Processing failed for {filename}: {processing_message}') + continue - # 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 + # Create content record (for non-presentation files or failed conversions) + if os.path.exists(filepath): + 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() cache.clear() log_action('info', f'Uploaded {uploaded_count} media files') - if playlist_id: + # Show appropriate flash message + if processing_errors: + error_summary = '; '.join(processing_errors[:3]) + if len(processing_errors) > 3: + error_summary += f' and {len(processing_errors) - 3} more...' + flash(f'Uploaded {uploaded_count} file(s). Errors: {error_summary}', 'warning') + elif playlist_id: playlist = Playlist.query.get(playlist_id) flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success') else: diff --git a/app/blueprints/players.py b/app/blueprints/players.py index 3ba0dcc..c1eef49 100644 --- a/app/blueprints/players.py +++ b/app/blueprints/players.py @@ -213,31 +213,8 @@ def regenerate_auth_code(player_id: int): @players_bp.route('/') @login_required def player_page(player_id: int): - """Display player page with content and controls.""" - try: - player = Player.query.get_or_404(player_id) - - # Get player's playlist - playlist = get_player_playlist(player_id) - - # Get player status - status_info = get_player_status_info(player_id) - - # Get recent feedback - recent_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\ - .order_by(PlayerFeedback.timestamp.desc())\ - .limit(10)\ - .all() - - return render_template('players/player_page.html', - player=player, - playlist=playlist, - status_info=status_info, - recent_feedback=recent_feedback) - except Exception as e: - log_action('error', f'Error loading player page: {str(e)}') - flash('Error loading player page.', 'danger') - return redirect(url_for('players.list')) + """Redirect to manage player page (combined view).""" + return redirect(url_for('players.manage_player', player_id=player_id)) @players_bp.route('//manage', methods=['GET', 'POST']) @@ -347,7 +324,7 @@ def player_fullscreen(player_id: int): @cache.memoize(timeout=300) # Cache for 5 minutes def get_player_playlist(player_id: int) -> List[dict]: - """Get playlist for a player based on their direct content assignment. + """Get playlist for a player based on their assigned playlist. Args: player_id: The player's database ID @@ -356,23 +333,26 @@ def get_player_playlist(player_id: int) -> List[dict]: List of content dictionaries with url, type, duration, and position """ player = Player.query.get(player_id) - if not player: + if not player or not player.playlist_id: return [] - # Get content directly assigned to this player - contents = Content.query.filter_by(player_id=player_id)\ - .order_by(Content.position, Content.uploaded_at)\ - .all() + # Get the player's assigned playlist + playlist_obj = Playlist.query.get(player.playlist_id) + if not playlist_obj: + return [] + + # Get ordered content from the playlist + ordered_content = playlist_obj.get_content_ordered() # Build playlist playlist = [] - for content in contents: + for content in ordered_content: playlist.append({ 'id': content.id, 'url': url_for('static', filename=f'uploads/{content.filename}'), 'type': content.content_type, - 'duration': content.duration or 10, # Default 10 seconds if not set - 'position': content.position, + 'duration': getattr(content, '_playlist_duration', content.duration or 10), + 'position': getattr(content, '_playlist_position', 0), 'filename': content.filename }) diff --git a/app/blueprints/playlist.py b/app/blueprints/playlist.py index 6c9cecf..5132c1a 100644 --- a/app/blueprints/playlist.py +++ b/app/blueprints/playlist.py @@ -2,11 +2,12 @@ from flask import (Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app) from flask_login import login_required -from sqlalchemy import desc +from sqlalchemy import desc, update import os from app.extensions import db, cache -from app.models import Player, Content +from app.models import Player, Content, Playlist +from app.models.playlist import playlist_content from app.utils.logger import log_action playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist') @@ -18,20 +19,22 @@ 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 content from player's assigned playlist + playlist_items = [] + if player.playlist_id: + playlist = Playlist.query.get(player.playlist_id) + if playlist: + playlist_items = playlist.get_content_ordered() - # 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] + # Get available content (all content not in current playlist) + all_content = Content.query.all() + playlist_content_ids = {item.id for item in playlist_items} + available_content = [c for c in all_content if c.id not in playlist_content_ids] return render_template('playlist/manage_playlist.html', player=player, - playlist_content=playlist_content, - available_files=available_files) + playlist_content=playlist_items, + available_content=available_content) @playlist_bp.route('//add', methods=['POST']) @@ -40,44 +43,46 @@ def add_to_playlist(player_id: int): """Add content to player's playlist.""" player = Player.query.get_or_404(player_id) + if not player.playlist_id: + flash('Player has no playlist assigned.', 'warning') + return redirect(url_for('playlist.manage_playlist', player_id=player_id)) + try: - filename = request.form.get('filename') + content_id = request.form.get('content_id', type=int) duration = request.form.get('duration', type=int, default=10) - if not filename: - flash('Please provide a filename.', 'warning') + if not content_id: + flash('Please select content.', 'warning') return redirect(url_for('playlist.manage_playlist', player_id=player_id)) + content = Content.query.get_or_404(content_id) + playlist = Playlist.query.get(player.playlist_id) + # Get max position - max_position = db.session.query(db.func.max(Content.position)).filter_by( - player_id=player_id + from sqlalchemy import select, func + max_pos = db.session.execute( + select(func.max(playlist_content.c.position)).where( + playlist_content.c.playlist_id == playlist.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 + # Add to playlist_content association table + stmt = playlist_content.insert().values( + playlist_id=playlist.id, + content_id=content.id, + position=max_pos + 1, + duration=duration ) - db.session.add(new_content) + db.session.execute(stmt) # Increment playlist version - player.playlist_version += 1 + playlist.increment_version() db.session.commit() cache.clear() - log_action('info', f'Added "{filename}" to playlist for player "{player.name}"') - flash(f'Added "{filename}" to playlist.', 'success') + log_action('info', f'Added "{content.filename}" to playlist for player "{player.name}"') + flash(f'Added "{content.filename}" to playlist.', 'success') except Exception as e: db.session.rollback() @@ -92,28 +97,41 @@ def add_to_playlist(player_id: int): 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') + if not player.playlist_id: + flash('Player has no playlist assigned.', 'danger') return redirect(url_for('playlist.manage_playlist', player_id=player_id)) try: + content = Content.query.get_or_404(content_id) + playlist = Playlist.query.get(player.playlist_id) filename = content.filename - # Delete content - db.session.delete(content) + # Remove from playlist_content association table + from sqlalchemy import delete + stmt = delete(playlist_content).where( + (playlist_content.c.playlist_id == playlist.id) & + (playlist_content.c.content_id == content_id) + ) + db.session.execute(stmt) # Reorder remaining content - remaining_content = Content.query.filter_by( - player_id=player_id - ).order_by(Content.position).all() + from sqlalchemy import select + remaining = db.session.execute( + select(playlist_content.c.content_id, playlist_content.c.position).where( + playlist_content.c.playlist_id == playlist.id + ).order_by(playlist_content.c.position) + ).fetchall() - for idx, item in enumerate(remaining_content, start=1): - item.position = idx + for idx, row in enumerate(remaining, start=1): + stmt = update(playlist_content).where( + (playlist_content.c.playlist_id == playlist.id) & + (playlist_content.c.content_id == row.content_id) + ).values(position=idx) + db.session.execute(stmt) # Increment playlist version - player.playlist_version += 1 + playlist.increment_version() db.session.commit() cache.clear() @@ -135,7 +153,12 @@ def reorder_playlist(player_id: int): """Reorder playlist items.""" player = Player.query.get_or_404(player_id) + if not player.playlist_id: + return jsonify({'success': False, 'message': 'Player has no playlist'}), 400 + try: + playlist = Playlist.query.get(player.playlist_id) + # Get new order from JSON data = request.get_json() content_ids = data.get('content_ids', []) @@ -143,24 +166,26 @@ def reorder_playlist(player_id: int): if not content_ids: return jsonify({'success': False, 'message': 'No content IDs provided'}), 400 - # Update positions + # Update positions in association table 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 + stmt = update(playlist_content).where( + (playlist_content.c.playlist_id == playlist.id) & + (playlist_content.c.content_id == content_id) + ).values(position=idx) + db.session.execute(stmt) # Increment playlist version - player.playlist_version += 1 + playlist.increment_version() db.session.commit() cache.clear() - log_action('info', f'Reordered playlist for player "{player.name}" (version {player.playlist_version})') + log_action('info', f'Reordered playlist for player "{player.name}" (version {playlist.version})') return jsonify({ 'success': True, 'message': 'Playlist reordered successfully', - 'version': player.playlist_version + 'version': playlist.version }) except Exception as e: @@ -174,19 +199,28 @@ def reorder_playlist(player_id: int): 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 + if not player.playlist_id: + return jsonify({'success': False, 'message': 'Player has no playlist'}), 400 try: + playlist = Playlist.query.get(player.playlist_id) + content = Content.query.get_or_404(content_id) + 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 + # Update duration in association table + stmt = update(playlist_content).where( + (playlist_content.c.playlist_id == playlist.id) & + (playlist_content.c.content_id == content_id) + ).values(duration=duration) + db.session.execute(stmt) + + # Increment playlist version + playlist.increment_version() db.session.commit() cache.clear() @@ -196,7 +230,7 @@ def update_duration(player_id: int, content_id: int): return jsonify({ 'success': True, 'message': 'Duration updated', - 'version': player.playlist_version + 'version': playlist.version }) except Exception as e: @@ -211,12 +245,22 @@ def clear_playlist(player_id: int): """Clear all content from player's playlist.""" player = Player.query.get_or_404(player_id) + if not player.playlist_id: + flash('Player has no playlist assigned.', 'warning') + return redirect(url_for('playlist.manage_playlist', player_id=player_id)) + try: - # Delete all content for this player - Content.query.filter_by(player_id=player_id).delete() + playlist = Playlist.query.get(player.playlist_id) + + # Delete all content from playlist + from sqlalchemy import delete + stmt = delete(playlist_content).where( + playlist_content.c.playlist_id == playlist.id + ) + db.session.execute(stmt) # Increment playlist version - player.playlist_version += 1 + playlist.increment_version() db.session.commit() cache.clear() diff --git a/app/config.py b/app/config.py index a0b232b..93713cd 100644 --- a/app/config.py +++ b/app/config.py @@ -16,10 +16,11 @@ class Config: SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_ECHO = False - # File Upload + # File Upload - use absolute paths MAX_CONTENT_LENGTH = 2048 * 1024 * 1024 # 2GB - UPLOAD_FOLDER = 'static/uploads' - UPLOAD_FOLDERLOGO = 'static/resurse' + _basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) + UPLOAD_FOLDER = os.path.join(_basedir, 'static', 'uploads') + UPLOAD_FOLDERLOGO = os.path.join(_basedir, 'static', 'resurse') ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'mp4', 'avi', 'mkv', 'mov', 'webm', 'pdf', 'ppt', 'pptx'} # Session diff --git a/app/templates/admin/admin.html b/app/templates/admin/admin.html index aca6843..94aaf89 100644 --- a/app/templates/admin/admin.html +++ b/app/templates/admin/admin.html @@ -11,24 +11,39 @@

๐Ÿ“Š System Overview

- Total Users: - {{ total_users or 0 }} +
๐Ÿ‘ฅ
+
+ Total Users + {{ total_users or 0 }} +
- Total Players: - {{ total_players or 0 }} +
๐Ÿ–ฅ๏ธ
+
+ Total Players + {{ total_players or 0 }} +
- Total Groups: - {{ total_groups or 0 }} +
๐Ÿ“‹
+
+ Total Playlists + {{ total_playlists or 0 }} +
- Total Content: - {{ total_content or 0 }} +
๐Ÿ“
+
+ Media Files + {{ total_content or 0 }} +
- Storage Used: - {{ storage_mb or 0 }} MB +
๐Ÿ’พ
+
+ Storage Used + {{ storage_mb or 0 }} MB +
@@ -44,13 +59,24 @@ + +
+

๐Ÿ—‘๏ธ Manage Leftover Media

+

Clean up media files not assigned to any playlist

+ +
+ @@ -71,22 +97,61 @@ .stat-item { display: flex; - justify-content: space-between; - padding: 10px; + align-items: center; + gap: 12px; + padding: 15px; background: #f8f9fa; - border-radius: 4px; + border-radius: 8px; + border: 1px solid #e2e8f0; + transition: all 0.2s; +} + +.stat-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.1); +} + +body.dark-mode .stat-item { + background: #1a202c; + border-color: #4a5568; +} + +body.dark-mode .stat-item:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.3); +} + +.stat-icon { + font-size: 2rem; + line-height: 1; +} + +.stat-content { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; } .stat-label { font-weight: 500; + font-size: 0.85rem; color: #666; } +body.dark-mode .stat-label { + color: #a0aec0; +} + .stat-value { font-weight: bold; + font-size: 1.5rem; color: #2c3e50; } +body.dark-mode .stat-value { + color: #e2e8f0; +} + .management-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; diff --git a/app/templates/admin/leftover_media.html b/app/templates/admin/leftover_media.html new file mode 100644 index 0000000..a19a46d --- /dev/null +++ b/app/templates/admin/leftover_media.html @@ -0,0 +1,203 @@ +{% extends "base.html" %} + +{% block title %}Leftover Media - Admin - DigiServer v2{% endblock %} + +{% block content %} +
+
+

+ ๐Ÿ—‘๏ธ Manage Leftover Media +

+ + โ† Back to Admin + +
+ + +
+

๐Ÿ“Š Overview

+

+ Media files that are not assigned to any playlist. These can be safely deleted to free up storage. +

+
+
+
Total Leftover Files
+
{{ total_leftover }}
+
+
+
Total Size
+
{{ "%.2f"|format(total_leftover_size_mb) }} MB
+
+
+
Images
+
{{ leftover_images|length }} ({{ "%.2f"|format(images_size_mb) }} MB)
+
+
+
Videos
+
{{ leftover_videos|length }} ({{ "%.2f"|format(videos_size_mb) }} MB)
+
+
+
+ + +
+
+

๐Ÿ“ท Leftover Images ({{ leftover_images|length }})

+ {% if leftover_images %} +
+ +
+ {% endif %} +
+ + {% if leftover_images %} +
+ + + + + + + + + + + {% for img in leftover_images %} + + + + + + + {% endfor %} + +
FilenameSizeDurationUploaded
๐Ÿ“ท {{ img.filename }}{{ "%.2f"|format(img.file_size_mb) }} MB{{ img.duration }}s{{ img.uploaded_at.strftime('%Y-%m-%d %H:%M') if img.uploaded_at else 'N/A' }}
+
+ {% else %} +
+
โœ“
+

No leftover images found. All images are assigned to playlists!

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

๐ŸŽฅ Leftover Videos ({{ leftover_videos|length }})

+ {% if leftover_videos %} +
+ +
+ {% endif %} +
+ + {% if leftover_videos %} +
+ + + + + + + + + + + {% for video in leftover_videos %} + + + + + + + {% endfor %} + +
FilenameSizeDurationUploaded
๐ŸŽฅ {{ video.filename }}{{ "%.2f"|format(video.file_size_mb) }} MB{{ video.duration }}s{{ video.uploaded_at.strftime('%Y-%m-%d %H:%M') if video.uploaded_at else 'N/A' }}
+
+ {% else %} +
+
โœ“
+

No leftover videos found. All videos are assigned to playlists!

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

๐Ÿ“„ Leftover PDFs ({{ leftover_pdfs|length }})

+ + {% if leftover_pdfs %} +
+ + + + + + + + + + + {% for pdf in leftover_pdfs %} + + + + + + + {% endfor %} + +
FilenameSizeDurationUploaded
๐Ÿ“„ {{ pdf.filename }}{{ "%.2f"|format(pdf.file_size_mb) }} MB{{ pdf.duration }}s{{ pdf.uploaded_at.strftime('%Y-%m-%d %H:%M') if pdf.uploaded_at else 'N/A' }}
+
+ {% else %} +
+
โœ“
+

No leftover PDFs found. All PDFs are assigned to playlists!

+
+ {% endif %} +
+
+ + + +{% endblock %} diff --git a/app/templates/content/content_list_new.html b/app/templates/content/content_list_new.html index bcd6ad9..ba60fcf 100644 --- a/app/templates/content/content_list_new.html +++ b/app/templates/content/content_list_new.html @@ -444,10 +444,19 @@ {% endif %} - - ๐Ÿ‘๏ธ View - +
+ + โš™๏ธ Manage + + {% if player.playlist_id %} + + ๐Ÿ–ฅ๏ธ Live + + {% endif %} +
{% endfor %} diff --git a/app/templates/content/upload_media.html b/app/templates/content/upload_media.html index 28d1b45..a572511 100644 --- a/app/templates/content/upload_media.html +++ b/app/templates/content/upload_media.html @@ -5,14 +5,15 @@ {% block content %}
-
-

- - Upload Media Files +
+

+ + Upload Media

- - + + Back to Playlists
- -
-

๐Ÿ“‹ Select Target Playlist (Optional)

-

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

+ +
-
- - - - ๐Ÿ’ก Tip: You can add files to playlists later from the media library - -
-
- - -
-

- - Select Files -

- -
- -

Drag and Drop Files Here

-

or

-
- - + +
+

+ + Select Files +

+ +
+ +

Drag & Drop

+

or

+
+ + +
+

+ Supported: JPG, PNG, GIF, MP4, AVI, MOV, PDF, PPT +

-

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

+ +
-
-
- - -
-

- - Upload Settings -

- -
- - - - This will be auto-detected from file extension - -
- -
- - - - How long each item should display (for images and PDFs) - + +
+

+ + Upload Settings +

+ +
+ + + + ๐Ÿ’ก Add to playlists later if needed + +
+ +
+ + + + Auto-detected from file extension + +
+ +
+ + + + Display time for images and PDFs + +
+ + +
- - -
@@ -350,18 +348,97 @@ // Handle file input fileInput.addEventListener('change', (e) => { - handleFiles(e.target.files); + console.log('File input changed, files:', e.target.files.length); + if (e.target.files.length > 0) { + handleFiles(e.target.files); + } }); - // Click upload zone to trigger file input - uploadZone.addEventListener('click', () => { + // Click upload zone to trigger file input (but not if clicking on the label) + uploadZone.addEventListener('click', (e) => { + // Don't trigger if clicking on the label or button + if (e.target.tagName === 'LABEL' || e.target.closest('label') || e.target.tagName === 'INPUT') { + return; + } fileInput.click(); }); function handleFiles(files) { + console.log('handleFiles called with', files.length, 'file(s)'); selectedFiles = Array.from(files); displayFiles(); uploadBtn.disabled = selectedFiles.length === 0; + + // Auto-detect media type and duration from first file + if (selectedFiles.length > 0) { + console.log('Auto-detecting for first file:', selectedFiles[0].name); + autoDetectMediaType(selectedFiles[0]); + autoDetectDuration(selectedFiles[0]); + } + } + + function autoDetectMediaType(file) { + const ext = file.name.split('.').pop().toLowerCase(); + const contentTypeSelect = document.getElementById('content_type'); + + console.log('Auto-detecting media type for:', file.name, 'Extension:', ext); + + if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) { + contentTypeSelect.value = 'image'; + console.log('Set type to: image'); + } else if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) { + contentTypeSelect.value = 'video'; + console.log('Set type to: video'); + } else if (ext === 'pdf') { + contentTypeSelect.value = 'pdf'; + console.log('Set type to: pdf'); + } else if (['ppt', 'pptx'].includes(ext)) { + contentTypeSelect.value = 'pptx'; + console.log('Set type to: pptx'); + } + } + + function autoDetectDuration(file) { + const ext = file.name.split('.').pop().toLowerCase(); + const durationInput = document.getElementById('duration'); + + console.log('Auto-detecting duration for:', file.name, 'Extension:', ext); + + // For videos, try to get actual duration + if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) { + console.log('Processing as video...'); + const video = document.createElement('video'); + video.preload = 'metadata'; + + video.onloadedmetadata = function() { + window.URL.revokeObjectURL(video.src); + const duration = Math.ceil(video.duration); + console.log('Video duration detected:', duration, 'seconds'); + if (duration && duration > 0) { + durationInput.value = duration; + } + }; + + video.onerror = function() { + console.log('Video loading error, using default 30s'); + window.URL.revokeObjectURL(video.src); + durationInput.value = 30; // Default for videos if can't read duration + }; + + video.src = URL.createObjectURL(file); + } else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) { + // Images: default 10 seconds + console.log('Setting image duration: 10s'); + durationInput.value = 10; + } else if (ext === 'pdf') { + // PDFs: default 15 seconds per page (estimate) + console.log('Setting PDF duration: 15s'); + durationInput.value = 15; + } else if (['ppt', 'pptx'].includes(ext)) { + // Presentations: default 20 seconds per slide (estimate) + console.log('Setting presentation duration: 20s'); + durationInput.value = 20; + } } function displayFiles() { diff --git a/app/templates/players/manage_player.html b/app/templates/players/manage_player.html index a2f5a71..d1bf485 100644 --- a/app/templates/players/manage_player.html +++ b/app/templates/players/manage_player.html @@ -3,6 +3,215 @@ {% block title %}Manage Player - {{ player.name }}{% endblock %} {% block content %} +

@@ -15,7 +224,7 @@

-
+

Status: {% if player.status == 'online' %} @@ -52,6 +261,47 @@

+ +{% if current_playlist %} +
+

+ + Current Playlist: {{ current_playlist.name }} +

+
+
+
Total Items
+
{{ current_playlist.contents.count() }}
+
+
+
Playlist Version
+
v{{ current_playlist.version }}
+
+
+
Last Updated
+
{{ current_playlist.updated_at.strftime('%Y-%m-%d %H:%M') }}
+
+
+
Orientation
+
{{ current_playlist.orientation }}
+
+
+ +
+{% endif %} +
@@ -64,33 +314,44 @@
-
- +
+ + required minlength="3" class="form-control">
-
- +
+ + placeholder="e.g., Main Lobby" class="form-control">
-
- -
-
-

Hostname: {{ player.hostname }}

-

Auth Code: {{ player.auth_code }}

-

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

+
+

๐Ÿ”‘ Player Credentials

+ +
+ Hostname +
{{ player.hostname }}
+
+ +
+ Auth Code +
{{ player.auth_code }}
+
+ +
+ Quick Connect Code (Hashed) +
{{ player.quickconnect_code or 'Not set' }}
+ โš ๏ธ This is the hashed version for security +
+ + +
-{% block title %}Player Fullscreen{% endblock %} - -{% block content %} -
-

Player Fullscreen View

-

Fullscreen player view - placeholder

-
-{% endblock %} + + + diff --git a/app/templates/players/players_list.html b/app/templates/players/players_list.html index d550caa..5a3d6a9 100644 --- a/app/templates/players/players_list.html +++ b/app/templates/players/players_list.html @@ -3,6 +3,129 @@ {% block title %}Players - DigiServer v2{% endblock %} {% block content %} +

Players

@@ -11,49 +134,49 @@ {% if players %}
- +
- - - - - - - - + + + + + + + + {% for player in players %} - - + - - - - - -
NameHostnameLocationOrientationStatusLast SeenActions
NameHostnameLocationOrientationStatusLast SeenActions
+
{{ player.name }} - {{ player.hostname }} + + {{ player.hostname }} + {{ player.location or '-' }} + {{ player.orientation or 'Landscape' }} + {% set status = player_statuses.get(player.id, {}) %} {% if status.get('is_online') %} - Online + Online {% else %} - Offline + Offline {% endif %} + {% if player.last_heartbeat %} {{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M') }} {% else %} - Never + Never {% endif %} + โš™๏ธ Manage @@ -65,8 +188,8 @@
{% else %} -
- โ„น๏ธ No players yet. Add your first player +
+ โ„น๏ธ No players yet. Add your first player
{% endif %}
diff --git a/app/templates/playlist/manage_playlist.html b/app/templates/playlist/manage_playlist.html index 519ab55..e225b87 100644 --- a/app/templates/playlist/manage_playlist.html +++ b/app/templates/playlist/manage_playlist.html @@ -219,6 +219,92 @@ .duration-input { width: 80px; text-align: center; + transition: all 0.3s ease; + } + + .duration-input:hover { + border-color: #667eea; + box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1); + } + + .duration-input:focus { + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2); + } + + .save-duration-btn { + transition: all 0.2s ease; + animation: fadeIn 0.2s ease; + } + + @keyframes fadeIn { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } + } + + /* Dark mode support */ + body.dark-mode .playlist-section { + background: #2d3748; + color: #e2e8f0; + } + + body.dark-mode .section-header h2 { + color: #e2e8f0; + } + + body.dark-mode .section-header { + border-bottom-color: #4a5568; + } + + body.dark-mode .playlist-table thead { + background: #1a202c; + } + + body.dark-mode .playlist-table th { + color: #cbd5e0; + border-bottom-color: #4a5568; + } + + body.dark-mode .playlist-table td { + border-bottom-color: #4a5568; + color: #e2e8f0; + } + + body.dark-mode .playlist-table tr:hover { + background: #1a202c; + } + + body.dark-mode .form-control { + background: #1a202c; + border-color: #4a5568; + color: #e2e8f0; + } + + body.dark-mode .form-control:focus { + border-color: #667eea; + background: #2d3748; + } + + body.dark-mode .add-content-form { + background: #1a202c; + } + + body.dark-mode .form-group label { + color: #e2e8f0; + } + + body.dark-mode .empty-state { + color: #718096; + } + + body.dark-mode .drag-handle { + color: #718096; } @@ -290,9 +376,9 @@ {% for content in playlist_content %} - + - โ‹ฎโ‹ฎ + โ‹ฎโ‹ฎ {{ loop.index }} {{ content.filename }} @@ -304,11 +390,28 @@ {% endif %} - +
+ + +
{{ "%.2f"|format(content.file_size_mb) }} MB @@ -335,7 +438,7 @@
- {% if available_files %} + {% if available_content %}

โž• Add Existing Content

@@ -344,11 +447,11 @@
- - + + {% for content in available_content %} + {% endfor %}
@@ -380,20 +483,41 @@ document.addEventListener('DOMContentLoaded', function() { const tbody = document.getElementById('playlist-tbody'); if (!tbody) return; - const rows = tbody.querySelectorAll('.draggable-row'); + // Set up drag handles + const dragHandles = tbody.querySelectorAll('.drag-handle'); + dragHandles.forEach(handle => { + handle.addEventListener('dragstart', handleDragStart); + }); + // Set up drop zones on rows + const rows = tbody.querySelectorAll('.draggable-row'); rows.forEach(row => { - row.addEventListener('dragstart', handleDragStart); row.addEventListener('dragover', handleDragOver); row.addEventListener('drop', handleDrop); row.addEventListener('dragend', handleDragEnd); }); + + // Prevent dragging from inputs and buttons + const inputs = document.querySelectorAll('.duration-input, button'); + inputs.forEach(input => { + input.addEventListener('mousedown', (e) => { + e.stopPropagation(); + }); + input.addEventListener('click', (e) => { + e.stopPropagation(); + }); + }); }); function handleDragStart(e) { - draggedElement = this; - this.classList.add('dragging'); + // Get the parent row + const row = e.target.closest('.draggable-row'); + if (!row) return; + + draggedElement = row; + row.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/html', row.innerHTML); } function handleDragOver(e) { @@ -464,7 +588,41 @@ function saveOrder() { }); } -function updateDuration(contentId, duration) { +function markDurationChanged(contentId) { + const saveBtn = document.getElementById(`save-btn-${contentId}`); + const input = document.getElementById(`duration-${contentId}`); + + // Show save button if value changed + if (input.value !== input.defaultValue) { + saveBtn.style.display = 'inline-block'; + input.style.borderColor = '#ffc107'; + } else { + saveBtn.style.display = 'none'; + input.style.borderColor = ''; + } +} + +function saveDuration(contentId) { + const inputElement = document.getElementById(`duration-${contentId}`); + const saveBtn = document.getElementById(`save-btn-${contentId}`); + const duration = parseInt(inputElement.value); + + // Validate duration + if (duration < 1) { + alert('Duration must be at least 1 second'); + inputElement.value = inputElement.defaultValue; + inputElement.style.borderColor = ''; + saveBtn.style.display = 'none'; + return; + } + + const originalValue = inputElement.defaultValue; + + // Visual feedback + inputElement.disabled = true; + saveBtn.disabled = true; + saveBtn.textContent = 'โณ'; + const formData = new FormData(); formData.append('duration', duration); @@ -476,15 +634,65 @@ function updateDuration(contentId, duration) { .then(data => { if (data.success) { console.log('Duration updated successfully'); - // Update total duration - location.reload(); + inputElement.style.borderColor = '#28a745'; + inputElement.defaultValue = duration; + saveBtn.textContent = 'โœ“'; + + // Update total duration display + updateTotalDuration(); + + setTimeout(() => { + inputElement.style.borderColor = ''; + inputElement.disabled = false; + saveBtn.style.display = 'none'; + saveBtn.textContent = '๐Ÿ’พ'; + saveBtn.disabled = false; + }, 1500); } else { + inputElement.style.borderColor = '#dc3545'; + inputElement.value = originalValue; + saveBtn.textContent = 'โœ–'; alert('Error updating duration: ' + data.message); + + setTimeout(() => { + inputElement.disabled = false; + inputElement.style.borderColor = ''; + saveBtn.style.display = 'none'; + saveBtn.textContent = '๐Ÿ’พ'; + saveBtn.disabled = false; + }, 1500); } }) .catch(error => { console.error('Error:', error); + inputElement.style.borderColor = '#dc3545'; + inputElement.value = originalValue; + saveBtn.textContent = 'โœ–'; alert('Error updating duration'); + + setTimeout(() => { + inputElement.disabled = false; + inputElement.style.borderColor = ''; + saveBtn.style.display = 'none'; + saveBtn.textContent = '๐Ÿ’พ'; + saveBtn.disabled = false; + }, 1500); + }); +} + +function updateTotalDuration() { + const durationInputs = document.querySelectorAll('.duration-input'); + let total = 0; + durationInputs.forEach(input => { + total += parseInt(input.value) || 0; + }); + + const statValues = document.querySelectorAll('.stat-value'); + statValues.forEach((element, index) => { + const label = element.parentElement.querySelector('.stat-label'); + if (label && label.textContent.includes('Total Duration')) { + element.textContent = total + 's'; + } }); } diff --git a/migrate_add_orientation.py b/migrate_add_orientation.py deleted file mode 100644 index 4cf2121..0000000 --- a/migrate_add_orientation.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -"""Add orientation column to playlist table - Direct SQLite approach""" - -import sqlite3 -import os - -def add_orientation_column(): - """Add orientation column to playlist table using direct SQLite connection.""" - db_path = 'instance/dev.db' - - if not os.path.exists(db_path): - print(f"โŒ Database not found at: {db_path}") - return - - print(f"๐Ÿ“ Opening database: {db_path}") - - try: - # Connect to database - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - # Check if column already exists - cursor.execute("PRAGMA table_info(playlist)") - columns = [row[1] for row in cursor.fetchall()] - - if 'orientation' in columns: - print("โœ… Column 'orientation' already exists in playlist table") - conn.close() - return - - # Add the column - print("Adding 'orientation' column to playlist table...") - cursor.execute(""" - ALTER TABLE playlist - ADD COLUMN orientation VARCHAR(20) DEFAULT 'Landscape' NOT NULL - """) - - conn.commit() - - print("โœ… Successfully added 'orientation' column to playlist table") - print(" Default value: 'Landscape'") - - # Verify the column was added - cursor.execute("PRAGMA table_info(playlist)") - columns = [row[1] for row in cursor.fetchall()] - if 'orientation' in columns: - print("โœ“ Verified: Column exists in database") - - conn.close() - - except sqlite3.Error as e: - print(f"โŒ SQLite Error: {e}") - return - except Exception as e: - print(f"โŒ Error: {e}") - return - -if __name__ == '__main__': - add_orientation_column() diff --git a/quick_test.sh b/quick_test.sh deleted file mode 100755 index c952301..0000000 --- a/quick_test.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/bash - -# Quick Test - DigiServer v2 -# Simple test script without virtual environment - -set -e - -echo "๐Ÿงช DigiServer v2 - Quick Test" -echo "==============================" -echo "" - -cd /home/pi/Desktop/digiserver-v2 - -# Check if database exists -if [ ! -f "instance/dashboard.db" ]; then - echo "๐Ÿ—„๏ธ Creating database..." - export FLASK_APP=app.app:create_app - python3 -c " -from app.app import create_app -from app.extensions import db -from app.models import User -from flask_bcrypt import bcrypt - -app = create_app() -with app.app_context(): - db.create_all() - # Create admin user - admin = User.query.filter_by(username='admin').first() - if not admin: - hashed = bcrypt.generate_password_hash('admin123').decode('utf-8') - admin = User(username='admin', password=hashed, role='admin') - db.session.add(admin) - db.session.commit() - print('โœ… Admin user created (admin/admin123)') - else: - print('โœ… Admin user already exists') -" -fi - -echo "" -echo "๐Ÿš€ Starting Flask server..." -echo "๐Ÿ“ URL: http://localhost:5000" -echo "๐Ÿ‘ค Login: admin / admin123" -echo "" -echo "Press Ctrl+C to stop" -echo "" - -export FLASK_APP=app.app:create_app -export FLASK_ENV=development -python3 -m flask run --host=0.0.0.0 --port=5000 diff --git a/reinit_db.sh b/reinit_db.sh deleted file mode 100755 index 839266c..0000000 --- a/reinit_db.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash -# Script to reinitialize database with new Player schema - -cd /home/pi/Desktop/digiserver-v2 - -echo "๐Ÿ—‘๏ธ Removing old database..." -rm -f instance/dev.db - -echo "๐Ÿš€ Recreating database with new schema..." -.venv/bin/python << 'EOF' -from app.app import create_app -from app.extensions import db, bcrypt -from app.models import User, Player -import secrets - -print('๐Ÿš€ Creating Flask app...') -app = create_app() - -with app.app_context(): - print('๐Ÿ—„๏ธ Creating database tables...') - db.create_all() - print('โœ… Database tables created') - - # Create admin user - hashed = bcrypt.generate_password_hash('admin123').decode('utf-8') - admin = User(username='admin', password=hashed, role='admin') - db.session.add(admin) - - # Create example player - player = Player( - name='Demo Player', - hostname='player-001', - location='Main Office', - auth_code=secrets.token_urlsafe(32), - orientation='Landscape' - ) - player.set_password('demo123') - player.set_quickconnect_code('QUICK123') - db.session.add(player) - - db.session.commit() - print('โœ… Admin user created (admin/admin123)') - print('โœ… Demo player created:') - print(f' - Hostname: player-001') - print(f' - Password: demo123') - print(f' - Quick Connect: QUICK123') - print(f' - Auth Code: {player.auth_code}') - -print('') -print('๐ŸŽ‰ Database ready!') -print('๐Ÿ“ Restart Flask server to use new database') -EOF diff --git a/static/uploads/130414-746934884.mp4 b/static/uploads/130414-746934884.mp4 new file mode 100644 index 0000000..8979cec Binary files /dev/null and b/static/uploads/130414-746934884.mp4 differ