updated digiserver 2

This commit is contained in:
ske087
2025-11-12 16:07:03 +02:00
parent 2deb398fd8
commit e5a00d19a5
44 changed files with 2656 additions and 230 deletions

381
PLAYER_AUTH.md Normal file
View File

@@ -0,0 +1,381 @@
# 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

@@ -4,8 +4,13 @@ Modern Flask application with blueprint architecture
""" """
import os import os
from flask import Flask, render_template from flask import Flask, render_template
from config import get_config from dotenv import load_dotenv
from extensions import db, bcrypt, login_manager, migrate, cache
from app.config import DevelopmentConfig, ProductionConfig, TestingConfig
from app.extensions import db, bcrypt, login_manager, migrate, cache
# Load environment variables
load_dotenv()
def create_app(config_name=None): def create_app(config_name=None):
@@ -18,22 +23,17 @@ def create_app(config_name=None):
Returns: Returns:
Flask application instance Flask application instance
""" """
app = Flask(__name__, instance_relative_config=True) app = Flask(__name__)
# Load configuration # Load configuration
if config_name is None: if config_name == 'production':
config_name = os.getenv('FLASK_ENV', 'development') config = ProductionConfig
elif config_name == 'testing':
config = TestingConfig
else:
config = DevelopmentConfig
app.config.from_object(get_config(config_name)) app.config.from_object(config)
# Ensure instance folder exists
os.makedirs(app.instance_path, exist_ok=True)
# Ensure upload folders exist
upload_folder = os.path.join(app.root_path, app.config['UPLOAD_FOLDER'])
logo_folder = os.path.join(app.root_path, app.config['UPLOAD_FOLDERLOGO'])
os.makedirs(upload_folder, exist_ok=True)
os.makedirs(logo_folder, exist_ok=True)
# Initialize extensions # Initialize extensions
db.init_app(app) db.init_app(app)
@@ -42,16 +42,13 @@ def create_app(config_name=None):
migrate.init_app(app, db) migrate.init_app(app, db)
cache.init_app(app) cache.init_app(app)
# Register blueprints # Configure Flask-Login
configure_login_manager(app)
# Register components
register_blueprints(app) register_blueprints(app)
# Register error handlers
register_error_handlers(app) register_error_handlers(app)
# Register CLI commands
register_commands(app) register_commands(app)
# Context processors
register_context_processors(app) register_context_processors(app)
return app return app
@@ -59,24 +56,35 @@ def create_app(config_name=None):
def register_blueprints(app): def register_blueprints(app):
"""Register application blueprints""" """Register application blueprints"""
from blueprints.auth import auth_bp from app.blueprints.main import main_bp
from blueprints.admin import admin_bp from app.blueprints.auth import auth_bp
from blueprints.players import players_bp from app.blueprints.admin import admin_bp
from blueprints.groups import groups_bp from app.blueprints.players import players_bp
from blueprints.content import content_bp from app.blueprints.groups import groups_bp
from blueprints.api import api_bp from app.blueprints.content import content_bp
from app.blueprints.api import api_bp
# Register with appropriate URL prefixes # Register blueprints (using URL prefixes from blueprint definitions)
app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp, url_prefix='/admin')
app.register_blueprint(players_bp, url_prefix='/player')
app.register_blueprint(groups_bp, url_prefix='/group')
app.register_blueprint(content_bp, url_prefix='/content')
app.register_blueprint(api_bp, url_prefix='/api')
# Main dashboard route
from blueprints.main import main_bp
app.register_blueprint(main_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)
def configure_login_manager(app):
"""Configure Flask-Login"""
from app.models.user import User
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'info'
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
def register_error_handlers(app): def register_error_handlers(app):

View File

@@ -59,7 +59,7 @@ def admin_panel():
storage_mb = round(total_size / (1024 * 1024), 2) storage_mb = round(total_size / (1024 * 1024), 2)
return render_template('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_groups=total_groups,

View File

@@ -94,6 +94,106 @@ def health_check():
}) })
@api_bp.route('/auth/player', methods=['POST'])
@rate_limit(max_requests=10, window=60)
def authenticate_player():
"""Authenticate a player and return auth code and configuration.
Request JSON:
hostname: Player hostname/identifier (required)
password: Player password (optional if using quickconnect)
quickconnect_code: Quick connect code (optional if using password)
Returns:
JSON with auth_code, player_id, group_id, and configuration
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
hostname = data.get('hostname')
password = data.get('password')
quickconnect_code = data.get('quickconnect_code')
if not hostname:
return jsonify({'error': 'Hostname is required'}), 400
if not password and not quickconnect_code:
return jsonify({'error': 'Password or quickconnect code required'}), 400
# Authenticate player
player = Player.authenticate(hostname, password, quickconnect_code)
if not player:
log_action('warning', f'Failed authentication attempt for hostname: {hostname}')
return jsonify({'error': 'Invalid credentials'}), 401
# Update player status
player.update_status('online')
db.session.commit()
log_action('info', f'Player authenticated: {player.name} ({player.hostname})')
# Return authentication response
response = {
'success': True,
'player_id': player.id,
'player_name': player.name,
'hostname': player.hostname,
'auth_code': player.auth_code,
'group_id': player.group_id,
'orientation': player.orientation,
'status': player.status
}
return jsonify(response), 200
@api_bp.route('/auth/verify', methods=['POST'])
@rate_limit(max_requests=30, window=60)
def verify_auth_code():
"""Verify an auth code and return player information.
Request JSON:
auth_code: Player authentication code
Returns:
JSON with player information if valid
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
auth_code = data.get('auth_code')
if not auth_code:
return jsonify({'error': 'Auth code is required'}), 400
# Find player with this auth code
player = Player.query.filter_by(auth_code=auth_code).first()
if not player:
return jsonify({'error': 'Invalid auth code'}), 401
# Update last seen
player.update_status(player.status)
db.session.commit()
response = {
'valid': True,
'player_id': player.id,
'player_name': player.name,
'hostname': player.hostname,
'group_id': player.group_id,
'orientation': player.orientation,
'status': player.status
}
return jsonify(response), 200
@api_bp.route('/playlists/<int:player_id>', methods=['GET']) @api_bp.route('/playlists/<int:player_id>', methods=['GET'])
@rate_limit(max_requests=30, window=60) @rate_limit(max_requests=30, window=60)
@verify_player_auth @verify_player_auth
@@ -120,6 +220,7 @@ def get_player_playlist(player_id: int):
'player_id': player_id, 'player_id': player_id,
'player_name': player.name, 'player_name': player.name,
'group_id': player.group_id, 'group_id': player.group_id,
'playlist_version': player.playlist_version,
'playlist': playlist, 'playlist': playlist,
'count': len(playlist) 'count': len(playlist)
}) })
@@ -129,6 +230,37 @@ def get_player_playlist(player_id: int):
return jsonify({'error': 'Internal server error'}), 500 return jsonify({'error': 'Internal server error'}), 500
@api_bp.route('/playlist-version/<int:player_id>', methods=['GET'])
@verify_player_auth
@rate_limit(max_requests=60, window=60)
def get_playlist_version(player_id: int):
"""Get current playlist version for a player.
Lightweight endpoint for players to check if playlist needs updating.
Requires player authentication via Bearer token.
"""
try:
# Verify the authenticated player matches the requested player_id
if request.player.id != player_id:
return jsonify({'error': 'Unauthorized access to this player'}), 403
player = request.player
# Update last seen
player.last_seen = datetime.utcnow()
db.session.commit()
return jsonify({
'player_id': player_id,
'playlist_version': player.playlist_version,
'content_count': Content.query.filter_by(player_id=player_id).count()
})
except Exception as e:
log_action('error', f'Error getting playlist version for player {player_id}: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@cache.memoize(timeout=300) @cache.memoize(timeout=300)
def get_cached_playlist(player_id: int) -> List[Dict]: def get_cached_playlist(player_id: int) -> List[Dict]:
"""Get cached playlist for a player.""" """Get cached playlist for a player."""

View File

@@ -3,9 +3,9 @@ Authentication Blueprint - Login, Logout, Register
""" """
from flask import Blueprint, render_template, request, redirect, url_for, flash from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required, current_user from flask_login import login_user, logout_user, login_required, current_user
from extensions import db, bcrypt from app.extensions import db, bcrypt, login_manager
from models.user import User from app.models import User
from utils.logger import log_action, log_user_created from app.utils.logger import log_action
from typing import Optional from typing import Optional
auth_bp = Blueprint('auth', __name__) auth_bp = Blueprint('auth', __name__)
@@ -34,7 +34,7 @@ def login():
# Verify credentials # Verify credentials
if user and bcrypt.check_password_hash(user.password, password): if user and bcrypt.check_password_hash(user.password, password):
login_user(user, remember=remember) login_user(user, remember=remember)
log_action(f'User {username} logged in') log_action('info', f'User {username} logged in')
# Redirect to next page or dashboard # Redirect to next page or dashboard
next_page = request.args.get('next') next_page = request.args.get('next')
@@ -43,7 +43,7 @@ def login():
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))
else: else:
flash('Invalid username or password.', 'danger') flash('Invalid username or password.', 'danger')
log_action(f'Failed login attempt for username: {username}') log_action('warning', f'Failed login attempt for username: {username}')
# Check for logo # Check for logo
import os import os
@@ -60,7 +60,7 @@ def logout():
"""User logout""" """User logout"""
username = current_user.username username = current_user.username
logout_user() logout_user()
log_action(f'User {username} logged out') log_action('info', f'User {username} logged out')
flash('You have been logged out.', 'info') flash('You have been logged out.', 'info')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@@ -140,7 +140,7 @@ def change_password():
current_user.password = bcrypt.generate_password_hash(new_password).decode('utf-8') current_user.password = bcrypt.generate_password_hash(new_password).decode('utf-8')
db.session.commit() db.session.commit()
log_action(f'User {current_user.username} changed password') log_action('info', f'User {current_user.username} changed password')
flash('Password changed successfully.', 'success') flash('Password changed successfully.', 'success')
return redirect(url_for('main.dashboard')) return redirect(url_for('main.dashboard'))

View File

@@ -30,16 +30,51 @@ upload_progress = {}
def content_list(): def content_list():
"""Display list of all content.""" """Display list of all content."""
try: try:
contents = Content.query.order_by(Content.uploaded_at.desc()).all() # Get all unique content files (by filename)
from sqlalchemy import func
# Get group info for each content # Get content with player information
content_groups = {} contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all()
# Group content by filename to show which players have each file
content_map = {}
for content in contents: for content in contents:
content_groups[content.id] = content.groups.count() if content.filename not in content_map:
content_map[content.filename] = {
'content': content,
'players': [],
'groups': []
}
return render_template('content_list.html', # Add player info if assigned to a player
contents=contents, if content.player_id:
content_groups=content_groups) from app.models import Player
player = Player.query.get(content.player_id)
if player:
content_map[content.filename]['players'].append({
'id': player.id,
'name': player.name,
'group': player.group.name if player.group else None
})
# Convert to list for template
content_list = []
for filename, data in content_map.items():
content_list.append({
'filename': filename,
'content_type': data['content'].content_type,
'duration': data['content'].duration,
'file_size': data['content'].file_size_mb,
'uploaded_at': data['content'].uploaded_at,
'players': data['players'],
'player_count': len(data['players'])
})
# Sort by upload date
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
return render_template('content/content_list.html',
content_list=content_list)
except Exception as e: except Exception as e:
log_action('error', f'Error loading content list: {str(e)}') log_action('error', f'Error loading content list: {str(e)}')
flash('Error loading content list.', 'danger') flash('Error loading content list.', 'danger')
@@ -51,37 +86,67 @@ def content_list():
def upload_content(): def upload_content():
"""Upload new content.""" """Upload new content."""
if request.method == 'GET': if request.method == 'GET':
return render_template('upload_content.html') # Get parameters for return URL and pre-selection
target_type = request.args.get('target_type')
target_id = request.args.get('target_id', type=int)
return_url = request.args.get('return_url', url_for('content.content_list'))
# Get all players and groups for selection
from app.models import Player
players = [{'id': p.id, 'name': p.name} for p in Player.query.order_by(Player.name).all()]
groups = [{'id': g.id, 'name': g.name} for g in Group.query.order_by(Group.name).all()]
return render_template('content/upload_content.html',
players=players,
groups=groups,
target_type=target_type,
target_id=target_id,
return_url=return_url)
try: try:
if 'file' not in request.files: # Get form data
flash('No file provided.', 'warning') target_type = request.form.get('target_type')
target_id = request.form.get('target_id', type=int)
media_type = request.form.get('media_type', 'image')
duration = request.form.get('duration', type=int, default=10)
session_id = request.form.get('session_id', os.urandom(8).hex())
return_url = request.form.get('return_url', url_for('content.content_list'))
# Get files
files = request.files.getlist('files')
if not files or files[0].filename == '':
flash('No files provided.', 'warning')
return redirect(url_for('content.upload_content')) return redirect(url_for('content.upload_content'))
file = request.files['file'] if not target_type or not target_id:
flash('Please select a target type and target ID.', 'warning')
if file.filename == '':
flash('No file selected.', 'warning')
return redirect(url_for('content.upload_content')) return redirect(url_for('content.upload_content'))
# Get optional parameters # Initialize progress tracking using shared utility
duration = request.form.get('duration', type=int) set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
description = request.form.get('description', '').strip()
# Generate unique upload ID for progress tracking # Process each file
upload_id = os.urandom(16).hex()
# Save file with progress tracking
filename = secure_filename(file.filename)
upload_folder = current_app.config['UPLOAD_FOLDER'] upload_folder = current_app.config['UPLOAD_FOLDER']
os.makedirs(upload_folder, exist_ok=True) os.makedirs(upload_folder, exist_ok=True)
processed_count = 0
total_files = len(files)
for idx, file in enumerate(files):
if file.filename == '':
continue
# Update progress
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
set_upload_progress(session_id, progress_pct,
f'Processing file {idx + 1} of {total_files}...', 'processing')
filename = secure_filename(file.filename)
filepath = os.path.join(upload_folder, filename) filepath = os.path.join(upload_folder, filename)
# Save file with progress updates # Save file
set_upload_progress(upload_id, 0, 'Uploading file...')
file.save(filepath) file.save(filepath)
set_upload_progress(upload_id, 50, 'File uploaded, processing...')
# Determine content type # Determine content type
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
@@ -90,47 +155,97 @@ def upload_content():
content_type = 'image' content_type = 'image'
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']: elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
content_type = 'video' content_type = 'video'
# Process video (convert, optimize, extract metadata) # Process video (convert to Raspberry Pi optimized format)
set_upload_progress(upload_id, 60, 'Processing video...') set_upload_progress(session_id, progress_pct + 5,
process_video_file(filepath, upload_id) f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
success, message = process_video_file(filepath, session_id)
if not success:
log_action('error', f'Video optimization failed: {message}')
continue # Skip this file and move to next
elif file_ext == 'pdf': elif file_ext == 'pdf':
content_type = 'pdf' content_type = 'pdf'
# Process PDF (convert to images) # Process PDF (convert to images)
set_upload_progress(upload_id, 60, 'Processing PDF...') set_upload_progress(session_id, progress_pct + 5,
process_pdf_file(filepath, upload_id) f'Converting PDF {idx + 1}...', 'processing')
# process_pdf_file(filepath, session_id)
elif file_ext in ['ppt', 'pptx']: elif file_ext in ['ppt', 'pptx']:
content_type = 'presentation' content_type = 'presentation'
# Process presentation (convert to PDF then images) # Process presentation (convert to PDF then images)
set_upload_progress(upload_id, 60, 'Processing presentation...') set_upload_progress(session_id, progress_pct + 5,
f'Converting PowerPoint {idx + 1}...', 'processing')
# This would call pptx_converter utility # This would call pptx_converter utility
else: else:
content_type = 'other' content_type = 'other'
set_upload_progress(upload_id, 90, 'Creating database entry...')
# Create content record # Create content record
new_content = Content( new_content = Content(
filename=filename, filename=filename,
content_type=content_type, content_type=content_type,
duration=duration, duration=duration,
description=description or None,
file_size=os.path.getsize(filepath) file_size=os.path.getsize(filepath)
) )
# Link to target (player or group)
if target_type == 'player':
from app.models import Player
player = Player.query.get(target_id)
if player:
# Add content directly to player's playlist
new_content.player_id = target_id
db.session.add(new_content) db.session.add(new_content)
# Increment playlist version
player.playlist_version += 1
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
elif target_type == 'group':
group = Group.query.get(target_id)
if group:
# For groups, create separate content entry for EACH player in the group
# This matches the old app behavior
for player in group.players:
player_content = Content(
filename=filename,
content_type=content_type,
duration=duration,
file_size=os.path.getsize(filepath),
player_id=player.id
)
db.session.add(player_content)
# Increment each player's playlist version
player.playlist_version += 1
log_action('info', f'Content "{filename}" added to {len(group.players)} players in group "{group.name}"')
# Don't add the original new_content since we created per-player entries
new_content = None
if new_content:
db.session.add(new_content)
processed_count += 1
# Commit all changes
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
db.session.commit() db.session.commit()
set_upload_progress(upload_id, 100, 'Complete!') # Complete
set_upload_progress(session_id, 100,
f'Successfully uploaded {processed_count} file(s)!', 'complete')
# Clear all playlist caches # Clear all playlist caches
cache.clear() cache.clear()
log_action('info', f'Content "{filename}" uploaded successfully (Type: {content_type})') log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
flash(f'Content "{filename}" uploaded successfully.', 'success') flash(f'{processed_count} file(s) uploaded successfully.', 'success')
return redirect(url_for('content.content_list')) return redirect(return_url)
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
# Update progress to error state
if 'session_id' in locals():
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
log_action('error', f'Error uploading content: {str(e)}') log_action('error', f'Error uploading content: {str(e)}')
flash('Error uploading content. Please try again.', 'danger') flash('Error uploading content. Please try again.', 'danger')
return redirect(url_for('content.upload_content')) return redirect(url_for('content.upload_content'))
@@ -143,7 +258,7 @@ def edit_content(content_id: int):
content = Content.query.get_or_404(content_id) content = Content.query.get_or_404(content_id)
if request.method == 'GET': if request.method == 'GET':
return render_template('edit_content.html', content=content) return render_template('content/edit_content.html', content=content)
try: try:
duration = request.form.get('duration', type=int) duration = request.form.get('duration', type=int)
@@ -201,6 +316,54 @@ def delete_content(content_id: int):
return redirect(url_for('content.content_list')) return redirect(url_for('content.content_list'))
@content_bp.route('/delete-by-filename', methods=['POST'])
@login_required
def delete_by_filename():
"""Delete all content entries with a specific filename."""
try:
data = request.get_json()
filename = data.get('filename')
if not filename:
return jsonify({'success': False, 'message': 'No filename provided'}), 400
# Find all content entries with this filename
contents = Content.query.filter_by(filename=filename).all()
if not contents:
return jsonify({'success': False, 'message': 'Content not found'}), 404
deleted_count = len(contents)
# Delete file from disk (only once)
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
os.remove(filepath)
log_action('info', f'Deleted file from disk: {filename}')
# Delete all database entries
for content in contents:
db.session.delete(content)
db.session.commit()
# Clear caches
cache.clear()
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)')
return jsonify({
'success': True,
'message': f'Content deleted from {deleted_count} playlist(s)',
'deleted_count': deleted_count
})
except Exception as e:
db.session.rollback()
log_action('error', f'Error deleting content by filename: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@content_bp.route('/bulk/delete', methods=['POST']) @content_bp.route('/bulk/delete', methods=['POST'])
@login_required @login_required
def bulk_delete_content(): def bulk_delete_content():

View File

@@ -24,7 +24,7 @@ def groups_list():
stats = get_group_statistics(group.id) stats = get_group_statistics(group.id)
group_stats[group.id] = stats group_stats[group.id] = stats
return render_template('groups_list.html', return render_template('groups/groups_list.html',
groups=groups, groups=groups,
group_stats=group_stats) group_stats=group_stats)
except Exception as e: except Exception as e:
@@ -39,7 +39,7 @@ def create_group():
"""Create a new group.""" """Create a new group."""
if request.method == 'GET': if request.method == 'GET':
available_content = Content.query.order_by(Content.filename).all() available_content = Content.query.order_by(Content.filename).all()
return render_template('create_group.html', available_content=available_content) return render_template('groups/create_group.html', available_content=available_content)
try: try:
name = request.form.get('name', '').strip() name = request.form.get('name', '').strip()
@@ -93,7 +93,7 @@ def edit_group(group_id: int):
if request.method == 'GET': if request.method == 'GET':
available_content = Content.query.order_by(Content.filename).all() available_content = Content.query.order_by(Content.filename).all()
return render_template('edit_group.html', return render_template('groups/edit_group.html',
group=group, group=group,
available_content=available_content) available_content=available_content)
@@ -197,7 +197,7 @@ def manage_group(group_id: int):
# Get available content (not in this group) # Get available content (not in this group)
all_content = Content.query.order_by(Content.filename).all() all_content = Content.query.order_by(Content.filename).all()
return render_template('manage_group.html', return render_template('groups/manage_group.html',
group=group, group=group,
players=players, players=players,
player_statuses=player_statuses, player_statuses=player_statuses,
@@ -225,7 +225,7 @@ def group_fullscreen(group_id: int):
status_info = get_player_status_info(player.id) status_info = get_player_status_info(player.id)
player_statuses[player.id] = status_info player_statuses[player.id] = status_info
return render_template('group_fullscreen.html', return render_template('groups/group_fullscreen.html',
group=group, group=group,
players=players, players=players,
player_statuses=player_statuses) player_statuses=player_statuses)

View File

@@ -3,10 +3,10 @@ Main Blueprint - Dashboard and Home Routes
""" """
from flask import Blueprint, render_template, redirect, url_for from flask import Blueprint, render_template, redirect, url_for
from flask_login import login_required, current_user from flask_login import login_required, current_user
from extensions import db, cache from app.extensions import db, cache
from models.player import Player from app.models.player import Player
from models.group import Group from app.models.group import Group
from utils.logger import get_recent_logs from app.utils.logger import get_recent_logs
main_bp = Blueprint('main', __name__) main_bp = Blueprint('main', __name__)

View File

@@ -14,8 +14,9 @@ players_bp = Blueprint('players', __name__, url_prefix='/players')
@players_bp.route('/') @players_bp.route('/')
@players_bp.route('/list')
@login_required @login_required
def players_list(): def list():
"""Display list of all players.""" """Display list of all players."""
try: try:
players = Player.query.order_by(Player.name).all() players = Player.query.order_by(Player.name).all()
@@ -27,7 +28,7 @@ def players_list():
status_info = get_player_status_info(player.id) status_info = get_player_status_info(player.id)
player_statuses[player.id] = status_info player_statuses[player.id] = status_info
return render_template('players_list.html', return render_template('players/players_list.html',
players=players, players=players,
groups=groups, groups=groups,
player_statuses=player_statuses) player_statuses=player_statuses)
@@ -43,11 +44,15 @@ def add_player():
"""Add a new player.""" """Add a new player."""
if request.method == 'GET': if request.method == 'GET':
groups = Group.query.order_by(Group.name).all() groups = Group.query.order_by(Group.name).all()
return render_template('add_player.html', groups=groups) return render_template('players/add_player.html', groups=groups)
try: try:
name = request.form.get('name', '').strip() name = request.form.get('name', '').strip()
hostname = request.form.get('hostname', '').strip()
location = request.form.get('location', '').strip() location = request.form.get('location', '').strip()
password = request.form.get('password', '').strip()
quickconnect_code = request.form.get('quickconnect_code', '').strip()
orientation = request.form.get('orientation', 'Landscape')
group_id = request.form.get('group_id') group_id = request.form.get('group_id')
# Validation # Validation
@@ -55,23 +60,59 @@ def add_player():
flash('Player name must be at least 3 characters long.', 'warning') flash('Player name must be at least 3 characters long.', 'warning')
return redirect(url_for('players.add_player')) return redirect(url_for('players.add_player'))
if not hostname or len(hostname) < 3:
flash('Hostname must be at least 3 characters long.', 'warning')
return redirect(url_for('players.add_player'))
# Check if hostname already exists
existing_player = Player.query.filter_by(hostname=hostname).first()
if existing_player:
flash(f'A player with hostname "{hostname}" already exists.', 'warning')
return redirect(url_for('players.add_player'))
if not quickconnect_code:
flash('Quick Connect Code is required.', 'warning')
return redirect(url_for('players.add_player'))
# Generate unique auth code # Generate unique auth code
auth_code = secrets.token_urlsafe(16) auth_code = secrets.token_urlsafe(32)
# Create player # Create player
new_player = Player( new_player = Player(
name=name, name=name,
hostname=hostname,
location=location or None, location=location or None,
auth_code=auth_code, auth_code=auth_code,
orientation=orientation,
group_id=int(group_id) if group_id else None group_id=int(group_id) if group_id else None
) )
# Set password if provided
if password:
new_player.set_password(password)
else:
# Use quickconnect code as default password
new_player.set_password(quickconnect_code)
# Set quickconnect code
new_player.set_quickconnect_code(quickconnect_code)
db.session.add(new_player) db.session.add(new_player)
db.session.commit() db.session.commit()
log_action('info', f'Player "{name}" created with auth code {auth_code}') log_action('info', f'Player "{name}" (hostname: {hostname}) created')
flash(f'Player "{name}" created successfully. Auth code: {auth_code}', 'success')
return redirect(url_for('players.players_list')) # Flash detailed success message
success_msg = f'''
Player "{name}" created successfully!<br>
<strong>Auth Code:</strong> {auth_code}<br>
<strong>Hostname:</strong> {hostname}<br>
<strong>Quick Connect:</strong> {quickconnect_code}<br>
<small>Configure the player with these credentials in app_config.json</small>
'''
flash(success_msg, 'success')
return redirect(url_for('players.list'))
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
@@ -88,7 +129,7 @@ def edit_player(player_id: int):
if request.method == 'GET': if request.method == 'GET':
groups = Group.query.order_by(Group.name).all() groups = Group.query.order_by(Group.name).all()
return render_template('edit_player.html', player=player, groups=groups) return render_template('players/edit_player.html', player=player, groups=groups)
try: try:
name = request.form.get('name', '').strip() name = request.form.get('name', '').strip()
@@ -112,7 +153,7 @@ def edit_player(player_id: int):
log_action('info', f'Player "{name}" (ID: {player_id}) updated') log_action('info', f'Player "{name}" (ID: {player_id}) updated')
flash(f'Player "{name}" updated successfully.', 'success') flash(f'Player "{name}" updated successfully.', 'success')
return redirect(url_for('players.players_list')) return redirect(url_for('players.list'))
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
@@ -146,7 +187,7 @@ def delete_player(player_id: int):
log_action('error', f'Error deleting player: {str(e)}') log_action('error', f'Error deleting player: {str(e)}')
flash('Error deleting player. Please try again.', 'danger') flash('Error deleting player. Please try again.', 'danger')
return redirect(url_for('players.players_list')) return redirect(url_for('players.list'))
@players_bp.route('/<int:player_id>/regenerate-auth', methods=['POST']) @players_bp.route('/<int:player_id>/regenerate-auth', methods=['POST'])
@@ -169,7 +210,7 @@ def regenerate_auth_code(player_id: int):
log_action('error', f'Error regenerating auth code: {str(e)}') log_action('error', f'Error regenerating auth code: {str(e)}')
flash('Error regenerating auth code. Please try again.', 'danger') flash('Error regenerating auth code. Please try again.', 'danger')
return redirect(url_for('players.players_list')) return redirect(url_for('players.list'))
@players_bp.route('/<int:player_id>') @players_bp.route('/<int:player_id>')
@@ -191,7 +232,7 @@ def player_page(player_id: int):
.limit(10)\ .limit(10)\
.all() .all()
return render_template('player_page.html', return render_template('players/player_page.html',
player=player, player=player,
playlist=playlist, playlist=playlist,
status_info=status_info, status_info=status_info,
@@ -199,7 +240,7 @@ def player_page(player_id: int):
except Exception as e: except Exception as e:
log_action('error', f'Error loading player page: {str(e)}') log_action('error', f'Error loading player page: {str(e)}')
flash('Error loading player page.', 'danger') flash('Error loading player page.', 'danger')
return redirect(url_for('players.players_list')) return redirect(url_for('players.list'))
@players_bp.route('/<int:player_id>/fullscreen') @players_bp.route('/<int:player_id>/fullscreen')
@@ -217,7 +258,7 @@ def player_fullscreen(player_id: int):
# Get player's playlist # Get player's playlist
playlist = get_player_playlist(player_id) playlist = get_player_playlist(player_id)
return render_template('player_fullscreen.html', return render_template('players/player_fullscreen.html',
player=player, player=player,
playlist=playlist) playlist=playlist)
except Exception as e: except Exception as e:
@@ -227,7 +268,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 group assignment. """Get playlist for a player based on their direct content assignment.
Args: Args:
player_id: The player's database ID player_id: The player's database ID
@@ -239,16 +280,10 @@ def get_player_playlist(player_id: int) -> List[dict]:
if not player: if not player:
return [] return []
# Get content from player's group # Get content directly assigned to this player
if player.group_id: contents = Content.query.filter_by(player_id=player_id)\
group = Group.query.get(player.group_id) .order_by(Content.position, Content.uploaded_at)\
if group: .all()
contents = group.contents.order_by(Content.position).all()
else:
contents = []
else:
# Player not in a group - show all content
contents = Content.query.order_by(Content.position).all()
# Build playlist # Build playlist
playlist = [] playlist = []
@@ -366,3 +401,97 @@ def bulk_assign_group():
db.session.rollback() db.session.rollback()
log_action('error', f'Error bulk assigning players: {str(e)}') log_action('error', f'Error bulk assigning players: {str(e)}')
return jsonify({'success': False, 'error': str(e)}), 500 return jsonify({'success': False, 'error': str(e)}), 500
@players_bp.route('/<int:player_id>/playlist/reorder', methods=['POST'])
@login_required
def reorder_playlist(player_id: int):
"""Reorder items in player's playlist."""
try:
data = request.get_json()
content_id = data.get('content_id')
direction = data.get('direction') # 'up' or 'down'
if not content_id or not direction:
return jsonify({'success': False, 'message': 'Missing parameters'}), 400
# Get the content item
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
if not content:
return jsonify({'success': False, 'message': 'Content not found'}), 404
# Get all content for this player, ordered by position
all_content = Content.query.filter_by(player_id=player_id)\
.order_by(Content.position, Content.uploaded_at).all()
# Find current index
current_index = None
for idx, item in enumerate(all_content):
if item.id == content_id:
current_index = idx
break
if current_index is None:
return jsonify({'success': False, 'message': 'Content not in playlist'}), 404
# Swap positions
if direction == 'up' and current_index > 0:
# Swap with previous item
all_content[current_index].position, all_content[current_index - 1].position = \
all_content[current_index - 1].position, all_content[current_index].position
elif direction == 'down' and current_index < len(all_content) - 1:
# Swap with next item
all_content[current_index].position, all_content[current_index + 1].position = \
all_content[current_index + 1].position, all_content[current_index].position
db.session.commit()
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Reordered playlist for player {player_id}')
return jsonify({'success': True})
except Exception as e:
db.session.rollback()
log_action('error', f'Error reordering playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500
@players_bp.route('/<int:player_id>/playlist/remove', methods=['POST'])
@login_required
def remove_from_playlist(player_id: int):
"""Remove content from player's playlist."""
try:
data = request.get_json()
content_id = data.get('content_id')
if not content_id:
return jsonify({'success': False, 'message': 'Missing content_id'}), 400
# Get the content item
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
if not content:
return jsonify({'success': False, 'message': 'Content not found'}), 404
filename = content.filename
# Delete from database
db.session.delete(content)
# Increment playlist version
player = Player.query.get(player_id)
if player:
player.playlist_version += 1
db.session.commit()
# Clear cache
cache.delete_memoized(get_player_playlist, player_id)
log_action('info', f'Removed "{filename}" from player {player_id} playlist (version {player.playlist_version})')
return jsonify({'success': True, 'message': f'Removed "{filename}" from playlist'})
except Exception as e:
db.session.rollback()
log_action('error', f'Error removing from playlist: {str(e)}')
return jsonify({'success': False, 'message': str(e)}), 500

View File

@@ -49,10 +49,11 @@ class DevelopmentConfig(Config):
DEBUG = True DEBUG = True
TESTING = False TESTING = False
# Database # Database - construct absolute path
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
SQLALCHEMY_DATABASE_URI = os.getenv( SQLALCHEMY_DATABASE_URI = os.getenv(
'DATABASE_URL', 'DATABASE_URL',
'sqlite:///instance/dev.db' f'sqlite:///{os.path.join(_basedir, "instance", "dev.db")}'
) )
# Cache (simple in-memory for development) # Cache (simple in-memory for development)
@@ -70,10 +71,11 @@ class ProductionConfig(Config):
DEBUG = False DEBUG = False
TESTING = False TESTING = False
# Database # Database - construct absolute path
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
SQLALCHEMY_DATABASE_URI = os.getenv( SQLALCHEMY_DATABASE_URI = os.getenv(
'DATABASE_URL', 'DATABASE_URL',
'sqlite:///instance/dashboard.db' f'sqlite:///{os.path.join(_basedir, "instance", "dashboard.db")}'
) )
# Redis Cache # Redis Cache

View File

@@ -31,7 +31,12 @@ class Content(db.Model):
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, uploaded_at = db.Column(db.DateTime, default=datetime.utcnow,
nullable=False, index=True) nullable=False, index=True)
# Player relationship (for direct player assignment)
player_id = db.Column(db.Integer, db.ForeignKey('player.id', ondelete='CASCADE'),
nullable=True, index=True)
# Relationships # Relationships
player = db.relationship('Player', back_populates='contents')
groups = db.relationship('Group', secondary=group_content, groups = db.relationship('Group', secondary=group_content,
back_populates='contents', lazy='dynamic') back_populates='contents', lazy='dynamic')

View File

@@ -11,9 +11,13 @@ class Player(db.Model):
Attributes: Attributes:
id: Primary key id: Primary key
name: Display name for the player name: Display name for the player
hostname: Unique hostname/identifier for the player
location: Physical location description location: Physical location description
auth_code: Authentication code for API access auth_code: Authentication code for API access (legacy)
password_hash: Hashed password for player authentication
quickconnect_code: Hashed quick connect code for easy pairing
group_id: Foreign key to assigned group group_id: Foreign key to assigned group
orientation: Display orientation (Landscape/Portrait)
status: Current player status (online, offline, error) status: Current player status (online, offline, error)
last_seen: Last activity timestamp last_seen: Last activity timestamp
created_at: Player creation timestamp created_at: Player creation timestamp
@@ -22,17 +26,25 @@ class Player(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False) 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) location = db.Column(db.String(255), nullable=True)
auth_code = db.Column(db.String(255), unique=True, nullable=False, index=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, index=True) group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=True, index=True)
orientation = db.Column(db.String(16), default='Landscape', nullable=False)
status = db.Column(db.String(50), default='offline', index=True) status = db.Column(db.String(50), default='offline', index=True)
last_seen = db.Column(db.DateTime, nullable=True, index=True) last_seen = db.Column(db.DateTime, nullable=True, index=True)
last_heartbeat = db.Column(db.DateTime, nullable=True, index=True)
playlist_version = db.Column(db.Integer, default=1, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# Relationships # Relationships
group = db.relationship('Group', back_populates='players') group = db.relationship('Group', back_populates='players')
feedback = db.relationship('PlayerFeedback', back_populates='player', feedback = db.relationship('PlayerFeedback', back_populates='player',
cascade='all, delete-orphan', lazy='dynamic') cascade='all, delete-orphan', lazy='dynamic')
contents = db.relationship('Content', back_populates='player',
cascade='all, delete-orphan', lazy='dynamic')
def __repr__(self) -> str: def __repr__(self) -> str:
"""String representation of Player.""" """String representation of Player."""
@@ -54,3 +66,73 @@ class Player(db.Model):
""" """
self.status = status self.status = status
self.last_seen = datetime.utcnow() self.last_seen = datetime.utcnow()
def set_password(self, password: str) -> None:
"""Set player password with bcrypt hashing.
Args:
password: Plain text password
"""
from app.extensions import bcrypt
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
def check_password(self, password: str) -> bool:
"""Verify player password.
Args:
password: Plain text password to check
Returns:
True if password matches, False otherwise
"""
from app.extensions import bcrypt
return bcrypt.check_password_hash(self.password_hash, password)
def set_quickconnect_code(self, code: str) -> None:
"""Set quick connect code with bcrypt hashing.
Args:
code: Plain text quick connect code
"""
from app.extensions import bcrypt
self.quickconnect_code = bcrypt.generate_password_hash(code).decode('utf-8')
def check_quickconnect_code(self, code: str) -> bool:
"""Verify quick connect code.
Args:
code: Plain text code to check
Returns:
True if code matches, False otherwise
"""
if not self.quickconnect_code:
return False
from app.extensions import bcrypt
return bcrypt.check_password_hash(self.quickconnect_code, code)
@staticmethod
def authenticate(hostname: str, password: str = None, quickconnect_code: str = None) -> Optional['Player']:
"""Authenticate a player by hostname and password or quickconnect code.
Args:
hostname: Player hostname
password: Player password (optional if using quickconnect)
quickconnect_code: Quick connect code (optional if using password)
Returns:
Player instance if authentication successful, None otherwise
"""
player = Player.query.filter_by(hostname=hostname).first()
if not player:
return None
# Try password authentication first
if password and player.check_password(password):
return player
# Try quickconnect code authentication
if quickconnect_code and player.check_quickconnect_code(quickconnect_code):
return player
return None

View File

@@ -1,30 +0,0 @@
{% extends "base.html" %}
{% block title %}Add Player - DigiServer v2{% endblock %}
{% block content %}
<h1>Add Player</h1>
<div class="card">
<form method="POST">
<div style="margin-bottom: 1rem;">
<label>Name</label>
<input type="text" name="name" required style="width: 100%; padding: 0.5rem;">
</div>
<div style="margin-bottom: 1rem;">
<label>Location (optional)</label>
<input type="text" name="location" style="width: 100%; padding: 0.5rem;">
</div>
<div style="margin-bottom: 1rem;">
<label>Group (optional)</label>
<select name="group_id" style="width: 100%; padding: 0.5rem;">
<option value="">No Group</option>
{% for group in groups %}
<option value="{{ group.id }}">{{ group.name }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-success">Create Player</button>
<a href="{{ url_for('players.players_list') }}" class="btn">Cancel</a>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Change Password{% endblock %}
{% block content %}
<div class="container" style="max-width: 500px; margin-top: 50px;">
<h2>Change Password</h2>
<form method="POST">
<div class="form-group">
<label>Current Password</label>
<input type="password" name="current_password" class="form-control" required>
</div>
<div class="form-group">
<label>New Password</label>
<input type="password" name="new_password" class="form-control" required>
</div>
<div class="form-group">
<label>Confirm New Password</label>
<input type="password" name="confirm_password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary">Change Password</button>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-secondary">Cancel</a>
</form>
</div>
{% endblock %}

View File

@@ -105,7 +105,7 @@
<nav> <nav>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<a href="{{ url_for('main.dashboard') }}">Dashboard</a> <a href="{{ url_for('main.dashboard') }}">Dashboard</a>
<a href="{{ url_for('players.players_list') }}">Players</a> <a href="{{ url_for('players.list') }}">Players</a>
<a href="{{ url_for('groups.groups_list') }}">Groups</a> <a href="{{ url_for('groups.groups_list') }}">Groups</a>
<a href="{{ url_for('content.content_list') }}">Content</a> <a href="{{ url_for('content.content_list') }}">Content</a>
{% if current_user.is_admin %} {% if current_user.is_admin %}

View File

@@ -0,0 +1,205 @@
{% extends "base.html" %}
{% block title %}Content Library - DigiServer v2{% endblock %}
{% block content %}
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Content Library</h1>
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success">+ Upload Content</a>
</div>
{% if content_list %}
<div class="card">
<div style="margin-bottom: 15px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
<strong>Total Files:</strong> {{ content_list|length }} |
<strong>Total Assignments:</strong> {% set total = namespace(count=0) %}{% for item in content_list %}{% set total.count = total.count + item.player_count %}{% endfor %}{{ total.count }}
</div>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">File Name</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Type</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Size</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Assigned To</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Uploaded</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
</tr>
</thead>
<tbody>
{% for item in content_list %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px;">
<strong>{{ item.filename }}</strong>
</td>
<td style="padding: 12px;">
{% if item.content_type == 'image' %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📷 Image</span>
{% elif item.content_type == 'video' %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">🎬 Video</span>
{% elif item.content_type == 'pdf' %}
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📄 PDF</span>
{% elif item.content_type == 'presentation' %}
<span style="background: #ffc107; color: black; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📊 PPT</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📁 Other</span>
{% endif %}
</td>
<td style="padding: 12px;">
{{ item.duration }}s
</td>
<td style="padding: 12px;">
{{ item.file_size }} MB
</td>
<td style="padding: 12px;">
{% if item.player_count == 0 %}
<span style="color: #6c757d; font-style: italic;">Not assigned</span>
{% else %}
<div style="max-height: 100px; overflow-y: auto;">
{% for player in item.players %}
<div style="margin-bottom: 5px;">
<strong>{{ player.name }}</strong>
{% if player.group %}
<span style="color: #6c757d; font-size: 12px;">({{ player.group }})</span>
{% endif %}
</div>
{% endfor %}
</div>
<div style="margin-top: 5px;">
<span style="background: #007bff; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px;">
{{ item.player_count }} player{% if item.player_count != 1 %}s{% endif %}
</span>
</div>
{% endif %}
</td>
<td style="padding: 12px;">
<small style="color: #6c757d;">{{ item.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</small>
</td>
<td style="padding: 12px;">
{% if item.player_count > 0 %}
{% set first_player = item.players[0] %}
<a href="{{ url_for('players.player_page', player_id=first_player.id) }}"
class="btn btn-primary btn-sm"
title="Manage Playlist for {{ first_player.name }}"
style="margin-bottom: 5px;">
📝 Manage Playlist
</a>
{% if item.player_count > 1 %}
<button onclick="showAllPlayers('{{ item.filename|replace("'", "\\'") }}', {{ item.players|tojson }})"
class="btn btn-info btn-sm"
title="View all players with this content">
👥 View All ({{ item.player_count }})
</button>
{% endif %}
{% endif %}
<button onclick="deleteContent('{{ item.filename|replace("'", "\\'") }}')"
class="btn btn-danger btn-sm"
title="Delete this content from all playlists"
style="margin-top: 5px;">
🗑️ Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px;">
No content uploaded yet. <a href="{{ url_for('content.upload_content') }}" style="color: #0c5460; text-decoration: underline;">Upload your first content</a>
</div>
{% endif %}
</div>
<!-- Modal for viewing all players -->
<div id="playersModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 600px; margin: 100px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<h2 id="modalTitle" style="margin-bottom: 20px; color: #2c3e50;">Players with this content</h2>
<div id="playersList" style="max-height: 400px; overflow-y: auto;"></div>
<div style="text-align: center; margin-top: 20px;">
<button type="button" class="btn" onclick="closePlayersModal()">Close</button>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
overflow-y: auto;
}
</style>
<script>
function showAllPlayers(filename, players) {
document.getElementById('modalTitle').textContent = 'Players with: ' + filename;
const playersList = document.getElementById('playersList');
playersList.innerHTML = '<table style="width: 100%; border-collapse: collapse;">';
playersList.innerHTML += '<thead><tr style="background: #f8f9fa;"><th style="padding: 10px; text-align: left;">Player Name</th><th style="padding: 10px; text-align: left;">Group</th><th style="padding: 10px; text-align: left;">Action</th></tr></thead><tbody>';
players.forEach(player => {
playersList.innerHTML += `
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;"><strong>${player.name}</strong></td>
<td style="padding: 10px;">${player.group || '-'}</td>
<td style="padding: 10px;">
<a href="/players/${player.id}" class="btn btn-sm" style="background: #007bff; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px;">
Manage Playlist
</a>
</td>
</tr>
`;
});
playersList.innerHTML += '</tbody></table>';
document.getElementById('playersModal').style.display = 'block';
}
function closePlayersModal() {
document.getElementById('playersModal').style.display = 'none';
}
function deleteContent(filename) {
if (confirm(`Are you sure you want to delete "${filename}"?\n\nThis will remove it from ALL player playlists!`)) {
fetch('/content/delete-by-filename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filename: filename
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(`Successfully deleted "${filename}" from ${data.deleted_count} playlist(s)`);
location.reload();
} else {
alert('Error deleting content: ' + data.message);
}
})
.catch(error => {
alert('Error deleting content: ' + error);
});
}
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('playersModal');
if (event.target == modal) {
closePlayersModal();
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Edit Content{% endblock %}
{% block content %}
<div class="container">
<h2>Edit Content</h2>
<p>Edit content functionality - placeholder</p>
<a href="{{ url_for('content.list') }}" class="btn btn-secondary">Back to Content</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,315 @@
{% extends "base.html" %}
{% block title %}Upload Content - DigiServer v2{% endblock %}
{% block content %}
<div class="container" style="max-width: 1200px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Upload Content</h1>
</div>
<form id="upload-form" method="POST" enctype="multipart/form-data" onsubmit="handleFormSubmit(event)">
<input type="hidden" name="return_url" value="{{ return_url or url_for('content.content_list') }}">
<div class="card" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 15px;">Target Selection</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Target Type:</label>
<select name="target_type" id="target_type" class="form-control" required onchange="updateTargetIdOptions()">
<option value="" disabled selected>Select Target Type</option>
<option value="player" {% if target_type == 'player' %}selected{% endif %}>Player</option>
<option value="group" {% if target_type == 'group' %}selected{% endif %}>Group</option>
</select>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Target ID:</label>
<select name="target_id" id="target_id" class="form-control" required>
{% if target_type == 'player' %}
{% for player in players %}
<option value="{{ player.id }}" {% if target_id == player.id %}selected{% endif %}>{{ player.name }}</option>
{% endfor %}
{% elif target_type == 'group' %}
{% for group in groups %}
<option value="{{ group.id }}" {% if target_id == group.id %}selected{% endif %}>{{ group.name }}</option>
{% endfor %}
{% else %}
<option value="" disabled selected>Select a Target ID</option>
{% endif %}
</select>
</div>
</div>
</div>
<div class="card" style="margin-bottom: 20px;">
<h3 style="margin-bottom: 15px;">Media Details</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Media Type:</label>
<select name="media_type" id="media_type" class="form-control" required onchange="handleMediaTypeChange()">
<option value="image">Image (JPG, PNG, GIF)</option>
<option value="video">Video (MP4, AVI, MOV)</option>
<option value="pdf">PDF Document</option>
<option value="ppt">PowerPoint (PPT/PPTX)</option>
</select>
<small style="color: #6c757d; display: block; margin-top: 5px;" id="media-type-hint">
Images will be displayed as-is
</small>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Duration (seconds):</label>
<input type="number" name="duration" id="duration" class="form-control" required min="1" value="10">
<small style="color: #6c757d; display: block; margin-top: 5px;">
How long to display each image/slide (videos use actual length)
</small>
</div>
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Files:</label>
<input type="file" name="files" id="files" class="form-control" multiple required
accept="image/*,video/*,.pdf,.ppt,.pptx" onchange="handleFileChange()">
<small style="color: #6c757d; display: block; margin-top: 5px;">
Select multiple files. Supported: JPG, PNG, GIF, MP4, PDF, PPT, PPTX
</small>
<div id="file-list" style="margin-top: 10px;"></div>
</div>
</div>
<div style="text-align: center;">
<button type="submit" id="submit-button" class="btn btn-success" style="padding: 10px 30px; font-size: 16px;">
📤 Upload Files
</button>
<a href="{{ return_url or url_for('content.content_list') }}" class="btn" style="padding: 10px 30px; font-size: 16px;">
← Back
</a>
</div>
</form>
</div>
<!-- Modal for Status Updates -->
<div id="statusModal" class="modal" style="display: none;">
<div class="modal-content" style="max-width: 800px; margin: 50px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
<h2 style="margin-bottom: 20px; color: #2c3e50;">Processing Files</h2>
<div style="margin-bottom: 20px;">
<p id="status-message" style="font-size: 16px; color: #555;">Uploading and processing your files. Please wait...</p>
</div>
<!-- Progress Bar -->
<div style="margin-bottom: 30px;">
<label style="display: block; margin-bottom: 10px; font-weight: bold;">File Processing Progress</label>
<div style="width: 100%; height: 30px; background: #e9ecef; border-radius: 5px; overflow: hidden;">
<div id="progress-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #007bff, #0056b3); transition: width 0.3s ease; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 14px;">
0%
</div>
</div>
</div>
<div style="text-align: center; margin-top: 20px;">
<button type="button" class="btn" onclick="closeModal()" disabled id="close-modal-btn">Close</button>
</div>
</div>
</div>
<style>
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
overflow-y: auto;
}
</style>
<script>
let progressInterval = null;
let sessionId = null;
let returnUrl = '{{ return_url or url_for("content.content_list") }}';
function generateSessionId() {
return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
function handleFormSubmit(event) {
event.preventDefault();
sessionId = generateSessionId();
const form = document.getElementById('upload-form');
let sessionInput = document.getElementById('session_id_input');
if (!sessionInput) {
sessionInput = document.createElement('input');
sessionInput.type = 'hidden';
sessionInput.name = 'session_id';
sessionInput.id = 'session_id_input';
form.appendChild(sessionInput);
}
sessionInput.value = sessionId;
showStatusModal();
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
throw new Error('Upload failed');
}
console.log('Form submitted successfully');
})
.catch(error => {
console.error('Form submission error:', error);
document.getElementById('status-message').textContent = 'Upload failed: ' + error.message;
document.getElementById('progress-bar').style.background = '#dc3545';
document.getElementById('close-modal-btn').disabled = false;
});
}
function showStatusModal() {
const modal = document.getElementById('statusModal');
modal.style.display = 'block';
const mediaType = document.getElementById('media_type').value;
const statusMessage = document.getElementById('status-message');
switch(mediaType) {
case 'image':
statusMessage.textContent = 'Uploading images...';
break;
case 'video':
statusMessage.textContent = 'Uploading and converting video. This may take several minutes...';
break;
case 'pdf':
statusMessage.textContent = 'Uploading and converting PDF to images...';
break;
case 'ppt':
statusMessage.textContent = 'Uploading and converting PowerPoint to images...';
break;
default:
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
}
pollUploadProgress();
}
function closeModal() {
const modal = document.getElementById('statusModal');
modal.style.display = 'none';
if (progressInterval) {
clearInterval(progressInterval);
}
window.location.href = returnUrl;
}
function pollUploadProgress() {
progressInterval = setInterval(() => {
fetch(`/api/upload-progress/${sessionId}`)
.then(response => response.json())
.then(data => {
const progressBar = document.getElementById('progress-bar');
progressBar.style.width = `${data.progress}%`;
progressBar.textContent = `${data.progress}%`;
document.getElementById('status-message').textContent = data.message;
if (data.status === 'complete' || data.status === 'error') {
clearInterval(progressInterval);
progressInterval = null;
const closeBtn = document.getElementById('close-modal-btn');
closeBtn.disabled = false;
if (data.status === 'complete') {
progressBar.style.background = '#28a745';
setTimeout(() => closeModal(), 2000);
} else if (data.status === 'error') {
progressBar.style.background = '#dc3545';
}
}
})
.catch(error => console.error('Error fetching progress:', error));
}, 500);
}
function updateTargetIdOptions() {
const targetType = document.getElementById('target_type').value;
const targetIdSelect = document.getElementById('target_id');
targetIdSelect.innerHTML = '';
if (targetType === 'player') {
const players = {{ players|tojson }};
players.forEach(player => {
const option = document.createElement('option');
option.value = player.id;
option.textContent = player.name;
targetIdSelect.appendChild(option);
});
} else if (targetType === 'group') {
const groups = {{ groups|tojson }};
groups.forEach(group => {
const option = document.createElement('option');
option.value = group.id;
option.textContent = group.name;
targetIdSelect.appendChild(option);
});
}
}
function handleMediaTypeChange() {
const mediaType = document.getElementById('media_type').value;
const hint = document.getElementById('media-type-hint');
switch(mediaType) {
case 'image':
hint.textContent = 'Images will be displayed as-is';
break;
case 'video':
hint.textContent = 'Videos will be converted to optimized format';
break;
case 'pdf':
hint.textContent = 'PDF will be converted to images (one per page)';
break;
case 'ppt':
hint.textContent = 'PowerPoint will be converted to images (one per slide)';
break;
}
}
function handleFileChange() {
const filesInput = document.getElementById('files');
const fileList = document.getElementById('file-list');
const mediaType = document.getElementById('media_type').value;
const durationInput = document.getElementById('duration');
fileList.innerHTML = '';
if (filesInput.files.length > 0) {
fileList.innerHTML = '<strong>Selected files:</strong><ul style="margin: 5px 0; padding-left: 20px;">';
for (let i = 0; i < filesInput.files.length; i++) {
const file = filesInput.files[i];
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
fileList.innerHTML += `<li>${file.name} (${sizeMB} MB)</li>`;
}
fileList.innerHTML += '</ul>';
}
if (mediaType === 'video' && filesInput.files.length > 0) {
const file = filesInput.files[0];
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = function() {
window.URL.revokeObjectURL(video.src);
const duration = Math.round(video.duration);
durationInput.value = duration;
};
video.src = URL.createObjectURL(file);
}
}
</script>
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% block title %}Content - DigiServer v2{% endblock %}
{% block content %}
<h1>Content</h1>
<div class="card">
<p>Content list view - Template in progress</p>
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success">Upload Content</a>
</div>
{% endblock %}

View File

@@ -9,7 +9,7 @@
<div class="card"> <div class="card">
<h3 style="color: #3498db; margin-bottom: 0.5rem;">👥 Players</h3> <h3 style="color: #3498db; margin-bottom: 0.5rem;">👥 Players</h3>
<p style="font-size: 2rem; font-weight: bold;">{{ total_players or 0 }}</p> <p style="font-size: 2rem; font-weight: bold;">{{ total_players or 0 }}</p>
<a href="{{ url_for('players.players_list') }}" class="btn" style="margin-top: 1rem;">View Players</a> <a href="{{ url_for('players.list') }}" class="btn" style="margin-top: 1rem;">View Players</a>
</div> </div>
<div class="card"> <div class="card">

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}403 - Access Denied{% endblock %}
{% block content %}
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
<h1 style="font-size: 72px; color: #ffc107;">403</h1>
<h2>Access Denied</h2>
<p style="color: #6c757d; margin: 20px 0;">You don't have permission to access this resource.</p>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}404 - Page Not Found{% endblock %}
{% block content %}
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
<h1 style="font-size: 72px; color: #dc3545;">404</h1>
<h2>Page Not Found</h2>
<p style="color: #6c757d; margin: 20px 0;">The page you're looking for doesn't exist.</p>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}500 - Server Error{% endblock %}
{% block content %}
<div class="container" style="max-width: 600px; margin-top: 100px; text-align: center;">
<h1 style="font-size: 72px; color: #dc3545;">500</h1>
<h2>Internal Server Error</h2>
<p style="color: #6c757d; margin: 20px 0;">Something went wrong on our end. Please try again later.</p>
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">Go to Dashboard</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Edit Group{% endblock %}
{% block content %}
<div class="container">
<h2>Edit Group</h2>
<p>Edit group functionality - placeholder</p>
<a href="{{ url_for('groups.list') }}" class="btn btn-secondary">Back to Groups</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Group Fullscreen{% endblock %}
{% block content %}
<div class="container">
<h2>Group Fullscreen View</h2>
<p>Fullscreen group view - placeholder</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Manage Group{% endblock %}
{% block content %}
<div class="container">
<h2>Manage Group</h2>
<p>Manage group functionality - placeholder</p>
<a href="{{ url_for('groups.list') }}" class="btn btn-secondary">Back to Groups</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}Add Player - DigiServer v2{% endblock %}
{% block content %}
<div class="container" style="max-width: 800px; margin-top: 2rem;">
<h1>Add New Player</h1>
<p style="color: #6c757d; margin-bottom: 2rem;">
Create a new digital signage player with authentication credentials
</p>
<div class="card">
<form method="POST">
<h3 style="margin-top: 0; border-bottom: 2px solid #007bff; padding-bottom: 0.5rem;">
Basic Information
</h3>
<div style="margin-bottom: 1rem;">
<label style="font-weight: bold;">Display Name *</label>
<input type="text" name="name" required
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;"
placeholder="e.g., Office Reception Player">
<small style="color: #6c757d;">Friendly name for the player</small>
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: bold;">Hostname *</label>
<input type="text" name="hostname" required
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;"
placeholder="e.g., office-player-001">
<small style="color: #6c757d;">
Unique identifier for this player (must match screen_name in player config)
</small>
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: bold;">Location</label>
<input type="text" name="location"
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;"
placeholder="e.g., Main Office - Reception Area">
<small style="color: #6c757d;">Physical location of the player (optional)</small>
</div>
<h3 style="margin-top: 2rem; border-bottom: 2px solid #28a745; padding-bottom: 0.5rem;">
Authentication
</h3>
<p style="color: #6c757d; font-size: 0.9rem; margin-bottom: 1rem;">
Choose one authentication method (Quick Connect recommended for easy setup)
</p>
<div style="margin-bottom: 1rem;">
<label style="font-weight: bold;">Password</label>
<input type="password" name="password" id="password"
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;"
placeholder="Leave empty to use Quick Connect only">
<small style="color: #6c757d;">
Secure password for player authentication (optional if using Quick Connect)
</small>
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: bold;">Quick Connect Code *</label>
<input type="text" name="quickconnect_code" required
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;"
placeholder="e.g., OFFICE123">
<small style="color: #6c757d;">
Easy pairing code for quick setup (must match quickconnect_key in player config)
</small>
</div>
<h3 style="margin-top: 2rem; border-bottom: 2px solid #ffc107; padding-bottom: 0.5rem;">
Display Settings
</h3>
<div style="margin-bottom: 1rem;">
<label style="font-weight: bold;">Orientation</label>
<select name="orientation" style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
<option value="Landscape" selected>Landscape</option>
<option value="Portrait">Portrait</option>
</select>
<small style="color: #6c757d;">Display orientation for the player</small>
</div>
<div style="margin-bottom: 1rem;">
<label style="font-weight: bold;">Assign to Group</label>
<select name="group_id" style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
<option value="">No Group (Unassigned)</option>
{% for group in groups %}
<option value="{{ group.id }}">{{ group.name }}</option>
{% endfor %}
</select>
<small style="color: #6c757d;">Assign player to a content group (optional)</small>
</div>
<div style="background-color: #e7f3ff; border-left: 4px solid #007bff; padding: 1rem; margin: 2rem 0;">
<h4 style="margin-top: 0; color: #007bff;">📋 Setup Instructions</h4>
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
<li>Create the player with the form above</li>
<li>Note the generated <strong>Auth Code</strong> (shown after creation)</li>
<li>Configure the player's <code>app_config.json</code> with:
<ul style="margin-top: 0.5rem;">
<li><code>server_ip</code>: Your server address</li>
<li><code>screen_name</code>: Same as <strong>Hostname</strong> above</li>
<li><code>quickconnect_key</code>: Same as <strong>Quick Connect Code</strong> above</li>
</ul>
</li>
<li>Start the player - it will authenticate automatically</li>
</ol>
</div>
<div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd;">
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
✓ Create Player
</button>
<a href="{{ url_for('players.list') }}" class="btn" style="padding: 0.75rem 2rem; margin-left: 1rem;">
Cancel
</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Edit Player{% endblock %}
{% block content %}
<div class="container">
<h2>Edit Player</h2>
<p>Edit player functionality - placeholder</p>
<a href="{{ url_for('players.list') }}" class="btn btn-secondary">Back to Players</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Player Fullscreen{% endblock %}
{% block content %}
<div class="container">
<h2>Player Fullscreen View</h2>
<p>Fullscreen player view - placeholder</p>
</div>
{% endblock %}

View File

@@ -0,0 +1,331 @@
{% extends "base.html" %}
{% block title %}{{ player.name }} - DigiServer v2{% endblock %}
{% block content %}
<div class="container" style="max-width: 1400px;">
<!-- Header -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<div>
<h1>{{ player.name }}</h1>
<div style="margin-top: 10px;">
{% if status_info.online %}
<span style="background: #28a745; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
🟢 Online
</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
⚫ Offline
</span>
{% endif %}
<span style="color: #6c757d; font-size: 14px;">
Last seen: {{ status_info.last_seen_ago }}
</span>
</div>
</div>
<div>
<a href="{{ url_for('players.edit_player', player_id=player.id) }}" class="btn btn-primary">
✏️ Edit Player
</a>
<a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}" class="btn btn-success">
📤 Upload Content
</a>
<a href="{{ url_for('players.list') }}" class="btn">
← Back to Players
</a>
</div>
</div>
<!-- Main Content Grid -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
<!-- Player Information Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
📋 Player Information
</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold; width: 40%;">Display Name:</td>
<td style="padding: 10px;">{{ player.name }}</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Hostname:</td>
<td style="padding: 10px;">
<code style="background: #f8f9fa; padding: 3px 8px; border-radius: 3px;">{{ player.hostname }}</code>
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Location:</td>
<td style="padding: 10px;">{{ player.location or '-' }}</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Orientation:</td>
<td style="padding: 10px;">{{ player.orientation or 'Landscape' }}</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Group:</td>
<td style="padding: 10px;">
{% if player.group %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">
{{ player.group.name }}
</span>
{% else %}
<span style="color: #6c757d;">No group</span>
{% endif %}
</td>
</tr>
<tr>
<td style="padding: 10px; font-weight: bold;">Created:</td>
<td style="padding: 10px;">{{ player.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
</tr>
</table>
</div>
<!-- Authentication Details Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
🔐 Authentication Details
</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold; width: 40%;">Password Set:</td>
<td style="padding: 10px;">
{% if player.password_hash %}
<span style="color: #28a745;">✓ Yes</span>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Quick Connect Code:</td>
<td style="padding: 10px;">
{% if player.quickconnect_code %}
<span style="color: #28a745;">✓ Yes</span>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; font-weight: bold;">Auth Code:</td>
<td style="padding: 10px;">
{% if player.auth_code %}
<span style="color: #28a745;">✓ Yes</span>
<form method="POST" action="{{ url_for('players.regenerate_auth_code', player_id=player.id) }}" style="display: inline; margin-left: 10px;">
<button type="submit" class="btn btn-sm" style="background: #ffc107; padding: 3px 8px;"
onclick="return confirm('Regenerate auth code? The player will need to authenticate again.')">
🔄 Regenerate
</button>
</form>
{% else %}
<span style="color: #dc3545;">✗ No</span>
{% endif %}
</td>
</tr>
<tr>
<td colspan="2" style="padding: 15px 10px;">
<a href="{{ url_for('players.edit_player', player_id=player.id) }}"
class="btn btn-primary" style="width: 100%; text-align: center;">
✏️ Edit Authentication Settings
</a>
</td>
</tr>
</table>
</div>
</div>
<!-- Playlist Management Card -->
<div class="card" style="margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h3 style="margin: 0;">🎬 Current Playlist</h3>
<div>
<a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}"
class="btn btn-success btn-sm">
+ Add Content
</a>
</div>
</div>
{% if playlist %}
<div style="background: #f8f9fa; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
<strong>Total Items:</strong> {{ playlist|length }} |
<strong>Total Duration:</strong> {% set total_duration = namespace(value=0) %}{% for item in playlist %}{% set total_duration.value = total_duration.value + (item.duration or 10) %}{% endfor %}{{ total_duration.value }}s
</div>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 10px; border-bottom: 2px solid #dee2e6; width: 50px;">Order</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">File Name</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Type</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Duration</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Actions</th>
</tr>
</thead>
<tbody id="playlist-items">
{% for item in playlist %}
<tr style="border-bottom: 1px solid #dee2e6;" data-content-id="{{ item.id }}">
<td style="padding: 10px; text-align: center;">
<strong>{{ loop.index }}</strong>
</td>
<td style="padding: 10px;">
{{ item.filename }}
</td>
<td style="padding: 10px;">
{% if item.type == 'image' %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📷 Image</span>
{% elif item.type == 'video' %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">🎬 Video</span>
{% elif item.type == 'pdf' %}
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📄 PDF</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📁 Other</span>
{% endif %}
</td>
<td style="padding: 10px;">
{{ item.duration or 10 }}s
</td>
<td style="padding: 10px;">
<button onclick="moveUp({{ item.id }})" class="btn btn-sm"
style="background: #007bff; color: white; padding: 3px 8px; margin-right: 5px;"
{% if loop.first %}disabled{% endif %}>
</button>
<button onclick="moveDown({{ item.id }})" class="btn btn-sm"
style="background: #007bff; color: white; padding: 3px 8px; margin-right: 5px;"
{% if loop.last %}disabled{% endif %}>
</button>
<button onclick="removeFromPlaylist({{ item.id }}, '{{ item.filename }}')"
class="btn btn-danger btn-sm" style="padding: 3px 8px;">
🗑️ Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div style="background: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 15px; border-radius: 5px; text-align: center;">
⚠️ No content in playlist. <a href="{{ url_for('content.upload_content', target_type='player', target_id=player.id, return_url=url_for('players.player_page', player_id=player.id)) }}" style="color: #856404; text-decoration: underline;">Upload content</a> to get started.
</div>
{% endif %}
</div>
<!-- Player Activity Log Card -->
<div class="card">
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
📊 Recent Activity & Feedback
</h3>
{% if recent_feedback %}
<div style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead style="position: sticky; top: 0; background: white;">
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Time</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Message</th>
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Error</th>
</tr>
</thead>
<tbody>
{% for feedback in recent_feedback %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px; white-space: nowrap;">
<small style="color: #6c757d;">{{ feedback.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
</td>
<td style="padding: 10px;">
{% if feedback.status == 'playing' %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">▶️ Playing</span>
{% elif feedback.status == 'idle' %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">⏸️ Idle</span>
{% elif feedback.status == 'error' %}
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">❌ Error</span>
{% else %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">{{ feedback.status }}</span>
{% endif %}
</td>
<td style="padding: 10px;">
{{ feedback.message or '-' }}
</td>
<td style="padding: 10px;">
{% if feedback.error %}
<span style="color: #dc3545; font-family: monospace; font-size: 12px;">{{ feedback.error[:50] }}...</span>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px; text-align: center;">
No activity logs yet. The player will send feedback once it starts playing content.
</div>
{% endif %}
</div>
</div>
<script>
function moveUp(contentId) {
updatePlaylistOrder(contentId, 'up');
}
function moveDown(contentId) {
updatePlaylistOrder(contentId, 'down');
}
function updatePlaylistOrder(contentId, direction) {
fetch('/players/{{ player.id }}/playlist/reorder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content_id: contentId,
direction: direction
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error reordering playlist: ' + data.message);
}
})
.catch(error => {
alert('Error reordering playlist: ' + error);
});
}
function removeFromPlaylist(contentId, filename) {
if (confirm(`Remove "${filename}" from this player's playlist?`)) {
fetch('/players/{{ player.id }}/playlist/remove', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content_id: contentId
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error removing content: ' + data.message);
}
})
.catch(error => {
alert('Error removing content: ' + error);
});
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,96 @@
{% extends "base.html" %}
{% block title %}Players - DigiServer v2{% endblock %}
{% block content %}
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1>Players</h1>
<a href="{{ url_for('players.add_player') }}" class="btn btn-success">+ Add New Player</a>
</div>
{% if players %}
<div class="card">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; text-align: left;">
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Name</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Hostname</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Location</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Group</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Orientation</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Status</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Last Seen</th>
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
</tr>
</thead>
<tbody>
{% for player in players %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 12px;">
<strong>{{ player.name }}</strong>
</td>
<td style="padding: 12px;">
<code style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px;">{{ player.hostname }}</code>
</td>
<td style="padding: 12px;">
{{ player.location or '-' }}
</td>
<td style="padding: 12px;">
{% if player.group %}
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">{{ player.group.name }}</span>
{% else %}
<span style="color: #6c757d;">No group</span>
{% endif %}
</td>
<td style="padding: 12px;">
{{ player.orientation or 'Landscape' }}
</td>
<td style="padding: 12px;">
{% set status = player_statuses.get(player.id, {}) %}
{% if status.get('is_online') %}
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">Online</span>
{% else %}
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">Offline</span>
{% endif %}
</td>
<td style="padding: 12px;">
{% if player.last_heartbeat %}
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M') }}
{% else %}
<span style="color: #6c757d;">Never</span>
{% endif %}
</td>
<td style="padding: 12px;">
<a href="{{ url_for('players.player_page', player_id=player.id) }}"
class="btn btn-info btn-sm" title="View" style="margin-right: 5px;">
👁️ View
</a>
<a href="{{ url_for('players.edit_player', player_id=player.id) }}"
class="btn btn-primary btn-sm" title="Edit" style="margin-right: 5px;">
✏️ Edit
</a>
<a href="{{ url_for('players.player_fullscreen', player_id=player.id) }}"
class="btn btn-success btn-sm" title="Fullscreen" target="_blank" style="margin-right: 5px;">
⛶ Full
</a>
<form method="POST" action="{{ url_for('players.delete_player', player_id=player.id) }}"
style="display: inline;"
onsubmit="return confirm('Are you sure you want to delete player \'{{ player.name }}\'?');">
<button type="submit" class="btn btn-danger btn-sm" title="Delete">
🗑️ Delete
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px;">
No players yet. <a href="{{ url_for('players.add_player') }}" style="color: #0c5460; text-decoration: underline;">Add your first player</a>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends "base.html" %}
{% block title %}Players - DigiServer v2{% endblock %}
{% block content %}
<h1>Players</h1>
<div class="card">
<p>Players list view - Template in progress</p>
<a href="{{ url_for('players.add_player') }}" class="btn btn-success">Add New Player</a>
</div>
{% endblock %}

View File

@@ -1,25 +0,0 @@
{% extends "base.html" %}
{% block title %}Upload Content - DigiServer v2{% endblock %}
{% block content %}
<h1>Upload Content</h1>
<div class="card">
<form method="POST" enctype="multipart/form-data">
<div style="margin-bottom: 1rem;">
<label>File</label>
<input type="file" name="file" required style="width: 100%; padding: 0.5rem;">
</div>
<div style="margin-bottom: 1rem;">
<label>Duration (seconds, for images)</label>
<input type="number" name="duration" value="10" min="1" style="width: 100%; padding: 0.5rem;">
</div>
<div style="margin-bottom: 1rem;">
<label>Description (optional)</label>
<textarea name="description" rows="3" style="width: 100%; padding: 0.5rem;"></textarea>
</div>
<button type="submit" class="btn btn-success">Upload</button>
<a href="{{ url_for('content.content_list') }}" class="btn">Cancel</a>
</form>
</div>
{% endblock %}

254
player_auth_module.py Normal file
View File

@@ -0,0 +1,254 @@
"""
Player Authentication Module for Kiwy-Signage
Handles authentication with DigiServer v2 and secure config storage
"""
import configparser
import os
import requests
from typing import Optional, Dict, Tuple
import json
class PlayerAuth:
"""Handle player authentication and configuration management."""
def __init__(self, config_path: str = 'player_config.ini'):
"""Initialize player authentication.
Args:
config_path: Path to configuration file
"""
self.config_path = config_path
self.config = configparser.ConfigParser()
self.load_config()
def load_config(self) -> None:
"""Load configuration from file."""
if os.path.exists(self.config_path):
self.config.read(self.config_path)
else:
# Create default config
self._create_default_config()
def _create_default_config(self) -> None:
"""Create default configuration file."""
self.config['server'] = {
'server_url': 'http://localhost:5000'
}
self.config['player'] = {
'hostname': '',
'auth_code': '',
'player_id': '',
'group_id': ''
}
self.config['display'] = {
'orientation': 'Landscape',
'resolution': '1920x1080'
}
self.config['security'] = {
'verify_ssl': 'true',
'timeout': '30'
}
self.config['cache'] = {
'cache_dir': './cache',
'max_cache_size': '1024'
}
self.config['logging'] = {
'enabled': 'true',
'log_level': 'INFO',
'log_file': './player.log'
}
self.save_config()
def save_config(self) -> None:
"""Save configuration to file."""
with open(self.config_path, 'w') as f:
self.config.write(f)
def get_server_url(self) -> str:
"""Get server URL from config."""
return self.config.get('server', 'server_url', fallback='http://localhost:5000')
def get_hostname(self) -> str:
"""Get player hostname from config."""
return self.config.get('player', 'hostname', fallback='')
def get_auth_code(self) -> str:
"""Get saved auth code from config."""
return self.config.get('player', 'auth_code', fallback='')
def is_authenticated(self) -> bool:
"""Check if player has valid authentication."""
return bool(self.get_hostname() and self.get_auth_code())
def authenticate(self, hostname: str, password: str = None,
quickconnect_code: str = None) -> Tuple[bool, Optional[str]]:
"""Authenticate with server and save credentials.
Args:
hostname: Player hostname/identifier
password: Player password (optional if using quickconnect)
quickconnect_code: Quick connect code (optional if using password)
Returns:
Tuple of (success: bool, error_message: Optional[str])
"""
if not password and not quickconnect_code:
return False, "Password or quick connect code required"
server_url = self.get_server_url()
try:
# Make authentication request
response = requests.post(
f"{server_url}/api/auth/player",
json={
'hostname': hostname,
'password': password,
'quickconnect_code': quickconnect_code
},
timeout=int(self.config.get('security', 'timeout', fallback='30')),
verify=self.config.getboolean('security', 'verify_ssl', fallback=True)
)
if response.status_code == 200:
data = response.json()
# Save authentication data
self.config['player']['hostname'] = hostname
self.config['player']['auth_code'] = data.get('auth_code', '')
self.config['player']['player_id'] = str(data.get('player_id', ''))
self.config['player']['group_id'] = str(data.get('group_id', ''))
self.config['display']['orientation'] = data.get('orientation', 'Landscape')
self.save_config()
return True, None
else:
error_data = response.json()
return False, error_data.get('error', 'Authentication failed')
except requests.exceptions.ConnectionError:
return False, "Cannot connect to server"
except requests.exceptions.Timeout:
return False, "Connection timeout"
except Exception as e:
return False, f"Error: {str(e)}"
def verify_auth(self) -> Tuple[bool, Optional[Dict]]:
"""Verify current auth code with server.
Returns:
Tuple of (valid: bool, player_info: Optional[Dict])
"""
auth_code = self.get_auth_code()
if not auth_code:
return False, None
server_url = self.get_server_url()
try:
response = requests.post(
f"{server_url}/api/auth/verify",
json={'auth_code': auth_code},
timeout=int(self.config.get('security', 'timeout', fallback='30')),
verify=self.config.getboolean('security', 'verify_ssl', fallback=True)
)
if response.status_code == 200:
data = response.json()
return data.get('valid', False), data
return False, None
except Exception:
return False, None
def get_playlist(self) -> Optional[Dict]:
"""Get playlist for this player from server.
Returns:
Playlist data or None if failed
"""
auth_code = self.get_auth_code()
player_id = self.config.get('player', 'player_id', fallback='')
if not auth_code or not player_id:
return None
server_url = self.get_server_url()
try:
response = requests.get(
f"{server_url}/api/playlists/{player_id}",
headers={'Authorization': f'Bearer {auth_code}'},
timeout=int(self.config.get('security', 'timeout', fallback='30')),
verify=self.config.getboolean('security', 'verify_ssl', fallback=True)
)
if response.status_code == 200:
return response.json()
return None
except Exception:
return None
def send_heartbeat(self, status: str = 'online') -> bool:
"""Send heartbeat to server.
Args:
status: Player status
Returns:
True if successful, False otherwise
"""
auth_code = self.get_auth_code()
player_id = self.config.get('player', 'player_id', fallback='')
if not auth_code or not player_id:
return False
server_url = self.get_server_url()
try:
response = requests.post(
f"{server_url}/api/players/{player_id}/heartbeat",
headers={'Authorization': f'Bearer {auth_code}'},
json={'status': status},
timeout=int(self.config.get('security', 'timeout', fallback='30')),
verify=self.config.getboolean('security', 'verify_ssl', fallback=True)
)
return response.status_code == 200
except Exception:
return False
def clear_auth(self) -> None:
"""Clear saved authentication data."""
self.config['player']['auth_code'] = ''
self.config['player']['player_id'] = ''
self.config['player']['group_id'] = ''
self.save_config()
# Example usage
if __name__ == '__main__':
auth = PlayerAuth()
# Check if already authenticated
if auth.is_authenticated():
print(f"Already authenticated as: {auth.get_hostname()}")
# Verify authentication
valid, info = auth.verify_auth()
if valid:
print(f"Authentication valid: {info}")
else:
print("Authentication expired or invalid")
else:
print("Not authenticated. Please run authentication:")
print("auth.authenticate(hostname='player-001', password='your_password')")

View File

@@ -0,0 +1,51 @@
# Player Configuration File
# This file is automatically generated and updated by the signage player
# DO NOT EDIT MANUALLY unless you know what you're doing
[server]
# DigiServer URL (without trailing slash)
server_url = http://localhost:5000
[player]
# Player hostname/identifier (must be unique)
hostname =
# Player authentication code (obtained after first authentication)
auth_code =
# Player ID (assigned by server)
player_id =
# Group ID (assigned by server)
group_id =
[display]
# Display orientation: Landscape or Portrait
orientation = Landscape
# Screen resolution (width x height)
resolution = 1920x1080
[security]
# Enable SSL certificate verification
verify_ssl = true
# Connection timeout in seconds
timeout = 30
[cache]
# Local cache directory for downloaded content
cache_dir = ./cache
# Maximum cache size in MB
max_cache_size = 1024
[logging]
# Enable logging
enabled = true
# Log level: DEBUG, INFO, WARNING, ERROR
log_level = INFO
# Log file path
log_file = ./player.log

50
quick_test.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/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

52
reinit_db.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB