Replace emoji icons with local SVG files for consistent rendering
- Created 10 SVG icon files in app/static/icons/ (Feather Icons style) - Updated base.html with SVG icons in navigation and dark mode toggle - Updated dashboard.html with icons in stats cards and quick actions - Updated content_list_new.html (playlist management) with SVG icons - Updated upload_media.html with upload-related icons - Updated manage_player.html with player management icons - Icons use currentColor for automatic theme adaptation - Removed emoji dependency for better Raspberry Pi compatibility - Added ICON_INTEGRATION.md documentation
138
ICON_INTEGRATION.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# SVG Icon Integration
|
||||
|
||||
## Overview
|
||||
Replaced all emoji icons with local SVG files to ensure consistent rendering across all systems, particularly on Raspberry Pi devices where emoji fonts may not be available.
|
||||
|
||||
## Icon Files Created
|
||||
Location: `/app/static/icons/`
|
||||
|
||||
1. **moon.svg** - Dark mode toggle (off state)
|
||||
2. **sun.svg** - Dark mode toggle (on state)
|
||||
3. **home.svg** - Dashboard/home navigation
|
||||
4. **monitor.svg** - Players/screens
|
||||
5. **playlist.svg** - Playlist management
|
||||
6. **edit.svg** - Edit actions
|
||||
7. **trash.svg** - Delete actions
|
||||
8. **upload.svg** - Upload/media files
|
||||
9. **info.svg** - Information/details
|
||||
10. **warning.svg** - Warnings/alerts
|
||||
|
||||
## Icon Specifications
|
||||
- **Size**: 24x24 viewBox (scalable)
|
||||
- **Style**: Feather Icons design (minimal, stroke-based)
|
||||
- **Color**: Uses `currentColor` for automatic theme adaptation
|
||||
- **Stroke**: 2px width, round line caps and joins
|
||||
- **Format**: Clean SVG with no fills (stroke-only for consistency)
|
||||
|
||||
## Templates Updated
|
||||
|
||||
### 1. base.html (Navigation & Dark Mode)
|
||||
- Header logo: monitor.svg
|
||||
- Dashboard link: home.svg
|
||||
- Players link: monitor.svg
|
||||
- Playlists link: playlist.svg
|
||||
- Dark mode toggle: moon.svg ↔ sun.svg (dynamic)
|
||||
|
||||
**CSS Changes:**
|
||||
- Added icon support to nav links with `display: flex`
|
||||
- Icons use `filter: brightness(0) invert(1)` for white color on dark header
|
||||
- Dark mode toggle icon changes via JavaScript using `src` attribute
|
||||
|
||||
### 2. dashboard.html (Stats Cards & Quick Actions)
|
||||
- Players card: monitor.svg
|
||||
- Playlists card: playlist.svg
|
||||
- Media Library card: upload.svg
|
||||
- Storage card: info.svg
|
||||
- Quick Actions buttons: monitor.svg, playlist.svg, upload.svg
|
||||
- Workflow Guide header: info.svg
|
||||
|
||||
### 3. content_list_new.html (Playlist Management)
|
||||
- Page header: playlist.svg
|
||||
- Playlists card header: playlist.svg
|
||||
- Delete button: trash.svg
|
||||
- Empty state: playlist.svg (64px, opacity 0.3)
|
||||
- Upload Media card header: upload.svg
|
||||
- Upload icon (large): upload.svg (96px, opacity 0.5)
|
||||
- Go to Upload button: upload.svg
|
||||
- Media library icons: info.svg (images), monitor.svg (videos)
|
||||
- Player Assignments header: monitor.svg
|
||||
|
||||
### 4. upload_media.html (Upload Page)
|
||||
- Page header: upload.svg
|
||||
- Back button: playlist.svg
|
||||
- Select Files card: upload.svg
|
||||
- Drag-drop zone: upload.svg (96px, opacity 0.3)
|
||||
- Browse button: upload.svg
|
||||
- Upload Settings header: info.svg
|
||||
- Upload button: upload.svg
|
||||
|
||||
### 5. manage_player.html (Player Management)
|
||||
- Page header: monitor.svg
|
||||
- Back button: monitor.svg
|
||||
- Status icons: info.svg (online), warning.svg (offline)
|
||||
- Edit Credentials card: edit.svg
|
||||
- Save button: edit.svg
|
||||
- Assign Playlist card: playlist.svg
|
||||
- Warning alert: warning.svg
|
||||
- Assign button: playlist.svg
|
||||
- Create Playlist button: playlist.svg
|
||||
- Edit Playlist button: edit.svg
|
||||
- Player Logs card: info.svg
|
||||
|
||||
## Usage Pattern
|
||||
```html
|
||||
<!-- Standard icon in heading -->
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Playlists
|
||||
</h2>
|
||||
|
||||
<!-- Icon in button (white on colored background) -->
|
||||
<button class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Upload Files
|
||||
</button>
|
||||
|
||||
<!-- Large decorative icon -->
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 96px; height: 96px; opacity: 0.3;">
|
||||
```
|
||||
|
||||
## 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
|
||||
250
KIVY_PLAYER_COMPATIBILITY.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Kivy Player Compatibility with New Playlist Architecture
|
||||
|
||||
## Overview
|
||||
This document outlines the compatibility verification and updates made to ensure the Kivy signage player works with the new playlist-centric architecture.
|
||||
|
||||
## Changes Made to DigiServer API
|
||||
|
||||
### 1. Content URL Format in Playlist Response
|
||||
**Location:** `/home/pi/Desktop/digiserver-v2/app/blueprints/api.py` - `get_cached_playlist()` function
|
||||
|
||||
**Issue:**
|
||||
- Player expects: `file_name` key in playlist items
|
||||
- Server was returning: `filename` key
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
playlist_data.append({
|
||||
'id': content.id,
|
||||
'file_name': content.filename, # Changed from 'filename' to 'file_name'
|
||||
'type': content.content_type,
|
||||
'duration': content._playlist_duration or content.duration or 10,
|
||||
'position': content._playlist_position or idx,
|
||||
'url': content_url, # Now returns full URL with server base
|
||||
'description': content.description
|
||||
})
|
||||
```
|
||||
|
||||
**What Changed:**
|
||||
- ✅ Changed `'filename'` to `'file_name'` to match player expectations
|
||||
- ✅ URL now includes full server base URL (e.g., `http://server:5000/static/uploads/image.jpg`)
|
||||
- ✅ Player can now download content directly without URL manipulation
|
||||
|
||||
### 2. Authentication Response - Removed group_id
|
||||
**Location:** `/home/pi/Desktop/digiserver-v2/app/blueprints/api.py`
|
||||
|
||||
**Endpoints Updated:**
|
||||
1. `/api/auth/player` (line ~145) - ✅ Already returns `playlist_id`
|
||||
2. `/api/auth/verify` (line ~192) - ✅ Changed from `group_id` to `playlist_id`
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
response = {
|
||||
'valid': True,
|
||||
'player_id': player.id,
|
||||
'player_name': player.name,
|
||||
'hostname': player.hostname,
|
||||
'group_id': player.group_id, # OLD
|
||||
'orientation': player.orientation,
|
||||
'status': player.status
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
response = {
|
||||
'valid': True,
|
||||
'player_id': player.id,
|
||||
'player_name': player.name,
|
||||
'hostname': player.hostname,
|
||||
'playlist_id': player.playlist_id, # NEW
|
||||
'orientation': player.orientation,
|
||||
'status': player.status
|
||||
}
|
||||
```
|
||||
|
||||
## Changes Made to Kivy Player
|
||||
|
||||
### 1. Updated player_auth.py
|
||||
**Location:** `/home/pi/Desktop/Kiwy-Signage/src/player_auth.py`
|
||||
|
||||
**Changes:**
|
||||
1. ✅ Default auth data structure: `group_id` → `playlist_id` (line ~42)
|
||||
2. ✅ Authentication data storage: `group_id` → `playlist_id` (line ~97)
|
||||
3. ✅ Clear auth method: `group_id` → `playlist_id` (line ~299)
|
||||
4. ✅ Example usage output: Updated to show `playlist_id` instead of `group_id`
|
||||
|
||||
**Impact:**
|
||||
- Player now stores and uses `playlist_id` instead of deprecated `group_id`
|
||||
- Backward compatible: old auth files will load but won't have `playlist_id` until re-authentication
|
||||
|
||||
## API Endpoints Used by Kivy Player
|
||||
|
||||
### Player Authentication Flow
|
||||
```
|
||||
1. POST /api/auth/player
|
||||
Body: { hostname, password OR quickconnect_code }
|
||||
Returns: { auth_code, player_id, playlist_id, orientation, ... }
|
||||
|
||||
2. GET /api/playlists/<player_id>
|
||||
Headers: Authorization: Bearer <auth_code>
|
||||
Returns: { playlist: [...], playlist_version: N, ... }
|
||||
|
||||
3. GET <full_url_from_playlist_item>
|
||||
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/<player_id>` 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
|
||||
44
add_orientation_column.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Add orientation column to playlist table
|
||||
Run this script to update the database schema
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app.app import app
|
||||
from app.extensions import db
|
||||
from sqlalchemy import text
|
||||
|
||||
def add_orientation_column():
|
||||
"""Add orientation column to playlist table."""
|
||||
with app.app_context():
|
||||
try:
|
||||
# Check if column exists
|
||||
result = db.session.execute(text("PRAGMA table_info(playlist)"))
|
||||
columns = [row[1] for row in result]
|
||||
|
||||
if 'orientation' in columns:
|
||||
print("✅ Column 'orientation' already exists in playlist table")
|
||||
return
|
||||
|
||||
# Add the column
|
||||
print("Adding 'orientation' column to playlist table...")
|
||||
db.session.execute(text("""
|
||||
ALTER TABLE playlist
|
||||
ADD COLUMN orientation VARCHAR(20) DEFAULT 'Landscape' NOT NULL
|
||||
"""))
|
||||
db.session.commit()
|
||||
|
||||
print("✅ Successfully added 'orientation' column to playlist table")
|
||||
print(" Default value: 'Landscape'")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error adding column: {str(e)}")
|
||||
db.session.rollback()
|
||||
raise
|
||||
|
||||
if __name__ == '__main__':
|
||||
add_orientation_column()
|
||||
@@ -62,6 +62,7 @@ def register_blueprints(app):
|
||||
from app.blueprints.players import players_bp
|
||||
from app.blueprints.groups import groups_bp
|
||||
from app.blueprints.content import content_bp
|
||||
from app.blueprints.playlist import playlist_bp
|
||||
from app.blueprints.api import api_bp
|
||||
|
||||
# Register blueprints (using URL prefixes from blueprint definitions)
|
||||
@@ -71,6 +72,7 @@ def register_blueprints(app):
|
||||
app.register_blueprint(players_bp)
|
||||
app.register_blueprint(groups_bp)
|
||||
app.register_blueprint(content_bp)
|
||||
app.register_blueprint(playlist_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from flask import Blueprint, request, jsonify, current_app
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
import secrets
|
||||
import bcrypt
|
||||
from typing import Optional, Dict, List
|
||||
|
||||
from app.extensions import db, cache
|
||||
@@ -142,7 +143,7 @@ def authenticate_player():
|
||||
'player_name': player.name,
|
||||
'hostname': player.hostname,
|
||||
'auth_code': player.auth_code,
|
||||
'group_id': player.group_id,
|
||||
'playlist_id': player.playlist_id,
|
||||
'orientation': player.orientation,
|
||||
'status': player.status
|
||||
}
|
||||
@@ -186,7 +187,7 @@ def verify_auth_code():
|
||||
'player_id': player.id,
|
||||
'player_name': player.name,
|
||||
'hostname': player.hostname,
|
||||
'group_id': player.group_id,
|
||||
'playlist_id': player.playlist_id,
|
||||
'orientation': player.orientation,
|
||||
'status': player.status
|
||||
}
|
||||
@@ -194,6 +195,103 @@ def verify_auth_code():
|
||||
return jsonify(response), 200
|
||||
|
||||
|
||||
@api_bp.route('/playlists', methods=['GET'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
def get_playlist_by_quickconnect():
|
||||
"""Get playlist using hostname and quickconnect code (Kivy player compatible).
|
||||
|
||||
Query parameters:
|
||||
hostname: Player hostname/identifier
|
||||
quickconnect_code: Quick connect code for authentication
|
||||
|
||||
Returns:
|
||||
JSON with playlist, playlist_version, and hashed_quickconnect
|
||||
"""
|
||||
try:
|
||||
import bcrypt
|
||||
|
||||
hostname = request.args.get('hostname')
|
||||
quickconnect_code = request.args.get('quickconnect_code')
|
||||
|
||||
if not hostname or not quickconnect_code:
|
||||
return jsonify({
|
||||
'error': 'hostname and quickconnect_code are required',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 400
|
||||
|
||||
# Find player by hostname and validate quickconnect
|
||||
player = Player.query.filter_by(hostname=hostname).first()
|
||||
|
||||
if not player:
|
||||
log_action('warning', f'Player not found with hostname: {hostname}')
|
||||
return jsonify({
|
||||
'error': 'Player not found',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 404
|
||||
|
||||
# Validate quickconnect code
|
||||
if not player.quickconnect_code:
|
||||
log_action('warning', f'Player {hostname} has no quickconnect code set')
|
||||
return jsonify({
|
||||
'error': 'Quickconnect not configured',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 403
|
||||
|
||||
# Check if quickconnect matches
|
||||
if player.quickconnect_code != quickconnect_code:
|
||||
log_action('warning', f'Invalid quickconnect code for player: {hostname}')
|
||||
return jsonify({
|
||||
'error': 'Invalid quickconnect code',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 403
|
||||
|
||||
# Get playlist (with caching)
|
||||
playlist = get_cached_playlist(player.id)
|
||||
|
||||
# Update player's last seen timestamp and status
|
||||
player.last_seen = datetime.utcnow()
|
||||
player.status = 'online'
|
||||
db.session.commit()
|
||||
|
||||
# Get playlist version from the assigned playlist
|
||||
playlist_version = 1
|
||||
if player.playlist_id:
|
||||
from app.models import Playlist
|
||||
assigned_playlist = Playlist.query.get(player.playlist_id)
|
||||
if assigned_playlist:
|
||||
playlist_version = assigned_playlist.version
|
||||
|
||||
# Hash the quickconnect code for validation on client side
|
||||
hashed_quickconnect = bcrypt.hashpw(
|
||||
quickconnect_code.encode('utf-8'),
|
||||
bcrypt.gensalt()
|
||||
).decode('utf-8')
|
||||
|
||||
log_action('info', f'Playlist fetched for player: {player.name} ({hostname})')
|
||||
|
||||
return jsonify({
|
||||
'player_id': player.id,
|
||||
'player_name': player.name,
|
||||
'playlist_id': player.playlist_id,
|
||||
'playlist_version': playlist_version,
|
||||
'playlist': playlist,
|
||||
'hashed_quickconnect': hashed_quickconnect,
|
||||
'count': len(playlist)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting playlist: {str(e)}')
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'playlist': [],
|
||||
'playlist_version': 0
|
||||
}), 500
|
||||
|
||||
|
||||
@api_bp.route('/playlists/<int:player_id>', methods=['GET'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
@verify_player_auth
|
||||
@@ -216,11 +314,19 @@ def get_player_playlist(player_id: int):
|
||||
player.last_seen = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Get playlist version from the assigned playlist
|
||||
playlist_version = 1
|
||||
if player.playlist_id:
|
||||
from app.models import Playlist
|
||||
assigned_playlist = Playlist.query.get(player.playlist_id)
|
||||
if assigned_playlist:
|
||||
playlist_version = assigned_playlist.version
|
||||
|
||||
return jsonify({
|
||||
'player_id': player_id,
|
||||
'player_name': player.name,
|
||||
'group_id': player.group_id,
|
||||
'playlist_version': player.playlist_version,
|
||||
'playlist_id': player.playlist_id,
|
||||
'playlist_version': playlist_version,
|
||||
'playlist': playlist,
|
||||
'count': len(playlist)
|
||||
})
|
||||
@@ -263,78 +369,110 @@ def get_playlist_version(player_id: int):
|
||||
|
||||
@cache.memoize(timeout=300)
|
||||
def get_cached_playlist(player_id: int) -> List[Dict]:
|
||||
"""Get cached playlist for a player."""
|
||||
"""Get cached playlist for a player based on assigned playlist."""
|
||||
from flask import url_for
|
||||
from app.models import Playlist
|
||||
|
||||
player = Player.query.get(player_id)
|
||||
if not player:
|
||||
if not player or not player.playlist_id:
|
||||
return []
|
||||
|
||||
# Get content based on group assignment
|
||||
if player.group_id:
|
||||
group = Group.query.get(player.group_id)
|
||||
contents = group.contents.order_by(Content.position).all() if group else []
|
||||
else:
|
||||
# Show all content if not in a group
|
||||
contents = Content.query.order_by(Content.position).all()
|
||||
# Get the playlist assigned to this player
|
||||
playlist = Playlist.query.get(player.playlist_id)
|
||||
if not playlist:
|
||||
return []
|
||||
|
||||
# Build playlist
|
||||
playlist = []
|
||||
for content in contents:
|
||||
playlist.append({
|
||||
# Get content from playlist (ordered)
|
||||
content_list = playlist.get_content_ordered()
|
||||
|
||||
# Build playlist response
|
||||
playlist_data = []
|
||||
for idx, content in enumerate(content_list, start=1):
|
||||
# Generate full URL for content
|
||||
from flask import request as current_request
|
||||
# Get server base URL
|
||||
server_base = current_request.host_url.rstrip('/')
|
||||
content_url = f"{server_base}/static/uploads/{content.filename}"
|
||||
|
||||
playlist_data.append({
|
||||
'id': content.id,
|
||||
'filename': content.filename,
|
||||
'file_name': content.filename, # Player expects 'file_name' not 'filename'
|
||||
'type': content.content_type,
|
||||
'duration': content.duration or 10,
|
||||
'position': content.position,
|
||||
'url': f"/static/uploads/{content.filename}",
|
||||
'duration': content._playlist_duration or content.duration or 10,
|
||||
'position': content._playlist_position or idx,
|
||||
'url': content_url, # Full URL for downloads
|
||||
'description': content.description
|
||||
})
|
||||
|
||||
return playlist
|
||||
return playlist_data
|
||||
|
||||
|
||||
@api_bp.route('/player-feedback', methods=['POST'])
|
||||
@rate_limit(max_requests=100, window=60)
|
||||
@verify_player_auth
|
||||
def receive_player_feedback():
|
||||
"""Receive feedback/status updates from players.
|
||||
"""Receive feedback/status updates from players (Kivy player compatible).
|
||||
|
||||
Expected JSON payload:
|
||||
{
|
||||
"status": "playing|paused|error",
|
||||
"current_content_id": 123,
|
||||
"message": "Optional status message",
|
||||
"error": "Optional error message"
|
||||
"player_name": "Screen1",
|
||||
"quickconnect_code": "ABC123",
|
||||
"status": "playing|paused|error|restarting",
|
||||
"message": "Status message",
|
||||
"playlist_version": 1,
|
||||
"error_details": "Optional error details",
|
||||
"timestamp": "ISO timestamp"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
player = request.player
|
||||
data = request.json
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
player_name = data.get('player_name')
|
||||
quickconnect_code = data.get('quickconnect_code')
|
||||
|
||||
if not player_name or not quickconnect_code:
|
||||
return jsonify({'error': 'player_name and quickconnect_code required'}), 400
|
||||
|
||||
# Find player by name and validate quickconnect
|
||||
player = Player.query.filter_by(name=player_name).first()
|
||||
|
||||
if not player:
|
||||
log_action('warning', f'Player feedback from unknown player: {player_name}')
|
||||
return jsonify({'error': 'Player not found'}), 404
|
||||
|
||||
# Validate quickconnect code
|
||||
if player.quickconnect_code != quickconnect_code:
|
||||
log_action('warning', f'Invalid quickconnect in feedback from: {player_name}')
|
||||
return jsonify({'error': 'Invalid quickconnect code'}), 403
|
||||
|
||||
# Create feedback record
|
||||
status = data.get('status', 'unknown')
|
||||
message = data.get('message', '')
|
||||
error_details = data.get('error_details')
|
||||
|
||||
feedback = PlayerFeedback(
|
||||
player_id=player.id,
|
||||
status=data.get('status', 'unknown'),
|
||||
current_content_id=data.get('current_content_id'),
|
||||
message=data.get('message'),
|
||||
error=data.get('error')
|
||||
status=status,
|
||||
message=message,
|
||||
error=error_details
|
||||
)
|
||||
db.session.add(feedback)
|
||||
|
||||
# Update player's last seen
|
||||
# Update player's last seen and status
|
||||
player.last_seen = datetime.utcnow()
|
||||
player.status = data.get('status', 'unknown')
|
||||
player.status = status
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Feedback received from {player_name}: {status} - {message}')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Feedback received'
|
||||
})
|
||||
'message': 'Feedback received',
|
||||
'player_id': player.id
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
|
||||
@@ -1,532 +1,376 @@
|
||||
"""Content blueprint for media upload and management."""
|
||||
"""Content blueprint - New playlist-centric workflow."""
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
flash, jsonify, current_app, send_from_directory)
|
||||
flash, jsonify, current_app)
|
||||
from flask_login import login_required
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
from typing import Optional, Dict
|
||||
import json
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Content, Group
|
||||
from app.models import Content, Playlist, Player
|
||||
from app.models.playlist import playlist_content
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.uploads import (
|
||||
save_uploaded_file,
|
||||
process_video_file,
|
||||
process_pdf_file,
|
||||
get_upload_progress,
|
||||
set_upload_progress
|
||||
)
|
||||
from app.utils.uploads import process_video_file, set_upload_progress
|
||||
|
||||
content_bp = Blueprint('content', __name__, url_prefix='/content')
|
||||
|
||||
|
||||
# In-memory storage for upload progress (for simple demo; use Redis in production)
|
||||
upload_progress = {}
|
||||
|
||||
|
||||
@content_bp.route('/')
|
||||
@login_required
|
||||
def content_list():
|
||||
"""Display list of all content."""
|
||||
try:
|
||||
# Get all unique content files (by filename)
|
||||
from sqlalchemy import func
|
||||
"""Main playlist management page."""
|
||||
playlists = Playlist.query.order_by(Playlist.created_at.desc()).all()
|
||||
media_files = Content.query.order_by(Content.uploaded_at.desc()).all()
|
||||
players = Player.query.order_by(Player.name).all()
|
||||
|
||||
# Get content with player information
|
||||
contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all()
|
||||
|
||||
# Group content by filename to show which players have each file
|
||||
content_map = {}
|
||||
for content in contents:
|
||||
if content.filename not in content_map:
|
||||
content_map[content.filename] = {
|
||||
'content': content,
|
||||
'players': [],
|
||||
'groups': []
|
||||
}
|
||||
|
||||
# Add player info if assigned to a player
|
||||
if content.player_id:
|
||||
from app.models import Player
|
||||
player = Player.query.get(content.player_id)
|
||||
if player:
|
||||
content_map[content.filename]['players'].append({
|
||||
'id': player.id,
|
||||
'name': player.name,
|
||||
'group': player.group.name if player.group else None
|
||||
})
|
||||
|
||||
# Convert to list for template
|
||||
content_list = []
|
||||
for filename, data in content_map.items():
|
||||
content_list.append({
|
||||
'filename': filename,
|
||||
'content_type': data['content'].content_type,
|
||||
'duration': data['content'].duration,
|
||||
'file_size': data['content'].file_size_mb,
|
||||
'uploaded_at': data['content'].uploaded_at,
|
||||
'players': data['players'],
|
||||
'player_count': len(data['players'])
|
||||
})
|
||||
|
||||
# Sort by upload date
|
||||
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
|
||||
|
||||
return render_template('content/content_list.html',
|
||||
content_list=content_list)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading content list: {str(e)}')
|
||||
flash('Error loading content list.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
return render_template('content/content_list_new.html',
|
||||
playlists=playlists,
|
||||
media_files=media_files,
|
||||
players=players)
|
||||
|
||||
|
||||
@content_bp.route('/upload', methods=['GET', 'POST'])
|
||||
@content_bp.route('/playlist/create', methods=['POST'])
|
||||
@login_required
|
||||
def upload_content():
|
||||
"""Upload new content."""
|
||||
if request.method == 'GET':
|
||||
# Get parameters for return URL and pre-selection
|
||||
target_type = request.args.get('target_type')
|
||||
target_id = request.args.get('target_id', type=int)
|
||||
return_url = request.args.get('return_url', url_for('content.content_list'))
|
||||
def create_playlist():
|
||||
"""Create a new playlist."""
|
||||
try:
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
orientation = request.form.get('orientation', 'Landscape')
|
||||
|
||||
# Get all players and groups for selection
|
||||
from app.models import Player
|
||||
players = [{'id': p.id, 'name': p.name} for p in Player.query.order_by(Player.name).all()]
|
||||
groups = [{'id': g.id, 'name': g.name} for g in Group.query.order_by(Group.name).all()]
|
||||
if not name:
|
||||
flash('Playlist name is required.', 'warning')
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
return render_template('content/upload_content.html',
|
||||
players=players,
|
||||
groups=groups,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
return_url=return_url)
|
||||
# Check if playlist name exists
|
||||
existing = Playlist.query.filter_by(name=name).first()
|
||||
if existing:
|
||||
flash(f'Playlist "{name}" already exists.', 'warning')
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
playlist = Playlist(
|
||||
name=name,
|
||||
description=description or None,
|
||||
orientation=orientation
|
||||
)
|
||||
db.session.add(playlist)
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Created playlist: {name}')
|
||||
flash(f'Playlist "{name}" created successfully!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error creating playlist: {str(e)}')
|
||||
flash('Error creating playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
|
||||
@content_bp.route('/playlist/<int:playlist_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_playlist(playlist_id: int):
|
||||
"""Delete a playlist."""
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
try:
|
||||
# Get form data
|
||||
target_type = request.form.get('target_type')
|
||||
target_id = request.form.get('target_id', type=int)
|
||||
media_type = request.form.get('media_type', 'image')
|
||||
duration = request.form.get('duration', type=int, default=10)
|
||||
session_id = request.form.get('session_id', os.urandom(8).hex())
|
||||
return_url = request.form.get('return_url', url_for('content.content_list'))
|
||||
name = playlist.name
|
||||
|
||||
# Get files
|
||||
# Unassign all players from this playlist
|
||||
Player.query.filter_by(playlist_id=playlist_id).update({'playlist_id': None})
|
||||
|
||||
db.session.delete(playlist)
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Deleted playlist: {name}')
|
||||
flash(f'Playlist "{name}" deleted successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting playlist: {str(e)}')
|
||||
flash('Error deleting playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
|
||||
@content_bp.route('/playlist/<int:playlist_id>/manage')
|
||||
@login_required
|
||||
def manage_playlist_content(playlist_id: int):
|
||||
"""Manage content in a specific playlist."""
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
# Get content in playlist (ordered)
|
||||
playlist_content = playlist.get_content_ordered()
|
||||
|
||||
# Get all available content not in this playlist
|
||||
all_content = Content.query.all()
|
||||
playlist_content_ids = {c.id for c in playlist_content}
|
||||
available_content = [c for c in all_content if c.id not in playlist_content_ids]
|
||||
|
||||
return render_template('content/manage_playlist_content.html',
|
||||
playlist=playlist,
|
||||
playlist_content=playlist_content,
|
||||
available_content=available_content)
|
||||
|
||||
|
||||
@content_bp.route('/playlist/<int:playlist_id>/add-content', methods=['POST'])
|
||||
@login_required
|
||||
def add_content_to_playlist(playlist_id: int):
|
||||
"""Add content to playlist."""
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
try:
|
||||
content_id = request.form.get('content_id', type=int)
|
||||
duration = request.form.get('duration', type=int, default=10)
|
||||
|
||||
if not content_id:
|
||||
flash('Please select content to add.', 'warning')
|
||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
# Get max position
|
||||
from sqlalchemy import select, func
|
||||
from app.models.playlist import playlist_content
|
||||
|
||||
max_pos = db.session.execute(
|
||||
select(func.max(playlist_content.c.position)).where(
|
||||
playlist_content.c.playlist_id == playlist_id
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# Add to playlist
|
||||
stmt = playlist_content.insert().values(
|
||||
playlist_id=playlist_id,
|
||||
content_id=content_id,
|
||||
position=max_pos + 1,
|
||||
duration=duration
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
|
||||
playlist.increment_version()
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Added "{content.filename}" to playlist "{playlist.name}"')
|
||||
flash(f'Added "{content.filename}" to playlist.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error adding content to playlist: {str(e)}')
|
||||
flash('Error adding content to playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||
|
||||
|
||||
@content_bp.route('/playlist/<int:playlist_id>/remove-content/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_content_from_playlist(playlist_id: int, content_id: int):
|
||||
"""Remove content from playlist."""
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
try:
|
||||
from app.models.playlist import playlist_content
|
||||
|
||||
# Remove from playlist
|
||||
stmt = playlist_content.delete().where(
|
||||
(playlist_content.c.playlist_id == playlist_id) &
|
||||
(playlist_content.c.content_id == content_id)
|
||||
)
|
||||
db.session.execute(stmt)
|
||||
|
||||
playlist.increment_version()
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Removed content from playlist "{playlist.name}"')
|
||||
flash('Content removed from playlist.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error removing content from playlist: {str(e)}')
|
||||
flash('Error removing content from playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||
|
||||
|
||||
@content_bp.route('/playlist/<int:playlist_id>/reorder', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_playlist_content(playlist_id: int):
|
||||
"""Reorder content in playlist."""
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
content_ids = data.get('content_ids', [])
|
||||
|
||||
if not content_ids:
|
||||
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
|
||||
|
||||
from app.models.playlist import playlist_content
|
||||
|
||||
# Update positions
|
||||
for idx, content_id in enumerate(content_ids, start=1):
|
||||
stmt = playlist_content.update().where(
|
||||
(playlist_content.c.playlist_id == playlist_id) &
|
||||
(playlist_content.c.content_id == content_id)
|
||||
).values(position=idx)
|
||||
db.session.execute(stmt)
|
||||
|
||||
playlist.increment_version()
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Reordered playlist "{playlist.name}"')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Playlist reordered successfully',
|
||||
'version': playlist.version
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error reordering playlist: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/upload-media-page')
|
||||
@login_required
|
||||
def upload_media_page():
|
||||
"""Display upload media page."""
|
||||
playlists = Playlist.query.order_by(Playlist.name).all()
|
||||
return render_template('content/upload_media.html', playlists=playlists)
|
||||
|
||||
|
||||
@content_bp.route('/upload-media', methods=['POST'])
|
||||
@login_required
|
||||
def upload_media():
|
||||
"""Upload media files to library."""
|
||||
try:
|
||||
files = request.files.getlist('files')
|
||||
content_type = request.form.get('content_type', 'image')
|
||||
duration = request.form.get('duration', type=int, default=10)
|
||||
playlist_id = request.form.get('playlist_id', type=int)
|
||||
|
||||
if not files or files[0].filename == '':
|
||||
flash('No files provided.', 'warning')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
return redirect(url_for('content.upload_media_page'))
|
||||
|
||||
if not target_type or not target_id:
|
||||
flash('Please select a target type and target ID.', 'warning')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
# Initialize progress tracking using shared utility
|
||||
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
|
||||
|
||||
# Process each file
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
|
||||
processed_count = 0
|
||||
total_files = len(files)
|
||||
uploaded_count = 0
|
||||
|
||||
for idx, file in enumerate(files):
|
||||
for file in files:
|
||||
if file.filename == '':
|
||||
continue
|
||||
|
||||
# Update progress
|
||||
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
|
||||
set_upload_progress(session_id, progress_pct,
|
||||
f'Processing file {idx + 1} of {total_files}...', 'processing')
|
||||
|
||||
filename = secure_filename(file.filename)
|
||||
filepath = os.path.join(upload_folder, filename)
|
||||
|
||||
# Check if file already exists
|
||||
existing = Content.query.filter_by(filename=filename).first()
|
||||
if existing:
|
||||
log_action('warning', f'File {filename} already exists, skipping')
|
||||
continue
|
||||
|
||||
# Save file
|
||||
file.save(filepath)
|
||||
|
||||
# Determine content type
|
||||
# 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']:
|
||||
content_type = 'image'
|
||||
detected_type = 'image'
|
||||
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
|
||||
content_type = 'video'
|
||||
# Process video (convert to Raspberry Pi optimized format)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
|
||||
success, message = process_video_file(filepath, session_id)
|
||||
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 optimization failed: {message}')
|
||||
continue # Skip this file and move to next
|
||||
log_action('error', f'Video processing failed: {message}')
|
||||
elif file_ext == 'pdf':
|
||||
content_type = 'pdf'
|
||||
# Process PDF (convert to images)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Converting PDF {idx + 1}...', 'processing')
|
||||
# process_pdf_file(filepath, session_id)
|
||||
elif file_ext in ['ppt', 'pptx']:
|
||||
content_type = 'presentation'
|
||||
# Process presentation (convert to PDF then images)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Converting PowerPoint {idx + 1}...', 'processing')
|
||||
# This would call pptx_converter utility
|
||||
detected_type = 'pdf'
|
||||
else:
|
||||
content_type = 'other'
|
||||
detected_type = 'other'
|
||||
|
||||
# Create content record
|
||||
new_content = Content(
|
||||
content = Content(
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
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)
|
||||
|
||||
# Link to target (player or group)
|
||||
if target_type == 'player':
|
||||
from app.models import Player
|
||||
player = Player.query.get(target_id)
|
||||
if player:
|
||||
# Add content directly to player's playlist
|
||||
new_content.player_id = target_id
|
||||
db.session.add(new_content)
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
|
||||
playlist.version += 1
|
||||
|
||||
elif target_type == 'group':
|
||||
group = Group.query.get(target_id)
|
||||
if group:
|
||||
# For groups, create separate content entry for EACH player in the group
|
||||
# This matches the old app behavior
|
||||
for player in group.players:
|
||||
player_content = Content(
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
duration=duration,
|
||||
file_size=os.path.getsize(filepath),
|
||||
player_id=player.id
|
||||
)
|
||||
db.session.add(player_content)
|
||||
# Increment each player's playlist version
|
||||
player.playlist_version += 1
|
||||
uploaded_count += 1
|
||||
|
||||
log_action('info', f'Content "{filename}" added to {len(group.players)} players in group "{group.name}"')
|
||||
# Don't add the original new_content since we created per-player entries
|
||||
new_content = None
|
||||
|
||||
if new_content:
|
||||
db.session.add(new_content)
|
||||
|
||||
processed_count += 1
|
||||
|
||||
# Commit all changes
|
||||
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
|
||||
db.session.commit()
|
||||
|
||||
# Complete
|
||||
set_upload_progress(session_id, 100,
|
||||
f'Successfully uploaded {processed_count} file(s)!', 'complete')
|
||||
|
||||
# Clear all playlist caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
|
||||
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
|
||||
log_action('info', f'Uploaded {uploaded_count} media files')
|
||||
|
||||
return redirect(return_url)
|
||||
if playlist_id:
|
||||
playlist = Playlist.query.get(playlist_id)
|
||||
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')
|
||||
else:
|
||||
flash(f'Successfully uploaded {uploaded_count} file(s) to media library!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error uploading media: {str(e)}')
|
||||
flash('Error uploading media files.', 'danger')
|
||||
|
||||
# Update progress to error state
|
||||
if 'session_id' in locals():
|
||||
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
|
||||
|
||||
log_action('error', f'Error uploading content: {str(e)}')
|
||||
flash('Error uploading content. Please try again.', 'danger')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
return redirect(url_for('content.upload_media_page'))
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/edit', methods=['GET', 'POST'])
|
||||
@content_bp.route('/player/<int:player_id>/assign-playlist', methods=['POST'])
|
||||
@login_required
|
||||
def edit_content(content_id: int):
|
||||
"""Edit content metadata."""
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template('content/edit_content.html', content=content)
|
||||
def assign_player_to_playlist(player_id: int):
|
||||
"""Assign a player to a playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
try:
|
||||
duration = request.form.get('duration', type=int)
|
||||
description = request.form.get('description', '').strip()
|
||||
playlist_id = request.form.get('playlist_id', type=int)
|
||||
|
||||
if playlist_id:
|
||||
playlist = Playlist.query.get_or_404(playlist_id)
|
||||
player.playlist_id = playlist_id
|
||||
log_action('info', f'Assigned player "{player.name}" to playlist "{playlist.name}"')
|
||||
flash(f'Player "{player.name}" assigned to playlist "{playlist.name}".', 'success')
|
||||
else:
|
||||
player.playlist_id = None
|
||||
log_action('info', f'Unassigned player "{player.name}" from playlist')
|
||||
flash(f'Player "{player.name}" unassigned from playlist.', 'success')
|
||||
|
||||
# Update content
|
||||
if duration is not None:
|
||||
content.duration = duration
|
||||
content.description = description or None
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated')
|
||||
flash(f'Content "{content.filename}" updated successfully.', 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error assigning player to playlist: {str(e)}')
|
||||
flash('Error assigning player to playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error updating content: {str(e)}')
|
||||
flash('Error updating content. Please try again.', 'danger')
|
||||
return redirect(url_for('content.edit_content', content_id=content_id))
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_content(content_id: int):
|
||||
"""Delete content and associated file."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
filename = content.filename
|
||||
|
||||
# Delete file from disk
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
# Delete from database
|
||||
db.session.delete(content)
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{filename}" (ID: {content_id}) deleted')
|
||||
flash(f'Content "{filename}" deleted successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting content: {str(e)}')
|
||||
flash('Error deleting content. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
|
||||
@content_bp.route('/delete-by-filename', methods=['POST'])
|
||||
@login_required
|
||||
def delete_by_filename():
|
||||
"""Delete all content entries with a specific filename."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
filename = data.get('filename')
|
||||
|
||||
if not filename:
|
||||
return jsonify({'success': False, 'message': 'No filename provided'}), 400
|
||||
|
||||
# Find all content entries with this filename
|
||||
contents = Content.query.filter_by(filename=filename).all()
|
||||
|
||||
if not contents:
|
||||
return jsonify({'success': False, 'message': 'Content not found'}), 404
|
||||
|
||||
deleted_count = len(contents)
|
||||
|
||||
# Delete file from disk (only once)
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
log_action('info', f'Deleted file from disk: {filename}')
|
||||
|
||||
# Delete all database entries
|
||||
for content in contents:
|
||||
db.session.delete(content)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Content deleted from {deleted_count} playlist(s)',
|
||||
'deleted_count': deleted_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting content by filename: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/bulk/delete', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_delete_content():
|
||||
"""Delete multiple content items at once."""
|
||||
try:
|
||||
content_ids = request.json.get('content_ids', [])
|
||||
|
||||
if not content_ids:
|
||||
return jsonify({'success': False, 'error': 'No content selected'}), 400
|
||||
|
||||
# Delete content
|
||||
deleted_count = 0
|
||||
for content_id in content_ids:
|
||||
content = Content.query.get(content_id)
|
||||
if content:
|
||||
# Delete file
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
db.session.delete(content)
|
||||
deleted_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Bulk deleted {deleted_count} content items')
|
||||
return jsonify({'success': True, 'deleted': deleted_count})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error bulk deleting content: {str(e)}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/upload-progress/<upload_id>')
|
||||
@login_required
|
||||
def upload_progress_status(upload_id: str):
|
||||
"""Get upload progress for a specific upload."""
|
||||
progress = get_upload_progress(upload_id)
|
||||
return jsonify(progress)
|
||||
|
||||
|
||||
@content_bp.route('/preview/<int:content_id>')
|
||||
@login_required
|
||||
def preview_content(content_id: int):
|
||||
"""Preview content in browser."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
# Serve file from uploads folder
|
||||
return send_from_directory(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
content.filename,
|
||||
as_attachment=False
|
||||
)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error previewing content: {str(e)}')
|
||||
return "Error loading content", 500
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/download')
|
||||
@login_required
|
||||
def download_content(content_id: int):
|
||||
"""Download content file."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
log_action('info', f'Content "{content.filename}" downloaded')
|
||||
|
||||
return send_from_directory(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
content.filename,
|
||||
as_attachment=True
|
||||
)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error downloading content: {str(e)}')
|
||||
return "Error downloading content", 500
|
||||
|
||||
|
||||
@content_bp.route('/statistics')
|
||||
@login_required
|
||||
def content_statistics():
|
||||
"""Get content statistics."""
|
||||
try:
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Count by type
|
||||
type_counts = {}
|
||||
for content_type in ['image', 'video', 'pdf', 'presentation', 'other']:
|
||||
count = Content.query.filter_by(content_type=content_type).count()
|
||||
type_counts[content_type] = count
|
||||
|
||||
# Calculate total storage
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
total_size = 0
|
||||
if os.path.exists(upload_folder):
|
||||
for dirpath, dirnames, filenames in os.walk(upload_folder):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
if os.path.exists(filepath):
|
||||
total_size += os.path.getsize(filepath)
|
||||
|
||||
return jsonify({
|
||||
'total': total_content,
|
||||
'by_type': type_counts,
|
||||
'total_size_mb': round(total_size / (1024 * 1024), 2)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting content statistics: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/check-duplicates')
|
||||
@login_required
|
||||
def check_duplicates():
|
||||
"""Check for duplicate filenames."""
|
||||
try:
|
||||
# Get all filenames
|
||||
all_content = Content.query.all()
|
||||
filename_counts = {}
|
||||
|
||||
for content in all_content:
|
||||
filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1
|
||||
|
||||
# Find duplicates
|
||||
duplicates = {fname: count for fname, count in filename_counts.items() if count > 1}
|
||||
|
||||
return jsonify({
|
||||
'has_duplicates': len(duplicates) > 0,
|
||||
'duplicates': duplicates
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error checking duplicates: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/groups')
|
||||
@login_required
|
||||
def content_groups_info(content_id: int):
|
||||
"""Get groups that contain this content."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
groups_data = []
|
||||
for group in content.groups:
|
||||
groups_data.append({
|
||||
'id': group.id,
|
||||
'name': group.name,
|
||||
'description': group.description,
|
||||
'player_count': group.players.count()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'content_id': content_id,
|
||||
'filename': content.filename,
|
||||
'groups': groups_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting content groups: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
500
app/blueprints/content_old.py
Normal file
@@ -0,0 +1,500 @@
|
||||
"""Content blueprint for media upload and management."""
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
flash, jsonify, current_app, send_from_directory)
|
||||
from flask_login import login_required
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
from typing import Optional, Dict
|
||||
import json
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Content, Group
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.uploads import (
|
||||
save_uploaded_file,
|
||||
process_video_file,
|
||||
process_pdf_file,
|
||||
get_upload_progress,
|
||||
set_upload_progress
|
||||
)
|
||||
|
||||
content_bp = Blueprint('content', __name__, url_prefix='/content')
|
||||
|
||||
|
||||
# In-memory storage for upload progress (for simple demo; use Redis in production)
|
||||
upload_progress = {}
|
||||
|
||||
|
||||
@content_bp.route('/')
|
||||
@login_required
|
||||
def content_list():
|
||||
"""Display list of all content."""
|
||||
try:
|
||||
# Get all unique content files (by filename)
|
||||
from sqlalchemy import func
|
||||
|
||||
# Get content with player information
|
||||
contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all()
|
||||
|
||||
# Group content by filename to show which players have each file
|
||||
content_map = {}
|
||||
for content in contents:
|
||||
if content.filename not in content_map:
|
||||
content_map[content.filename] = {
|
||||
'content': content,
|
||||
'players': [],
|
||||
'groups': []
|
||||
}
|
||||
|
||||
# Add player info if assigned to a player
|
||||
if content.player_id:
|
||||
from app.models import Player
|
||||
player = Player.query.get(content.player_id)
|
||||
if player:
|
||||
content_map[content.filename]['players'].append({
|
||||
'id': player.id,
|
||||
'name': player.name,
|
||||
'group': player.group.name if player.group else None
|
||||
})
|
||||
|
||||
# Convert to list for template
|
||||
content_list = []
|
||||
for filename, data in content_map.items():
|
||||
content_list.append({
|
||||
'filename': filename,
|
||||
'content_type': data['content'].content_type,
|
||||
'duration': data['content'].duration,
|
||||
'file_size': data['content'].file_size_mb,
|
||||
'uploaded_at': data['content'].uploaded_at,
|
||||
'players': data['players'],
|
||||
'player_count': len(data['players'])
|
||||
})
|
||||
|
||||
# Sort by upload date
|
||||
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
|
||||
|
||||
return render_template('content/content_list.html',
|
||||
content_list=content_list)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading content list: {str(e)}')
|
||||
flash('Error loading content list.', 'danger')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@content_bp.route('/upload', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def upload_content():
|
||||
"""Upload new content."""
|
||||
if request.method == 'GET':
|
||||
# Get parameters for return URL and pre-selection
|
||||
player_id = request.args.get('player_id', type=int)
|
||||
return_url = request.args.get('return_url', url_for('content.content_list'))
|
||||
|
||||
# Get all players for selection
|
||||
from app.models import Player
|
||||
players = Player.query.order_by(Player.name).all()
|
||||
|
||||
return render_template('content/upload_content.html',
|
||||
players=players,
|
||||
selected_player_id=player_id,
|
||||
return_url=return_url)
|
||||
|
||||
try:
|
||||
# Get form data
|
||||
player_id = request.form.get('player_id', type=int)
|
||||
media_type = request.form.get('media_type', 'image')
|
||||
duration = request.form.get('duration', type=int, default=10)
|
||||
session_id = request.form.get('session_id', os.urandom(8).hex())
|
||||
return_url = request.form.get('return_url', url_for('content.content_list'))
|
||||
|
||||
# Get files
|
||||
files = request.files.getlist('files')
|
||||
|
||||
if not files or files[0].filename == '':
|
||||
flash('No files provided.', 'warning')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
if not player_id:
|
||||
flash('Please select a player.', 'warning')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
# Initialize progress tracking using shared utility
|
||||
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
|
||||
|
||||
# Process each file
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
|
||||
processed_count = 0
|
||||
total_files = len(files)
|
||||
|
||||
for idx, file in enumerate(files):
|
||||
if file.filename == '':
|
||||
continue
|
||||
|
||||
# Update progress
|
||||
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
|
||||
set_upload_progress(session_id, progress_pct,
|
||||
f'Processing file {idx + 1} of {total_files}...', 'processing')
|
||||
|
||||
filename = secure_filename(file.filename)
|
||||
filepath = os.path.join(upload_folder, filename)
|
||||
|
||||
# Save file
|
||||
file.save(filepath)
|
||||
|
||||
# Determine content type
|
||||
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||
|
||||
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
|
||||
content_type = 'image'
|
||||
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
|
||||
content_type = 'video'
|
||||
# Process video (convert to Raspberry Pi optimized format)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
|
||||
success, message = process_video_file(filepath, session_id)
|
||||
if not success:
|
||||
log_action('error', f'Video optimization failed: {message}')
|
||||
continue # Skip this file and move to next
|
||||
elif file_ext == 'pdf':
|
||||
content_type = 'pdf'
|
||||
# Process PDF (convert to images)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Converting PDF {idx + 1}...', 'processing')
|
||||
# process_pdf_file(filepath, session_id)
|
||||
elif file_ext in ['ppt', 'pptx']:
|
||||
content_type = 'presentation'
|
||||
# Process presentation (convert to PDF then images)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Converting PowerPoint {idx + 1}...', 'processing')
|
||||
# This would call pptx_converter utility
|
||||
else:
|
||||
content_type = 'other'
|
||||
|
||||
# Create content record linked to player
|
||||
from app.models import Player
|
||||
player = Player.query.get(player_id)
|
||||
if player:
|
||||
new_content = Content(
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
duration=duration,
|
||||
file_size=os.path.getsize(filepath),
|
||||
player_id=player_id
|
||||
)
|
||||
db.session.add(new_content)
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
|
||||
|
||||
processed_count += 1
|
||||
|
||||
# Commit all changes
|
||||
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
|
||||
db.session.commit()
|
||||
|
||||
# Complete
|
||||
set_upload_progress(session_id, 100,
|
||||
f'Successfully uploaded {processed_count} file(s)!', 'complete')
|
||||
|
||||
# Clear all playlist caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
|
||||
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
|
||||
|
||||
return redirect(return_url)
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
|
||||
# Update progress to error state
|
||||
if 'session_id' in locals():
|
||||
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
|
||||
|
||||
log_action('error', f'Error uploading content: {str(e)}')
|
||||
flash('Error uploading content. Please try again.', 'danger')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_content(content_id: int):
|
||||
"""Edit content metadata."""
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template('content/edit_content.html', content=content)
|
||||
|
||||
try:
|
||||
duration = request.form.get('duration', type=int)
|
||||
description = request.form.get('description', '').strip()
|
||||
|
||||
# Update content
|
||||
if duration is not None:
|
||||
content.duration = duration
|
||||
content.description = description or None
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated')
|
||||
flash(f'Content "{content.filename}" updated successfully.', 'success')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error updating content: {str(e)}')
|
||||
flash('Error updating content. Please try again.', 'danger')
|
||||
return redirect(url_for('content.edit_content', content_id=content_id))
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_content(content_id: int):
|
||||
"""Delete content and associated file."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
filename = content.filename
|
||||
|
||||
# Delete file from disk
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
# Delete from database
|
||||
db.session.delete(content)
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{filename}" (ID: {content_id}) deleted')
|
||||
flash(f'Content "{filename}" deleted successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting content: {str(e)}')
|
||||
flash('Error deleting content. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
|
||||
@content_bp.route('/delete-by-filename', methods=['POST'])
|
||||
@login_required
|
||||
def delete_by_filename():
|
||||
"""Delete all content entries with a specific filename."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
filename = data.get('filename')
|
||||
|
||||
if not filename:
|
||||
return jsonify({'success': False, 'message': 'No filename provided'}), 400
|
||||
|
||||
# Find all content entries with this filename
|
||||
contents = Content.query.filter_by(filename=filename).all()
|
||||
|
||||
if not contents:
|
||||
return jsonify({'success': False, 'message': 'Content not found'}), 404
|
||||
|
||||
deleted_count = len(contents)
|
||||
|
||||
# Delete file from disk (only once)
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
log_action('info', f'Deleted file from disk: {filename}')
|
||||
|
||||
# Delete all database entries
|
||||
for content in contents:
|
||||
db.session.delete(content)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Content deleted from {deleted_count} playlist(s)',
|
||||
'deleted_count': deleted_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting content by filename: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/bulk/delete', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_delete_content():
|
||||
"""Delete multiple content items at once."""
|
||||
try:
|
||||
content_ids = request.json.get('content_ids', [])
|
||||
|
||||
if not content_ids:
|
||||
return jsonify({'success': False, 'error': 'No content selected'}), 400
|
||||
|
||||
# Delete content
|
||||
deleted_count = 0
|
||||
for content_id in content_ids:
|
||||
content = Content.query.get(content_id)
|
||||
if content:
|
||||
# Delete file
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
|
||||
db.session.delete(content)
|
||||
deleted_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Bulk deleted {deleted_count} content items')
|
||||
return jsonify({'success': True, 'deleted': deleted_count})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error bulk deleting content: {str(e)}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/upload-progress/<upload_id>')
|
||||
@login_required
|
||||
def upload_progress_status(upload_id: str):
|
||||
"""Get upload progress for a specific upload."""
|
||||
progress = get_upload_progress(upload_id)
|
||||
return jsonify(progress)
|
||||
|
||||
|
||||
@content_bp.route('/preview/<int:content_id>')
|
||||
@login_required
|
||||
def preview_content(content_id: int):
|
||||
"""Preview content in browser."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
# Serve file from uploads folder
|
||||
return send_from_directory(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
content.filename,
|
||||
as_attachment=False
|
||||
)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error previewing content: {str(e)}')
|
||||
return "Error loading content", 500
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/download')
|
||||
@login_required
|
||||
def download_content(content_id: int):
|
||||
"""Download content file."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
log_action('info', f'Content "{content.filename}" downloaded')
|
||||
|
||||
return send_from_directory(
|
||||
current_app.config['UPLOAD_FOLDER'],
|
||||
content.filename,
|
||||
as_attachment=True
|
||||
)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error downloading content: {str(e)}')
|
||||
return "Error downloading content", 500
|
||||
|
||||
|
||||
@content_bp.route('/statistics')
|
||||
@login_required
|
||||
def content_statistics():
|
||||
"""Get content statistics."""
|
||||
try:
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Count by type
|
||||
type_counts = {}
|
||||
for content_type in ['image', 'video', 'pdf', 'presentation', 'other']:
|
||||
count = Content.query.filter_by(content_type=content_type).count()
|
||||
type_counts[content_type] = count
|
||||
|
||||
# Calculate total storage
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
total_size = 0
|
||||
if os.path.exists(upload_folder):
|
||||
for dirpath, dirnames, filenames in os.walk(upload_folder):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
if os.path.exists(filepath):
|
||||
total_size += os.path.getsize(filepath)
|
||||
|
||||
return jsonify({
|
||||
'total': total_content,
|
||||
'by_type': type_counts,
|
||||
'total_size_mb': round(total_size / (1024 * 1024), 2)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting content statistics: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/check-duplicates')
|
||||
@login_required
|
||||
def check_duplicates():
|
||||
"""Check for duplicate filenames."""
|
||||
try:
|
||||
# Get all filenames
|
||||
all_content = Content.query.all()
|
||||
filename_counts = {}
|
||||
|
||||
for content in all_content:
|
||||
filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1
|
||||
|
||||
# Find duplicates
|
||||
duplicates = {fname: count for fname, count in filename_counts.items() if count > 1}
|
||||
|
||||
return jsonify({
|
||||
'has_duplicates': len(duplicates) > 0,
|
||||
'duplicates': duplicates
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error checking duplicates: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/<int:content_id>/groups')
|
||||
@login_required
|
||||
def content_groups_info(content_id: int):
|
||||
"""Get groups that contain this content."""
|
||||
try:
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
groups_data = []
|
||||
for group in content.groups:
|
||||
groups_data.append({
|
||||
'id': group.id,
|
||||
'name': group.name,
|
||||
'description': group.description,
|
||||
'player_count': group.players.count()
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'content_id': content_id,
|
||||
'filename': content.filename,
|
||||
'groups': groups_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_action('error', f'Error getting content groups: {str(e)}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
@@ -5,8 +5,10 @@ from flask import Blueprint, render_template, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db, cache
|
||||
from app.models.player import Player
|
||||
from app.models.group import Group
|
||||
from app.models.playlist import Playlist
|
||||
from app.models.content import Content
|
||||
from app.utils.logger import get_recent_logs
|
||||
import os
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
@@ -16,15 +18,30 @@ main_bp = Blueprint('main', __name__)
|
||||
@cache.cached(timeout=60, unless=lambda: current_user.role != 'viewer')
|
||||
def dashboard():
|
||||
"""Main dashboard page"""
|
||||
players = Player.query.all()
|
||||
groups = Group.query.all()
|
||||
# Get statistics
|
||||
total_players = Player.query.count()
|
||||
total_playlists = Playlist.query.count()
|
||||
total_content = Content.query.count()
|
||||
|
||||
# Calculate storage usage
|
||||
upload_folder = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'uploads')
|
||||
storage_mb = 0
|
||||
if os.path.exists(upload_folder):
|
||||
for filename in os.listdir(upload_folder):
|
||||
filepath = os.path.join(upload_folder, filename)
|
||||
if os.path.isfile(filepath):
|
||||
storage_mb += os.path.getsize(filepath)
|
||||
storage_mb = round(storage_mb / (1024 * 1024), 2) # Convert to MB
|
||||
|
||||
server_logs = get_recent_logs(20)
|
||||
|
||||
return render_template(
|
||||
'dashboard.html',
|
||||
players=players,
|
||||
groups=groups,
|
||||
server_logs=server_logs
|
||||
total_players=total_players,
|
||||
total_playlists=total_playlists,
|
||||
total_content=total_content,
|
||||
storage_mb=storage_mb,
|
||||
recent_logs=server_logs
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import secrets
|
||||
from typing import Optional, List
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Player, Group, Content, PlayerFeedback
|
||||
from app.models import Player, Content, PlayerFeedback, Playlist
|
||||
from app.utils.logger import log_action
|
||||
from app.utils.group_player_management import get_player_status_info
|
||||
|
||||
@@ -20,7 +20,7 @@ def list():
|
||||
"""Display list of all players."""
|
||||
try:
|
||||
players = Player.query.order_by(Player.name).all()
|
||||
groups = Group.query.all()
|
||||
playlists = Playlist.query.all()
|
||||
|
||||
# Get player status for each player
|
||||
player_statuses = {}
|
||||
@@ -30,7 +30,7 @@ def list():
|
||||
|
||||
return render_template('players/players_list.html',
|
||||
players=players,
|
||||
groups=groups,
|
||||
playlists=playlists,
|
||||
player_statuses=player_statuses)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading players list: {str(e)}')
|
||||
@@ -43,8 +43,7 @@ def list():
|
||||
def add_player():
|
||||
"""Add a new player."""
|
||||
if request.method == 'GET':
|
||||
groups = Group.query.order_by(Group.name).all()
|
||||
return render_template('players/add_player.html', groups=groups)
|
||||
return render_template('players/add_player.html')
|
||||
|
||||
try:
|
||||
name = request.form.get('name', '').strip()
|
||||
@@ -53,7 +52,6 @@ def add_player():
|
||||
password = request.form.get('password', '').strip()
|
||||
quickconnect_code = request.form.get('quickconnect_code', '').strip()
|
||||
orientation = request.form.get('orientation', 'Landscape')
|
||||
group_id = request.form.get('group_id')
|
||||
|
||||
# Validation
|
||||
if not name or len(name) < 3:
|
||||
@@ -83,8 +81,7 @@ def add_player():
|
||||
hostname=hostname,
|
||||
location=location or None,
|
||||
auth_code=auth_code,
|
||||
orientation=orientation,
|
||||
group_id=int(group_id) if group_id else None
|
||||
orientation=orientation
|
||||
)
|
||||
|
||||
# Set password if provided
|
||||
@@ -128,13 +125,11 @@ def edit_player(player_id: int):
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
groups = Group.query.order_by(Group.name).all()
|
||||
return render_template('players/edit_player.html', player=player, groups=groups)
|
||||
return render_template('players/edit_player.html', player=player)
|
||||
|
||||
try:
|
||||
name = request.form.get('name', '').strip()
|
||||
location = request.form.get('location', '').strip()
|
||||
group_id = request.form.get('group_id')
|
||||
|
||||
# Validation
|
||||
if not name or len(name) < 3:
|
||||
@@ -144,7 +139,6 @@ def edit_player(player_id: int):
|
||||
# Update player
|
||||
player.name = name
|
||||
player.location = location or None
|
||||
player.group_id = int(group_id) if group_id else None
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache for this player
|
||||
@@ -243,6 +237,88 @@ def player_page(player_id: int):
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/manage', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def manage_player(player_id: int):
|
||||
"""Manage player - edit credentials, assign playlist, view logs."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
try:
|
||||
if action == 'update_credentials':
|
||||
# Update player name, location, orientation
|
||||
name = request.form.get('name', '').strip()
|
||||
location = request.form.get('location', '').strip()
|
||||
orientation = request.form.get('orientation', 'Landscape')
|
||||
|
||||
if not name or len(name) < 3:
|
||||
flash('Player name must be at least 3 characters long.', 'warning')
|
||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||
|
||||
player.name = name
|
||||
player.location = location or None
|
||||
player.orientation = orientation
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Player "{name}" credentials updated')
|
||||
flash(f'Player "{name}" updated successfully.', 'success')
|
||||
|
||||
elif action == 'assign_playlist':
|
||||
# Assign playlist to player
|
||||
playlist_id = request.form.get('playlist_id')
|
||||
|
||||
if playlist_id:
|
||||
playlist = Playlist.query.get(int(playlist_id))
|
||||
if playlist:
|
||||
player.playlist_id = int(playlist_id)
|
||||
db.session.commit()
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
log_action('info', f'Player "{player.name}" assigned to playlist "{playlist.name}"')
|
||||
flash(f'Player assigned to playlist "{playlist.name}".', 'success')
|
||||
else:
|
||||
flash('Invalid playlist selected.', 'warning')
|
||||
else:
|
||||
# Unassign playlist
|
||||
player.playlist_id = None
|
||||
db.session.commit()
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
log_action('info', f'Player "{player.name}" unassigned from playlist')
|
||||
flash('Player unassigned from playlist.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error managing player: {str(e)}')
|
||||
flash('Error updating player. Please try again.', 'danger')
|
||||
|
||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||
|
||||
# GET request - show manage page
|
||||
playlists = Playlist.query.order_by(Playlist.name).all()
|
||||
|
||||
# Get player's current playlist
|
||||
current_playlist = None
|
||||
if player.playlist_id:
|
||||
current_playlist = Playlist.query.get(player.playlist_id)
|
||||
|
||||
# Get recent feedback/logs from player
|
||||
recent_logs = PlayerFeedback.query.filter_by(player_id=player_id)\
|
||||
.order_by(PlayerFeedback.timestamp.desc())\
|
||||
.limit(20)\
|
||||
.all()
|
||||
|
||||
# Get player status
|
||||
status_info = get_player_status_info(player_id)
|
||||
|
||||
return render_template('players/manage_player.html',
|
||||
player=player,
|
||||
playlists=playlists,
|
||||
current_playlist=current_playlist,
|
||||
recent_logs=recent_logs,
|
||||
status_info=status_info)
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/fullscreen')
|
||||
def player_fullscreen(player_id: int):
|
||||
"""Display player fullscreen view (no authentication required for players)."""
|
||||
@@ -303,35 +379,11 @@ def get_player_playlist(player_id: int) -> List[dict]:
|
||||
@players_bp.route('/<int:player_id>/reorder', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_content(player_id: int):
|
||||
"""Reorder content for a player's group."""
|
||||
try:
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
if not player.group_id:
|
||||
flash('Player is not assigned to a group.', 'warning')
|
||||
return redirect(url_for('players.player_page', player_id=player_id))
|
||||
|
||||
# Get new order from request
|
||||
content_order = request.json.get('order', [])
|
||||
|
||||
# Update positions
|
||||
for idx, content_id in enumerate(content_order):
|
||||
content = Content.query.get(content_id)
|
||||
if content and content in player.group.contents:
|
||||
content.position = idx
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear cache
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
|
||||
log_action('info', f'Content reordered for player {player_id}')
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error reordering content: {str(e)}')
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
"""Legacy endpoint - Content reordering now handled in playlist management."""
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Content reordering is now managed through playlists. Use the Playlists page to reorder content.'
|
||||
}), 400
|
||||
|
||||
|
||||
@players_bp.route('/bulk/delete', methods=['POST'])
|
||||
@@ -366,35 +418,35 @@ def bulk_delete_players():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@players_bp.route('/bulk/assign-group', methods=['POST'])
|
||||
@players_bp.route('/bulk/assign-playlist', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_assign_group():
|
||||
"""Assign multiple players to a group."""
|
||||
def bulk_assign_playlist():
|
||||
"""Assign multiple players to a playlist."""
|
||||
try:
|
||||
player_ids = request.json.get('player_ids', [])
|
||||
group_id = request.json.get('group_id')
|
||||
playlist_id = request.json.get('playlist_id')
|
||||
|
||||
if not player_ids:
|
||||
return jsonify({'success': False, 'error': 'No players selected'}), 400
|
||||
|
||||
# Validate group
|
||||
if group_id:
|
||||
group = Group.query.get(group_id)
|
||||
if not group:
|
||||
return jsonify({'success': False, 'error': 'Invalid group'}), 400
|
||||
# Validate playlist
|
||||
if playlist_id:
|
||||
playlist = Playlist.query.get(playlist_id)
|
||||
if not playlist:
|
||||
return jsonify({'success': False, 'error': 'Invalid playlist'}), 400
|
||||
|
||||
# Assign players
|
||||
updated_count = 0
|
||||
for player_id in player_ids:
|
||||
player = Player.query.get(player_id)
|
||||
if player:
|
||||
player.group_id = group_id
|
||||
player.playlist_id = playlist_id
|
||||
cache.delete_memoized(get_player_playlist, player_id)
|
||||
updated_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
log_action('info', f'Bulk assigned {updated_count} players to group {group_id}')
|
||||
log_action('info', f'Bulk assigned {updated_count} players to playlist {playlist_id}')
|
||||
return jsonify({'success': True, 'updated': updated_count})
|
||||
|
||||
except Exception as e:
|
||||
|
||||
232
app/blueprints/playlist.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Playlist blueprint for managing player playlists."""
|
||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
||||
flash, jsonify, current_app)
|
||||
from flask_login import login_required
|
||||
from sqlalchemy import desc
|
||||
import os
|
||||
|
||||
from app.extensions import db, cache
|
||||
from app.models import Player, Content
|
||||
from app.utils.logger import log_action
|
||||
|
||||
playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>')
|
||||
@login_required
|
||||
def manage_playlist(player_id: int):
|
||||
"""Manage playlist for a specific player."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
# Get all content for this player, ordered by position
|
||||
playlist_content = Content.query.filter_by(
|
||||
player_id=player_id
|
||||
).order_by(Content.position).all()
|
||||
|
||||
# Get available content (files not already in this player's playlist)
|
||||
all_files = db.session.query(Content.filename).distinct().all()
|
||||
playlist_filenames = {c.filename for c in playlist_content}
|
||||
available_files = [f[0] for f in all_files if f[0] not in playlist_filenames]
|
||||
|
||||
return render_template('playlist/manage_playlist.html',
|
||||
player=player,
|
||||
playlist_content=playlist_content,
|
||||
available_files=available_files)
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])
|
||||
@login_required
|
||||
def add_to_playlist(player_id: int):
|
||||
"""Add content to player's playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
try:
|
||||
filename = request.form.get('filename')
|
||||
duration = request.form.get('duration', type=int, default=10)
|
||||
|
||||
if not filename:
|
||||
flash('Please provide a filename.', 'warning')
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
# Get max position
|
||||
max_position = db.session.query(db.func.max(Content.position)).filter_by(
|
||||
player_id=player_id
|
||||
).scalar() or 0
|
||||
|
||||
# Get file info from existing content
|
||||
existing_content = Content.query.filter_by(filename=filename).first()
|
||||
if not existing_content:
|
||||
flash('File not found.', 'danger')
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
# Create new content entry for this player
|
||||
new_content = Content(
|
||||
filename=filename,
|
||||
content_type=existing_content.content_type,
|
||||
duration=duration,
|
||||
file_size=existing_content.file_size,
|
||||
player_id=player_id,
|
||||
position=max_position + 1
|
||||
)
|
||||
db.session.add(new_content)
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Added "{filename}" to playlist for player "{player.name}"')
|
||||
flash(f'Added "{filename}" to playlist.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error adding to playlist: {str(e)}')
|
||||
flash('Error adding to playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/remove/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
def remove_from_playlist(player_id: int, content_id: int):
|
||||
"""Remove content from player's playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
if content.player_id != player_id:
|
||||
flash('Content does not belong to this player.', 'danger')
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
try:
|
||||
filename = content.filename
|
||||
|
||||
# Delete content
|
||||
db.session.delete(content)
|
||||
|
||||
# Reorder remaining content
|
||||
remaining_content = Content.query.filter_by(
|
||||
player_id=player_id
|
||||
).order_by(Content.position).all()
|
||||
|
||||
for idx, item in enumerate(remaining_content, start=1):
|
||||
item.position = idx
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Removed "{filename}" from playlist for player "{player.name}"')
|
||||
flash(f'Removed "{filename}" from playlist.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error removing from playlist: {str(e)}')
|
||||
flash('Error removing from playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/reorder', methods=['POST'])
|
||||
@login_required
|
||||
def reorder_playlist(player_id: int):
|
||||
"""Reorder playlist items."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
try:
|
||||
# Get new order from JSON
|
||||
data = request.get_json()
|
||||
content_ids = data.get('content_ids', [])
|
||||
|
||||
if not content_ids:
|
||||
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
|
||||
|
||||
# Update positions
|
||||
for idx, content_id in enumerate(content_ids, start=1):
|
||||
content = Content.query.get(content_id)
|
||||
if content and content.player_id == player_id:
|
||||
content.position = idx
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Reordered playlist for player "{player.name}" (version {player.playlist_version})')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Playlist reordered successfully',
|
||||
'version': player.playlist_version
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error reordering playlist: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/update-duration/<int:content_id>', methods=['POST'])
|
||||
@login_required
|
||||
def update_duration(player_id: int, content_id: int):
|
||||
"""Update content duration in playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
if content.player_id != player_id:
|
||||
return jsonify({'success': False, 'message': 'Content does not belong to this player'}), 403
|
||||
|
||||
try:
|
||||
duration = request.form.get('duration', type=int)
|
||||
|
||||
if not duration or duration < 1:
|
||||
return jsonify({'success': False, 'message': 'Invalid duration'}), 400
|
||||
|
||||
content.duration = duration
|
||||
player.playlist_version += 1
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Updated duration for "{content.filename}" in player "{player.name}" playlist')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Duration updated',
|
||||
'version': player.playlist_version
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error updating duration: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@playlist_bp.route('/<int:player_id>/clear', methods=['POST'])
|
||||
@login_required
|
||||
def clear_playlist(player_id: int):
|
||||
"""Clear all content from player's playlist."""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
try:
|
||||
# Delete all content for this player
|
||||
Content.query.filter_by(player_id=player_id).delete()
|
||||
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
|
||||
db.session.commit()
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Cleared playlist for player "{player.name}"')
|
||||
flash('Playlist cleared successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error clearing playlist: {str(e)}')
|
||||
flash('Error clearing playlist.', 'danger')
|
||||
|
||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
||||
@@ -2,6 +2,7 @@
|
||||
from app.models.user import User
|
||||
from app.models.player import Player
|
||||
from app.models.group import Group, group_content
|
||||
from app.models.playlist import Playlist, playlist_content
|
||||
from app.models.content import Content
|
||||
from app.models.server_log import ServerLog
|
||||
from app.models.player_feedback import PlayerFeedback
|
||||
@@ -10,8 +11,10 @@ __all__ = [
|
||||
'User',
|
||||
'Player',
|
||||
'Group',
|
||||
'Playlist',
|
||||
'Content',
|
||||
'ServerLog',
|
||||
'PlayerFeedback',
|
||||
'group_content',
|
||||
'playlist_content',
|
||||
]
|
||||
|
||||
@@ -3,7 +3,6 @@ from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.group import group_content
|
||||
|
||||
|
||||
class Content(db.Model):
|
||||
@@ -13,31 +12,26 @@ class Content(db.Model):
|
||||
id: Primary key
|
||||
filename: Original filename
|
||||
content_type: Type of content (image, video, pdf, presentation, other)
|
||||
duration: Display duration in seconds
|
||||
duration: Default display duration in seconds
|
||||
file_size: File size in bytes
|
||||
description: Optional content description
|
||||
position: Display order position
|
||||
uploaded_at: Upload timestamp
|
||||
"""
|
||||
__tablename__ = 'content'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
filename = db.Column(db.String(255), nullable=False, index=True)
|
||||
filename = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
||||
content_type = db.Column(db.String(50), nullable=False, index=True)
|
||||
duration = db.Column(db.Integer, default=10, nullable=True)
|
||||
file_size = db.Column(db.BigInteger, nullable=True)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
position = db.Column(db.Integer, default=0, index=True)
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
nullable=False, index=True)
|
||||
|
||||
# Player relationship (for direct player assignment)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('player.id', ondelete='CASCADE'),
|
||||
nullable=True, index=True)
|
||||
|
||||
# Relationships
|
||||
player = db.relationship('Player', back_populates='contents')
|
||||
groups = db.relationship('Group', secondary=group_content,
|
||||
# Relationships - many-to-many with playlists
|
||||
playlists = db.relationship('Playlist', secondary='playlist_content',
|
||||
back_populates='contents', lazy='dynamic')
|
||||
groups = db.relationship('Group', secondary='group_content',
|
||||
back_populates='contents', lazy='dynamic')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
|
||||
@@ -32,7 +32,6 @@ class Group(db.Model):
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
players = db.relationship('Player', back_populates='group', lazy='dynamic')
|
||||
contents = db.relationship('Content', secondary=group_content,
|
||||
back_populates='groups', lazy='dynamic')
|
||||
|
||||
@@ -40,10 +39,7 @@ class Group(db.Model):
|
||||
"""String representation of Group."""
|
||||
return f'<Group {self.name} (ID={self.id})>'
|
||||
|
||||
@property
|
||||
def player_count(self) -> int:
|
||||
"""Get number of players in this group."""
|
||||
return self.players.count()
|
||||
|
||||
|
||||
@property
|
||||
def content_count(self) -> int:
|
||||
|
||||
@@ -16,10 +16,10 @@ class Player(db.Model):
|
||||
auth_code: Authentication code for API access (legacy)
|
||||
password_hash: Hashed password for player authentication
|
||||
quickconnect_code: Hashed quick connect code for easy pairing
|
||||
group_id: Foreign key to assigned group
|
||||
orientation: Display orientation (Landscape/Portrait)
|
||||
status: Current player status (online, offline, error)
|
||||
last_seen: Last activity timestamp
|
||||
playlist_version: Version number for playlist synchronization
|
||||
created_at: Player creation timestamp
|
||||
"""
|
||||
__tablename__ = 'player'
|
||||
@@ -31,20 +31,20 @@ class Player(db.Model):
|
||||
auth_code = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
quickconnect_code = db.Column(db.String(255), nullable=True)
|
||||
group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=True, index=True)
|
||||
orientation = db.Column(db.String(16), default='Landscape', nullable=False)
|
||||
status = db.Column(db.String(50), default='offline', index=True)
|
||||
last_seen = db.Column(db.DateTime, nullable=True, index=True)
|
||||
last_heartbeat = db.Column(db.DateTime, nullable=True, index=True)
|
||||
playlist_version = db.Column(db.Integer, default=1, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Playlist assignment
|
||||
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
|
||||
nullable=True, index=True)
|
||||
|
||||
# Relationships
|
||||
group = db.relationship('Group', back_populates='players')
|
||||
playlist = db.relationship('Playlist', back_populates='players')
|
||||
feedback = db.relationship('PlayerFeedback', back_populates='player',
|
||||
cascade='all, delete-orphan', lazy='dynamic')
|
||||
contents = db.relationship('Content', back_populates='player',
|
||||
cascade='all, delete-orphan', lazy='dynamic')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of Player."""
|
||||
|
||||
97
app/models/playlist.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Playlist model for managing content collections."""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
# Association table for many-to-many relationship between playlists and content
|
||||
playlist_content = db.Table('playlist_content',
|
||||
db.Column('playlist_id', db.Integer, db.ForeignKey('playlist.id', ondelete='CASCADE'), primary_key=True),
|
||||
db.Column('content_id', db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), primary_key=True),
|
||||
db.Column('position', db.Integer, default=0),
|
||||
db.Column('duration', db.Integer, default=10)
|
||||
)
|
||||
|
||||
|
||||
class Playlist(db.Model):
|
||||
"""Playlist model representing a collection of content.
|
||||
|
||||
Attributes:
|
||||
id: Primary key
|
||||
name: Unique playlist name
|
||||
description: Optional playlist description
|
||||
version: Version number for synchronization
|
||||
is_active: Whether playlist is active
|
||||
created_at: Playlist creation timestamp
|
||||
updated_at: Last modification timestamp
|
||||
"""
|
||||
__tablename__ = 'playlist'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(100), nullable=False, unique=True, index=True)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
orientation = db.Column(db.String(20), default='Landscape', nullable=False)
|
||||
version = db.Column(db.Integer, default=1, nullable=False)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
players = db.relationship('Player', back_populates='playlist', lazy='dynamic')
|
||||
contents = db.relationship('Content', secondary=playlist_content,
|
||||
back_populates='playlists', lazy='dynamic')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of Playlist."""
|
||||
return f'<Playlist {self.name} (ID={self.id}, Version={self.version})>'
|
||||
|
||||
@property
|
||||
def player_count(self) -> int:
|
||||
"""Get number of players assigned to this playlist."""
|
||||
return self.players.count()
|
||||
|
||||
@property
|
||||
def content_count(self) -> int:
|
||||
"""Get number of content items in this playlist."""
|
||||
return self.contents.count()
|
||||
|
||||
@property
|
||||
def total_duration(self) -> int:
|
||||
"""Calculate total duration of all content in seconds."""
|
||||
total = 0
|
||||
for content in self.contents:
|
||||
total += content.duration or 10
|
||||
return total
|
||||
|
||||
def increment_version(self) -> None:
|
||||
"""Increment playlist version for sync detection."""
|
||||
self.version += 1
|
||||
self.updated_at = datetime.utcnow()
|
||||
|
||||
def get_content_ordered(self) -> List:
|
||||
"""Get content items ordered by position."""
|
||||
# Query through association table to get position
|
||||
from sqlalchemy import select
|
||||
stmt = select(playlist_content.c.content_id,
|
||||
playlist_content.c.position,
|
||||
playlist_content.c.duration).where(
|
||||
playlist_content.c.playlist_id == self.id
|
||||
).order_by(playlist_content.c.position)
|
||||
|
||||
results = db.session.execute(stmt).fetchall()
|
||||
|
||||
ordered_content = []
|
||||
for row in results:
|
||||
content = db.session.get(Content, row.content_id)
|
||||
if content:
|
||||
content._playlist_position = row.position
|
||||
content._playlist_duration = row.duration
|
||||
ordered_content.append(content)
|
||||
|
||||
return ordered_content
|
||||
|
||||
|
||||
# Import Content here to avoid circular import
|
||||
from app.models.content import Content
|
||||
4
app/static/icons/edit.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9"></path>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 294 B |
4
app/static/icons/home.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
|
||||
<polyline points="9 22 9 12 15 12 15 22"></polyline>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 311 B |
5
app/static/icons/info.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 329 B |
4
app/static/icons/monitor.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="7" width="20" height="15" rx="2" ry="2"></rect>
|
||||
<polyline points="17 2 12 7 7 2"></polyline>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 301 B |
3
app/static/icons/moon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 257 B |
3
app/static/icons/playlist.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 259 B |
11
app/static/icons/sun.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 651 B |
4
app/static/icons/trash.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 334 B |
5
app/static/icons/upload.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 345 B |
5
app/static/icons/warning.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 396 B |
@@ -5,12 +5,53 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}DigiServer v2{% endblock %}</title>
|
||||
<style>
|
||||
/* Ensure emoji font support */
|
||||
@supports (font-family: "Apple Color Emoji") {
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Light Mode Colors */
|
||||
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--primary-color: #667eea;
|
||||
--primary-dark: #5568d3;
|
||||
--secondary-color: #764ba2;
|
||||
--bg-color: #f5f7fa;
|
||||
--card-bg: #ffffff;
|
||||
--text-color: #2d3748;
|
||||
--text-secondary: #718096;
|
||||
--border-color: #e2e8f0;
|
||||
--header-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.07);
|
||||
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
body.dark-mode {
|
||||
/* Dark Mode Colors */
|
||||
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--primary-color: #7c3aed;
|
||||
--primary-dark: #6d28d9;
|
||||
--secondary-color: #8b5cf6;
|
||||
--bg-color: #1a202c;
|
||||
--card-bg: #2d3748;
|
||||
--text-color: #e2e8f0;
|
||||
--text-secondary: #a0aec0;
|
||||
--border-color: #4a5568;
|
||||
--header-bg: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%);
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background: #f5f5f5;
|
||||
color: var(--text-color);
|
||||
background: var(--bg-color);
|
||||
transition: background 0.3s, color 0.3s;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
@@ -18,81 +59,248 @@
|
||||
padding: 20px;
|
||||
}
|
||||
header {
|
||||
background: #2c3e50;
|
||||
background: var(--header-bg);
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
header .container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
header h1 { font-size: 1.5rem; }
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
nav a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
margin-left: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
nav a:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
nav a img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.dark-mode-toggle {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
margin-left: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.dark-mode-toggle:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.dark-mode-toggle img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* Emoji fallback for systems without emoji fonts */
|
||||
.emoji-fallback::before {
|
||||
content: attr(data-emoji);
|
||||
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
|
||||
}
|
||||
nav a:hover { background: rgba(255,255,255,0.1); }
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
border-color: #28a745;
|
||||
color: #155724;
|
||||
}
|
||||
body.dark-mode .alert-success {
|
||||
background: rgba(40, 167, 69, 0.2);
|
||||
border-color: #28a745;
|
||||
color: #7ce3a3;
|
||||
}
|
||||
.alert-danger {
|
||||
background: #f8d7da;
|
||||
border-color: #dc3545;
|
||||
color: #721c24;
|
||||
}
|
||||
body.dark-mode .alert-danger {
|
||||
background: rgba(220, 53, 69, 0.2);
|
||||
border-color: #dc3545;
|
||||
color: #f88f9a;
|
||||
}
|
||||
.alert-warning {
|
||||
background: #fff3cd;
|
||||
border-color: #ffc107;
|
||||
color: #856404;
|
||||
}
|
||||
body.dark-mode .alert-warning {
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
border-color: #ffc107;
|
||||
color: #ffd454;
|
||||
}
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
border-color: #17a2b8;
|
||||
color: #0c5460;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
body.dark-mode .alert-info {
|
||||
background: rgba(23, 162, 184, 0.2);
|
||||
border-color: #17a2b8;
|
||||
color: #7dd3e0;
|
||||
}
|
||||
.card h2 { margin-bottom: 1rem; color: #2c3e50; }
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.3s;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.card h2 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.card h3 {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Card with gradient header */
|
||||
.card-header {
|
||||
background: var(--primary-gradient);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
margin: -1.5rem -1.5rem 1.5rem -1.5rem;
|
||||
}
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3498db;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary-gradient);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
.btn:hover { background: #2980b9; }
|
||||
.btn-danger { background: #e74c3c; }
|
||||
.btn-danger:hover { background: #c0392b; }
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.4);
|
||||
}
|
||||
.btn-success { background: #27ae60; }
|
||||
.btn-success:hover { background: #229954; }
|
||||
.btn-success:hover {
|
||||
background: #229954;
|
||||
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4);
|
||||
}
|
||||
.btn-info {
|
||||
background: #3498db;
|
||||
}
|
||||
.btn-info:hover {
|
||||
background: #2980b9;
|
||||
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Form Inputs */
|
||||
input, textarea, select {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: var(--primary-color);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
body.dark-mode input:focus,
|
||||
body.dark-mode textarea:focus,
|
||||
body.dark-mode select:focus {
|
||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
}
|
||||
th {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
tr {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
code, pre {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
color: #7f8c8d;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -101,17 +309,31 @@
|
||||
<body>
|
||||
<header>
|
||||
<div class="container">
|
||||
<h1>📺 DigiServer v2</h1>
|
||||
<h1>
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="DigiServer" style="width: 28px; height: 28px; filter: brightness(0) invert(1);">
|
||||
DigiServer v2
|
||||
</h1>
|
||||
<nav>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
|
||||
<a href="{{ url_for('players.list') }}">Players</a>
|
||||
<a href="{{ url_for('groups.groups_list') }}">Groups</a>
|
||||
<a href="{{ url_for('content.content_list') }}">Content</a>
|
||||
<a href="{{ url_for('main.dashboard') }}">
|
||||
<img src="{{ url_for('static', filename='icons/home.svg') }}" alt="">
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('players.list') }}">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="">
|
||||
Players
|
||||
</a>
|
||||
<a href="{{ url_for('content.content_list') }}">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="">
|
||||
Playlists
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.admin_panel') }}">Admin</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('auth.logout') }}">Logout ({{ current_user.username }})</a>
|
||||
<button class="dark-mode-toggle" onclick="toggleDarkMode()" title="Toggle Dark Mode">
|
||||
<img id="theme-icon" src="{{ url_for('static', filename='icons/moon.svg') }}" alt="Toggle theme">
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}">Login</a>
|
||||
<a href="{{ url_for('auth.register') }}">Register</a>
|
||||
@@ -140,6 +362,38 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Dark Mode Toggle
|
||||
function toggleDarkMode() {
|
||||
const body = document.body;
|
||||
const themeIcon = document.getElementById('theme-icon');
|
||||
|
||||
body.classList.toggle('dark-mode');
|
||||
|
||||
// Update icon
|
||||
if (body.classList.contains('dark-mode')) {
|
||||
themeIcon.src = "{{ url_for('static', filename='icons/sun.svg') }}";
|
||||
localStorage.setItem('darkMode', 'enabled');
|
||||
} else {
|
||||
themeIcon.src = "{{ url_for('static', filename='icons/moon.svg') }}";
|
||||
localStorage.setItem('darkMode', 'disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved theme preference
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const darkMode = localStorage.getItem('darkMode');
|
||||
const themeIcon = document.getElementById('theme-icon');
|
||||
|
||||
if (darkMode === 'enabled') {
|
||||
document.body.classList.add('dark-mode');
|
||||
if (themeIcon) {
|
||||
themeIcon.src = "{{ url_for('static', filename='icons/sun.svg') }}";
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
438
app/templates/content/content_list_new.html
Normal file
@@ -0,0 +1,438 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Playlist Management - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
margin: -1.5rem -1.5rem 1.5rem -1.5rem;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.playlist-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.playlist-item {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #667eea;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.playlist-item:hover {
|
||||
background: #e9ecef;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.playlist-info h3 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.playlist-stats {
|
||||
font-size: 14px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.playlist-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: #667eea;
|
||||
background: #f0f2ff;
|
||||
}
|
||||
|
||||
.upload-zone.drag-over {
|
||||
border-color: #667eea;
|
||||
background: #e7e9ff;
|
||||
}
|
||||
|
||||
.file-input-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.file-input-wrapper input[type=file] {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
}
|
||||
|
||||
.media-library {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
background: white;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.media-item:hover {
|
||||
border-color: #667eea;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.media-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.media-name {
|
||||
font-size: 12px;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container" style="max-width: 1400px;">
|
||||
<h1 style="margin-bottom: 25px; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||
Playlist Management
|
||||
</h1>
|
||||
|
||||
<div class="main-grid">
|
||||
<!-- Create/Manage Playlists Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
|
||||
Playlists
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Create New Playlist Form -->
|
||||
<form method="POST" action="{{ url_for('content.create_playlist') }}" style="margin-bottom: 25px;">
|
||||
<h3 style="margin-bottom: 15px;">Create New Playlist</h3>
|
||||
<div class="form-group">
|
||||
<label for="playlist_name">Playlist Name *</label>
|
||||
<input type="text" name="name" id="playlist_name" class="form-control" required
|
||||
placeholder="e.g., Main Lobby Display">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="playlist_orientation">Content Orientation *</label>
|
||||
<select name="orientation" id="playlist_orientation" class="form-control" required>
|
||||
<option value="Landscape">Landscape (Horizontal)</option>
|
||||
<option value="Portrait">Portrait (Vertical)</option>
|
||||
</select>
|
||||
<small style="color: #6c757d; font-size: 12px; display: block; margin-top: 5px;">
|
||||
Select the orientation that matches your display screens
|
||||
</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="playlist_description">Description (Optional)</label>
|
||||
<textarea name="description" id="playlist_description" class="form-control"
|
||||
placeholder="Describe the purpose of this playlist..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
➕ Create Playlist
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<hr style="margin: 25px 0;">
|
||||
|
||||
<!-- Existing Playlists -->
|
||||
<h3 style="margin-bottom: 15px;">Existing Playlists</h3>
|
||||
<div class="playlist-list">
|
||||
{% if playlists %}
|
||||
{% for playlist in playlists %}
|
||||
<div class="playlist-item">
|
||||
<div class="playlist-info">
|
||||
<h3>{{ playlist.name }}</h3>
|
||||
<div class="playlist-stats">
|
||||
📊 {{ playlist.content_count }} items |
|
||||
👥 {{ playlist.player_count }} players |
|
||||
🔄 v{{ playlist.version }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="playlist-actions">
|
||||
<a href="{{ url_for('content.manage_playlist_content', playlist_id=playlist.id) }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
✏️ Manage
|
||||
</a>
|
||||
<form method="POST"
|
||||
action="{{ url_for('content.delete_playlist', playlist_id=playlist.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Delete playlist {{ playlist.name }}?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm" style="display: flex; align-items: center; gap: 0.3rem;">
|
||||
<img src="{{ url_for('static', filename='icons/trash.svg') }}" alt="" style="width: 14px; height: 14px; filter: brightness(0) invert(1);">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; color: #999;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 64px; height: 64px; opacity: 0.3; margin-bottom: 10px;">
|
||||
<p>No playlists yet. Create your first playlist above!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Media Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
|
||||
Upload Media
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; padding: 40px 20px;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 96px; height: 96px; opacity: 0.5; margin-bottom: 20px;">
|
||||
<h3 style="margin-bottom: 15px;">Upload Media Files</h3>
|
||||
<p style="color: #6c757d; margin-bottom: 25px;">
|
||||
Upload images, videos, and PDFs to your media library.<br>
|
||||
Assign them to playlists during or after upload.
|
||||
</p>
|
||||
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="padding: 15px 40px; font-size: 16px; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Go to Upload Page
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Media Library Preview -->
|
||||
<hr style="margin: 25px 0;">
|
||||
<h3 style="margin-bottom: 15px;">Media Library ({{ media_files|length }} files)</h3>
|
||||
<div class="media-library">
|
||||
{% if media_files %}
|
||||
{% for media in media_files[:12] %}
|
||||
<div class="media-item" title="{{ media.filename }}">
|
||||
<div class="media-icon">
|
||||
{% if media.content_type == 'image' %}
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="Image" style="width: 48px; height: 48px; opacity: 0.5;">
|
||||
{% elif media.content_type == 'video' %}
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="Video" style="width: 48px; height: 48px; opacity: 0.5;">
|
||||
{% elif media.content_type == 'pdf' %}
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="PDF" style="width: 48px; height: 48px; opacity: 0.5;">
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="File" style="width: 48px; height: 48px; opacity: 0.5;">
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="media-name">{{ media.filename[:20] }}...</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 20px; color: #999;">
|
||||
<p>No media files yet. Upload your first file!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if media_files|length > 12 %}
|
||||
<p style="text-align: center; margin-top: 15px; color: #999;">
|
||||
+ {{ media_files|length - 12 }} more files
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assign Players to Playlists Card -->
|
||||
<div class="card full-width">
|
||||
<div class="card-header">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
|
||||
Player Assignments
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background: #f8f9fa; text-align: left;">
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Player Name</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Hostname</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Location</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Assigned Playlist</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Status</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for player in players %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 12px;"><strong>{{ player.name }}</strong></td>
|
||||
<td style="padding: 12px;">
|
||||
<code style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px;">
|
||||
{{ player.hostname }}
|
||||
</code>
|
||||
</td>
|
||||
<td style="padding: 12px;">{{ player.location or '-' }}</td>
|
||||
<td style="padding: 12px;">
|
||||
<form method="POST" action="{{ url_for('content.assign_player_to_playlist', player_id=player.id) }}"
|
||||
style="display: inline;">
|
||||
<select name="playlist_id" class="form-control" style="width: auto; display: inline-block;"
|
||||
onchange="this.form.submit()">
|
||||
<option value="">No Playlist</option>
|
||||
{% for playlist in playlists %}
|
||||
<option value="{{ playlist.id }}"
|
||||
{% if player.playlist_id == playlist.id %}selected{% endif %}>
|
||||
{{ playlist.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
{% if player.is_online %}
|
||||
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
|
||||
🟢 Online
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
|
||||
⚫ Offline
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
👁️ View
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// File upload handling
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const uploadZone = document.getElementById('upload-zone');
|
||||
const fileList = document.getElementById('file-list');
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
|
||||
fileInput.addEventListener('change', handleFiles);
|
||||
|
||||
// Drag and drop
|
||||
uploadZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.add('drag-over');
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('dragleave', () => {
|
||||
uploadZone.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
uploadZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadZone.classList.remove('drag-over');
|
||||
fileInput.files = e.dataTransfer.files;
|
||||
handleFiles();
|
||||
});
|
||||
|
||||
function handleFiles() {
|
||||
const files = fileInput.files;
|
||||
fileList.innerHTML = '';
|
||||
|
||||
if (files.length > 0) {
|
||||
uploadBtn.disabled = false;
|
||||
const ul = document.createElement('ul');
|
||||
ul.style.cssText = 'list-style: none; padding: 0;';
|
||||
|
||||
for (let file of files) {
|
||||
const li = document.createElement('li');
|
||||
li.style.cssText = 'padding: 8px; background: #f8f9fa; margin-bottom: 5px; border-radius: 4px;';
|
||||
li.textContent = `📎 ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`;
|
||||
ul.appendChild(li);
|
||||
}
|
||||
|
||||
fileList.appendChild(ul);
|
||||
} else {
|
||||
uploadBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
304
app/templates/content/manage_playlist_content.html
Normal file
@@ -0,0 +1,304 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage {{ playlist.name }} - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.playlist-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.playlist-header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.playlist-table th {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.playlist-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.draggable-row {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.draggable-row:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.available-content {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.content-item {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container" style="max-width: 1400px;">
|
||||
<div class="playlist-header">
|
||||
<h1>🎬 {{ playlist.name }}</h1>
|
||||
{% if playlist.description %}
|
||||
<p style="margin: 5px 0; opacity: 0.9;">{{ playlist.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Content Items</span>
|
||||
<span class="stat-value">{{ playlist_content|length }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Total Duration</span>
|
||||
<span class="stat-value">{{ playlist.total_duration }}s</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Version</span>
|
||||
<span class="stat-value">{{ playlist.version }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Players Assigned</span>
|
||||
<span class="stat-value">{{ playlist.player_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">
|
||||
← Back to Playlists
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom: 20px;">📋 Playlist Content (Drag to Reorder)</h2>
|
||||
|
||||
{% if playlist_content %}
|
||||
<table class="playlist-table" id="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th style="width: 50px;">#</th>
|
||||
<th>Filename</th>
|
||||
<th style="width: 100px;">Type</th>
|
||||
<th style="width: 100px;">Duration</th>
|
||||
<th style="width: 100px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="playlist-tbody">
|
||||
{% for content in playlist_content %}
|
||||
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
|
||||
<td><span class="drag-handle">⋮⋮</span></td>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ content.filename }}</td>
|
||||
<td>
|
||||
{% if content.content_type == 'image' %}📷 Image
|
||||
{% elif content.content_type == 'video' %}🎥 Video
|
||||
{% elif content.content_type == 'pdf' %}📄 PDF
|
||||
{% else %}📁 Other{% endif %}
|
||||
</td>
|
||||
<td>{{ content._playlist_duration or content.duration }}s</td>
|
||||
<td>
|
||||
<form method="POST"
|
||||
action="{{ url_for('content.remove_content_from_playlist', playlist_id=playlist.id, content_id=content.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Remove from playlist?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
✕
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; color: #999;">
|
||||
<div style="font-size: 48px;">📭</div>
|
||||
<p>No content in playlist yet. Add content from the right panel.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin-bottom: 20px;">➕ Add Content</h2>
|
||||
|
||||
{% if available_content %}
|
||||
<div class="available-content">
|
||||
{% for content in available_content %}
|
||||
<div class="content-item">
|
||||
<div>
|
||||
<div>
|
||||
{% if content.content_type == 'image' %}📷
|
||||
{% elif content.content_type == 'video' %}🎥
|
||||
{% elif content.content_type == 'pdf' %}📄
|
||||
{% else %}📁{% endif %}
|
||||
{{ content.filename }}
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #999;">
|
||||
{{ content.file_size_mb }} MB
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST"
|
||||
action="{{ url_for('content.add_content_to_playlist', playlist_id=playlist.id) }}"
|
||||
style="display: inline;">
|
||||
<input type="hidden" name="content_id" value="{{ content.id }}">
|
||||
<input type="hidden" name="duration" value="{{ content.duration }}">
|
||||
<button type="submit" class="btn btn-success btn-sm">
|
||||
+ Add
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 40px; color: #999;">
|
||||
<p>All available content has been added to this playlist!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let draggedElement = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tbody = document.getElementById('playlist-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
function handleDragStart(e) {
|
||||
draggedElement = this;
|
||||
this.style.opacity = '0.5';
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
if (e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
if (e.stopPropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (draggedElement !== this) {
|
||||
const tbody = document.getElementById('playlist-tbody');
|
||||
const allRows = [...tbody.querySelectorAll('.draggable-row')];
|
||||
const draggedIndex = allRows.indexOf(draggedElement);
|
||||
const targetIndex = allRows.indexOf(this);
|
||||
|
||||
if (draggedIndex < targetIndex) {
|
||||
this.parentNode.insertBefore(draggedElement, this.nextSibling);
|
||||
} else {
|
||||
this.parentNode.insertBefore(draggedElement, this);
|
||||
}
|
||||
|
||||
updateRowNumbers();
|
||||
saveOrder();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleDragEnd(e) {
|
||||
this.style.opacity = '1';
|
||||
}
|
||||
|
||||
function updateRowNumbers() {
|
||||
const rows = document.querySelectorAll('#playlist-tbody tr');
|
||||
rows.forEach((row, index) => {
|
||||
row.querySelector('td:nth-child(2)').textContent = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
function saveOrder() {
|
||||
const rows = document.querySelectorAll('#playlist-tbody .draggable-row');
|
||||
const contentIds = Array.from(rows).map(row => parseInt(row.dataset.contentId));
|
||||
|
||||
fetch('{{ url_for("content.reorder_playlist_content", playlist_id=playlist.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content_ids: contentIds })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
alert('Error reordering: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -12,34 +12,19 @@
|
||||
<input type="hidden" name="return_url" value="{{ return_url or url_for('content.content_list') }}">
|
||||
|
||||
<div class="card" style="margin-bottom: 20px;">
|
||||
<h3 style="margin-bottom: 15px;">Target Selection</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
|
||||
<h3 style="margin-bottom: 15px;">Select Player</h3>
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Target Type:</label>
|
||||
<select name="target_type" id="target_type" class="form-control" required onchange="updateTargetIdOptions()">
|
||||
<option value="" disabled selected>Select Target Type</option>
|
||||
<option value="player" {% if target_type == 'player' %}selected{% endif %}>Player</option>
|
||||
<option value="group" {% if target_type == 'group' %}selected{% endif %}>Group</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Target ID:</label>
|
||||
<select name="target_id" id="target_id" class="form-control" required>
|
||||
{% if target_type == 'player' %}
|
||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Player:</label>
|
||||
<select name="player_id" id="player_id" class="form-control" required>
|
||||
<option value="" disabled {% if not selected_player_id %}selected{% endif %}>Select a Player</option>
|
||||
{% for player in players %}
|
||||
<option value="{{ player.id }}" {% if target_id == player.id %}selected{% endif %}>{{ player.name }}</option>
|
||||
<option value="{{ player.id }}" {% if selected_player_id == player.id %}selected{% endif %}>
|
||||
{{ player.name }} - {{ player.location or 'No location' }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
{% elif target_type == 'group' %}
|
||||
{% for group in groups %}
|
||||
<option value="{{ group.id }}" {% if target_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<option value="" disabled selected>Select a Target ID</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom: 20px;">
|
||||
<h3 style="margin-bottom: 15px;">Media Details</h3>
|
||||
@@ -238,29 +223,7 @@ function pollUploadProgress() {
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function updateTargetIdOptions() {
|
||||
const targetType = document.getElementById('target_type').value;
|
||||
const targetIdSelect = document.getElementById('target_id');
|
||||
targetIdSelect.innerHTML = '';
|
||||
|
||||
if (targetType === 'player') {
|
||||
const players = {{ players|tojson }};
|
||||
players.forEach(player => {
|
||||
const option = document.createElement('option');
|
||||
option.value = player.id;
|
||||
option.textContent = player.name;
|
||||
targetIdSelect.appendChild(option);
|
||||
});
|
||||
} else if (targetType === 'group') {
|
||||
const groups = {{ groups|tojson }};
|
||||
groups.forEach(group => {
|
||||
const option = document.createElement('option');
|
||||
option.value = group.id;
|
||||
option.textContent = group.name;
|
||||
targetIdSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleMediaTypeChange() {
|
||||
const mediaType = document.getElementById('media_type').value;
|
||||
|
||||
361
app/templates/content/upload_media.html
Normal file
@@ -0,0 +1,361 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Upload Media - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.upload-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
border: 3px dashed #ced4da;
|
||||
border-radius: 12px;
|
||||
padding: 60px 40px;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: #667eea;
|
||||
background: #f0f2ff;
|
||||
}
|
||||
|
||||
.upload-zone.dragover {
|
||||
border-color: #667eea;
|
||||
background: #e8ebff;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.file-input-wrapper {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.file-input-wrapper input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.remove-file {
|
||||
cursor: pointer;
|
||||
color: #dc3545;
|
||||
font-weight: bold;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.remove-file:hover {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.btn-upload {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 40px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-upload:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-upload:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.playlist-selector {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
border: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.playlist-selector.selected {
|
||||
border-color: #667eea;
|
||||
background: #f0f2ff;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="upload-container">
|
||||
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h1 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||
Upload Media Files
|
||||
</h1>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Back to Playlists
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form id="upload-form" method="POST" action="{{ url_for('content.upload_media') }}" enctype="multipart/form-data">
|
||||
|
||||
<!-- Playlist Selector -->
|
||||
<div class="card" style="margin-bottom: 30px;">
|
||||
<h2 style="margin-bottom: 15px;">📋 Select Target Playlist (Optional)</h2>
|
||||
<p style="color: #6c757d; margin-bottom: 20px;">
|
||||
Choose a playlist to directly add uploaded files, or leave blank to add to media library only.
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="playlist_id">Target Playlist</label>
|
||||
<select name="playlist_id" id="playlist_id" class="form-control">
|
||||
<option value="">-- Media Library Only (Don't add to playlist) --</option>
|
||||
{% for playlist in playlists %}
|
||||
<option value="{{ playlist.id }}">
|
||||
{{ playlist.name }} ({{ playlist.orientation }}) - v{{ playlist.version }} - {{ playlist.content_count }} items
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small style="color: #6c757d; display: block; margin-top: 5px;">
|
||||
💡 Tip: You can add files to playlists later from the media library
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Zone -->
|
||||
<div class="card" style="margin-bottom: 30px;">
|
||||
<h2 style="margin-bottom: 20px; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Select Files
|
||||
</h2>
|
||||
|
||||
<div class="upload-zone" id="upload-zone">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 96px; height: 96px; opacity: 0.3; margin-bottom: 20px;">
|
||||
<h3 style="margin-bottom: 10px;">Drag and Drop Files Here</h3>
|
||||
<p style="color: #6c757d; margin: 15px 0;">or</p>
|
||||
<div class="file-input-wrapper">
|
||||
<label for="file-input" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Browse Files
|
||||
</label>
|
||||
<input type="file" id="file-input" name="files" multiple
|
||||
accept="image/*,video/*,.pdf,.ppt,.pptx">
|
||||
</div>
|
||||
<p style="font-size: 14px; color: #999; margin-top: 20px;">
|
||||
<strong>Supported formats:</strong><br>
|
||||
Images: JPG, PNG, GIF, BMP<br>
|
||||
Videos: MP4, AVI, MOV, MKV, WEBM<br>
|
||||
Documents: PDF, PPT, PPTX
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="file-list" class="file-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Settings -->
|
||||
<div class="card" style="margin-bottom: 30px;">
|
||||
<h2 style="margin-bottom: 20px; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Upload Settings
|
||||
</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content_type">Media Type</label>
|
||||
<select name="content_type" id="content_type" class="form-control">
|
||||
<option value="image">Image</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="pdf">PDF Document</option>
|
||||
</select>
|
||||
<small style="color: #6c757d; display: block; margin-top: 5px;">
|
||||
This will be auto-detected from file extension
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="duration">Default Duration (seconds)</label>
|
||||
<input type="number" name="duration" id="duration" class="form-control"
|
||||
value="10" min="1" max="300">
|
||||
<small style="color: #6c757d; display: block; margin-top: 5px;">
|
||||
How long each item should display (for images and PDFs)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Button -->
|
||||
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 20px; height: 20px; filter: brightness(0) invert(1);">
|
||||
Upload Files
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const uploadZone = document.getElementById('upload-zone');
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const fileList = document.getElementById('file-list');
|
||||
const uploadBtn = document.getElementById('upload-btn');
|
||||
const uploadForm = document.getElementById('upload-form');
|
||||
|
||||
let selectedFiles = [];
|
||||
|
||||
// Prevent default drag behaviors
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
uploadZone.addEventListener(eventName, preventDefaults, false);
|
||||
document.body.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// Highlight drop zone when item is dragged over it
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
uploadZone.addEventListener(eventName, () => {
|
||||
uploadZone.classList.add('dragover');
|
||||
});
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
uploadZone.addEventListener(eventName, () => {
|
||||
uploadZone.classList.remove('dragover');
|
||||
});
|
||||
});
|
||||
|
||||
// Handle dropped files
|
||||
uploadZone.addEventListener('drop', (e) => {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
handleFiles(files);
|
||||
});
|
||||
|
||||
// Handle file input
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
handleFiles(e.target.files);
|
||||
});
|
||||
|
||||
// Click upload zone to trigger file input
|
||||
uploadZone.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
function handleFiles(files) {
|
||||
selectedFiles = Array.from(files);
|
||||
displayFiles();
|
||||
uploadBtn.disabled = selectedFiles.length === 0;
|
||||
}
|
||||
|
||||
function displayFiles() {
|
||||
fileList.innerHTML = '';
|
||||
|
||||
if (selectedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFiles.forEach((file, index) => {
|
||||
const fileItem = document.createElement('div');
|
||||
fileItem.className = 'file-item';
|
||||
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
let icon = '📁';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(ext)) icon = '📷';
|
||||
else if (['mp4', 'avi', 'mov', 'mkv', 'webm'].includes(ext)) icon = '🎥';
|
||||
else if (ext === 'pdf') icon = '📄';
|
||||
else if (['ppt', 'pptx'].includes(ext)) icon = '📊';
|
||||
|
||||
const sizeInMB = (file.size / (1024 * 1024)).toFixed(2);
|
||||
|
||||
fileItem.innerHTML = `
|
||||
<div class="file-info">
|
||||
<span class="file-icon">${icon}</span>
|
||||
<div>
|
||||
<div style="font-weight: 600;">${file.name}</div>
|
||||
<div style="font-size: 12px; color: #6c757d;">${sizeInMB} MB</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="remove-file" onclick="removeFile(${index})">✕</span>
|
||||
`;
|
||||
|
||||
fileList.appendChild(fileItem);
|
||||
});
|
||||
}
|
||||
|
||||
function removeFile(index) {
|
||||
selectedFiles.splice(index, 1);
|
||||
displayFiles();
|
||||
uploadBtn.disabled = selectedFiles.length === 0;
|
||||
|
||||
// Update file input
|
||||
const dt = new DataTransfer();
|
||||
selectedFiles.forEach(file => dt.items.add(file));
|
||||
fileInput.files = dt.files;
|
||||
}
|
||||
|
||||
// Form submission
|
||||
uploadForm.addEventListener('submit', (e) => {
|
||||
if (selectedFiles.length === 0) {
|
||||
e.preventDefault();
|
||||
alert('Please select files to upload');
|
||||
return;
|
||||
}
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.innerHTML = '⏳ Uploading...';
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -7,42 +7,80 @@
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
|
||||
<div class="card">
|
||||
<h3 style="color: #3498db; margin-bottom: 0.5rem;">👥 Players</h3>
|
||||
<h3 style="color: #3498db; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Players
|
||||
</h3>
|
||||
<p style="font-size: 2rem; font-weight: bold;">{{ total_players or 0 }}</p>
|
||||
<a href="{{ url_for('players.list') }}" class="btn" style="margin-top: 1rem;">View Players</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color: #9b59b6; margin-bottom: 0.5rem;">📁 Groups</h3>
|
||||
<p style="font-size: 2rem; font-weight: bold;">{{ total_groups or 0 }}</p>
|
||||
<a href="{{ url_for('groups.groups_list') }}" class="btn" style="margin-top: 1rem;">View Groups</a>
|
||||
<h3 style="color: #9b59b6; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Playlists
|
||||
</h3>
|
||||
<p style="font-size: 2rem; font-weight: bold;">{{ total_playlists or 0 }}</p>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn" style="margin-top: 1rem;">Manage Playlists</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color: #e67e22; margin-bottom: 0.5rem;">🎬 Content</h3>
|
||||
<h3 style="color: #e67e22; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Media Library
|
||||
</h3>
|
||||
<p style="font-size: 2rem; font-weight: bold;">{{ total_content or 0 }}</p>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn" style="margin-top: 1rem;">View Content</a>
|
||||
<p style="font-size: 0.9rem; color: #7f8c8d; margin-top: 0.5rem;">Unique media files</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="color: #27ae60; margin-bottom: 0.5rem;">💾 Storage</h3>
|
||||
<h3 style="color: #27ae60; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Storage
|
||||
</h3>
|
||||
<p style="font-size: 2rem; font-weight: bold;">{{ storage_mb or 0 }} MB</p>
|
||||
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success" style="margin-top: 1rem;">Upload Content</a>
|
||||
<p style="font-size: 0.9rem; color: #7f8c8d; margin-top: 0.5rem;">Total uploads</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Quick Actions</h2>
|
||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem;">
|
||||
<a href="{{ url_for('players.add_player') }}" class="btn btn-success">➕ Add Player</a>
|
||||
<a href="{{ url_for('groups.create_group') }}" class="btn btn-success">➕ Create Group</a>
|
||||
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success">⬆️ Upload Content</a>
|
||||
<a href="{{ url_for('players.add_player') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Add Player
|
||||
</a>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Create Playlist
|
||||
</a>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Upload Media
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn">⚙️ Admin Panel</a>
|
||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn">Admin Panel</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Workflow Guide
|
||||
</h2>
|
||||
<div style="margin-top: 1rem; padding: 1rem; background: #f8f9fa; border-radius: 4px;">
|
||||
<ol style="line-height: 2; margin: 0; padding-left: 1.5rem;">
|
||||
<li><strong>Create a Playlist</strong> - Group your content into themed collections</li>
|
||||
<li><strong>Upload Media</strong> - Add images, videos, or PDFs to your media library</li>
|
||||
<li><strong>Add Content to Playlist</strong> - Build your playlist with drag-and-drop ordering</li>
|
||||
<li><strong>Add Player</strong> - Register physical display devices</li>
|
||||
<li><strong>Assign Playlist</strong> - Connect players to their playlists</li>
|
||||
<li><strong>Players Auto-Download</strong> - Devices fetch and display content automatically</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if recent_logs %}
|
||||
<div class="card">
|
||||
<h2>Recent Activity</h2>
|
||||
@@ -63,7 +101,8 @@
|
||||
<div class="card">
|
||||
<h2>System Status</h2>
|
||||
<p>✅ All systems operational</p>
|
||||
<p>🔄 Blueprint architecture active</p>
|
||||
<p>⚡ Flask {{ config.get('FLASK_VERSION', '3.1.0') }}</p>
|
||||
<p><EFBFBD> Playlist-centric architecture active</p>
|
||||
<p>🔄 Groups removed - Streamlined workflow</p>
|
||||
<p>⚡ DigiServer v2.0</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
252
app/templates/players/manage_player.html
Normal file
@@ -0,0 +1,252 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage Player - {{ player.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="margin-bottom: 2rem;">
|
||||
<h1 style="display: inline-block; margin-right: 1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||
Manage Player: {{ player.name }}
|
||||
</h1>
|
||||
<a href="{{ url_for('players.list') }}" class="btn" style="float: right; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Back to Players
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Player Status Overview -->
|
||||
<div class="card" style="margin-bottom: 2rem; background: {% if player.status == 'online' %}#d4edda{% elif player.status == 'offline' %}#f8d7da{% else %}#fff3cd{% endif %};">
|
||||
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
Status:
|
||||
{% if player.status == 'online' %}
|
||||
<span style="color: #28a745; display: flex; align-items: center; gap: 0.3rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px; color: #28a745;">
|
||||
Online
|
||||
</span>
|
||||
{% elif player.status == 'offline' %}
|
||||
<span style="color: #dc3545; display: flex; align-items: center; gap: 0.3rem;">
|
||||
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 20px; height: 20px; color: #dc3545;">
|
||||
Offline
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="color: #ffc107; display: flex; align-items: center; gap: 0.3rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px; color: #ffc107;">
|
||||
{{ player.status|title }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
|
||||
<p><strong>Last Seen:</strong>
|
||||
{% if player.last_seen %}
|
||||
{{ player.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</p>
|
||||
<p><strong>Assigned Playlist:</strong>
|
||||
{% if current_playlist %}
|
||||
<span style="color: #28a745; font-weight: bold;">{{ current_playlist.name }} (v{{ current_playlist.version }})</span>
|
||||
{% else %}
|
||||
<span style="color: #dc3545;">No playlist assigned</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Three Column Layout -->
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1.5rem;">
|
||||
|
||||
<!-- Card 1: Edit Credentials -->
|
||||
<div class="card">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Edit Credentials
|
||||
</h2>
|
||||
<form method="POST" style="margin-top: 1rem;">
|
||||
<input type="hidden" name="action" value="update_credentials">
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Player Name *</label>
|
||||
<input type="text" id="name" name="name" value="{{ player.name }}"
|
||||
required minlength="3"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="location" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Location</label>
|
||||
<input type="text" id="location" name="location" value="{{ player.location or '' }}"
|
||||
placeholder="e.g., Main Lobby"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="orientation" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Orientation</label>
|
||||
<select id="orientation" name="orientation"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<option value="Landscape" {% if player.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
|
||||
<option value="Portrait" {% if player.orientation == 'Portrait' %}selected{% endif %}>Portrait</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="padding: 1rem; background: #f8f9fa; border-radius: 4px; margin-bottom: 1rem;">
|
||||
<p style="margin: 0; font-size: 0.9rem;"><strong>Hostname:</strong> {{ player.hostname }}</p>
|
||||
<p style="margin: 0.5rem 0 0 0; font-size: 0.9rem;"><strong>Auth Code:</strong> <code>{{ player.auth_code }}</code></p>
|
||||
<p style="margin: 0.5rem 0 0 0; font-size: 0.9rem;"><strong>Quick Connect:</strong> {{ player.quickconnect_code or 'Not set' }}</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Save Changes
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Assign Playlist -->
|
||||
<div class="card">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Assign Playlist
|
||||
</h2>
|
||||
<form method="POST" style="margin-top: 1rem;">
|
||||
<input type="hidden" name="action" value="assign_playlist">
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="playlist_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Select Playlist</label>
|
||||
<select id="playlist_id" name="playlist_id"
|
||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<option value="">-- No Playlist (Unassign) --</option>
|
||||
{% for playlist in playlists %}
|
||||
<option value="{{ playlist.id }}"
|
||||
{% if player.playlist_id == playlist.id %}selected{% endif %}>
|
||||
{{ playlist.name }} (v{{ playlist.version }}) - {{ playlist.contents.count() }} items
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if current_playlist %}
|
||||
<div style="padding: 1rem; background: #d4edda; border-radius: 4px; margin-bottom: 1rem;">
|
||||
<h4 style="margin: 0 0 0.5rem 0; color: #155724;">Currently Assigned:</h4>
|
||||
<p style="margin: 0;"><strong>{{ current_playlist.name }}</strong></p>
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Version: {{ current_playlist.version }}</p>
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Content Items: {{ current_playlist.contents.count() }}</p>
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem; color: #6c757d;">
|
||||
Updated: {{ current_playlist.updated_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding: 1rem; background: #fff3cd; border-radius: 4px; margin-bottom: 1rem;">
|
||||
<p style="margin: 0; color: #856404; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 18px; height: 18px;">
|
||||
No playlist currently assigned to this player.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Assign Playlist
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid #ddd;">
|
||||
<h4>Quick Actions:</h4>
|
||||
<a href="{{ url_for('content.content_list') }}" class="btn" style="width: 100%; margin-top: 0.5rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Create New Playlist
|
||||
</a>
|
||||
{% if current_playlist %}
|
||||
<a href="{{ url_for('content.manage_playlist_content', playlist_id=current_playlist.id) }}"
|
||||
class="btn" style="width: 100%; margin-top: 0.5rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||
Edit Current Playlist
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 3: Player Logs -->
|
||||
<div class="card">
|
||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||
Player Logs
|
||||
</h2>
|
||||
<p style="color: #6c757d; font-size: 0.9rem;">Recent feedback from the player device</p>
|
||||
|
||||
<div style="max-height: 500px; overflow-y: auto; margin-top: 1rem;">
|
||||
{% if recent_logs %}
|
||||
{% for log in recent_logs %}
|
||||
<div style="padding: 0.75rem; margin-bottom: 0.5rem; border-left: 4px solid
|
||||
{% if log.status == 'error' %}#dc3545
|
||||
{% elif log.status == 'warning' %}#ffc107
|
||||
{% elif log.status == 'playing' %}#28a745
|
||||
{% else %}#17a2b8{% endif %};
|
||||
background: #f8f9fa; border-radius: 4px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div style="flex: 1;">
|
||||
<strong style="color:
|
||||
{% if log.status == 'error' %}#dc3545
|
||||
{% elif log.status == 'warning' %}#ffc107
|
||||
{% elif log.status == 'playing' %}#28a745
|
||||
{% else %}#17a2b8{% endif %};">
|
||||
{% if log.status == 'error' %}❌
|
||||
{% elif log.status == 'warning' %}⚠️
|
||||
{% elif log.status == 'playing' %}▶️
|
||||
{% elif log.status == 'restarting' %}🔄
|
||||
{% else %}ℹ️{% endif %}
|
||||
{{ log.status|upper }}
|
||||
</strong>
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">{{ log.message }}</p>
|
||||
{% if log.playlist_version %}
|
||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.85rem; color: #6c757d;">
|
||||
Playlist v{{ log.playlist_version }}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if log.error_details %}
|
||||
<details style="margin-top: 0.5rem;">
|
||||
<summary style="cursor: pointer; font-size: 0.85rem; color: #dc3545;">Error Details</summary>
|
||||
<pre style="margin: 0.5rem 0 0 0; padding: 0.5rem; background: #fff; border: 1px solid #ddd; border-radius: 4px; font-size: 0.8rem; overflow-x: auto;">{{ log.error_details }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
<small style="color: #6c757d; white-space: nowrap; margin-left: 1rem;">
|
||||
{{ log.timestamp.strftime('%m/%d %H:%M') }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="text-align: center; padding: 2rem; color: #6c757d;">
|
||||
<p>📭 No logs received yet</p>
|
||||
<p style="font-size: 0.9rem;">Logs will appear here once the player starts sending feedback</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Additional Info Section -->
|
||||
<div class="card" style="margin-top: 2rem;">
|
||||
<h2>ℹ️ Player Information</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-top: 1rem;">
|
||||
<div>
|
||||
<p><strong>Player ID:</strong> {{ player.id }}</p>
|
||||
<p><strong>Created:</strong> {{ player.created_at.strftime('%Y-%m-%d %H:%M') if player.created_at else 'N/A' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p><strong>Orientation:</strong> {{ player.orientation }}</p>
|
||||
<p><strong>Location:</strong> {{ player.location or 'Not set' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p><strong>Last Heartbeat:</strong>
|
||||
{% if player.last_heartbeat %}
|
||||
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -27,8 +27,8 @@
|
||||
<a href="{{ url_for('players.edit_player', player_id=player.id) }}" class="btn btn-primary">
|
||||
✏️ Edit Player
|
||||
</a>
|
||||
<a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}" class="btn btn-success">
|
||||
📤 Upload Content
|
||||
<a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}" class="btn btn-success">
|
||||
🎬 Manage Playlist
|
||||
</a>
|
||||
<a href="{{ url_for('players.list') }}" class="btn">
|
||||
← Back to Players
|
||||
@@ -62,18 +62,6 @@
|
||||
<td style="padding: 10px; font-weight: bold;">Orientation:</td>
|
||||
<td style="padding: 10px;">{{ player.orientation or 'Landscape' }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 10px; font-weight: bold;">Group:</td>
|
||||
<td style="padding: 10px;">
|
||||
{% if player.group %}
|
||||
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
|
||||
{{ player.group.name }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span style="color: #6c757d;">No group</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 10px; font-weight: bold;">Created:</td>
|
||||
<td style="padding: 10px;">{{ player.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
@@ -138,77 +126,43 @@
|
||||
<!-- Playlist Management Card -->
|
||||
<div class="card" style="margin-bottom: 20px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||
<h3 style="margin: 0;">🎬 Current Playlist</h3>
|
||||
<div>
|
||||
<a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}"
|
||||
class="btn btn-success btn-sm">
|
||||
+ Add Content
|
||||
</a>
|
||||
</div>
|
||||
<h3 style="margin: 0;">🎬 Playlist Management</h3>
|
||||
</div>
|
||||
|
||||
{% if playlist %}
|
||||
<div style="background: #f8f9fa; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
|
||||
<strong>Total Items:</strong> {{ playlist|length }} |
|
||||
<strong>Total Duration:</strong> {% set total_duration = namespace(value=0) %}{% for item in playlist %}{% set total_duration.value = total_duration.value + (item.duration or 10) %}{% endfor %}{{ total_duration.value }}s
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 15px;">
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px;">
|
||||
<div>
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Items</div>
|
||||
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ playlist|length }}</div>
|
||||
</div>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background: #f8f9fa; text-align: left;">
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6; width: 50px;">Order</th>
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">File Name</th>
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Type</th>
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Duration</th>
|
||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="playlist-items">
|
||||
<div>
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Duration</div>
|
||||
<div style="font-size: 24px; font-weight: bold; color: #333;">
|
||||
{% set total_duration = namespace(value=0) %}
|
||||
{% for item in playlist %}
|
||||
<tr style="border-bottom: 1px solid #dee2e6;" data-content-id="{{ item.id }}">
|
||||
<td style="padding: 10px; text-align: center;">
|
||||
<strong>{{ loop.index }}</strong>
|
||||
</td>
|
||||
<td style="padding: 10px;">
|
||||
{{ item.filename }}
|
||||
</td>
|
||||
<td style="padding: 10px;">
|
||||
{% if item.type == 'image' %}
|
||||
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📷 Image</span>
|
||||
{% elif item.type == 'video' %}
|
||||
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">🎬 Video</span>
|
||||
{% elif item.type == 'pdf' %}
|
||||
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📄 PDF</span>
|
||||
{% else %}
|
||||
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📁 Other</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 10px;">
|
||||
{{ item.duration or 10 }}s
|
||||
</td>
|
||||
<td style="padding: 10px;">
|
||||
<button onclick="moveUp({{ item.id }})" class="btn btn-sm"
|
||||
style="background: #007bff; color: white; padding: 3px 8px; margin-right: 5px;"
|
||||
{% if loop.first %}disabled{% endif %}>
|
||||
↑
|
||||
</button>
|
||||
<button onclick="moveDown({{ item.id }})" class="btn btn-sm"
|
||||
style="background: #007bff; color: white; padding: 3px 8px; margin-right: 5px;"
|
||||
{% if loop.last %}disabled{% endif %}>
|
||||
↓
|
||||
</button>
|
||||
<button onclick="removeFromPlaylist({{ item.id }}, '{{ item.filename }}')"
|
||||
class="btn btn-danger btn-sm" style="padding: 3px 8px;">
|
||||
🗑️ Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% set total_duration.value = total_duration.value + (item.duration or 10) %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div style="background: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 15px; border-radius: 5px; text-align: center;">
|
||||
⚠️ No content in playlist. <a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}" style="color: #856404; text-decoration: underline;">Upload content</a> to get started.
|
||||
{{ total_duration.value }}s
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Playlist Version</div>
|
||||
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ player.playlist_version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}"
|
||||
class="btn btn-primary"
|
||||
style="display: inline-block; width: 100%; text-align: center; padding: 15px; font-size: 16px;">
|
||||
🎬 Open Playlist Manager
|
||||
</a>
|
||||
|
||||
{% if not playlist %}
|
||||
<div style="background: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 15px; border-radius: 5px; text-align: center; margin-top: 15px;">
|
||||
⚠️ No content in playlist. Open the playlist manager to add content.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -270,62 +224,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function moveUp(contentId) {
|
||||
updatePlaylistOrder(contentId, 'up');
|
||||
}
|
||||
|
||||
function moveDown(contentId) {
|
||||
updatePlaylistOrder(contentId, 'down');
|
||||
}
|
||||
|
||||
function updatePlaylistOrder(contentId, direction) {
|
||||
fetch('/players/{{ player.id }}/playlist/reorder', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content_id: contentId,
|
||||
direction: direction
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error reordering playlist: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error reordering playlist: ' + error);
|
||||
});
|
||||
}
|
||||
|
||||
function removeFromPlaylist(contentId, filename) {
|
||||
if (confirm(`Remove "${filename}" from this player's playlist?`)) {
|
||||
fetch('/players/{{ player.id }}/playlist/remove', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content_id: contentId
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error removing content: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error removing content: ' + error);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Name</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Hostname</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Location</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Group</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Orientation</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Status</th>
|
||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Last Seen</th>
|
||||
@@ -36,13 +35,6 @@
|
||||
<td style="padding: 12px;">
|
||||
{{ player.location or '-' }}
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
{% if player.group %}
|
||||
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">{{ player.group.name }}</span>
|
||||
{% else %}
|
||||
<span style="color: #6c757d;">No group</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
{{ player.orientation or 'Landscape' }}
|
||||
</td>
|
||||
@@ -62,25 +54,10 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 12px;">
|
||||
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
|
||||
class="btn btn-info btn-sm" title="View" style="margin-right: 5px;">
|
||||
👁️ View
|
||||
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||
class="btn btn-info btn-sm" title="Manage Player">
|
||||
⚙️ Manage
|
||||
</a>
|
||||
<a href="{{ url_for('players.edit_player', player_id=player.id) }}"
|
||||
class="btn btn-primary btn-sm" title="Edit" style="margin-right: 5px;">
|
||||
✏️ Edit
|
||||
</a>
|
||||
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
|
||||
class="btn btn-success btn-sm" title="Fullscreen" target="_blank" style="margin-right: 5px;">
|
||||
⛶ Full
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('players.delete_player', player_id=player.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to delete player \'{{ player.name }}\'?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm" title="Delete">
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
492
app/templates/playlist/manage_playlist.html
Normal file
@@ -0,0 +1,492 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Manage Playlist - {{ player.name }} - DigiServer v2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.playlist-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.player-info-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 25px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.player-info-card h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.player-info-card p {
|
||||
margin: 5px 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.playlist-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
margin-bottom: 25px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.playlist-table thead {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.playlist-table th {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.playlist-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.playlist-table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.draggable-row {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.draggable-row.dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.add-content-form {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.duration-input {
|
||||
width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="playlist-container">
|
||||
<!-- Player Info Card -->
|
||||
<div class="player-info-card">
|
||||
<h1>🎬 {{ player.name }}</h1>
|
||||
<p>📍 {{ player.location or 'No location' }}</p>
|
||||
<p>🖥️ Hostname: {{ player.hostname }}</p>
|
||||
<p>📊 Status: {{ '🟢 Online' if player.is_online else '🔴 Offline' }}</p>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Playlist Items</div>
|
||||
<div class="stat-value">{{ playlist_content|length }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Playlist Version</div>
|
||||
<div class="stat-value">{{ player.playlist_version }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">Total Duration</div>
|
||||
<div class="stat-value">{{ playlist_content|sum(attribute='duration') }}s</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div style="margin-bottom: 20px; display: flex; gap: 10px;">
|
||||
<a href="{{ url_for('players.player_page', player_id=player.id) }}" class="btn btn-secondary">
|
||||
← Back to Player
|
||||
</a>
|
||||
<a href="{{ url_for('content.upload_content', player_id=player.id, return_url=url_for('playlist.manage_playlist', player_id=player.id)) }}"
|
||||
class="btn btn-success">
|
||||
➕ Upload New Content
|
||||
</a>
|
||||
{% if playlist_content %}
|
||||
<form method="POST" action="{{ url_for('playlist.clear_playlist', player_id=player.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Are you sure you want to clear the entire playlist?');">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
🗑️ Clear Playlist
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Current Playlist -->
|
||||
<div class="playlist-section">
|
||||
<div class="section-header">
|
||||
<h2>📋 Current Playlist</h2>
|
||||
<span style="color: #999; font-size: 14px;">
|
||||
Drag and drop to reorder
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if playlist_content %}
|
||||
<table class="playlist-table" id="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th style="width: 50px;">#</th>
|
||||
<th>Filename</th>
|
||||
<th style="width: 100px;">Type</th>
|
||||
<th style="width: 120px;">Duration (s)</th>
|
||||
<th style="width: 100px;">Size</th>
|
||||
<th style="width: 150px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="playlist-tbody">
|
||||
{% for content in playlist_content %}
|
||||
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
|
||||
<td>
|
||||
<span class="drag-handle">⋮⋮</span>
|
||||
</td>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ content.filename }}</td>
|
||||
<td>
|
||||
{% if content.content_type == 'image' %}📷 Image
|
||||
{% elif content.content_type == 'video' %}🎥 Video
|
||||
{% elif content.content_type == 'pdf' %}📄 PDF
|
||||
{% else %}📁 {{ content.content_type }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<input type="number"
|
||||
class="form-control duration-input"
|
||||
value="{{ content.duration }}"
|
||||
min="1"
|
||||
onchange="updateDuration({{ content.id }}, this.value)">
|
||||
</td>
|
||||
<td>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
|
||||
<td>
|
||||
<form method="POST"
|
||||
action="{{ url_for('playlist.remove_from_playlist', player_id=player.id, content_id=content.id) }}"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Remove {{ content.filename }} from playlist?');">
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
✕ Remove
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">📭</div>
|
||||
<h3>No content in playlist</h3>
|
||||
<p>Upload content or add existing files to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Add Content Section -->
|
||||
{% if available_files %}
|
||||
<div class="playlist-section">
|
||||
<div class="section-header">
|
||||
<h2>➕ Add Existing Content</h2>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('playlist.add_to_playlist', player_id=player.id) }}"
|
||||
class="add-content-form">
|
||||
<div class="form-group">
|
||||
<label for="filename">Select File:</label>
|
||||
<select name="filename" id="filename" class="form-control" required>
|
||||
<option value="" disabled selected>Choose a file...</option>
|
||||
{% for filename in available_files %}
|
||||
<option value="{{ filename }}">{{ filename }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="duration">Display Duration (seconds):</label>
|
||||
<input type="number"
|
||||
name="duration"
|
||||
id="duration"
|
||||
class="form-control"
|
||||
value="10"
|
||||
min="1"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success">
|
||||
➕ Add to Playlist
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let draggedElement = null;
|
||||
|
||||
// Initialize drag and drop
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tbody = document.getElementById('playlist-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
function handleDragStart(e) {
|
||||
draggedElement = this;
|
||||
this.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
if (e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
if (e.stopPropagation) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (draggedElement !== this) {
|
||||
const tbody = document.getElementById('playlist-tbody');
|
||||
const allRows = [...tbody.querySelectorAll('.draggable-row')];
|
||||
const draggedIndex = allRows.indexOf(draggedElement);
|
||||
const targetIndex = allRows.indexOf(this);
|
||||
|
||||
if (draggedIndex < targetIndex) {
|
||||
this.parentNode.insertBefore(draggedElement, this.nextSibling);
|
||||
} else {
|
||||
this.parentNode.insertBefore(draggedElement, this);
|
||||
}
|
||||
|
||||
updateRowNumbers();
|
||||
saveOrder();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleDragEnd(e) {
|
||||
this.classList.remove('dragging');
|
||||
}
|
||||
|
||||
function updateRowNumbers() {
|
||||
const rows = document.querySelectorAll('#playlist-tbody tr');
|
||||
rows.forEach((row, index) => {
|
||||
row.querySelector('td:nth-child(2)').textContent = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
function saveOrder() {
|
||||
const rows = document.querySelectorAll('#playlist-tbody .draggable-row');
|
||||
const contentIds = Array.from(rows).map(row => parseInt(row.dataset.contentId));
|
||||
|
||||
fetch('{{ url_for("playlist.reorder_playlist", player_id=player.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content_ids: contentIds })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Playlist reordered successfully');
|
||||
} else {
|
||||
alert('Error reordering playlist: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error reordering playlist');
|
||||
});
|
||||
}
|
||||
|
||||
function updateDuration(contentId, duration) {
|
||||
const formData = new FormData();
|
||||
formData.append('duration', duration);
|
||||
|
||||
fetch(`{{ url_for("playlist.update_duration", player_id=player.id, content_id=0) }}`.replace('/0', `/${contentId}`), {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Duration updated successfully');
|
||||
// Update total duration
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Error updating duration: ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Error updating duration');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
10
install_emoji_fonts.sh
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Install emoji fonts for Raspberry Pi
|
||||
echo "Installing emoji font support for Raspberry Pi..."
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y fonts-noto-color-emoji fonts-noto-emoji
|
||||
|
||||
echo "✅ Emoji fonts installed!"
|
||||
echo "Please restart your browser to see the changes."
|
||||
59
migrate_add_orientation.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add orientation column to playlist table - Direct SQLite approach"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
def add_orientation_column():
|
||||
"""Add orientation column to playlist table using direct SQLite connection."""
|
||||
db_path = 'instance/dev.db'
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"❌ Database not found at: {db_path}")
|
||||
return
|
||||
|
||||
print(f"📁 Opening database: {db_path}")
|
||||
|
||||
try:
|
||||
# Connect to database
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if column already exists
|
||||
cursor.execute("PRAGMA table_info(playlist)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
|
||||
if 'orientation' in columns:
|
||||
print("✅ Column 'orientation' already exists in playlist table")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# Add the column
|
||||
print("Adding 'orientation' column to playlist table...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE playlist
|
||||
ADD COLUMN orientation VARCHAR(20) DEFAULT 'Landscape' NOT NULL
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
|
||||
print("✅ Successfully added 'orientation' column to playlist table")
|
||||
print(" Default value: 'Landscape'")
|
||||
|
||||
# Verify the column was added
|
||||
cursor.execute("PRAGMA table_info(playlist)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
if 'orientation' in columns:
|
||||
print("✓ Verified: Column exists in database")
|
||||
|
||||
conn.close()
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"❌ SQLite Error: {e}")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
return
|
||||
|
||||
if __name__ == '__main__':
|
||||
add_orientation_column()
|
||||