updated features to upload pptx files

This commit is contained in:
DigiServer Developer
2025-11-15 01:26:12 +02:00
parent 9d4f932a95
commit 930a5bf636
24 changed files with 1963 additions and 2218 deletions

View File

@@ -1,336 +0,0 @@
# Blueprint Architecture - Quick Reference
## 📦 Blueprint Structure
```
app/
├── blueprints/
│ ├── __init__.py # Package initialization
│ ├── main.py # Dashboard, health check
│ ├── auth.py # Login, register, logout
│ ├── admin.py # Admin panel, user management
│ ├── players.py # Player CRUD, fullscreen view
│ ├── groups.py # Group management, assignments
│ ├── content.py # Media upload, file management
│ └── api.py # REST API endpoints
```
---
## 🔗 URL Mapping
### Main Blueprint (`/`)
- `GET /` - Dashboard with statistics
- `GET /health` - Health check (database, disk)
### Auth Blueprint (`/auth`)
- `GET /auth/login` - Login page
- `POST /auth/login` - Process login
- `GET /auth/logout` - Logout
- `GET /auth/register` - Register page
- `POST /auth/register` - Process registration
- `GET /auth/change-password` - Change password page
- `POST /auth/change-password` - Update password
### Admin Blueprint (`/admin`)
- `GET /admin/` - Admin panel
- `POST /admin/user/create` - Create user
- `POST /admin/user/<id>/role` - Change user role
- `POST /admin/user/<id>/delete` - Delete user
- `POST /admin/theme` - Change theme
- `POST /admin/logo/upload` - Upload logo
- `POST /admin/logs/clear` - Clear logs
- `GET /admin/system/info` - System info (JSON)
### Players Blueprint (`/players`)
- `GET /players/` - List all players
- `GET /players/add` - Add player page
- `POST /players/add` - Create player
- `GET /players/<id>/edit` - Edit player page
- `POST /players/<id>/edit` - Update player
- `POST /players/<id>/delete` - Delete player
- `POST /players/<id>/regenerate-auth` - Regenerate auth code
- `GET /players/<id>` - Player page
- `GET /players/<id>/fullscreen` - Player fullscreen view
- `POST /players/<id>/reorder` - Reorder content
- `POST /players/bulk/delete` - Bulk delete players
- `POST /players/bulk/assign-group` - Bulk assign to group
### Groups Blueprint (`/groups`)
- `GET /groups/` - List all groups
- `GET /groups/create` - Create group page
- `POST /groups/create` - Create group
- `GET /groups/<id>/edit` - Edit group page
- `POST /groups/<id>/edit` - Update group
- `POST /groups/<id>/delete` - Delete group
- `GET /groups/<id>/manage` - Manage group page
- `GET /groups/<id>/fullscreen` - Group fullscreen view
- `POST /groups/<id>/add-player` - Add player to group
- `POST /groups/<id>/remove-player/<player_id>` - Remove player
- `POST /groups/<id>/add-content` - Add content to group
- `POST /groups/<id>/remove-content/<content_id>` - Remove content
- `POST /groups/<id>/reorder-content` - Reorder content
- `GET /groups/<id>/stats` - Group statistics (JSON)
### Content Blueprint (`/content`)
- `GET /content/` - List all content
- `GET /content/upload` - Upload page
- `POST /content/upload` - Upload file
- `GET /content/<id>/edit` - Edit content page
- `POST /content/<id>/edit` - Update content
- `POST /content/<id>/delete` - Delete content
- `POST /content/bulk/delete` - Bulk delete content
- `GET /content/upload-progress/<upload_id>` - Upload progress (JSON)
- `GET /content/preview/<id>` - Preview content
- `GET /content/<id>/download` - Download content
- `GET /content/statistics` - Content statistics (JSON)
- `GET /content/check-duplicates` - Check duplicates (JSON)
- `GET /content/<id>/groups` - Content groups info (JSON)
### API Blueprint (`/api`)
- `GET /api/health` - API health check
- `GET /api/playlists/<player_id>` - Get player playlist (auth required)
- `POST /api/player-feedback` - Submit player feedback (auth required)
- `GET /api/player-status/<player_id>` - Get player status
- `GET /api/upload-progress/<upload_id>` - Get upload progress
- `GET /api/system-info` - System statistics
- `GET /api/groups` - List all groups
- `GET /api/content` - List all content
- `GET /api/logs` - Get server logs (query params: limit, level, since)
---
## 🔒 Authentication & Authorization
### Login Required
Most routes require authentication via `@login_required` decorator:
```python
from flask_login import login_required
@players_bp.route('/')
@login_required
def players_list():
# Route logic
```
### Admin Required
Admin routes use custom `@admin_required` decorator:
```python
from app.blueprints.admin import admin_required
@admin_bp.route('/')
@login_required
@admin_required
def admin_panel():
# Route logic
```
### API Authentication
API routes use `@verify_player_auth` for player authentication:
```python
from app.blueprints.api import verify_player_auth
@api_bp.route('/playlists/<int:player_id>')
@verify_player_auth
def get_player_playlist(player_id):
# Access authenticated player via request.player
```
---
## 🚀 Performance Features
### Caching
Routes with caching enabled:
- Dashboard: 60 seconds
- Player playlist: 5 minutes (memoized function)
Clear cache on data changes:
```python
from app.extensions import cache
# Clear specific memoized function
cache.delete_memoized(get_player_playlist, player_id)
# Clear all cache
cache.clear()
```
### Rate Limiting
API endpoints have rate limiting:
```python
@api_bp.route('/playlists/<int:player_id>')
@rate_limit(max_requests=30, window=60) # 30 requests per minute
def get_player_playlist(player_id):
# Route logic
```
---
## 🎨 Template Usage
### URL Generation
Always use blueprint-qualified names in templates:
**Old (v1):**
```html
<a href="{{ url_for('login') }}">Login</a>
<a href="{{ url_for('dashboard') }}">Dashboard</a>
<a href="{{ url_for('add_player') }}">Add Player</a>
```
**New (v2):**
```html
<a href="{{ url_for('auth.login') }}">Login</a>
<a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('players.add_player') }}">Add Player</a>
```
### Context Processors
Available in all templates:
- `server_version` - Server version string
- `build_date` - Build date string
- `logo_exists` - Boolean, whether custom logo exists
- `theme` - Current theme ('light' or 'dark')
---
## 📝 Common Patterns
### Creating a New Route
1. **Choose the appropriate blueprint** based on functionality
2. **Add the route** with proper decorators:
```python
@blueprint_name.route('/path', methods=['GET', 'POST'])
@login_required # If authentication needed
def route_name():
try:
# Route logic
log_action('info', 'Success message')
flash('Success message', 'success')
return redirect(url_for('blueprint.route'))
except Exception as e:
db.session.rollback()
log_action('error', f'Error: {str(e)}')
flash('Error message', 'danger')
return redirect(url_for('blueprint.route'))
```
### Adding Caching
For view caching:
```python
@cache.cached(timeout=60, key_prefix='my_key')
def my_route():
# Route logic
```
For function memoization:
```python
@cache.memoize(timeout=300)
def my_function(param):
# Function logic
```
### API Response Format
Consistent JSON responses:
```python
# Success
return jsonify({
'success': True,
'data': {...}
})
# Error
return jsonify({
'error': 'Error message'
}), 400
```
---
## 🔧 Extension Access
Import extensions from centralized location:
```python
from app.extensions import db, bcrypt, cache, login_manager
```
Never initialize extensions directly in blueprints - they're initialized in `extensions.py` and registered in `app.py`.
---
## 🧪 Testing Routes
### Manual Testing
```bash
# Start development server
flask run
# Test specific blueprint
curl http://localhost:5000/api/health
curl http://localhost:5000/health
# Test authenticated route
curl -H "Authorization: Bearer <auth_code>" http://localhost:5000/api/playlists/1
```
### Unit Testing
```python
def test_dashboard(client, auth):
auth.login()
response = client.get('/')
assert response.status_code == 200
```
---
## 📊 Blueprint Registration
Blueprints are registered in `app/app.py`:
```python
def register_blueprints(app: Flask) -> None:
"""Register all blueprints."""
from app.blueprints.main import main_bp
from app.blueprints.auth import auth_bp
from app.blueprints.admin import admin_bp
from app.blueprints.players import players_bp
from app.blueprints.groups import groups_bp
from app.blueprints.content import content_bp
from app.blueprints.api import api_bp
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(players_bp)
app.register_blueprint(groups_bp)
app.register_blueprint(content_bp)
app.register_blueprint(api_bp)
```
---
## ✅ Checklist for Adding New Features
- [ ] Choose appropriate blueprint
- [ ] Add route with proper decorators
- [ ] Implement error handling (try/except)
- [ ] Add logging with `log_action()`
- [ ] Add flash messages for user feedback
- [ ] Clear cache if data changes
- [ ] Add type hints to parameters
- [ ] Update this documentation
- [ ] Add unit tests
- [ ] Test manually in browser/API client
---
This architecture provides:
-**Separation of concerns** - Each blueprint handles specific functionality
-**Scalability** - Easy to add new blueprints
-**Maintainability** - Clear organization and naming
-**Performance** - Built-in caching and optimization
-**Security** - Proper authentication and authorization
-**API-first** - RESTful API alongside web interface

View File

@@ -1,138 +0,0 @@
# SVG Icon Integration
## Overview
Replaced all emoji icons with local SVG files to ensure consistent rendering across all systems, particularly on Raspberry Pi devices where emoji fonts may not be available.
## Icon Files Created
Location: `/app/static/icons/`
1. **moon.svg** - Dark mode toggle (off state)
2. **sun.svg** - Dark mode toggle (on state)
3. **home.svg** - Dashboard/home navigation
4. **monitor.svg** - Players/screens
5. **playlist.svg** - Playlist management
6. **edit.svg** - Edit actions
7. **trash.svg** - Delete actions
8. **upload.svg** - Upload/media files
9. **info.svg** - Information/details
10. **warning.svg** - Warnings/alerts
## Icon Specifications
- **Size**: 24x24 viewBox (scalable)
- **Style**: Feather Icons design (minimal, stroke-based)
- **Color**: Uses `currentColor` for automatic theme adaptation
- **Stroke**: 2px width, round line caps and joins
- **Format**: Clean SVG with no fills (stroke-only for consistency)
## Templates Updated
### 1. base.html (Navigation & Dark Mode)
- Header logo: monitor.svg
- Dashboard link: home.svg
- Players link: monitor.svg
- Playlists link: playlist.svg
- Dark mode toggle: moon.svg ↔ sun.svg (dynamic)
**CSS Changes:**
- Added icon support to nav links with `display: flex`
- Icons use `filter: brightness(0) invert(1)` for white color on dark header
- Dark mode toggle icon changes via JavaScript using `src` attribute
### 2. dashboard.html (Stats Cards & Quick Actions)
- Players card: monitor.svg
- Playlists card: playlist.svg
- Media Library card: upload.svg
- Storage card: info.svg
- Quick Actions buttons: monitor.svg, playlist.svg, upload.svg
- Workflow Guide header: info.svg
### 3. content_list_new.html (Playlist Management)
- Page header: playlist.svg
- Playlists card header: playlist.svg
- Delete button: trash.svg
- Empty state: playlist.svg (64px, opacity 0.3)
- Upload Media card header: upload.svg
- Upload icon (large): upload.svg (96px, opacity 0.5)
- Go to Upload button: upload.svg
- Media library icons: info.svg (images), monitor.svg (videos)
- Player Assignments header: monitor.svg
### 4. upload_media.html (Upload Page)
- Page header: upload.svg
- Back button: playlist.svg
- Select Files card: upload.svg
- Drag-drop zone: upload.svg (96px, opacity 0.3)
- Browse button: upload.svg
- Upload Settings header: info.svg
- Upload button: upload.svg
### 5. manage_player.html (Player Management)
- Page header: monitor.svg
- Back button: monitor.svg
- Status icons: info.svg (online), warning.svg (offline)
- Edit Credentials card: edit.svg
- Save button: edit.svg
- Assign Playlist card: playlist.svg
- Warning alert: warning.svg
- Assign button: playlist.svg
- Create Playlist button: playlist.svg
- Edit Playlist button: edit.svg
- Player Logs card: info.svg
## Usage Pattern
```html
<!-- 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

View File

@@ -1,250 +0,0 @@
# Kivy Player Compatibility with New Playlist Architecture
## Overview
This document outlines the compatibility verification and updates made to ensure the Kivy signage player works with the new playlist-centric architecture.
## Changes Made to DigiServer API
### 1. Content URL Format in Playlist Response
**Location:** `/home/pi/Desktop/digiserver-v2/app/blueprints/api.py` - `get_cached_playlist()` function
**Issue:**
- Player expects: `file_name` key in playlist items
- Server was returning: `filename` key
**Fix:**
```python
playlist_data.append({
'id': content.id,
'file_name': content.filename, # Changed from 'filename' to 'file_name'
'type': content.content_type,
'duration': content._playlist_duration or content.duration or 10,
'position': content._playlist_position or idx,
'url': content_url, # Now returns full URL with server base
'description': content.description
})
```
**What Changed:**
- ✅ Changed `'filename'` to `'file_name'` to match player expectations
- ✅ URL now includes full server base URL (e.g., `http://server:5000/static/uploads/image.jpg`)
- ✅ Player can now download content directly without URL manipulation
### 2. Authentication Response - Removed group_id
**Location:** `/home/pi/Desktop/digiserver-v2/app/blueprints/api.py`
**Endpoints Updated:**
1. `/api/auth/player` (line ~145) - ✅ Already returns `playlist_id`
2. `/api/auth/verify` (line ~192) - ✅ Changed from `group_id` to `playlist_id`
**Before:**
```python
response = {
'valid': True,
'player_id': player.id,
'player_name': player.name,
'hostname': player.hostname,
'group_id': player.group_id, # OLD
'orientation': player.orientation,
'status': player.status
}
```
**After:**
```python
response = {
'valid': True,
'player_id': player.id,
'player_name': player.name,
'hostname': player.hostname,
'playlist_id': player.playlist_id, # NEW
'orientation': player.orientation,
'status': player.status
}
```
## Changes Made to Kivy Player
### 1. Updated player_auth.py
**Location:** `/home/pi/Desktop/Kiwy-Signage/src/player_auth.py`
**Changes:**
1. ✅ Default auth data structure: `group_id``playlist_id` (line ~42)
2. ✅ Authentication data storage: `group_id``playlist_id` (line ~97)
3. ✅ Clear auth method: `group_id``playlist_id` (line ~299)
4. ✅ Example usage output: Updated to show `playlist_id` instead of `group_id`
**Impact:**
- Player now stores and uses `playlist_id` instead of deprecated `group_id`
- Backward compatible: old auth files will load but won't have `playlist_id` until re-authentication
## API Endpoints Used by Kivy Player
### Player Authentication Flow
```
1. POST /api/auth/player
Body: { hostname, password OR quickconnect_code }
Returns: { auth_code, player_id, playlist_id, orientation, ... }
2. GET /api/playlists/<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

View File

@@ -1,381 +0,0 @@
# Player Authentication System - DigiServer v2 & Kiwy-Signage
## Overview
DigiServer v2 now includes a secure player authentication system compatible with Kiwy-Signage players. Players can authenticate using either a password or quick connect code, and their credentials are securely stored locally.
## Features
**Dual Authentication Methods**
- Password-based authentication (secure bcrypt hashing)
- Quick Connect codes for easy pairing
**Secure Credential Storage**
- Auth codes saved locally in encrypted configuration
- No need to re-authenticate on every restart
**Automatic Session Management**
- Auth codes persist across player restarts
- Automatic status updates and heartbeats
**Player Identification**
- Unique hostname for each player
- Configurable display orientation (Landscape/Portrait)
## Database Schema
The Player model now includes:
```python
class Player(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False)
hostname = db.Column(db.String(255), unique=True, nullable=False, index=True)
location = db.Column(db.String(255), nullable=True)
auth_code = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
quickconnect_code = db.Column(db.String(255), nullable=True)
group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=True)
orientation = db.Column(db.String(16), default='Landscape')
status = db.Column(db.String(50), default='offline')
last_seen = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
```
## API Endpoints
### 1. Player Authentication
**POST** `/api/auth/player`
Authenticate a player and receive auth code.
**Request:**
```json
{
"hostname": "player-001",
"password": "your_password",
"quickconnect_code": "QUICK123" // Optional if using password
}
```
**Response (200 OK):**
```json
{
"success": true,
"player_id": 1,
"player_name": "Demo Player",
"hostname": "player-001",
"auth_code": "abc123xyz...",
"group_id": 5,
"orientation": "Landscape",
"status": "online"
}
```
**Error Response (401 Unauthorized):**
```json
{
"error": "Invalid credentials"
}
```
### 2. Verify Auth Code
**POST** `/api/auth/verify`
Verify an existing auth code.
**Request:**
```json
{
"auth_code": "abc123xyz..."
}
```
**Response (200 OK):**
```json
{
"valid": true,
"player_id": 1,
"player_name": "Demo Player",
"hostname": "player-001",
"group_id": 5,
"orientation": "Landscape",
"status": "online"
}
```
## Player Configuration File
Players store their configuration in `player_config.ini`:
```ini
[server]
server_url = http://your-server:5000
[player]
hostname = player-001
auth_code = abc123xyz...
player_id = 1
group_id = 5
[display]
orientation = Landscape
resolution = 1920x1080
[security]
verify_ssl = true
timeout = 30
[cache]
cache_dir = ./cache
max_cache_size = 1024
[logging]
enabled = true
log_level = INFO
log_file = ./player.log
```
## Integration with Kiwy-Signage
### Step 1: Copy Authentication Module
Copy `player_auth_module.py` to your Kiwy-Signage project:
```bash
cp digiserver-v2/player_auth_module.py signage-player/src/player_auth.py
```
### Step 2: Initialize Authentication
In your main signage player code:
```python
from player_auth import PlayerAuth
# Initialize authentication
auth = PlayerAuth(config_path='player_config.ini')
# Check if already authenticated
if auth.is_authenticated():
# Verify saved credentials
valid, info = auth.verify_auth()
if valid:
print(f"Authenticated as: {info['player_name']}")
else:
# Re-authenticate
success, error = auth.authenticate(
hostname='player-001',
password='your_password'
)
else:
# First time setup
hostname = input("Enter player hostname: ")
password = input("Enter password: ")
success, error = auth.authenticate(hostname, password)
if success:
print("Authentication successful!")
else:
print(f"Authentication failed: {error}")
```
### Step 3: Use Authentication for API Calls
```python
# Get playlist with authentication
playlist = auth.get_playlist()
# Send heartbeat
auth.send_heartbeat(status='online')
# Make authenticated API request
import requests
auth_code = auth.get_auth_code()
player_id = auth.config.get('player', 'player_id')
server_url = auth.get_server_url()
response = requests.get(
f"{server_url}/api/playlists/{player_id}",
headers={'Authorization': f'Bearer {auth_code}'}
)
```
## Server Setup
### 1. Create Players in DigiServer
Via Web Interface:
1. Log in as admin (admin/admin123)
2. Navigate to Players → Add Player
3. Fill in:
- **Name**: Display name
- **Hostname**: Unique identifier (e.g., `player-001`)
- **Location**: Physical location
- **Password**: Secure password
- **Quick Connect Code**: Optional easy pairing code
- **Orientation**: Landscape or Portrait
Via Python:
```python
from app.extensions import db
from app.models import Player
import secrets
player = Player(
name='Office Player',
hostname='office-player-001',
location='Main Office - Reception',
auth_code=secrets.token_urlsafe(32),
orientation='Landscape'
)
player.set_password('secure_password_123')
player.set_quickconnect_code('OFFICE123')
db.session.add(player)
db.session.commit()
```
### 2. Distribute Credentials
Securely provide each player with:
- Server URL
- Hostname
- Password OR Quick Connect Code
## Security Considerations
**Passwords**: Hashed with bcrypt (cost factor 12)
**Auth Codes**: 32-byte URL-safe tokens
**HTTPS**: Enable SSL in production
**Rate Limiting**: API endpoints protected (10 req/min for auth)
**Local Storage**: Config file permissions should be 600
## Troubleshooting
### Player Can't Authenticate
1. Check server connectivity:
```bash
curl http://your-server:5000/api/health
```
2. Verify credentials in database
3. Check server logs for authentication attempts
4. Ensure hostname is unique
### Auth Code Invalid
1. Clear saved config: `rm player_config.ini`
2. Re-authenticate with password
3. Check if player was deleted from server
### Connection Timeout
1. Increase timeout in `player_config.ini`:
```ini
[security]
timeout = 60
```
2. Check network connectivity
3. Verify server is running
## Migration from v1
If migrating from DigiServer v1:
1. **Export player data** from v1 database
2. **Create players** in v2 with hostname = old username
3. **Set passwords** using `player.set_password()`
4. **Update player apps** with new authentication module
5. **Test authentication** before full deployment
## Example: Complete Player Setup
```python
#!/usr/bin/env python3
"""
Complete player setup example
"""
from player_auth import PlayerAuth
import sys
def setup_player():
"""Interactive player setup"""
auth = PlayerAuth()
# Check if already configured
if auth.is_authenticated():
print(f"✅ Already configured as: {auth.get_hostname()}")
# Test connection
valid, info = auth.verify_auth()
if valid:
print(f"✅ Connection successful")
print(f" Player: {info['player_name']}")
print(f" Group: {info.get('group_id', 'None')}")
return True
else:
print("❌ Saved credentials invalid, reconfiguring...")
auth.clear_auth()
# First time setup
print("\n🚀 Player Setup")
print("-" * 50)
# Get server URL
server_url = input("Server URL [http://localhost:5000]: ").strip()
if server_url:
auth.config['server']['server_url'] = server_url
auth.save_config()
# Get hostname
hostname = input("Player hostname: ").strip()
if not hostname:
print("❌ Hostname required")
return False
# Authentication method
print("\nAuthentication method:")
print("1. Password")
print("2. Quick Connect Code")
choice = input("Choice [1]: ").strip() or "1"
if choice == "1":
password = input("Password: ").strip()
success, error = auth.authenticate(hostname, password=password)
else:
code = input("Quick Connect Code: ").strip()
success, error = auth.authenticate(hostname, quickconnect_code=code)
if success:
print("\n✅ Authentication successful!")
print(f" Config saved to: {auth.config_path}")
return True
else:
print(f"\n❌ Authentication failed: {error}")
return False
if __name__ == '__main__':
if setup_player():
sys.exit(0)
else:
sys.exit(1)
```
## Files Included
- `player_auth_module.py` - Python authentication module for players
- `player_config_template.ini` - Configuration template
- `reinit_db.sh` - Script to recreate database with new schema
- `PLAYER_AUTH.md` - This documentation
## Support
For issues or questions:
1. Check server logs: `app/instance/logs/`
2. Check player logs: `player.log`
3. Verify API health: `/api/health`
4. Review authentication attempts in server logs

View File

@@ -1,282 +0,0 @@
# Digiserver v2 - Blueprint Architecture Implementation Progress
## ✅ Completed Components
### Core Infrastructure (100%)
-**config.py** - Environment-based configuration (Development, Production, Testing)
-**extensions.py** - Centralized Flask extension initialization
-**app.py** - Application factory with blueprint registration
-**.env.example** - Environment variable template
-**.gitignore** - Git ignore patterns
-**requirements.txt** - Python dependencies with Flask 3.1.0, Flask-Caching, Redis
### Blueprints (100%)
All 6 blueprints completed with full functionality:
#### 1. **main.py** ✅
- Dashboard with caching (60s timeout)
- Health check endpoint with database and disk validation
- Server statistics display
#### 2. **auth.py** ✅
- Login with bcrypt password verification, remember me, next page redirect
- Logout with logging
- Register with validation (min 6 chars, username uniqueness check)
- Change password with current password verification
- Input sanitization and error handling
#### 3. **admin.py** ✅
- Admin panel with system overview (users, players, groups, content, storage)
- User management (create, change role, delete)
- Theme settings (light/dark mode)
- Logo upload functionality
- System logs management (view, clear)
- System information API endpoint (CPU, memory, disk)
- `admin_required` decorator for authorization
#### 4. **players.py** ✅
- Players list with status information
- Add player with auth code generation
- Edit player (name, location, group assignment)
- Delete player with cascade feedback deletion
- Regenerate auth code
- Player page with playlist and feedback
- Player fullscreen view with auth code verification
- Playlist caching (5 min timeout)
- Content reordering
- Bulk operations (delete players, assign to group)
#### 5. **groups.py** ✅
- Groups list with statistics
- Create group with content assignment
- Edit group (name, description, content)
- Delete group with player unassignment
- Manage group page (player status cards, content management)
- Group fullscreen view for monitoring
- Add/remove players from group
- Add/remove content from group
- Content reordering within group
- Group statistics API endpoint
#### 6. **content.py** ✅
- Content list with group information
- Upload content with progress tracking
- File type detection (image, video, PDF, presentation)
- Edit content metadata (duration, description)
- Delete content with file removal
- Bulk delete content
- Upload progress API endpoint
- Content preview/download
- Content statistics (total, by type, storage)
- Duplicate filename checker
- Content groups information API
#### 7. **api.py** ✅
- Health check endpoint
- Get player playlist (with auth and rate limiting)
- Player feedback submission
- Player status endpoint
- Upload progress tracking
- System information API
- List groups API
- List content API
- Server logs API with filtering
- Rate limiting decorator (60 req/min default)
- Player authentication via Bearer token
- API error handlers (404, 405, 500)
### Documentation (100%)
-**README.md** - Comprehensive project documentation
- ✅ Project structure diagram
- ✅ Quick start guide (development and Docker)
- ✅ Features list and optimization summary
---
## 📋 Pending Tasks
### Models (Priority: Critical)
Need to copy and adapt from v1 with improvements:
1. **User Model** - Add indexes on username
2. **Player Model** - Add indexes on auth_code, group_id, last_seen
3. **Group Model** - Add indexes on name
4. **Content Model** - Add indexes on content_type, position, uploaded_at
5. **ServerLog Model** - Add indexes on level, timestamp
6. **PlayerFeedback Model** - Add indexes on player_id, timestamp
7. **Association Tables** - group_content for many-to-many relationship
### Utils (Priority: Critical)
Need to copy and adapt from v1 with type hints:
1. **logger.py** - Logging utility with type hints
2. **uploads.py** - File upload utilities with progress tracking
3. **group_player_management.py** - Group/player management functions
4. **pptx_converter.py** - PowerPoint conversion utility
### Templates (Priority: High)
Need to copy from v1 and update route references:
1. Update all `url_for()` calls to use blueprint naming:
- `url_for('login')``url_for('auth.login')`
- `url_for('dashboard')``url_for('main.dashboard')`
- `url_for('add_player')``url_for('players.add_player')`
- etc.
2. Organize templates into subdirectories:
- `auth/` - login.html, register.html
- `admin/` - admin.html
- `players/` - players_list.html, add_player.html, edit_player.html, player_page.html, player_fullscreen.html
- `groups/` - groups_list.html, create_group.html, edit_group.html, manage_group.html, group_fullscreen.html
- `content/` - content_list.html, upload_content.html, edit_content.html
- `errors/` - 404.html, 403.html, 500.html
- `base.html` - Main layout template
### Docker Configuration (Priority: High)
1. **Dockerfile** - Multi-stage build targeting 800MB
2. **docker-compose.yml** - Services: digiserver, redis, optional worker
3. **nginx.conf** - Reverse proxy configuration
4. **.dockerignore** - Docker ignore patterns
### Testing (Priority: Medium)
1. **tests/conftest.py** - Test fixtures
2. **tests/test_auth.py** - Authentication tests
3. **tests/test_players.py** - Player management tests
4. **tests/test_groups.py** - Group management tests
5. **tests/test_content.py** - Content management tests
6. **tests/test_api.py** - API endpoint tests
### Git Repository (Priority: Medium)
1. Initialize Git repository
2. Create initial commit
3. Push to remote (if desired)
---
## 🎯 Next Steps
### Immediate Actions
1. **Copy models from v1** - Add to `app/models/` with:
- Type hints
- Database indexes
- Proper relationships
- `__repr__` methods
2. **Copy utils from v1** - Add to `app/utils/` with:
- Type hints
- Improved error handling
- Documentation
3. **Create model init file** - `app/models/__init__.py` to export all models
### After Models/Utils
4. **Copy templates from v1** - Update route references to blueprint naming
5. **Create Docker configuration** - Multi-stage build with optimization
6. **Test application** - Ensure all routes work correctly
7. **Initialize Git** - Create repository and initial commit
---
## 📊 Progress Summary
| Component | Status | Progress |
|-----------|--------|----------|
| Core Infrastructure | ✅ Complete | 100% |
| Blueprints (6) | ✅ Complete | 100% |
| Documentation | ✅ Complete | 100% |
| Models | ⏳ Pending | 0% |
| Utils | ⏳ Pending | 0% |
| Templates | ⏳ Pending | 0% |
| Docker | ⏳ Pending | 0% |
| Testing | ⏳ Pending | 0% |
| Git | ⏳ Pending | 0% |
**Overall Progress: ~50%**
---
## 🔧 Key Features Implemented
### Blueprint Architecture
- ✅ Modular route organization (7 blueprints)
- ✅ URL prefixes for namespace separation
- ✅ Blueprint-specific error handlers in API
- ✅ Shared extension initialization
### Performance Optimizations
- ✅ Flask-Caching with Redis support
- ✅ Playlist caching (5 min timeout)
- ✅ Dashboard caching (60s timeout)
- ✅ Memoized functions with cache.memoize()
- ✅ Cache clearing on data changes
### Security Features
- ✅ Bcrypt password hashing
- ✅ Flask-Login session management
-`admin_required` decorator
- ✅ Player authentication via auth codes
- ✅ API Bearer token authentication
- ✅ Rate limiting on API endpoints
- ✅ Input validation and sanitization
- ✅ CSRF protection (Flask-WTF ready)
### API Features
- ✅ RESTful endpoints for players, groups, content
- ✅ Player playlist API with caching
- ✅ Player feedback submission
- ✅ Upload progress tracking
- ✅ System information API
- ✅ Rate limiting (60 req/min default)
- ✅ JSON error responses
- ✅ Health check endpoint
### Development Features
- ✅ Application factory pattern
- ✅ Environment-based configuration
- ✅ CLI commands (init-db, create-admin, seed-db)
- ✅ Comprehensive logging
- ✅ Context processors for templates
- ✅ Error handlers for common HTTP errors
---
## 📝 Notes
### Architecture Decisions
- **Blueprint organization**: Logical separation by functionality
- **Caching strategy**: Redis for production, SimpleCache for development
- **Authentication**: Flask-Login for web, Bearer tokens for API
- **Rate limiting**: In-memory for now, should use Redis in production
- **File uploads**: Direct save with progress tracking, can add Celery for async processing
### Optimization Opportunities
- Add Celery for async video processing
- Implement WebSocket for real-time player updates
- Add comprehensive database migrations
- Implement API versioning (v1, v2)
- Add comprehensive test coverage
- Add Sentry for error tracking
### Migration from v1
When copying from v1, remember to:
- Update all route references to blueprint naming
- Add type hints to all functions
- Add database indexes for performance
- Update imports to match new structure
- Update static file paths in templates
- Test all functionality thoroughly
---
## 🚀 Ready for Next Phase
The blueprint architecture is now **fully implemented** with all 6 blueprints:
- ✅ main (dashboard, health)
- ✅ auth (login, register, logout)
- ✅ admin (user management, system settings)
- ✅ players (CRUD, fullscreen, bulk ops)
- ✅ groups (CRUD, manage, fullscreen)
- ✅ content (upload, manage, API)
- ✅ api (REST endpoints, authentication, rate limiting)
**Ready to proceed with models and utils migration!**

287
README.md
View File

@@ -1,287 +0,0 @@
# DigiServer v2 - Blueprint Architecture
Modern Flask application with blueprint architecture, designed for scalability and maintainability.
## 🎯 Project Goals
- **Modular Architecture**: Blueprints for better code organization
- **Scalability**: Redis caching, Celery background tasks
- **Security**: Input validation, rate limiting, CSRF protection
- **Performance**: Optimized Docker image, database indexes
- **Maintainability**: Type hints, comprehensive tests, clear documentation
## 📁 Project Structure
```
digiserver-v2/
├── app/
│ ├── app.py # Application factory
│ ├── config.py # Environment-based configuration
│ ├── extensions.py # Flask extensions
│ ├── blueprints/
│ │ ├── __init__.py
│ │ ├── main.py # Dashboard & home
│ │ ├── auth.py # Authentication
│ │ ├── admin.py # Admin panel
│ │ ├── players.py # Player management
│ │ ├── groups.py # Group management
│ │ ├── content.py # Media upload & management
│ │ └── api.py # REST API endpoints
│ ├── models/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ ├── player.py
│ │ ├── group.py
│ │ ├── content.py
│ │ ├── player_feedback.py
│ │ └── server_log.py
│ ├── utils/
│ │ ├── __init__.py
│ │ ├── logger.py
│ │ ├── uploads.py
│ │ ├── decorators.py
│ │ └── validators.py
│ ├── templates/
│ │ ├── base.html
│ │ ├── auth/
│ │ ├── admin/
│ │ ├── players/
│ │ ├── groups/
│ │ └── errors/
│ └── static/
│ ├── css/
│ ├── js/
│ ├── uploads/
│ └── resurse/
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_auth.py
│ ├── test_players.py
│ └── test_api.py
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
├── .env.example
└── README.md
```
## 🚀 Quick Start
### Development
```bash
# Clone the repository
git clone <repository-url>
cd digiserver-v2
# Create virtual environment
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Set up environment variables
cp .env.example .env
# Edit .env with your settings
# Initialize database
flask init-db
flask create-admin
# Run development server
flask run
```
### Docker (Production)
```bash
# Build and start containers
docker compose up -d
# View logs
docker compose logs -f
# Stop containers
docker compose down
```
## 🔧 Configuration
Configuration is environment-based (development, production, testing).
### Environment Variables
Create a `.env` file:
```env
# Flask
FLASK_ENV=production
SECRET_KEY=your-secret-key-here
# Database
DATABASE_URL=sqlite:///instance/dashboard.db
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
# Admin Defaults
ADMIN_USER=admin
ADMIN_PASSWORD=secure-password
# Optional
SENTRY_DSN=your-sentry-dsn
```
## 📊 Features
### Current (v2.0.0)
- ✅ Blueprint architecture
- ✅ Environment-based configuration
- ✅ User authentication & authorization
- ✅ Admin panel
- ✅ Player management
- ✅ Group management
- ✅ Content upload & management
- ✅ REST API
- ✅ Redis caching (production)
- ✅ Health check endpoint
### Planned
- ⏳ Celery background tasks
- ⏳ Rate limiting
- ⏳ API authentication (JWT)
- ⏳ Unit & integration tests
- ⏳ API documentation (Swagger)
- ⏳ Monitoring & metrics (Prometheus)
## 🛠️ Development
### Running Tests
```bash
pytest
pytest --cov=app tests/
```
### Database Migrations
```bash
# Create migration
flask db migrate -m "Description"
# Apply migration
flask db upgrade
# Rollback
flask db downgrade
```
### Code Quality
```bash
# Format code
black app/
# Lint
flake8 app/
pylint app/
# Type check
mypy app/
```
## 📖 API Documentation
### Authentication
All API endpoints require authentication via session or API key.
### Endpoints
- `GET /api/playlists` - Get playlist for player
- `POST /api/player-feedback` - Submit player feedback
- `GET /health` - Health check
See `/docs` for full API documentation (Swagger UI).
## 🔒 Security
- CSRF protection enabled
- Rate limiting on API endpoints
- Input validation using Marshmallow
- SQL injection prevention (SQLAlchemy ORM)
- XSS prevention (Jinja2 autoescaping)
- Secure password hashing (bcrypt)
## 📈 Performance
- Redis caching for frequently accessed data
- Database indexes on foreign keys
- Lazy loading for relationships
- Static file compression (nginx)
- Multi-stage Docker build (~800MB)
## 🐳 Docker
### Multi-stage Build
```dockerfile
# Build stage (heavy dependencies)
FROM python:3.11-slim as builder
# ... build dependencies
# Runtime stage (slim)
FROM python:3.11-slim
# ... only runtime dependencies
```
### Docker Compose Services
- **digiserver**: Main Flask application
- **redis**: Cache and session storage
- **worker**: Celery background worker (optional)
- **nginx**: Reverse proxy (production)
## 📝 Migration from v1
See `MIGRATION.md` for detailed migration guide from digiserver v1.
Key differences:
- Blueprint-based routing instead of monolithic app.py
- Environment-based configuration
- Redis caching in production
- Improved error handling
- Type hints throughout codebase
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
## 📄 License
This project is part of the DigiServer digital signage system.
## 🙏 Acknowledgments
- Built with Flask and modern Python practices
- Inspired by Flask best practices and 12-factor app principles
- Based on lessons learned from DigiServer v1
## 📞 Support
For issues and feature requests, please use the GitHub issue tracker.
---
**Status**: 🚧 Work in Progress - Blueprint architecture implementation
**Version**: 2.0.0-alpha
**Last Updated**: 2025-11-12

View File

@@ -1,44 +0,0 @@
"""
Add orientation column to playlist table
Run this script to update the database schema
"""
import sys
import os
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app.app import app
from app.extensions import db
from sqlalchemy import text
def add_orientation_column():
"""Add orientation column to playlist table."""
with app.app_context():
try:
# Check if column exists
result = db.session.execute(text("PRAGMA table_info(playlist)"))
columns = [row[1] for row in result]
if 'orientation' in columns:
print("✅ Column 'orientation' already exists in playlist table")
return
# Add the column
print("Adding 'orientation' column to playlist table...")
db.session.execute(text("""
ALTER TABLE playlist
ADD COLUMN orientation VARCHAR(20) DEFAULT 'Landscape' NOT NULL
"""))
db.session.commit()
print("✅ Successfully added 'orientation' column to playlist table")
print(" Default value: 'Landscape'")
except Exception as e:
print(f"❌ Error adding column: {str(e)}")
db.session.rollback()
raise
if __name__ == '__main__':
add_orientation_column()

View File

@@ -8,7 +8,7 @@ from datetime import datetime
from typing import Optional from typing import Optional
from app.extensions import db, bcrypt from app.extensions import db, bcrypt
from app.models import User, Player, Group, Content, ServerLog from app.models import User, Player, Group, Content, ServerLog, Playlist
from app.utils.logger import log_action from app.utils.logger import log_action
admin_bp = Blueprint('admin', __name__, url_prefix='/admin') admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
@@ -38,7 +38,7 @@ def admin_panel():
# Get statistics # Get statistics
total_users = User.query.count() total_users = User.query.count()
total_players = Player.query.count() total_players = Player.query.count()
total_groups = Group.query.count() total_playlists = Playlist.query.count()
total_content = Content.query.count() total_content = Content.query.count()
# Get recent logs # Get recent logs
@@ -62,7 +62,7 @@ def admin_panel():
return render_template('admin/admin.html', return render_template('admin/admin.html',
total_users=total_users, total_users=total_users,
total_players=total_players, total_players=total_players,
total_groups=total_groups, total_playlists=total_playlists,
total_content=total_content, total_content=total_content,
storage_mb=storage_mb, storage_mb=storage_mb,
users=users, users=users,
@@ -347,3 +347,146 @@ def system_info():
except Exception as e: except Exception as e:
log_action('error', f'Error getting system info: {str(e)}') log_action('error', f'Error getting system info: {str(e)}')
return jsonify({'error': str(e)}), 500 return jsonify({'error': str(e)}), 500
@admin_bp.route('/leftover-media')
@login_required
@admin_required
def leftover_media():
"""Display leftover media files not assigned to any playlist."""
from app.models.playlist import playlist_content
from sqlalchemy import select
try:
# Get all content IDs that are in playlists
stmt = select(playlist_content.c.content_id).distinct()
content_in_playlists = set(row[0] for row in db.session.execute(stmt))
# Get all content
all_content = Content.query.all()
# Filter content not in any playlist
leftover_content = [c for c in all_content if c.id not in content_in_playlists]
# Separate by type
leftover_images = [c for c in leftover_content if c.content_type == 'image']
leftover_videos = [c for c in leftover_content if c.content_type == 'video']
leftover_pdfs = [c for c in leftover_content if c.content_type == 'pdf']
leftover_pptx = [c for c in leftover_content if c.content_type == 'pptx']
# Calculate storage
total_leftover_size = sum(c.file_size for c in leftover_content)
images_size = sum(c.file_size for c in leftover_images)
videos_size = sum(c.file_size for c in leftover_videos)
pdfs_size = sum(c.file_size for c in leftover_pdfs)
pptx_size = sum(c.file_size for c in leftover_pptx)
return render_template('admin/leftover_media.html',
leftover_images=leftover_images,
leftover_videos=leftover_videos,
leftover_pdfs=leftover_pdfs,
leftover_pptx=leftover_pptx,
total_leftover=len(leftover_content),
total_leftover_size_mb=total_leftover_size / (1024 * 1024),
images_size_mb=images_size / (1024 * 1024),
videos_size_mb=videos_size / (1024 * 1024),
pdfs_size_mb=pdfs_size / (1024 * 1024),
pptx_size_mb=pptx_size / (1024 * 1024))
except Exception as e:
log_action('error', f'Error loading leftover media: {str(e)}')
flash('Error loading leftover media.', 'danger')
return redirect(url_for('admin.admin_panel'))
@admin_bp.route('/delete-leftover-images', methods=['POST'])
@login_required
@admin_required
def delete_leftover_images():
"""Delete all leftover images that are not part of any playlist"""
from app.models.playlist import playlist_content
try:
# Find all leftover image content
leftover_images = db.session.query(Content).filter(
Content.media_type == 'image',
~Content.id.in_(
db.session.query(playlist_content.c.content_id)
)
).all()
deleted_count = 0
errors = []
for content in leftover_images:
try:
# Delete physical file
if content.file_path:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
if os.path.exists(file_path):
os.remove(file_path)
# Delete database record
db.session.delete(content)
deleted_count += 1
except Exception as e:
errors.append(f"Error deleting {content.file_path}: {str(e)}")
db.session.commit()
if errors:
flash(f'Deleted {deleted_count} images with {len(errors)} errors', 'warning')
else:
flash(f'Successfully deleted {deleted_count} leftover images', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting leftover images: {str(e)}', 'danger')
return redirect(url_for('admin.leftover_media'))
@admin_bp.route('/delete-leftover-videos', methods=['POST'])
@login_required
@admin_required
def delete_leftover_videos():
"""Delete all leftover videos that are not part of any playlist"""
from app.models.playlist import playlist_content
try:
# Find all leftover video content
leftover_videos = db.session.query(Content).filter(
Content.media_type == 'video',
~Content.id.in_(
db.session.query(playlist_content.c.content_id)
)
).all()
deleted_count = 0
errors = []
for content in leftover_videos:
try:
# Delete physical file
if content.file_path:
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
if os.path.exists(file_path):
os.remove(file_path)
# Delete database record
db.session.delete(content)
deleted_count += 1
except Exception as e:
errors.append(f"Error deleting {content.file_path}: {str(e)}")
db.session.commit()
if errors:
flash(f'Deleted {deleted_count} videos with {len(errors)} errors', 'warning')
else:
flash(f'Successfully deleted {deleted_count} leftover videos', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting leftover videos: {str(e)}', 'danger')
return redirect(url_for('admin.leftover_media'))

View File

@@ -243,10 +243,222 @@ def upload_media_page():
return render_template('content/upload_media.html', playlists=playlists) return render_template('content/upload_media.html', playlists=playlists)
def process_image_file(filepath: str, filename: str) -> tuple[bool, str]:
"""Process and optimize image files."""
try:
from PIL import Image
# Open and optimize image
img = Image.open(filepath)
# Convert RGBA to RGB for JPEGs
if img.mode == 'RGBA' and filename.lower().endswith(('.jpg', '.jpeg')):
rgb_img = Image.new('RGB', img.size, (255, 255, 255))
rgb_img.paste(img, mask=img.split()[3])
img = rgb_img
# Resize if too large (max 1920x1080 for display efficiency)
max_size = (1920, 1080)
if img.width > max_size[0] or img.height > max_size[1]:
img.thumbnail(max_size, Image.Resampling.LANCZOS)
img.save(filepath, optimize=True, quality=85)
log_action('info', f'Optimized image: {filename}')
return True, "Image processed successfully"
except Exception as e:
return False, f"Image processing error: {str(e)}"
def process_video_file_extended(filepath: str, filename: str) -> tuple[bool, str]:
"""Process and optimize video files for Raspberry Pi playback."""
try:
# Basic video validation
import subprocess
# Check if video is playable
result = subprocess.run(
['ffprobe', '-v', 'error', '-select_streams', 'v:0',
'-show_entries', 'stream=codec_name,width,height',
'-of', 'default=noprint_wrappers=1', filepath],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
log_action('info', f'Video validated: {filename}')
return True, "Video validated successfully"
else:
return False, "Video validation failed"
except Exception as e:
# If ffprobe not available, just accept the video
log_action('warning', f'Video validation skipped (ffprobe unavailable): {filename}')
return True, "Video accepted without validation"
def process_pdf_file(filepath: str, filename: str) -> tuple[bool, str]:
"""Process PDF files."""
try:
# Basic PDF validation - check if it's a valid PDF
with open(filepath, 'rb') as f:
header = f.read(5)
if header != b'%PDF-':
return False, "Invalid PDF file"
log_action('info', f'PDF validated: {filename}')
return True, "PDF processed successfully"
except Exception as e:
return False, f"PDF processing error: {str(e)}"
def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
"""Process PowerPoint presentation files by converting slides to images."""
try:
import subprocess
import tempfile
import shutil
from pathlib import Path
# Basic validation - check file exists and has content
file_size = os.path.getsize(filepath)
if file_size < 1024: # Less than 1KB is suspicious
return False, "File too small to be a valid presentation"
# Check if LibreOffice is available
libreoffice_paths = [
'/usr/bin/libreoffice',
'/usr/bin/soffice',
'/snap/bin/libreoffice',
'libreoffice', # Try in PATH
'soffice'
]
libreoffice_cmd = None
for cmd in libreoffice_paths:
try:
result = subprocess.run([cmd, '--version'],
capture_output=True,
timeout=5)
if result.returncode == 0:
libreoffice_cmd = cmd
log_action('info', f'Found LibreOffice at: {cmd}')
break
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
if not libreoffice_cmd:
log_action('warning', f'LibreOffice not found, skipping slide conversion for: {filename}')
return True, "Presentation accepted without conversion (LibreOffice unavailable)"
# Create temporary directory for conversion
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Copy presentation to temp directory
temp_ppt = temp_path / filename
shutil.copy2(filepath, temp_ppt)
# Convert presentation to images (PNG format)
# Using LibreOffice headless mode with custom resolution
convert_cmd = [
libreoffice_cmd,
'--headless',
'--convert-to', 'png',
'--outdir', str(temp_path),
str(temp_ppt)
]
log_action('info', f'Converting presentation to images: {filename}')
try:
result = subprocess.run(
convert_cmd,
capture_output=True,
text=True,
timeout=120 # 2 minutes timeout
)
if result.returncode != 0:
log_action('error', f'LibreOffice conversion failed: {result.stderr}')
return True, "Presentation accepted without conversion (conversion failed)"
# Find generated PNG files
png_files = sorted(temp_path.glob('*.png'))
if not png_files:
log_action('warning', f'No images generated from presentation: {filename}')
return True, "Presentation accepted without images"
# Get upload folder from app config
upload_folder = current_app.config['UPLOAD_FOLDER']
base_name = os.path.splitext(filename)[0]
# Move converted images to upload folder
slide_count = 0
for idx, png_file in enumerate(png_files, start=1):
# Create descriptive filename
slide_filename = f"{base_name}_slide_{idx:03d}.png"
destination = os.path.join(upload_folder, slide_filename)
shutil.move(str(png_file), destination)
# Optimize the image to Full HD (1920x1080)
optimize_image_to_fullhd(destination)
slide_count += 1
log_action('info', f'Converted {slide_count} slides from {filename} to images')
# Remove original PPTX file as we now have the images
os.remove(filepath)
return True, f"Presentation converted to {slide_count} Full HD images"
except subprocess.TimeoutExpired:
log_action('error', f'LibreOffice conversion timeout for: {filename}')
return True, "Presentation accepted without conversion (timeout)"
except Exception as e:
log_action('error', f'Presentation processing error: {str(e)}')
return False, f"Presentation processing error: {str(e)}"
def optimize_image_to_fullhd(filepath: str) -> bool:
"""Optimize and resize image to Full HD (1920x1080) maintaining aspect ratio."""
try:
from PIL import Image
img = Image.open(filepath)
# Target Full HD resolution
target_size = (1920, 1080)
# Calculate resize maintaining aspect ratio
img.thumbnail(target_size, Image.Resampling.LANCZOS)
# Create Full HD canvas with white background
fullhd_img = Image.new('RGB', target_size, (255, 255, 255))
# Center the image on the canvas
x = (target_size[0] - img.width) // 2
y = (target_size[1] - img.height) // 2
if img.mode == 'RGBA':
fullhd_img.paste(img, (x, y), img)
else:
fullhd_img.paste(img, (x, y))
# Save optimized image
fullhd_img.save(filepath, 'PNG', optimize=True)
return True
except Exception as e:
log_action('error', f'Image optimization error: {str(e)}')
return False
@content_bp.route('/upload-media', methods=['POST']) @content_bp.route('/upload-media', methods=['POST'])
@login_required @login_required
def upload_media(): def upload_media():
"""Upload media files to library.""" """Upload media files to library with type-specific processing."""
try: try:
files = request.files.getlist('files') files = request.files.getlist('files')
content_type = request.form.get('content_type', 'image') content_type = request.form.get('content_type', 'image')
@@ -261,6 +473,7 @@ def upload_media():
os.makedirs(upload_folder, exist_ok=True) os.makedirs(upload_folder, exist_ok=True)
uploaded_count = 0 uploaded_count = 0
processing_errors = []
for file in files: for file in files:
if file.filename == '': if file.filename == '':
@@ -275,26 +488,94 @@ def upload_media():
log_action('warning', f'File {filename} already exists, skipping') log_action('warning', f'File {filename} already exists, skipping')
continue continue
# Save file # Save file first
file.save(filepath) file.save(filepath)
# Determine content type from extension # Determine content type from extension
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']: # Process file based on type
processing_success = True
processing_message = ""
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']:
detected_type = 'image' detected_type = 'image'
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']: processing_success, processing_message = process_image_file(filepath, filename)
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']:
detected_type = 'video' detected_type = 'video'
# Process video for Raspberry Pi processing_success, processing_message = process_video_file_extended(filepath, filename)
success, message = process_video_file(filepath, os.urandom(8).hex())
if not success:
log_action('error', f'Video processing failed: {message}')
elif file_ext == 'pdf': elif file_ext == 'pdf':
detected_type = 'pdf' detected_type = 'pdf'
processing_success, processing_message = process_pdf_file(filepath, filename)
elif file_ext in ['ppt', 'pptx']:
detected_type = 'pptx'
processing_success, processing_message = process_presentation_file(filepath, filename)
# For presentations, slides are converted to individual images
# We need to add each slide image as a separate content item
if processing_success and "converted to" in processing_message.lower():
# Find all slide images that were created
base_name = os.path.splitext(filename)[0]
slide_pattern = f"{base_name}_slide_*.png"
import glob
slide_files = sorted(glob.glob(os.path.join(upload_folder, slide_pattern)))
if slide_files:
max_position = 0
if playlist_id:
playlist = Playlist.query.get(playlist_id)
max_position = db.session.query(db.func.max(playlist_content.c.position))\
.filter(playlist_content.c.playlist_id == playlist_id)\
.scalar() or 0
# Add each slide as separate content
for slide_file in slide_files:
slide_filename = os.path.basename(slide_file)
# Create content record for slide
slide_content = Content(
filename=slide_filename,
content_type='image',
duration=duration,
file_size=os.path.getsize(slide_file)
)
db.session.add(slide_content)
db.session.flush()
# Add to playlist if specified
if playlist_id:
max_position += 1
stmt = playlist_content.insert().values(
playlist_id=playlist_id,
content_id=slide_content.id,
position=max_position,
duration=duration
)
db.session.execute(stmt)
uploaded_count += 1
# Increment playlist version if slides were added
if playlist_id and slide_files:
playlist.version += 1
continue # Skip normal content creation below
else: else:
detected_type = 'other' detected_type = 'other'
# Create content record if not processing_success:
processing_errors.append(f"{filename}: {processing_message}")
if os.path.exists(filepath):
os.remove(filepath) # Remove failed file
log_action('error', f'Processing failed for {filename}: {processing_message}')
continue
# Create content record (for non-presentation files or failed conversions)
if os.path.exists(filepath):
content = Content( content = Content(
filename=filename, filename=filename,
content_type=detected_type, content_type=detected_type,
@@ -332,7 +613,13 @@ def upload_media():
log_action('info', f'Uploaded {uploaded_count} media files') log_action('info', f'Uploaded {uploaded_count} media files')
if playlist_id: # Show appropriate flash message
if processing_errors:
error_summary = '; '.join(processing_errors[:3])
if len(processing_errors) > 3:
error_summary += f' and {len(processing_errors) - 3} more...'
flash(f'Uploaded {uploaded_count} file(s). Errors: {error_summary}', 'warning')
elif playlist_id:
playlist = Playlist.query.get(playlist_id) playlist = Playlist.query.get(playlist_id)
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success') flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')
else: else:

View File

@@ -213,31 +213,8 @@ def regenerate_auth_code(player_id: int):
@players_bp.route('/<int:player_id>') @players_bp.route('/<int:player_id>')
@login_required @login_required
def player_page(player_id: int): def player_page(player_id: int):
"""Display player page with content and controls.""" """Redirect to manage player page (combined view)."""
try: return redirect(url_for('players.manage_player', player_id=player_id))
player = Player.query.get_or_404(player_id)
# Get player's playlist
playlist = get_player_playlist(player_id)
# Get player status
status_info = get_player_status_info(player_id)
# Get recent feedback
recent_feedback = PlayerFeedback.query.filter_by(player_id=player_id)\
.order_by(PlayerFeedback.timestamp.desc())\
.limit(10)\
.all()
return render_template('players/player_page.html',
player=player,
playlist=playlist,
status_info=status_info,
recent_feedback=recent_feedback)
except Exception as e:
log_action('error', f'Error loading player page: {str(e)}')
flash('Error loading player page.', 'danger')
return redirect(url_for('players.list'))
@players_bp.route('/<int:player_id>/manage', methods=['GET', 'POST']) @players_bp.route('/<int:player_id>/manage', methods=['GET', 'POST'])
@@ -347,7 +324,7 @@ def player_fullscreen(player_id: int):
@cache.memoize(timeout=300) # Cache for 5 minutes @cache.memoize(timeout=300) # Cache for 5 minutes
def get_player_playlist(player_id: int) -> List[dict]: def get_player_playlist(player_id: int) -> List[dict]:
"""Get playlist for a player based on their direct content assignment. """Get playlist for a player based on their assigned playlist.
Args: Args:
player_id: The player's database ID player_id: The player's database ID
@@ -356,23 +333,26 @@ def get_player_playlist(player_id: int) -> List[dict]:
List of content dictionaries with url, type, duration, and position List of content dictionaries with url, type, duration, and position
""" """
player = Player.query.get(player_id) player = Player.query.get(player_id)
if not player: if not player or not player.playlist_id:
return [] return []
# Get content directly assigned to this player # Get the player's assigned playlist
contents = Content.query.filter_by(player_id=player_id)\ playlist_obj = Playlist.query.get(player.playlist_id)
.order_by(Content.position, Content.uploaded_at)\ if not playlist_obj:
.all() return []
# Get ordered content from the playlist
ordered_content = playlist_obj.get_content_ordered()
# Build playlist # Build playlist
playlist = [] playlist = []
for content in contents: for content in ordered_content:
playlist.append({ playlist.append({
'id': content.id, 'id': content.id,
'url': url_for('static', filename=f'uploads/{content.filename}'), 'url': url_for('static', filename=f'uploads/{content.filename}'),
'type': content.content_type, 'type': content.content_type,
'duration': content.duration or 10, # Default 10 seconds if not set 'duration': getattr(content, '_playlist_duration', content.duration or 10),
'position': content.position, 'position': getattr(content, '_playlist_position', 0),
'filename': content.filename 'filename': content.filename
}) })

View File

@@ -2,11 +2,12 @@
from flask import (Blueprint, render_template, request, redirect, url_for, from flask import (Blueprint, render_template, request, redirect, url_for,
flash, jsonify, current_app) flash, jsonify, current_app)
from flask_login import login_required from flask_login import login_required
from sqlalchemy import desc from sqlalchemy import desc, update
import os import os
from app.extensions import db, cache from app.extensions import db, cache
from app.models import Player, Content from app.models import Player, Content, Playlist
from app.models.playlist import playlist_content
from app.utils.logger import log_action from app.utils.logger import log_action
playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist') playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
@@ -18,20 +19,22 @@ def manage_playlist(player_id: int):
"""Manage playlist for a specific player.""" """Manage playlist for a specific player."""
player = Player.query.get_or_404(player_id) player = Player.query.get_or_404(player_id)
# Get all content for this player, ordered by position # Get content from player's assigned playlist
playlist_content = Content.query.filter_by( playlist_items = []
player_id=player_id if player.playlist_id:
).order_by(Content.position).all() playlist = Playlist.query.get(player.playlist_id)
if playlist:
playlist_items = playlist.get_content_ordered()
# Get available content (files not already in this player's playlist) # Get available content (all content not in current playlist)
all_files = db.session.query(Content.filename).distinct().all() all_content = Content.query.all()
playlist_filenames = {c.filename for c in playlist_content} playlist_content_ids = {item.id for item in playlist_items}
available_files = [f[0] for f in all_files if f[0] not in playlist_filenames] available_content = [c for c in all_content if c.id not in playlist_content_ids]
return render_template('playlist/manage_playlist.html', return render_template('playlist/manage_playlist.html',
player=player, player=player,
playlist_content=playlist_content, playlist_content=playlist_items,
available_files=available_files) available_content=available_content)
@playlist_bp.route('/<int:player_id>/add', methods=['POST']) @playlist_bp.route('/<int:player_id>/add', methods=['POST'])
@@ -40,44 +43,46 @@ def add_to_playlist(player_id: int):
"""Add content to player's playlist.""" """Add content to player's playlist."""
player = Player.query.get_or_404(player_id) player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try: try:
filename = request.form.get('filename') content_id = request.form.get('content_id', type=int)
duration = request.form.get('duration', type=int, default=10) duration = request.form.get('duration', type=int, default=10)
if not filename: if not content_id:
flash('Please provide a filename.', 'warning') flash('Please select content.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id)) return redirect(url_for('playlist.manage_playlist', player_id=player_id))
content = Content.query.get_or_404(content_id)
playlist = Playlist.query.get(player.playlist_id)
# Get max position # Get max position
max_position = db.session.query(db.func.max(Content.position)).filter_by( from sqlalchemy import select, func
player_id=player_id max_pos = db.session.execute(
select(func.max(playlist_content.c.position)).where(
playlist_content.c.playlist_id == playlist.id
)
).scalar() or 0 ).scalar() or 0
# Get file info from existing content # Add to playlist_content association table
existing_content = Content.query.filter_by(filename=filename).first() stmt = playlist_content.insert().values(
if not existing_content: playlist_id=playlist.id,
flash('File not found.', 'danger') content_id=content.id,
return redirect(url_for('playlist.manage_playlist', player_id=player_id)) position=max_pos + 1,
duration=duration
# 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) db.session.execute(stmt)
# Increment playlist version # Increment playlist version
player.playlist_version += 1 playlist.increment_version()
db.session.commit() db.session.commit()
cache.clear() cache.clear()
log_action('info', f'Added "{filename}" to playlist for player "{player.name}"') log_action('info', f'Added "{content.filename}" to playlist for player "{player.name}"')
flash(f'Added "{filename}" to playlist.', 'success') flash(f'Added "{content.filename}" to playlist.', 'success')
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
@@ -92,28 +97,41 @@ def add_to_playlist(player_id: int):
def remove_from_playlist(player_id: int, content_id: int): def remove_from_playlist(player_id: int, content_id: int):
"""Remove content from player's playlist.""" """Remove content from player's playlist."""
player = Player.query.get_or_404(player_id) player = Player.query.get_or_404(player_id)
content = Content.query.get_or_404(content_id)
if content.player_id != player_id: if not player.playlist_id:
flash('Content does not belong to this player.', 'danger') flash('Player has no playlist assigned.', 'danger')
return redirect(url_for('playlist.manage_playlist', player_id=player_id)) return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try: try:
content = Content.query.get_or_404(content_id)
playlist = Playlist.query.get(player.playlist_id)
filename = content.filename filename = content.filename
# Delete content # Remove from playlist_content association table
db.session.delete(content) from sqlalchemy import delete
stmt = delete(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
)
db.session.execute(stmt)
# Reorder remaining content # Reorder remaining content
remaining_content = Content.query.filter_by( from sqlalchemy import select
player_id=player_id remaining = db.session.execute(
).order_by(Content.position).all() select(playlist_content.c.content_id, playlist_content.c.position).where(
playlist_content.c.playlist_id == playlist.id
).order_by(playlist_content.c.position)
).fetchall()
for idx, item in enumerate(remaining_content, start=1): for idx, row in enumerate(remaining, start=1):
item.position = idx stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == row.content_id)
).values(position=idx)
db.session.execute(stmt)
# Increment playlist version # Increment playlist version
player.playlist_version += 1 playlist.increment_version()
db.session.commit() db.session.commit()
cache.clear() cache.clear()
@@ -135,7 +153,12 @@ def reorder_playlist(player_id: int):
"""Reorder playlist items.""" """Reorder playlist items."""
player = Player.query.get_or_404(player_id) player = Player.query.get_or_404(player_id)
if not player.playlist_id:
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try: try:
playlist = Playlist.query.get(player.playlist_id)
# Get new order from JSON # Get new order from JSON
data = request.get_json() data = request.get_json()
content_ids = data.get('content_ids', []) content_ids = data.get('content_ids', [])
@@ -143,24 +166,26 @@ def reorder_playlist(player_id: int):
if not content_ids: if not content_ids:
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400 return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
# Update positions # Update positions in association table
for idx, content_id in enumerate(content_ids, start=1): for idx, content_id in enumerate(content_ids, start=1):
content = Content.query.get(content_id) stmt = update(playlist_content).where(
if content and content.player_id == player_id: (playlist_content.c.playlist_id == playlist.id) &
content.position = idx (playlist_content.c.content_id == content_id)
).values(position=idx)
db.session.execute(stmt)
# Increment playlist version # Increment playlist version
player.playlist_version += 1 playlist.increment_version()
db.session.commit() db.session.commit()
cache.clear() cache.clear()
log_action('info', f'Reordered playlist for player "{player.name}" (version {player.playlist_version})') log_action('info', f'Reordered playlist for player "{player.name}" (version {playlist.version})')
return jsonify({ return jsonify({
'success': True, 'success': True,
'message': 'Playlist reordered successfully', 'message': 'Playlist reordered successfully',
'version': player.playlist_version 'version': playlist.version
}) })
except Exception as e: except Exception as e:
@@ -174,19 +199,28 @@ def reorder_playlist(player_id: int):
def update_duration(player_id: int, content_id: int): def update_duration(player_id: int, content_id: int):
"""Update content duration in playlist.""" """Update content duration in playlist."""
player = Player.query.get_or_404(player_id) player = Player.query.get_or_404(player_id)
content = Content.query.get_or_404(content_id)
if content.player_id != player_id: if not player.playlist_id:
return jsonify({'success': False, 'message': 'Content does not belong to this player'}), 403 return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
try: try:
playlist = Playlist.query.get(player.playlist_id)
content = Content.query.get_or_404(content_id)
duration = request.form.get('duration', type=int) duration = request.form.get('duration', type=int)
if not duration or duration < 1: if not duration or duration < 1:
return jsonify({'success': False, 'message': 'Invalid duration'}), 400 return jsonify({'success': False, 'message': 'Invalid duration'}), 400
content.duration = duration # Update duration in association table
player.playlist_version += 1 stmt = update(playlist_content).where(
(playlist_content.c.playlist_id == playlist.id) &
(playlist_content.c.content_id == content_id)
).values(duration=duration)
db.session.execute(stmt)
# Increment playlist version
playlist.increment_version()
db.session.commit() db.session.commit()
cache.clear() cache.clear()
@@ -196,7 +230,7 @@ def update_duration(player_id: int, content_id: int):
return jsonify({ return jsonify({
'success': True, 'success': True,
'message': 'Duration updated', 'message': 'Duration updated',
'version': player.playlist_version 'version': playlist.version
}) })
except Exception as e: except Exception as e:
@@ -211,12 +245,22 @@ def clear_playlist(player_id: int):
"""Clear all content from player's playlist.""" """Clear all content from player's playlist."""
player = Player.query.get_or_404(player_id) player = Player.query.get_or_404(player_id)
if not player.playlist_id:
flash('Player has no playlist assigned.', 'warning')
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
try: try:
# Delete all content for this player playlist = Playlist.query.get(player.playlist_id)
Content.query.filter_by(player_id=player_id).delete()
# Delete all content from playlist
from sqlalchemy import delete
stmt = delete(playlist_content).where(
playlist_content.c.playlist_id == playlist.id
)
db.session.execute(stmt)
# Increment playlist version # Increment playlist version
player.playlist_version += 1 playlist.increment_version()
db.session.commit() db.session.commit()
cache.clear() cache.clear()

View File

@@ -16,10 +16,11 @@ class Config:
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ECHO = False SQLALCHEMY_ECHO = False
# File Upload # File Upload - use absolute paths
MAX_CONTENT_LENGTH = 2048 * 1024 * 1024 # 2GB MAX_CONTENT_LENGTH = 2048 * 1024 * 1024 # 2GB
UPLOAD_FOLDER = 'static/uploads' _basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
UPLOAD_FOLDERLOGO = 'static/resurse' UPLOAD_FOLDER = os.path.join(_basedir, 'static', 'uploads')
UPLOAD_FOLDERLOGO = os.path.join(_basedir, 'static', 'resurse')
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'mp4', 'avi', 'mkv', 'mov', 'webm', 'pdf', 'ppt', 'pptx'} ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'mp4', 'avi', 'mkv', 'mov', 'webm', 'pdf', 'ppt', 'pptx'}
# Session # Session

View File

@@ -11,27 +11,42 @@
<h2>📊 System Overview</h2> <h2>📊 System Overview</h2>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-item"> <div class="stat-item">
<span class="stat-label">Total Users:</span> <div class="stat-icon">👥</div>
<div class="stat-content">
<span class="stat-label">Total Users</span>
<span class="stat-value">{{ total_users or 0 }}</span> <span class="stat-value">{{ total_users or 0 }}</span>
</div> </div>
</div>
<div class="stat-item"> <div class="stat-item">
<span class="stat-label">Total Players:</span> <div class="stat-icon">🖥️</div>
<div class="stat-content">
<span class="stat-label">Total Players</span>
<span class="stat-value">{{ total_players or 0 }}</span> <span class="stat-value">{{ total_players or 0 }}</span>
</div> </div>
<div class="stat-item">
<span class="stat-label">Total Groups:</span>
<span class="stat-value">{{ total_groups or 0 }}</span>
</div> </div>
<div class="stat-item"> <div class="stat-item">
<span class="stat-label">Total Content:</span> <div class="stat-icon">📋</div>
<div class="stat-content">
<span class="stat-label">Total Playlists</span>
<span class="stat-value">{{ total_playlists or 0 }}</span>
</div>
</div>
<div class="stat-item">
<div class="stat-icon">📁</div>
<div class="stat-content">
<span class="stat-label">Media Files</span>
<span class="stat-value">{{ total_content or 0 }}</span> <span class="stat-value">{{ total_content or 0 }}</span>
</div> </div>
</div>
<div class="stat-item"> <div class="stat-item">
<span class="stat-label">Storage Used:</span> <div class="stat-icon">💾</div>
<div class="stat-content">
<span class="stat-label">Storage Used</span>
<span class="stat-value">{{ storage_mb or 0 }} MB</span> <span class="stat-value">{{ storage_mb or 0 }} MB</span>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- User Management Card --> <!-- User Management Card -->
<div class="card management-card"> <div class="card management-card">
@@ -44,13 +59,24 @@
</div> </div>
</div> </div>
<!-- Leftover Media Management Card -->
<div class="card management-card">
<h2>🗑️ Manage Leftover Media</h2>
<p>Clean up media files not assigned to any playlist</p>
<div class="card-actions">
<a href="{{ url_for('admin.leftover_media') }}" class="btn btn-warning">
Manage Leftover Files
</a>
</div>
</div>
<!-- Quick Actions Card --> <!-- Quick Actions Card -->
<div class="card"> <div class="card">
<h2>⚡ Quick Actions</h2> <h2>⚡ Quick Actions</h2>
<div class="quick-actions"> <div class="quick-actions">
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">View Players</a> <a href="{{ url_for('players.list') }}" class="btn btn-secondary">🖥️ View Players</a>
<a href="{{ url_for('groups.groups_list') }}" class="btn btn-secondary">View Groups</a> <a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📋 View Playlists</a>
<a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">View Content</a> <a href="{{ url_for('content.content_list') }}" class="btn btn-secondary">📁 View Media Library</a>
</div> </div>
</div> </div>
</div> </div>
@@ -71,22 +97,61 @@
.stat-item { .stat-item {
display: flex; display: flex;
justify-content: space-between; align-items: center;
padding: 10px; gap: 12px;
padding: 15px;
background: #f8f9fa; background: #f8f9fa;
border-radius: 4px; border-radius: 8px;
border: 1px solid #e2e8f0;
transition: all 0.2s;
}
.stat-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
body.dark-mode .stat-item {
background: #1a202c;
border-color: #4a5568;
}
body.dark-mode .stat-item:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.stat-icon {
font-size: 2rem;
line-height: 1;
}
.stat-content {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
} }
.stat-label { .stat-label {
font-weight: 500; font-weight: 500;
font-size: 0.85rem;
color: #666; color: #666;
} }
body.dark-mode .stat-label {
color: #a0aec0;
}
.stat-value { .stat-value {
font-weight: bold; font-weight: bold;
font-size: 1.5rem;
color: #2c3e50; color: #2c3e50;
} }
body.dark-mode .stat-value {
color: #e2e8f0;
}
.management-card { .management-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;

View File

@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% block title %}Leftover Media - Admin - DigiServer v2{% endblock %}
{% block content %}
<div style="max-width: 1400px; margin: 0 auto;">
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center;">
<h1 style="display: flex; align-items: center; gap: 0.5rem;">
🗑️ Manage Leftover Media
</h1>
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
← Back to Admin
</a>
</div>
<!-- Overview Stats -->
<div class="card" style="margin-bottom: 30px;">
<h2>📊 Overview</h2>
<p style="color: #6c757d; margin-bottom: 20px;">
Media files that are not assigned to any playlist. These can be safely deleted to free up storage.
</p>
<div class="stats-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Leftover Files</div>
<div style="font-size: 24px; font-weight: bold;">{{ total_leftover }}</div>
</div>
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Size</div>
<div style="font-size: 24px; font-weight: bold;">{{ "%.2f"|format(total_leftover_size_mb) }} MB</div>
</div>
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Images</div>
<div style="font-size: 24px; font-weight: bold;">{{ leftover_images|length }} ({{ "%.2f"|format(images_size_mb) }} MB)</div>
</div>
<div class="stat-item" style="background: #f8f9fa; padding: 15px; border-radius: 8px;">
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Videos</div>
<div style="font-size: 24px; font-weight: bold;">{{ leftover_videos|length }} ({{ "%.2f"|format(videos_size_mb) }} MB)</div>
</div>
</div>
</div>
<!-- Images Section -->
<div class="card" style="margin-bottom: 30px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2>📷 Leftover Images ({{ leftover_images|length }})</h2>
{% if leftover_images %}
<form method="POST" action="{{ url_for('admin.delete_leftover_images') }}"
onsubmit="return confirm('Are you sure you want to delete ALL {{ leftover_images|length }} leftover images? This cannot be undone!');"
style="display: inline;">
<button type="submit" class="btn btn-danger">
🗑️ Delete All Images
</button>
</form>
{% endif %}
</div>
{% if leftover_images %}
<div style="max-height: 400px; overflow-y: auto;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: #f8f9fa;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
</tr>
</thead>
<tbody>
{% for img in leftover_images %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">📷 {{ img.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(img.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ img.duration }}s</td>
<td style="padding: 10px;">{{ img.uploaded_at.strftime('%Y-%m-%d %H:%M') if img.uploaded_at else 'N/A' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px; margin-bottom: 15px;"></div>
<p>No leftover images found. All images are assigned to playlists!</p>
</div>
{% endif %}
</div>
<!-- Videos Section -->
<div class="card" style="margin-bottom: 30px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h2 style="margin: 0;">🎥 Leftover Videos ({{ leftover_videos|length }})</h2>
{% if leftover_videos %}
<form method="POST" action="{{ url_for('admin.delete_leftover_videos') }}" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete ALL {{ leftover_videos|length }} leftover videos? This action cannot be undone!');">
<button type="submit" class="btn btn-danger" style="padding: 8px 16px; font-size: 14px;">
🗑️ Delete All Videos
</button>
</form>
{% endif %}
</div>
{% if leftover_videos %}
<div style="max-height: 400px; overflow-y: auto; margin-top: 20px;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: #f8f9fa;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
</tr>
</thead>
<tbody>
{% for video in leftover_videos %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">🎥 {{ video.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(video.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ video.duration }}s</td>
<td style="padding: 10px;">{{ video.uploaded_at.strftime('%Y-%m-%d %H:%M') if video.uploaded_at else 'N/A' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px; margin-bottom: 15px;"></div>
<p>No leftover videos found. All videos are assigned to playlists!</p>
</div>
{% endif %}
</div>
<!-- PDFs Section -->
<div class="card" style="margin-bottom: 30px;">
<h2>📄 Leftover PDFs ({{ leftover_pdfs|length }})</h2>
{% if leftover_pdfs %}
<div style="max-height: 400px; overflow-y: auto; margin-top: 20px;">
<table class="table" style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: #f8f9fa;">
<tr>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Filename</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Uploaded</th>
</tr>
</thead>
<tbody>
{% for pdf in leftover_pdfs %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">📄 {{ pdf.filename }}</td>
<td style="padding: 10px;">{{ "%.2f"|format(pdf.file_size_mb) }} MB</td>
<td style="padding: 10px;">{{ pdf.duration }}s</td>
<td style="padding: 10px;">{{ pdf.uploaded_at.strftime('%Y-%m-%d %H:%M') if pdf.uploaded_at else 'N/A' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="text-align: center; padding: 40px; color: #999;">
<div style="font-size: 48px; margin-bottom: 15px;"></div>
<p>No leftover PDFs found. All PDFs are assigned to playlists!</p>
</div>
{% endif %}
</div>
</div>
<style>
body.dark-mode .card {
background: #2d3748;
color: #e2e8f0;
}
body.dark-mode h1,
body.dark-mode h2 {
color: #e2e8f0;
}
body.dark-mode .stat-item {
background: #1a202c !important;
color: #e2e8f0;
}
body.dark-mode .table thead {
background: #1a202c !important;
}
body.dark-mode .table th,
body.dark-mode .table td {
color: #e2e8f0;
border-color: #4a5568 !important;
}
body.dark-mode .table tr {
border-color: #4a5568 !important;
}
body.dark-mode .table tr:hover {
background: #1a202c;
}
</style>
{% endblock %}

View File

@@ -444,10 +444,19 @@
{% endif %} {% endif %}
</td> </td>
<td style="padding: 12px;"> <td style="padding: 12px;">
<div style="display: flex; gap: 0.5rem;">
<a href="{{ url_for('players.player_page', player_id=player.id) }}" <a href="{{ url_for('players.player_page', player_id=player.id) }}"
class="btn btn-primary btn-sm"> class="btn btn-primary btn-sm">
👁️ View ⚙️ Manage
</a> </a>
{% if player.playlist_id %}
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
class="btn btn-success btn-sm" target="_blank"
title="View live content preview">
🖥️ Live
</a>
{% endif %}
</div>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -5,14 +5,15 @@
{% block content %} {% block content %}
<style> <style>
.upload-container { .upload-container {
max-width: 900px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
padding: 20px;
} }
.upload-zone { .upload-zone {
border: 3px dashed #ced4da; border: 2px dashed #ced4da;
border-radius: 12px; border-radius: 8px;
padding: 60px 40px; padding: 25px 20px;
text-align: center; text-align: center;
background: #f8f9fa; background: #f8f9fa;
transition: all 0.3s; transition: all 0.3s;
@@ -54,18 +55,21 @@
} }
.file-list { .file-list {
margin-top: 20px; margin-top: 15px;
max-height: 200px;
overflow-y: auto;
} }
.file-item { .file-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 12px; padding: 8px 10px;
background: #fff; background: #fff;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 6px; border-radius: 4px;
margin-bottom: 10px; margin-bottom: 6px;
font-size: 13px;
} }
body.dark-mode .file-item { body.dark-mode .file-item {
@@ -80,7 +84,7 @@
} }
.file-icon { .file-icon {
font-size: 24px; font-size: 20px;
} }
.remove-file { .remove-file {
@@ -97,7 +101,7 @@
} }
.form-group { .form-group {
margin-bottom: 20px; margin-bottom: 15px;
} }
.form-group label { .form-group label {
@@ -205,88 +209,81 @@
</style> </style>
<div class="upload-container"> <div class="upload-container">
<div style="margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center;"> <div style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<h1 style="display: flex; align-items: center; gap: 0.5rem;"> <h1 style="display: flex; align-items: center; gap: 0.5rem; font-size: 24px; margin: 0;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 32px; height: 32px;"> <img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 28px; height: 28px;">
Upload Media Files Upload Media
</h1> </h1>
<a href="{{ url_for('content.content_list') }}" class="btn" style="display: flex; align-items: center; gap: 0.5rem;"> <a href="{{ url_for('content.content_list') }}" class="btn" style="display: flex; align-items: center; gap: 0.5rem; padding: 8px 16px;">
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);"> <img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
Back to Playlists Back to Playlists
</a> </a>
</div> </div>
<form id="upload-form" method="POST" action="{{ url_for('content.upload_media') }}" enctype="multipart/form-data"> <form id="upload-form" method="POST" action="{{ url_for('content.upload_media') }}" enctype="multipart/form-data">
<!-- Playlist Selector --> <!-- Compact Two-Column Layout -->
<div class="card" style="margin-bottom: 30px;"> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<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"> <!-- Left Column: Upload Zone -->
<label for="playlist_id">Target Playlist</label> <div class="card">
<select name="playlist_id" id="playlist_id" class="form-control"> <h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
<option value="">-- Media Library Only (Don't add to playlist) --</option> <img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 20px; height: 20px;">
{% 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 Select Files
</h2> </h2>
<div class="upload-zone" id="upload-zone"> <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;"> <img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 48px; height: 48px; opacity: 0.3; margin-bottom: 10px;">
<h3 style="margin-bottom: 10px;">Drag and Drop Files Here</h3> <h3 style="margin-bottom: 8px; font-size: 16px;">Drag & Drop</h3>
<p style="color: #6c757d; margin: 15px 0;">or</p> <p style="color: #6c757d; margin: 8px 0; font-size: 13px;">or</p>
<div class="file-input-wrapper"> <div class="file-input-wrapper">
<label for="file-input" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;"> <label for="file-input" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem; padding: 8px 16px; font-size: 14px;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);"> <img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 16px; height: 16px; filter: brightness(0) invert(1);">
Browse Files Browse
</label> </label>
<input type="file" id="file-input" name="files" multiple <input type="file" id="file-input" name="files" multiple
accept="image/*,video/*,.pdf,.ppt,.pptx"> accept="image/*,video/*,.pdf,.ppt,.pptx">
</div> </div>
<p style="font-size: 14px; color: #999; margin-top: 20px;"> <p style="font-size: 11px; color: #999; margin-top: 12px; line-height: 1.4;">
<strong>Supported formats:</strong><br> <strong>Supported:</strong> JPG, PNG, GIF, MP4, AVI, MOV, PDF, PPT
Images: JPG, PNG, GIF, BMP<br>
Videos: MP4, AVI, MOV, MKV, WEBM<br>
Documents: PDF, PPT, PPTX
</p> </p>
</div> </div>
<div id="file-list" class="file-list"></div> <div id="file-list" class="file-list"></div>
</div> </div>
<!-- Upload Settings --> <!-- Right Column: Settings -->
<div class="card" style="margin-bottom: 30px;"> <div class="card">
<h2 style="margin-bottom: 20px; display: flex; align-items: center; gap: 0.5rem;"> <h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;"> <img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 20px; height: 20px;">
Upload Settings Upload Settings
</h2> </h2>
<div class="form-group">
<label for="playlist_id">Target Playlist (Optional)</label>
<select name="playlist_id" id="playlist_id" class="form-control">
<option value="">-- Media Library Only --</option>
{% for playlist in playlists %}
<option value="{{ playlist.id }}">
{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items
</option>
{% endfor %}
</select>
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
💡 Add to playlists later if needed
</small>
</div>
<div class="form-group"> <div class="form-group">
<label for="content_type">Media Type</label> <label for="content_type">Media Type</label>
<select name="content_type" id="content_type" class="form-control"> <select name="content_type" id="content_type" class="form-control">
<option value="image">Image</option> <option value="image">Image</option>
<option value="video">Video</option> <option value="video">Video</option>
<option value="pdf">PDF Document</option> <option value="pdf">PDF</option>
<option value="pptx">PPTX</option>
</select> </select>
<small style="color: #6c757d; display: block; margin-top: 5px;"> <small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
This will be auto-detected from file extension Auto-detected from file extension
</small> </small>
</div> </div>
@@ -294,17 +291,18 @@
<label for="duration">Default Duration (seconds)</label> <label for="duration">Default Duration (seconds)</label>
<input type="number" name="duration" id="duration" class="form-control" <input type="number" name="duration" id="duration" class="form-control"
value="10" min="1" max="300"> value="10" min="1" max="300">
<small style="color: #6c757d; display: block; margin-top: 5px;"> <small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
How long each item should display (for images and PDFs) Display time for images and PDFs
</small> </small>
</div> </div>
</div>
<!-- Upload Button --> <!-- Upload Button -->
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem;"> <button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 20px; padding: 12px 30px;">
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 20px; height: 20px; filter: brightness(0) invert(1);"> <img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
Upload Files Upload Files
</button> </button>
</div>
</div>
</form> </form>
</div> </div>
@@ -350,18 +348,97 @@
// Handle file input // Handle file input
fileInput.addEventListener('change', (e) => { fileInput.addEventListener('change', (e) => {
console.log('File input changed, files:', e.target.files.length);
if (e.target.files.length > 0) {
handleFiles(e.target.files); handleFiles(e.target.files);
}
}); });
// Click upload zone to trigger file input // Click upload zone to trigger file input (but not if clicking on the label)
uploadZone.addEventListener('click', () => { uploadZone.addEventListener('click', (e) => {
// Don't trigger if clicking on the label or button
if (e.target.tagName === 'LABEL' || e.target.closest('label') || e.target.tagName === 'INPUT') {
return;
}
fileInput.click(); fileInput.click();
}); });
function handleFiles(files) { function handleFiles(files) {
console.log('handleFiles called with', files.length, 'file(s)');
selectedFiles = Array.from(files); selectedFiles = Array.from(files);
displayFiles(); displayFiles();
uploadBtn.disabled = selectedFiles.length === 0; uploadBtn.disabled = selectedFiles.length === 0;
// Auto-detect media type and duration from first file
if (selectedFiles.length > 0) {
console.log('Auto-detecting for first file:', selectedFiles[0].name);
autoDetectMediaType(selectedFiles[0]);
autoDetectDuration(selectedFiles[0]);
}
}
function autoDetectMediaType(file) {
const ext = file.name.split('.').pop().toLowerCase();
const contentTypeSelect = document.getElementById('content_type');
console.log('Auto-detecting media type for:', file.name, 'Extension:', ext);
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
contentTypeSelect.value = 'image';
console.log('Set type to: image');
} else if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
contentTypeSelect.value = 'video';
console.log('Set type to: video');
} else if (ext === 'pdf') {
contentTypeSelect.value = 'pdf';
console.log('Set type to: pdf');
} else if (['ppt', 'pptx'].includes(ext)) {
contentTypeSelect.value = 'pptx';
console.log('Set type to: pptx');
}
}
function autoDetectDuration(file) {
const ext = file.name.split('.').pop().toLowerCase();
const durationInput = document.getElementById('duration');
console.log('Auto-detecting duration for:', file.name, 'Extension:', ext);
// For videos, try to get actual duration
if (['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) {
console.log('Processing as video...');
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = function() {
window.URL.revokeObjectURL(video.src);
const duration = Math.ceil(video.duration);
console.log('Video duration detected:', duration, 'seconds');
if (duration && duration > 0) {
durationInput.value = duration;
}
};
video.onerror = function() {
console.log('Video loading error, using default 30s');
window.URL.revokeObjectURL(video.src);
durationInput.value = 30; // Default for videos if can't read duration
};
video.src = URL.createObjectURL(file);
} else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) {
// Images: default 10 seconds
console.log('Setting image duration: 10s');
durationInput.value = 10;
} else if (ext === 'pdf') {
// PDFs: default 15 seconds per page (estimate)
console.log('Setting PDF duration: 15s');
durationInput.value = 15;
} else if (['ppt', 'pptx'].includes(ext)) {
// Presentations: default 20 seconds per slide (estimate)
console.log('Setting presentation duration: 20s');
durationInput.value = 20;
}
} }
function displayFiles() { function displayFiles() {

View File

@@ -3,6 +3,215 @@
{% block title %}Manage Player - {{ player.name }}{% endblock %} {% block title %}Manage Player - {{ player.name }}{% endblock %}
{% block content %} {% block content %}
<style>
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .form-control:focus {
border-color: #7c3aed;
outline: none;
}
.info-box {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.info-box.neutral {
background: #f8f9fa;
}
body.dark-mode .info-box.neutral {
background: #1a202c;
border: 1px solid #4a5568;
}
.info-box.success {
background: #d4edda;
color: #155724;
}
body.dark-mode .info-box.success {
background: #1a4d2e;
color: #86efac;
border: 1px solid #48bb78;
}
.info-box.warning {
background: #fff3cd;
color: #856404;
}
body.dark-mode .info-box.warning {
background: #4a3800;
color: #fbbf24;
border: 1px solid #ecc94b;
}
.log-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
border-radius: 4px;
background: #f8f9fa;
border-left: 4px solid;
}
body.dark-mode .log-item {
background: #2d3748;
}
.log-item pre {
background: #fff;
border: 1px solid #ddd;
}
body.dark-mode .log-item pre {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode h1,
body.dark-mode h2,
body.dark-mode h3,
body.dark-mode h4 {
color: #e2e8f0;
}
body.dark-mode p {
color: #a0aec0;
}
body.dark-mode strong {
color: #e2e8f0;
}
body.dark-mode code {
background: #1a202c;
color: #e2e8f0;
padding: 2px 6px;
border-radius: 3px;
}
body.dark-mode small {
color: #718096;
}
.credential-item {
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #e2e8f0;
}
body.dark-mode .credential-item {
border-bottom-color: #4a5568;
}
.credential-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.credential-label {
font-weight: 600;
display: block;
margin-bottom: 0.25rem;
font-size: 0.85rem;
color: #495057;
}
body.dark-mode .credential-label {
color: #a0aec0;
}
.credential-value {
font-family: 'Courier New', monospace;
background: #f8f9fa;
padding: 0.5rem;
border-radius: 4px;
word-break: break-all;
font-size: 0.85rem;
border: 1px solid #dee2e6;
}
body.dark-mode .credential-value {
background: #0d1117;
border-color: #4a5568;
color: #e2e8f0;
}
.status-card {
margin-bottom: 2rem;
}
.status-card.online {
background: #d4edda;
}
body.dark-mode .status-card.online {
background: #1a4d2e;
border: 1px solid #48bb78;
}
.status-card.offline {
background: #f8d7da;
}
body.dark-mode .status-card.offline {
background: #4a1a1a;
border: 1px solid #dc3545;
}
.status-card.other {
background: #fff3cd;
}
body.dark-mode .status-card.other {
background: #4a3800;
border: 1px solid #ecc94b;
}
.playlist-stats {
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
}
body.dark-mode .playlist-stats {
background: #1a202c;
border: 1px solid #4a5568;
}
body.dark-mode .playlist-stats > div > div:first-child {
color: #a0aec0;
}
</style>
<div style="margin-bottom: 2rem;"> <div style="margin-bottom: 2rem;">
<h1 style="display: inline-block; margin-right: 1rem; display: flex; align-items: center; gap: 0.5rem;"> <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;"> <img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 32px; height: 32px;">
@@ -15,7 +224,7 @@
</div> </div>
<!-- Player Status Overview --> <!-- Player Status Overview -->
<div class="card" style="margin-bottom: 2rem; background: {% if player.status == 'online' %}#d4edda{% elif player.status == 'offline' %}#f8d7da{% else %}#fff3cd{% endif %};"> <div class="card status-card {% if player.status == 'online' %}online{% elif player.status == 'offline' %}offline{% else %}other{% endif %}">
<h3 style="display: flex; align-items: center; gap: 0.5rem;"> <h3 style="display: flex; align-items: center; gap: 0.5rem;">
Status: Status:
{% if player.status == 'online' %} {% if player.status == 'online' %}
@@ -52,6 +261,47 @@
</p> </p>
</div> </div>
<!-- Playlist Overview Card -->
{% if current_playlist %}
<div class="card" style="margin-bottom: 2rem;">
<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;">
Current Playlist: {{ current_playlist.name }}
</h2>
<div class="playlist-stats" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-top: 1rem;">
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Total Items</div>
<div style="font-size: 1.5rem; font-weight: bold;">{{ current_playlist.contents.count() }}</div>
</div>
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Playlist Version</div>
<div style="font-size: 1.5rem; font-weight: bold;">v{{ current_playlist.version }}</div>
</div>
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Last Updated</div>
<div style="font-size: 1rem; font-weight: bold;">{{ current_playlist.updated_at.strftime('%Y-%m-%d %H:%M') }}</div>
</div>
<div>
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Orientation</div>
<div style="font-size: 1.5rem; font-weight: bold;">{{ current_playlist.orientation }}</div>
</div>
</div>
<div style="margin-top: 1rem; display: flex; gap: 0.5rem;">
<a href="{{ url_for('content.manage_playlist_content', playlist_id=current_playlist.id) }}"
class="btn btn-primary" style="display: inline-flex; align-items: 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 Playlist Content
</a>
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;"
target="_blank">
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
View Live Content
</a>
</div>
</div>
{% endif %}
<!-- Three Column Layout --> <!-- Three Column Layout -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1.5rem;"> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1.5rem;">
@@ -64,33 +314,44 @@
<form method="POST" style="margin-top: 1rem;"> <form method="POST" style="margin-top: 1rem;">
<input type="hidden" name="action" value="update_credentials"> <input type="hidden" name="action" value="update_credentials">
<div style="margin-bottom: 1rem;"> <div class="form-group">
<label for="name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Player Name *</label> <label for="name">Player Name *</label>
<input type="text" id="name" name="name" value="{{ player.name }}" <input type="text" id="name" name="name" value="{{ player.name }}"
required minlength="3" required minlength="3" class="form-control">
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
</div> </div>
<div style="margin-bottom: 1rem;"> <div class="form-group">
<label for="location" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Location</label> <label for="location">Location</label>
<input type="text" id="location" name="location" value="{{ player.location or '' }}" <input type="text" id="location" name="location" value="{{ player.location or '' }}"
placeholder="e.g., Main Lobby" placeholder="e.g., Main Lobby" class="form-control">
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
</div> </div>
<div style="margin-bottom: 1rem;"> <div class="form-group">
<label for="orientation" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Orientation</label> <label for="orientation">Orientation</label>
<select id="orientation" name="orientation" <select id="orientation" name="orientation" class="form-control">
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="Landscape" {% if player.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
<option value="Portrait" {% if player.orientation == 'Portrait' %}selected{% endif %}>Portrait</option> <option value="Portrait" {% if player.orientation == 'Portrait' %}selected{% endif %}>Portrait</option>
</select> </select>
</div> </div>
<div style="padding: 1rem; background: #f8f9fa; border-radius: 4px; margin-bottom: 1rem;"> <div class="info-box neutral">
<p style="margin: 0; font-size: 0.9rem;"><strong>Hostname:</strong> {{ player.hostname }}</p> <h4 style="margin: 0 0 1rem 0; font-size: 0.95rem; color: #495057;">🔑 Player Credentials</h4>
<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 class="credential-item">
<span class="credential-label">Hostname</span>
<div class="credential-value">{{ player.hostname }}</div>
</div>
<div class="credential-item">
<span class="credential-label">Auth Code</span>
<div class="credential-value">{{ player.auth_code }}</div>
</div>
<div class="credential-item">
<span class="credential-label">Quick Connect Code (Hashed)</span>
<div class="credential-value" style="font-size: 0.75rem;">{{ player.quickconnect_code or 'Not set' }}</div>
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">⚠️ This is the hashed version for security</small>
</div>
</div> </div>
<button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;"> <button type="submit" class="btn btn-success" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
@@ -109,10 +370,9 @@
<form method="POST" style="margin-top: 1rem;"> <form method="POST" style="margin-top: 1rem;">
<input type="hidden" name="action" value="assign_playlist"> <input type="hidden" name="action" value="assign_playlist">
<div style="margin-bottom: 1rem;"> <div class="form-group">
<label for="playlist_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">Select Playlist</label> <label for="playlist_id">Select Playlist</label>
<select id="playlist_id" name="playlist_id" <select id="playlist_id" name="playlist_id" class="form-control">
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
<option value="">-- No Playlist (Unassign) --</option> <option value="">-- No Playlist (Unassign) --</option>
{% for playlist in playlists %} {% for playlist in playlists %}
<option value="{{ playlist.id }}" <option value="{{ playlist.id }}"
@@ -124,8 +384,8 @@
</div> </div>
{% if current_playlist %} {% if current_playlist %}
<div style="padding: 1rem; background: #d4edda; border-radius: 4px; margin-bottom: 1rem;"> <div class="info-box success">
<h4 style="margin: 0 0 0.5rem 0; color: #155724;">Currently Assigned:</h4> <h4 style="margin: 0 0 0.5rem 0;">Currently Assigned:</h4>
<p style="margin: 0;"><strong>{{ current_playlist.name }}</strong></p> <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;">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;">Content Items: {{ current_playlist.contents.count() }}</p>
@@ -134,8 +394,8 @@
</p> </p>
</div> </div>
{% else %} {% else %}
<div style="padding: 1rem; background: #fff3cd; border-radius: 4px; margin-bottom: 1rem;"> <div class="info-box warning">
<p style="margin: 0; color: #856404; display: flex; align-items: center; gap: 0.5rem;"> <p style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
<img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 18px; height: 18px;"> <img src="{{ url_for('static', filename='icons/warning.svg') }}" alt="" style="width: 18px; height: 18px;">
No playlist currently assigned to this player. No playlist currently assigned to this player.
</p> </p>

View File

@@ -1,10 +1,234 @@
{% extends "base.html" %} <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ player.name }} - Live Preview</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
{% block title %}Player Fullscreen{% endblock %} body {
background: #000;
overflow: hidden;
font-family: Arial, sans-serif;
}
{% block content %} #player-container {
<div class="container"> width: 100vw;
<h2>Player Fullscreen View</h2> height: 100vh;
<p>Fullscreen player view - placeholder</p> display: flex;
</div> align-items: center;
{% endblock %} justify-content: center;
position: relative;
}
#media-display {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
object-fit: contain;
display: none;
}
#media-display.active {
display: block;
}
.loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 24px;
text-align: center;
}
.info-overlay {
position: fixed;
bottom: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
z-index: 1000;
}
.controls {
position: fixed;
top: 20px;
right: 20px;
display: flex;
gap: 10px;
z-index: 1000;
transition: opacity 0.3s ease;
}
.controls button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 10px 15px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.controls button:hover {
background: rgba(255, 255, 255, 0.3);
}
.no-content {
color: white;
text-align: center;
font-size: 24px;
}
</style>
</head>
<body>
<div id="player-container">
<div class="loading" id="loading">Loading playlist...</div>
<img id="media-display" alt="Content">
<video id="video-display" style="display: none; max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain;"></video>
<div class="no-content" id="no-content" style="display: none;">
<p>💭 No content in playlist</p>
<p style="font-size: 16px; margin-top: 10px; opacity: 0.7;">Add content to the playlist to preview</p>
</div>
</div>
<div class="info-overlay" id="info-overlay" style="display: none;">
<div id="current-item">Item: -</div>
<div id="playlist-info">Playlist: {{ playlist|length }} items</div>
</div>
<div class="controls">
<button onclick="toggleFullscreen()">🔳 Fullscreen</button>
<button onclick="restartPlaylist()">🔄 Restart</button>
<button onclick="window.close()">✖️ Close</button>
</div>
<script>
const playlist = {{ playlist|tojson }};
let currentIndex = 0;
let timer = null;
let inactivityTimer = null;
const imgDisplay = document.getElementById('media-display');
const videoDisplay = document.getElementById('video-display');
const loading = document.getElementById('loading');
const noContent = document.getElementById('no-content');
const infoOverlay = document.getElementById('info-overlay');
const currentItemDiv = document.getElementById('current-item');
const controls = document.querySelector('.controls');
function playNext() {
if (!playlist || playlist.length === 0) {
loading.style.display = 'none';
noContent.style.display = 'block';
return;
}
const item = playlist[currentIndex];
loading.style.display = 'none';
infoOverlay.style.display = 'block';
// Update info
currentItemDiv.textContent = `Item ${currentIndex + 1}/${playlist.length}: ${item.filename}`;
// Hide both displays
imgDisplay.style.display = 'none';
videoDisplay.style.display = 'none';
videoDisplay.pause();
if (item.type === 'video') {
videoDisplay.src = item.url;
videoDisplay.style.display = 'block';
videoDisplay.play();
// When video ends, move to next
videoDisplay.onended = () => {
currentIndex = (currentIndex + 1) % playlist.length;
playNext();
};
} else {
// Image or PDF
imgDisplay.src = item.url;
imgDisplay.style.display = 'block';
imgDisplay.classList.add('active');
// Clear any existing timer
if (timer) clearTimeout(timer);
// Show for specified duration
timer = setTimeout(() => {
currentIndex = (currentIndex + 1) % playlist.length;
playNext();
}, (item.duration || 10) * 1000);
}
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
}
function restartPlaylist() {
currentIndex = 0;
if (timer) clearTimeout(timer);
videoDisplay.pause();
playNext();
}
// Auto-hide controls after 5 seconds of inactivity
function resetInactivityTimer() {
// Show controls
controls.style.opacity = '1';
controls.style.pointerEvents = 'auto';
// Clear existing timer
if (inactivityTimer) {
clearTimeout(inactivityTimer);
}
// Set new timer to hide controls after 5 seconds
inactivityTimer = setTimeout(() => {
controls.style.opacity = '0';
controls.style.pointerEvents = 'none';
}, 5000);
}
// Track user activity to show/hide controls
document.addEventListener('mousemove', resetInactivityTimer);
document.addEventListener('mousedown', resetInactivityTimer);
document.addEventListener('keydown', resetInactivityTimer);
document.addEventListener('touchstart', resetInactivityTimer);
// Start playing when page loads
window.addEventListener('load', () => {
playNext();
resetInactivityTimer(); // Start inactivity timer
});
// Handle keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'f' || e.key === 'F') {
toggleFullscreen();
} else if (e.key === 'r' || e.key === 'R') {
restartPlaylist();
} else if (e.key === 'Escape' && document.fullscreenElement) {
document.exitFullscreen();
}
});
</script>
</body>
</html>

View File

@@ -3,6 +3,129 @@
{% block title %}Players - DigiServer v2{% endblock %} {% block title %}Players - DigiServer v2{% endblock %}
{% block content %} {% block content %}
<style>
body.dark-mode h1 {
color: #e2e8f0;
}
.players-table {
width: 100%;
border-collapse: collapse;
}
.players-table thead tr {
background: #f8f9fa;
text-align: left;
}
body.dark-mode .players-table thead tr {
background: #1a202c;
}
.players-table th {
padding: 12px;
border-bottom: 2px solid #dee2e6;
font-weight: 600;
color: #495057;
}
body.dark-mode .players-table th {
border-bottom-color: #4a5568;
color: #e2e8f0;
}
.players-table tbody tr {
border-bottom: 1px solid #dee2e6;
}
body.dark-mode .players-table tbody tr {
border-bottom-color: #4a5568;
}
.players-table tbody tr:hover {
background: #f8f9fa;
}
body.dark-mode .players-table tbody tr:hover {
background: #2d3748;
}
.players-table td {
padding: 12px;
color: #2d3748;
}
body.dark-mode .players-table td {
color: #e2e8f0;
}
.players-table td strong {
color: #2d3748;
}
body.dark-mode .players-table td strong {
color: #e2e8f0;
}
.players-table code {
background: #f8f9fa;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}
body.dark-mode .players-table code {
background: #1a202c;
color: #e2e8f0;
}
.status-badge {
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
color: white;
}
.status-badge.online {
background: #28a745;
}
.status-badge.offline {
background: #6c757d;
}
.text-muted {
color: #6c757d;
}
body.dark-mode .text-muted {
color: #718096;
}
.info-box {
background: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
padding: 15px;
border-radius: 5px;
}
body.dark-mode .info-box {
background: #1a365d;
border-color: #2c5282;
color: #90cdf4;
}
.info-box a {
color: #0c5460;
text-decoration: underline;
}
body.dark-mode .info-box a {
color: #90cdf4;
}
</style>
<div class="container"> <div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Players</h1> <h1>Players</h1>
@@ -11,49 +134,49 @@
{% if players %} {% if players %}
<div class="card"> <div class="card">
<table style="width: 100%; border-collapse: collapse;"> <table class="players-table">
<thead> <thead>
<tr style="background: #f8f9fa; text-align: left;"> <tr>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Name</th> <th>Name</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Hostname</th> <th>Hostname</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Location</th> <th>Location</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Orientation</th> <th>Orientation</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Status</th> <th>Status</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Last Seen</th> <th>Last Seen</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for player in players %} {% for player in players %}
<tr style="border-bottom: 1px solid #dee2e6;"> <tr>
<td style="padding: 12px;"> <td>
<strong>{{ player.name }}</strong> <strong>{{ player.name }}</strong>
</td> </td>
<td style="padding: 12px;"> <td>
<code style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px;">{{ player.hostname }}</code> <code>{{ player.hostname }}</code>
</td> </td>
<td style="padding: 12px;"> <td>
{{ player.location or '-' }} {{ player.location or '-' }}
</td> </td>
<td style="padding: 12px;"> <td>
{{ player.orientation or 'Landscape' }} {{ player.orientation or 'Landscape' }}
</td> </td>
<td style="padding: 12px;"> <td>
{% set status = player_statuses.get(player.id, {}) %} {% set status = player_statuses.get(player.id, {}) %}
{% if status.get('is_online') %} {% if status.get('is_online') %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">Online</span> <span class="status-badge online">Online</span>
{% else %} {% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">Offline</span> <span class="status-badge offline">Offline</span>
{% endif %} {% endif %}
</td> </td>
<td style="padding: 12px;"> <td>
{% if player.last_heartbeat %} {% if player.last_heartbeat %}
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M') }} {{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M') }}
{% else %} {% else %}
<span style="color: #6c757d;">Never</span> <span class="text-muted">Never</span>
{% endif %} {% endif %}
</td> </td>
<td style="padding: 12px;"> <td>
<a href="{{ url_for('players.manage_player', player_id=player.id) }}" <a href="{{ url_for('players.manage_player', player_id=player.id) }}"
class="btn btn-info btn-sm" title="Manage Player"> class="btn btn-info btn-sm" title="Manage Player">
⚙️ Manage ⚙️ Manage
@@ -65,8 +188,8 @@
</table> </table>
</div> </div>
{% else %} {% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px;"> <div class="info-box">
No players yet. <a href="{{ url_for('players.add_player') }}" style="color: #0c5460; text-decoration: underline;">Add your first player</a> No players yet. <a href="{{ url_for('players.add_player') }}">Add your first player</a>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -219,6 +219,92 @@
.duration-input { .duration-input {
width: 80px; width: 80px;
text-align: center; text-align: center;
transition: all 0.3s ease;
}
.duration-input:hover {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
.duration-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
.save-duration-btn {
transition: all 0.2s ease;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Dark mode support */
body.dark-mode .playlist-section {
background: #2d3748;
color: #e2e8f0;
}
body.dark-mode .section-header h2 {
color: #e2e8f0;
}
body.dark-mode .section-header {
border-bottom-color: #4a5568;
}
body.dark-mode .playlist-table thead {
background: #1a202c;
}
body.dark-mode .playlist-table th {
color: #cbd5e0;
border-bottom-color: #4a5568;
}
body.dark-mode .playlist-table td {
border-bottom-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .playlist-table tr:hover {
background: #1a202c;
}
body.dark-mode .form-control {
background: #1a202c;
border-color: #4a5568;
color: #e2e8f0;
}
body.dark-mode .form-control:focus {
border-color: #667eea;
background: #2d3748;
}
body.dark-mode .add-content-form {
background: #1a202c;
}
body.dark-mode .form-group label {
color: #e2e8f0;
}
body.dark-mode .empty-state {
color: #718096;
}
body.dark-mode .drag-handle {
color: #718096;
} }
</style> </style>
@@ -290,9 +376,9 @@
</thead> </thead>
<tbody id="playlist-tbody"> <tbody id="playlist-tbody">
{% for content in playlist_content %} {% for content in playlist_content %}
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}"> <tr class="draggable-row" data-content-id="{{ content.id }}">
<td> <td>
<span class="drag-handle">⋮⋮</span> <span class="drag-handle" draggable="true">⋮⋮</span>
</td> </td>
<td>{{ loop.index }}</td> <td>{{ loop.index }}</td>
<td>{{ content.filename }}</td> <td>{{ content.filename }}</td>
@@ -304,11 +390,28 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
<div style="display: flex; align-items: center; gap: 5px;">
<input type="number" <input type="number"
class="form-control duration-input" class="form-control duration-input"
value="{{ content.duration }}" id="duration-{{ content.id }}"
value="{{ content._playlist_duration }}"
min="1" min="1"
onchange="updateDuration({{ content.id }}, this.value)"> draggable="false"
onclick="event.stopPropagation()"
onmousedown="event.stopPropagation()"
oninput="markDurationChanged({{ content.id }})"
onkeypress="if(event.key==='Enter') saveDuration({{ content.id }})"
style="width: 60px; padding: 5px 8px;">
<button type="button"
class="btn btn-success btn-sm save-duration-btn"
id="save-btn-{{ content.id }}"
onclick="event.stopPropagation(); saveDuration({{ content.id }})"
onmousedown="event.stopPropagation()"
style="display: none; padding: 5px 10px; font-size: 12px; cursor: pointer;"
title="Save duration (or press Enter)">
💾
</button>
</div>
</td> </td>
<td>{{ "%.2f"|format(content.file_size_mb) }} MB</td> <td>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
<td> <td>
@@ -335,7 +438,7 @@
</div> </div>
<!-- Add Content Section --> <!-- Add Content Section -->
{% if available_files %} {% if available_content %}
<div class="playlist-section"> <div class="playlist-section">
<div class="section-header"> <div class="section-header">
<h2> Add Existing Content</h2> <h2> Add Existing Content</h2>
@@ -344,11 +447,11 @@
<form method="POST" action="{{ url_for('playlist.add_to_playlist', player_id=player.id) }}" <form method="POST" action="{{ url_for('playlist.add_to_playlist', player_id=player.id) }}"
class="add-content-form"> class="add-content-form">
<div class="form-group"> <div class="form-group">
<label for="filename">Select File:</label> <label for="content_id">Select Content:</label>
<select name="filename" id="filename" class="form-control" required> <select name="content_id" id="content_id" class="form-control" required>
<option value="" disabled selected>Choose a file...</option> <option value="" disabled selected>Choose content...</option>
{% for filename in available_files %} {% for content in available_content %}
<option value="{{ filename }}">{{ filename }}</option> <option value="{{ content.id }}">{{ content.filename }} ({{ content.content_type }})</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
@@ -380,20 +483,41 @@ document.addEventListener('DOMContentLoaded', function() {
const tbody = document.getElementById('playlist-tbody'); const tbody = document.getElementById('playlist-tbody');
if (!tbody) return; if (!tbody) return;
const rows = tbody.querySelectorAll('.draggable-row'); // Set up drag handles
const dragHandles = tbody.querySelectorAll('.drag-handle');
dragHandles.forEach(handle => {
handle.addEventListener('dragstart', handleDragStart);
});
// Set up drop zones on rows
const rows = tbody.querySelectorAll('.draggable-row');
rows.forEach(row => { rows.forEach(row => {
row.addEventListener('dragstart', handleDragStart);
row.addEventListener('dragover', handleDragOver); row.addEventListener('dragover', handleDragOver);
row.addEventListener('drop', handleDrop); row.addEventListener('drop', handleDrop);
row.addEventListener('dragend', handleDragEnd); row.addEventListener('dragend', handleDragEnd);
}); });
// Prevent dragging from inputs and buttons
const inputs = document.querySelectorAll('.duration-input, button');
inputs.forEach(input => {
input.addEventListener('mousedown', (e) => {
e.stopPropagation();
});
input.addEventListener('click', (e) => {
e.stopPropagation();
});
});
}); });
function handleDragStart(e) { function handleDragStart(e) {
draggedElement = this; // Get the parent row
this.classList.add('dragging'); const row = e.target.closest('.draggable-row');
if (!row) return;
draggedElement = row;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/html', row.innerHTML);
} }
function handleDragOver(e) { function handleDragOver(e) {
@@ -464,7 +588,41 @@ function saveOrder() {
}); });
} }
function updateDuration(contentId, duration) { function markDurationChanged(contentId) {
const saveBtn = document.getElementById(`save-btn-${contentId}`);
const input = document.getElementById(`duration-${contentId}`);
// Show save button if value changed
if (input.value !== input.defaultValue) {
saveBtn.style.display = 'inline-block';
input.style.borderColor = '#ffc107';
} else {
saveBtn.style.display = 'none';
input.style.borderColor = '';
}
}
function saveDuration(contentId) {
const inputElement = document.getElementById(`duration-${contentId}`);
const saveBtn = document.getElementById(`save-btn-${contentId}`);
const duration = parseInt(inputElement.value);
// Validate duration
if (duration < 1) {
alert('Duration must be at least 1 second');
inputElement.value = inputElement.defaultValue;
inputElement.style.borderColor = '';
saveBtn.style.display = 'none';
return;
}
const originalValue = inputElement.defaultValue;
// Visual feedback
inputElement.disabled = true;
saveBtn.disabled = true;
saveBtn.textContent = '⏳';
const formData = new FormData(); const formData = new FormData();
formData.append('duration', duration); formData.append('duration', duration);
@@ -476,15 +634,65 @@ function updateDuration(contentId, duration) {
.then(data => { .then(data => {
if (data.success) { if (data.success) {
console.log('Duration updated successfully'); console.log('Duration updated successfully');
// Update total duration inputElement.style.borderColor = '#28a745';
location.reload(); inputElement.defaultValue = duration;
saveBtn.textContent = '✓';
// Update total duration display
updateTotalDuration();
setTimeout(() => {
inputElement.style.borderColor = '';
inputElement.disabled = false;
saveBtn.style.display = 'none';
saveBtn.textContent = '💾';
saveBtn.disabled = false;
}, 1500);
} else { } else {
inputElement.style.borderColor = '#dc3545';
inputElement.value = originalValue;
saveBtn.textContent = '✖';
alert('Error updating duration: ' + data.message); alert('Error updating duration: ' + data.message);
setTimeout(() => {
inputElement.disabled = false;
inputElement.style.borderColor = '';
saveBtn.style.display = 'none';
saveBtn.textContent = '💾';
saveBtn.disabled = false;
}, 1500);
} }
}) })
.catch(error => { .catch(error => {
console.error('Error:', error); console.error('Error:', error);
inputElement.style.borderColor = '#dc3545';
inputElement.value = originalValue;
saveBtn.textContent = '✖';
alert('Error updating duration'); alert('Error updating duration');
setTimeout(() => {
inputElement.disabled = false;
inputElement.style.borderColor = '';
saveBtn.style.display = 'none';
saveBtn.textContent = '💾';
saveBtn.disabled = false;
}, 1500);
});
}
function updateTotalDuration() {
const durationInputs = document.querySelectorAll('.duration-input');
let total = 0;
durationInputs.forEach(input => {
total += parseInt(input.value) || 0;
});
const statValues = document.querySelectorAll('.stat-value');
statValues.forEach((element, index) => {
const label = element.parentElement.querySelector('.stat-label');
if (label && label.textContent.includes('Total Duration')) {
element.textContent = total + 's';
}
}); });
} }
</script> </script>

View File

@@ -1,59 +0,0 @@
#!/usr/bin/env python3
"""Add orientation column to playlist table - Direct SQLite approach"""
import sqlite3
import os
def add_orientation_column():
"""Add orientation column to playlist table using direct SQLite connection."""
db_path = 'instance/dev.db'
if not os.path.exists(db_path):
print(f"❌ Database not found at: {db_path}")
return
print(f"📁 Opening database: {db_path}")
try:
# Connect to database
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check if column already exists
cursor.execute("PRAGMA table_info(playlist)")
columns = [row[1] for row in cursor.fetchall()]
if 'orientation' in columns:
print("✅ Column 'orientation' already exists in playlist table")
conn.close()
return
# Add the column
print("Adding 'orientation' column to playlist table...")
cursor.execute("""
ALTER TABLE playlist
ADD COLUMN orientation VARCHAR(20) DEFAULT 'Landscape' NOT NULL
""")
conn.commit()
print("✅ Successfully added 'orientation' column to playlist table")
print(" Default value: 'Landscape'")
# Verify the column was added
cursor.execute("PRAGMA table_info(playlist)")
columns = [row[1] for row in cursor.fetchall()]
if 'orientation' in columns:
print("✓ Verified: Column exists in database")
conn.close()
except sqlite3.Error as e:
print(f"❌ SQLite Error: {e}")
return
except Exception as e:
print(f"❌ Error: {e}")
return
if __name__ == '__main__':
add_orientation_column()

View File

@@ -1,50 +0,0 @@
#!/bin/bash
# Quick Test - DigiServer v2
# Simple test script without virtual environment
set -e
echo "🧪 DigiServer v2 - Quick Test"
echo "=============================="
echo ""
cd /home/pi/Desktop/digiserver-v2
# Check if database exists
if [ ! -f "instance/dashboard.db" ]; then
echo "🗄️ Creating database..."
export FLASK_APP=app.app:create_app
python3 -c "
from app.app import create_app
from app.extensions import db
from app.models import User
from flask_bcrypt import bcrypt
app = create_app()
with app.app_context():
db.create_all()
# Create admin user
admin = User.query.filter_by(username='admin').first()
if not admin:
hashed = bcrypt.generate_password_hash('admin123').decode('utf-8')
admin = User(username='admin', password=hashed, role='admin')
db.session.add(admin)
db.session.commit()
print('✅ Admin user created (admin/admin123)')
else:
print('✅ Admin user already exists')
"
fi
echo ""
echo "🚀 Starting Flask server..."
echo "📍 URL: http://localhost:5000"
echo "👤 Login: admin / admin123"
echo ""
echo "Press Ctrl+C to stop"
echo ""
export FLASK_APP=app.app:create_app
export FLASK_ENV=development
python3 -m flask run --host=0.0.0.0 --port=5000

View File

@@ -1,52 +0,0 @@
#!/bin/bash
# Script to reinitialize database with new Player schema
cd /home/pi/Desktop/digiserver-v2
echo "🗑️ Removing old database..."
rm -f instance/dev.db
echo "🚀 Recreating database with new schema..."
.venv/bin/python << 'EOF'
from app.app import create_app
from app.extensions import db, bcrypt
from app.models import User, Player
import secrets
print('🚀 Creating Flask app...')
app = create_app()
with app.app_context():
print('🗄️ Creating database tables...')
db.create_all()
print('✅ Database tables created')
# Create admin user
hashed = bcrypt.generate_password_hash('admin123').decode('utf-8')
admin = User(username='admin', password=hashed, role='admin')
db.session.add(admin)
# Create example player
player = Player(
name='Demo Player',
hostname='player-001',
location='Main Office',
auth_code=secrets.token_urlsafe(32),
orientation='Landscape'
)
player.set_password('demo123')
player.set_quickconnect_code('QUICK123')
db.session.add(player)
db.session.commit()
print('✅ Admin user created (admin/admin123)')
print('✅ Demo player created:')
print(f' - Hostname: player-001')
print(f' - Password: demo123')
print(f' - Quick Connect: QUICK123')
print(f' - Auth Code: {player.auth_code}')
print('')
print('🎉 Database ready!')
print('📍 Restart Flask server to use new database')
EOF

Binary file not shown.