updated digiserver 2
This commit is contained in:
381
PLAYER_AUTH.md
Normal file
381
PLAYER_AUTH.md
Normal 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
|
||||
84
app/app.py
84
app/app.py
@@ -4,8 +4,13 @@ Modern Flask application with blueprint architecture
|
||||
"""
|
||||
import os
|
||||
from flask import Flask, render_template
|
||||
from config import get_config
|
||||
from extensions import db, bcrypt, login_manager, migrate, cache
|
||||
from dotenv import load_dotenv
|
||||
|
||||
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):
|
||||
@@ -18,22 +23,17 @@ def create_app(config_name=None):
|
||||
Returns:
|
||||
Flask application instance
|
||||
"""
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
app = Flask(__name__)
|
||||
|
||||
# Load configuration
|
||||
if config_name is None:
|
||||
config_name = os.getenv('FLASK_ENV', 'development')
|
||||
if config_name == 'production':
|
||||
config = ProductionConfig
|
||||
elif config_name == 'testing':
|
||||
config = TestingConfig
|
||||
else:
|
||||
config = DevelopmentConfig
|
||||
|
||||
app.config.from_object(get_config(config_name))
|
||||
|
||||
# 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)
|
||||
app.config.from_object(config)
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
@@ -42,16 +42,13 @@ def create_app(config_name=None):
|
||||
migrate.init_app(app, db)
|
||||
cache.init_app(app)
|
||||
|
||||
# Register blueprints
|
||||
# Configure Flask-Login
|
||||
configure_login_manager(app)
|
||||
|
||||
# Register components
|
||||
register_blueprints(app)
|
||||
|
||||
# Register error handlers
|
||||
register_error_handlers(app)
|
||||
|
||||
# Register CLI commands
|
||||
register_commands(app)
|
||||
|
||||
# Context processors
|
||||
register_context_processors(app)
|
||||
|
||||
return app
|
||||
@@ -59,24 +56,35 @@ def create_app(config_name=None):
|
||||
|
||||
def register_blueprints(app):
|
||||
"""Register application blueprints"""
|
||||
from blueprints.auth import auth_bp
|
||||
from blueprints.admin import admin_bp
|
||||
from blueprints.players import players_bp
|
||||
from blueprints.groups import groups_bp
|
||||
from blueprints.content import content_bp
|
||||
from blueprints.api import api_bp
|
||||
from app.blueprints.main import main_bp
|
||||
from app.blueprints.auth import auth_bp
|
||||
from app.blueprints.admin import admin_bp
|
||||
from app.blueprints.players import players_bp
|
||||
from app.blueprints.groups import groups_bp
|
||||
from app.blueprints.content import content_bp
|
||||
from app.blueprints.api import api_bp
|
||||
|
||||
# Register with appropriate URL prefixes
|
||||
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
|
||||
# Register blueprints (using URL prefixes from blueprint definitions)
|
||||
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):
|
||||
|
||||
@@ -59,7 +59,7 @@ def admin_panel():
|
||||
|
||||
storage_mb = round(total_size / (1024 * 1024), 2)
|
||||
|
||||
return render_template('admin.html',
|
||||
return render_template('admin/admin.html',
|
||||
total_users=total_users,
|
||||
total_players=total_players,
|
||||
total_groups=total_groups,
|
||||
|
||||
@@ -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'])
|
||||
@rate_limit(max_requests=30, window=60)
|
||||
@verify_player_auth
|
||||
@@ -120,6 +220,7 @@ def get_player_playlist(player_id: int):
|
||||
'player_id': player_id,
|
||||
'player_name': player.name,
|
||||
'group_id': player.group_id,
|
||||
'playlist_version': player.playlist_version,
|
||||
'playlist': playlist,
|
||||
'count': len(playlist)
|
||||
})
|
||||
@@ -129,6 +230,37 @@ def get_player_playlist(player_id: int):
|
||||
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)
|
||||
def get_cached_playlist(player_id: int) -> List[Dict]:
|
||||
"""Get cached playlist for a player."""
|
||||
|
||||
@@ -3,9 +3,9 @@ Authentication Blueprint - Login, Logout, Register
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from extensions import db, bcrypt
|
||||
from models.user import User
|
||||
from utils.logger import log_action, log_user_created
|
||||
from app.extensions import db, bcrypt, login_manager
|
||||
from app.models import User
|
||||
from app.utils.logger import log_action
|
||||
from typing import Optional
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
@@ -34,7 +34,7 @@ def login():
|
||||
# Verify credentials
|
||||
if user and bcrypt.check_password_hash(user.password, password):
|
||||
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
|
||||
next_page = request.args.get('next')
|
||||
@@ -43,7 +43,7 @@ def login():
|
||||
return redirect(url_for('main.dashboard'))
|
||||
else:
|
||||
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
|
||||
import os
|
||||
@@ -60,7 +60,7 @@ def logout():
|
||||
"""User logout"""
|
||||
username = current_user.username
|
||||
logout_user()
|
||||
log_action(f'User {username} logged out')
|
||||
log_action('info', f'User {username} logged out')
|
||||
flash('You have been logged out.', 'info')
|
||||
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')
|
||||
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')
|
||||
return redirect(url_for('main.dashboard'))
|
||||
|
||||
|
||||
@@ -30,16 +30,51 @@ upload_progress = {}
|
||||
def content_list():
|
||||
"""Display list of all content."""
|
||||
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
|
||||
content_groups = {}
|
||||
# Get content with player information
|
||||
contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all()
|
||||
|
||||
# Group content by filename to show which players have each file
|
||||
content_map = {}
|
||||
for content in contents:
|
||||
content_groups[content.id] = content.groups.count()
|
||||
if content.filename not in content_map:
|
||||
content_map[content.filename] = {
|
||||
'content': content,
|
||||
'players': [],
|
||||
'groups': []
|
||||
}
|
||||
|
||||
# Add player info if assigned to a player
|
||||
if content.player_id:
|
||||
from app.models import Player
|
||||
player = Player.query.get(content.player_id)
|
||||
if player:
|
||||
content_map[content.filename]['players'].append({
|
||||
'id': player.id,
|
||||
'name': player.name,
|
||||
'group': player.group.name if player.group else None
|
||||
})
|
||||
|
||||
return render_template('content_list.html',
|
||||
contents=contents,
|
||||
content_groups=content_groups)
|
||||
# Convert to list for template
|
||||
content_list = []
|
||||
for filename, data in content_map.items():
|
||||
content_list.append({
|
||||
'filename': filename,
|
||||
'content_type': data['content'].content_type,
|
||||
'duration': data['content'].duration,
|
||||
'file_size': data['content'].file_size_mb,
|
||||
'uploaded_at': data['content'].uploaded_at,
|
||||
'players': data['players'],
|
||||
'player_count': len(data['players'])
|
||||
})
|
||||
|
||||
# Sort by upload date
|
||||
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
|
||||
|
||||
return render_template('content/content_list.html',
|
||||
content_list=content_list)
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading content list: {str(e)}')
|
||||
flash('Error loading content list.', 'danger')
|
||||
@@ -51,86 +86,166 @@ def content_list():
|
||||
def upload_content():
|
||||
"""Upload new content."""
|
||||
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:
|
||||
if 'file' not in request.files:
|
||||
flash('No file provided.', 'warning')
|
||||
# Get form data
|
||||
target_type = request.form.get('target_type')
|
||||
target_id = request.form.get('target_id', type=int)
|
||||
media_type = request.form.get('media_type', 'image')
|
||||
duration = request.form.get('duration', type=int, default=10)
|
||||
session_id = request.form.get('session_id', os.urandom(8).hex())
|
||||
return_url = request.form.get('return_url', url_for('content.content_list'))
|
||||
|
||||
# Get files
|
||||
files = request.files.getlist('files')
|
||||
|
||||
if not files or files[0].filename == '':
|
||||
flash('No files provided.', 'warning')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
file = request.files['file']
|
||||
|
||||
if file.filename == '':
|
||||
flash('No file selected.', 'warning')
|
||||
if not target_type or not target_id:
|
||||
flash('Please select a target type and target ID.', 'warning')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
|
||||
# Get optional parameters
|
||||
duration = request.form.get('duration', type=int)
|
||||
description = request.form.get('description', '').strip()
|
||||
# Initialize progress tracking using shared utility
|
||||
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
|
||||
|
||||
# Generate unique upload ID for progress tracking
|
||||
upload_id = os.urandom(16).hex()
|
||||
|
||||
# Save file with progress tracking
|
||||
filename = secure_filename(file.filename)
|
||||
# Process each file
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
|
||||
filepath = os.path.join(upload_folder, filename)
|
||||
processed_count = 0
|
||||
total_files = len(files)
|
||||
|
||||
# Save file with progress updates
|
||||
set_upload_progress(upload_id, 0, 'Uploading file...')
|
||||
file.save(filepath)
|
||||
set_upload_progress(upload_id, 50, 'File uploaded, processing...')
|
||||
for idx, file in enumerate(files):
|
||||
if file.filename == '':
|
||||
continue
|
||||
|
||||
# Update progress
|
||||
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
|
||||
set_upload_progress(session_id, progress_pct,
|
||||
f'Processing file {idx + 1} of {total_files}...', 'processing')
|
||||
|
||||
filename = secure_filename(file.filename)
|
||||
filepath = os.path.join(upload_folder, filename)
|
||||
|
||||
# Save file
|
||||
file.save(filepath)
|
||||
|
||||
# Determine content type
|
||||
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||
|
||||
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
|
||||
content_type = 'image'
|
||||
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
|
||||
content_type = 'video'
|
||||
# Process video (convert to Raspberry Pi optimized format)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
|
||||
success, message = process_video_file(filepath, session_id)
|
||||
if not success:
|
||||
log_action('error', f'Video optimization failed: {message}')
|
||||
continue # Skip this file and move to next
|
||||
elif file_ext == 'pdf':
|
||||
content_type = 'pdf'
|
||||
# Process PDF (convert to images)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Converting PDF {idx + 1}...', 'processing')
|
||||
# process_pdf_file(filepath, session_id)
|
||||
elif file_ext in ['ppt', 'pptx']:
|
||||
content_type = 'presentation'
|
||||
# Process presentation (convert to PDF then images)
|
||||
set_upload_progress(session_id, progress_pct + 5,
|
||||
f'Converting PowerPoint {idx + 1}...', 'processing')
|
||||
# This would call pptx_converter utility
|
||||
else:
|
||||
content_type = 'other'
|
||||
|
||||
# Create content record
|
||||
new_content = Content(
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
duration=duration,
|
||||
file_size=os.path.getsize(filepath)
|
||||
)
|
||||
|
||||
# Link to target (player or group)
|
||||
if target_type == 'player':
|
||||
from app.models import Player
|
||||
player = Player.query.get(target_id)
|
||||
if player:
|
||||
# Add content directly to player's playlist
|
||||
new_content.player_id = target_id
|
||||
db.session.add(new_content)
|
||||
# Increment playlist version
|
||||
player.playlist_version += 1
|
||||
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
|
||||
|
||||
elif target_type == 'group':
|
||||
group = Group.query.get(target_id)
|
||||
if group:
|
||||
# For groups, create separate content entry for EACH player in the group
|
||||
# This matches the old app behavior
|
||||
for player in group.players:
|
||||
player_content = Content(
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
duration=duration,
|
||||
file_size=os.path.getsize(filepath),
|
||||
player_id=player.id
|
||||
)
|
||||
db.session.add(player_content)
|
||||
# Increment each player's playlist version
|
||||
player.playlist_version += 1
|
||||
|
||||
log_action('info', f'Content "{filename}" added to {len(group.players)} players in group "{group.name}"')
|
||||
# Don't add the original new_content since we created per-player entries
|
||||
new_content = None
|
||||
|
||||
if new_content:
|
||||
db.session.add(new_content)
|
||||
|
||||
processed_count += 1
|
||||
|
||||
# Determine content type
|
||||
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||
|
||||
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
|
||||
content_type = 'image'
|
||||
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
|
||||
content_type = 'video'
|
||||
# Process video (convert, optimize, extract metadata)
|
||||
set_upload_progress(upload_id, 60, 'Processing video...')
|
||||
process_video_file(filepath, upload_id)
|
||||
elif file_ext == 'pdf':
|
||||
content_type = 'pdf'
|
||||
# Process PDF (convert to images)
|
||||
set_upload_progress(upload_id, 60, 'Processing PDF...')
|
||||
process_pdf_file(filepath, upload_id)
|
||||
elif file_ext in ['ppt', 'pptx']:
|
||||
content_type = 'presentation'
|
||||
# Process presentation (convert to PDF then images)
|
||||
set_upload_progress(upload_id, 60, 'Processing presentation...')
|
||||
# This would call pptx_converter utility
|
||||
else:
|
||||
content_type = 'other'
|
||||
|
||||
set_upload_progress(upload_id, 90, 'Creating database entry...')
|
||||
|
||||
# Create content record
|
||||
new_content = Content(
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
duration=duration,
|
||||
description=description or None,
|
||||
file_size=os.path.getsize(filepath)
|
||||
)
|
||||
db.session.add(new_content)
|
||||
# Commit all changes
|
||||
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
|
||||
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
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{filename}" uploaded successfully (Type: {content_type})')
|
||||
flash(f'Content "{filename}" uploaded successfully.', 'success')
|
||||
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
|
||||
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
|
||||
|
||||
return redirect(url_for('content.content_list'))
|
||||
return redirect(return_url)
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
|
||||
# Update progress to error state
|
||||
if 'session_id' in locals():
|
||||
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
|
||||
|
||||
log_action('error', f'Error uploading content: {str(e)}')
|
||||
flash('Error uploading content. Please try again.', 'danger')
|
||||
return redirect(url_for('content.upload_content'))
|
||||
@@ -143,7 +258,7 @@ def edit_content(content_id: int):
|
||||
content = Content.query.get_or_404(content_id)
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template('edit_content.html', content=content)
|
||||
return render_template('content/edit_content.html', content=content)
|
||||
|
||||
try:
|
||||
duration = request.form.get('duration', type=int)
|
||||
@@ -201,6 +316,54 @@ def delete_content(content_id: int):
|
||||
return redirect(url_for('content.content_list'))
|
||||
|
||||
|
||||
@content_bp.route('/delete-by-filename', methods=['POST'])
|
||||
@login_required
|
||||
def delete_by_filename():
|
||||
"""Delete all content entries with a specific filename."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
filename = data.get('filename')
|
||||
|
||||
if not filename:
|
||||
return jsonify({'success': False, 'message': 'No filename provided'}), 400
|
||||
|
||||
# Find all content entries with this filename
|
||||
contents = Content.query.filter_by(filename=filename).all()
|
||||
|
||||
if not contents:
|
||||
return jsonify({'success': False, 'message': 'Content not found'}), 404
|
||||
|
||||
deleted_count = len(contents)
|
||||
|
||||
# Delete file from disk (only once)
|
||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
log_action('info', f'Deleted file from disk: {filename}')
|
||||
|
||||
# Delete all database entries
|
||||
for content in contents:
|
||||
db.session.delete(content)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Clear caches
|
||||
cache.clear()
|
||||
|
||||
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)')
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Content deleted from {deleted_count} playlist(s)',
|
||||
'deleted_count': deleted_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error deleting content by filename: {str(e)}')
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@content_bp.route('/bulk/delete', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_delete_content():
|
||||
|
||||
@@ -24,7 +24,7 @@ def groups_list():
|
||||
stats = get_group_statistics(group.id)
|
||||
group_stats[group.id] = stats
|
||||
|
||||
return render_template('groups_list.html',
|
||||
return render_template('groups/groups_list.html',
|
||||
groups=groups,
|
||||
group_stats=group_stats)
|
||||
except Exception as e:
|
||||
@@ -39,7 +39,7 @@ def create_group():
|
||||
"""Create a new group."""
|
||||
if request.method == 'GET':
|
||||
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:
|
||||
name = request.form.get('name', '').strip()
|
||||
@@ -93,7 +93,7 @@ def edit_group(group_id: int):
|
||||
|
||||
if request.method == 'GET':
|
||||
available_content = Content.query.order_by(Content.filename).all()
|
||||
return render_template('edit_group.html',
|
||||
return render_template('groups/edit_group.html',
|
||||
group=group,
|
||||
available_content=available_content)
|
||||
|
||||
@@ -197,7 +197,7 @@ def manage_group(group_id: int):
|
||||
# Get available content (not in this group)
|
||||
all_content = Content.query.order_by(Content.filename).all()
|
||||
|
||||
return render_template('manage_group.html',
|
||||
return render_template('groups/manage_group.html',
|
||||
group=group,
|
||||
players=players,
|
||||
player_statuses=player_statuses,
|
||||
@@ -225,7 +225,7 @@ def group_fullscreen(group_id: int):
|
||||
status_info = get_player_status_info(player.id)
|
||||
player_statuses[player.id] = status_info
|
||||
|
||||
return render_template('group_fullscreen.html',
|
||||
return render_template('groups/group_fullscreen.html',
|
||||
group=group,
|
||||
players=players,
|
||||
player_statuses=player_statuses)
|
||||
|
||||
@@ -3,10 +3,10 @@ Main Blueprint - Dashboard and Home Routes
|
||||
"""
|
||||
from flask import Blueprint, render_template, redirect, url_for
|
||||
from flask_login import login_required, current_user
|
||||
from extensions import db, cache
|
||||
from models.player import Player
|
||||
from models.group import Group
|
||||
from utils.logger import get_recent_logs
|
||||
from app.extensions import db, cache
|
||||
from app.models.player import Player
|
||||
from app.models.group import Group
|
||||
from app.utils.logger import get_recent_logs
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@ players_bp = Blueprint('players', __name__, url_prefix='/players')
|
||||
|
||||
|
||||
@players_bp.route('/')
|
||||
@players_bp.route('/list')
|
||||
@login_required
|
||||
def players_list():
|
||||
def list():
|
||||
"""Display list of all players."""
|
||||
try:
|
||||
players = Player.query.order_by(Player.name).all()
|
||||
@@ -27,7 +28,7 @@ def players_list():
|
||||
status_info = get_player_status_info(player.id)
|
||||
player_statuses[player.id] = status_info
|
||||
|
||||
return render_template('players_list.html',
|
||||
return render_template('players/players_list.html',
|
||||
players=players,
|
||||
groups=groups,
|
||||
player_statuses=player_statuses)
|
||||
@@ -43,11 +44,15 @@ def add_player():
|
||||
"""Add a new player."""
|
||||
if request.method == 'GET':
|
||||
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:
|
||||
name = request.form.get('name', '').strip()
|
||||
hostname = request.form.get('hostname', '').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')
|
||||
|
||||
# Validation
|
||||
@@ -55,23 +60,59 @@ def add_player():
|
||||
flash('Player name must be at least 3 characters long.', 'warning')
|
||||
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
|
||||
auth_code = secrets.token_urlsafe(16)
|
||||
auth_code = secrets.token_urlsafe(32)
|
||||
|
||||
# Create player
|
||||
new_player = Player(
|
||||
name=name,
|
||||
hostname=hostname,
|
||||
location=location or None,
|
||||
auth_code=auth_code,
|
||||
orientation=orientation,
|
||||
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.commit()
|
||||
|
||||
log_action('info', f'Player "{name}" created with auth code {auth_code}')
|
||||
flash(f'Player "{name}" created successfully. Auth code: {auth_code}', 'success')
|
||||
log_action('info', f'Player "{name}" (hostname: {hostname}) created')
|
||||
|
||||
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:
|
||||
db.session.rollback()
|
||||
@@ -88,7 +129,7 @@ def edit_player(player_id: int):
|
||||
|
||||
if request.method == 'GET':
|
||||
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:
|
||||
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')
|
||||
flash(f'Player "{name}" updated successfully.', 'success')
|
||||
|
||||
return redirect(url_for('players.players_list'))
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
@@ -146,7 +187,7 @@ def delete_player(player_id: int):
|
||||
log_action('error', f'Error deleting player: {str(e)}')
|
||||
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'])
|
||||
@@ -169,7 +210,7 @@ def regenerate_auth_code(player_id: int):
|
||||
log_action('error', f'Error regenerating auth code: {str(e)}')
|
||||
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>')
|
||||
@@ -191,7 +232,7 @@ def player_page(player_id: int):
|
||||
.limit(10)\
|
||||
.all()
|
||||
|
||||
return render_template('player_page.html',
|
||||
return render_template('players/player_page.html',
|
||||
player=player,
|
||||
playlist=playlist,
|
||||
status_info=status_info,
|
||||
@@ -199,7 +240,7 @@ def player_page(player_id: int):
|
||||
except Exception as e:
|
||||
log_action('error', f'Error loading player page: {str(e)}')
|
||||
flash('Error loading player page.', 'danger')
|
||||
return redirect(url_for('players.players_list'))
|
||||
return redirect(url_for('players.list'))
|
||||
|
||||
|
||||
@players_bp.route('/<int:player_id>/fullscreen')
|
||||
@@ -217,7 +258,7 @@ def player_fullscreen(player_id: int):
|
||||
# Get player's playlist
|
||||
playlist = get_player_playlist(player_id)
|
||||
|
||||
return render_template('player_fullscreen.html',
|
||||
return render_template('players/player_fullscreen.html',
|
||||
player=player,
|
||||
playlist=playlist)
|
||||
except Exception as e:
|
||||
@@ -227,7 +268,7 @@ def player_fullscreen(player_id: int):
|
||||
|
||||
@cache.memoize(timeout=300) # Cache for 5 minutes
|
||||
def get_player_playlist(player_id: int) -> List[dict]:
|
||||
"""Get playlist for a player based on their group assignment.
|
||||
"""Get playlist for a player based on their direct content assignment.
|
||||
|
||||
Args:
|
||||
player_id: The player's database ID
|
||||
@@ -239,16 +280,10 @@ def get_player_playlist(player_id: int) -> List[dict]:
|
||||
if not player:
|
||||
return []
|
||||
|
||||
# Get content from player's group
|
||||
if player.group_id:
|
||||
group = Group.query.get(player.group_id)
|
||||
if group:
|
||||
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()
|
||||
# Get content directly assigned to this player
|
||||
contents = Content.query.filter_by(player_id=player_id)\
|
||||
.order_by(Content.position, Content.uploaded_at)\
|
||||
.all()
|
||||
|
||||
# Build playlist
|
||||
playlist = []
|
||||
@@ -366,3 +401,97 @@ def bulk_assign_group():
|
||||
db.session.rollback()
|
||||
log_action('error', f'Error bulk assigning players: {str(e)}')
|
||||
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
|
||||
|
||||
|
||||
@@ -49,10 +49,11 @@ class DevelopmentConfig(Config):
|
||||
DEBUG = True
|
||||
TESTING = False
|
||||
|
||||
# Database
|
||||
# Database - construct absolute path
|
||||
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv(
|
||||
'DATABASE_URL',
|
||||
'sqlite:///instance/dev.db'
|
||||
f'sqlite:///{os.path.join(_basedir, "instance", "dev.db")}'
|
||||
)
|
||||
|
||||
# Cache (simple in-memory for development)
|
||||
@@ -70,10 +71,11 @@ class ProductionConfig(Config):
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
|
||||
# Database
|
||||
# Database - construct absolute path
|
||||
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv(
|
||||
'DATABASE_URL',
|
||||
'sqlite:///instance/dashboard.db'
|
||||
f'sqlite:///{os.path.join(_basedir, "instance", "dashboard.db")}'
|
||||
)
|
||||
|
||||
# Redis Cache
|
||||
|
||||
@@ -31,7 +31,12 @@ class Content(db.Model):
|
||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||
nullable=False, index=True)
|
||||
|
||||
# Player relationship (for direct player assignment)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('player.id', ondelete='CASCADE'),
|
||||
nullable=True, index=True)
|
||||
|
||||
# Relationships
|
||||
player = db.relationship('Player', back_populates='contents')
|
||||
groups = db.relationship('Group', secondary=group_content,
|
||||
back_populates='contents', lazy='dynamic')
|
||||
|
||||
|
||||
@@ -11,9 +11,13 @@ class Player(db.Model):
|
||||
Attributes:
|
||||
id: Primary key
|
||||
name: Display name for the player
|
||||
hostname: Unique hostname/identifier for the player
|
||||
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
|
||||
orientation: Display orientation (Landscape/Portrait)
|
||||
status: Current player status (online, offline, error)
|
||||
last_seen: Last activity timestamp
|
||||
created_at: Player creation timestamp
|
||||
@@ -22,17 +26,25 @@ 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, index=True)
|
||||
orientation = db.Column(db.String(16), default='Landscape', nullable=False)
|
||||
status = db.Column(db.String(50), default='offline', index=True)
|
||||
last_seen = db.Column(db.DateTime, nullable=True, index=True)
|
||||
last_heartbeat = db.Column(db.DateTime, nullable=True, index=True)
|
||||
playlist_version = db.Column(db.Integer, default=1, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
group = db.relationship('Group', back_populates='players')
|
||||
feedback = db.relationship('PlayerFeedback', back_populates='player',
|
||||
cascade='all, delete-orphan', lazy='dynamic')
|
||||
contents = db.relationship('Content', back_populates='player',
|
||||
cascade='all, delete-orphan', lazy='dynamic')
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of Player."""
|
||||
@@ -54,3 +66,73 @@ class Player(db.Model):
|
||||
"""
|
||||
self.status = status
|
||||
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
|
||||
|
||||
@@ -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 %}
|
||||
25
app/templates/auth/change_password.html
Normal file
25
app/templates/auth/change_password.html
Normal 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 %}
|
||||
@@ -105,7 +105,7 @@
|
||||
<nav>
|
||||
{% if current_user.is_authenticated %}
|
||||
<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('content.content_list') }}">Content</a>
|
||||
{% if current_user.is_admin %}
|
||||
|
||||
205
app/templates/content/content_list.html
Normal file
205
app/templates/content/content_list.html
Normal 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 %}
|
||||
11
app/templates/content/edit_content.html
Normal file
11
app/templates/content/edit_content.html
Normal 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 %}
|
||||
315
app/templates/content/upload_content.html
Normal file
315
app/templates/content/upload_content.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="card">
|
||||
<h3 style="color: #3498db; margin-bottom: 0.5rem;">👥 Players</h3>
|
||||
<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 class="card">
|
||||
|
||||
12
app/templates/errors/403.html
Normal file
12
app/templates/errors/403.html
Normal 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 %}
|
||||
12
app/templates/errors/404.html
Normal file
12
app/templates/errors/404.html
Normal 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 %}
|
||||
12
app/templates/errors/500.html
Normal file
12
app/templates/errors/500.html
Normal 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 %}
|
||||
11
app/templates/groups/edit_group.html
Normal file
11
app/templates/groups/edit_group.html
Normal 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 %}
|
||||
10
app/templates/groups/group_fullscreen.html
Normal file
10
app/templates/groups/group_fullscreen.html
Normal 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 %}
|
||||
11
app/templates/groups/manage_group.html
Normal file
11
app/templates/groups/manage_group.html
Normal 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 %}
|
||||
122
app/templates/players/add_player.html
Normal file
122
app/templates/players/add_player.html
Normal 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 %}
|
||||
11
app/templates/players/edit_player.html
Normal file
11
app/templates/players/edit_player.html
Normal 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 %}
|
||||
10
app/templates/players/player_fullscreen.html
Normal file
10
app/templates/players/player_fullscreen.html
Normal 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 %}
|
||||
331
app/templates/players/player_page.html
Normal file
331
app/templates/players/player_page.html
Normal 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 %}
|
||||
96
app/templates/players/players_list.html
Normal file
96
app/templates/players/players_list.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
254
player_auth_module.py
Normal 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')")
|
||||
51
player_config_template.ini
Normal file
51
player_config_template.ini
Normal 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
50
quick_test.sh
Executable 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
52
reinit_db.sh
Executable 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
|
||||
BIN
static/uploads/big-buck-bunny-1080p-60fps-30sec.mp4
Normal file
BIN
static/uploads/big-buck-bunny-1080p-60fps-30sec.mp4
Normal file
Binary file not shown.
BIN
static/uploads/call-of-duty-black-3840x2160-23674.jpg
Normal file
BIN
static/uploads/call-of-duty-black-3840x2160-23674.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Reference in New Issue
Block a user