- Created 10 SVG icon files in app/static/icons/ (Feather Icons style) - Updated base.html with SVG icons in navigation and dark mode toggle - Updated dashboard.html with icons in stats cards and quick actions - Updated content_list_new.html (playlist management) with SVG icons - Updated upload_media.html with upload-related icons - Updated manage_player.html with player management icons - Icons use currentColor for automatic theme adaptation - Removed emoji dependency for better Raspberry Pi compatibility - Added ICON_INTEGRATION.md documentation
501 lines
18 KiB
Python
501 lines
18 KiB
Python
"""Content blueprint for media upload and management."""
|
|
from flask import (Blueprint, render_template, request, redirect, url_for,
|
|
flash, jsonify, current_app, send_from_directory)
|
|
from flask_login import login_required
|
|
from werkzeug.utils import secure_filename
|
|
import os
|
|
from typing import Optional, Dict
|
|
import json
|
|
|
|
from app.extensions import db, cache
|
|
from app.models import Content, Group
|
|
from app.utils.logger import log_action
|
|
from app.utils.uploads import (
|
|
save_uploaded_file,
|
|
process_video_file,
|
|
process_pdf_file,
|
|
get_upload_progress,
|
|
set_upload_progress
|
|
)
|
|
|
|
content_bp = Blueprint('content', __name__, url_prefix='/content')
|
|
|
|
|
|
# In-memory storage for upload progress (for simple demo; use Redis in production)
|
|
upload_progress = {}
|
|
|
|
|
|
@content_bp.route('/')
|
|
@login_required
|
|
def content_list():
|
|
"""Display list of all content."""
|
|
try:
|
|
# Get all unique content files (by filename)
|
|
from sqlalchemy import func
|
|
|
|
# Get content with player information
|
|
contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all()
|
|
|
|
# Group content by filename to show which players have each file
|
|
content_map = {}
|
|
for content in contents:
|
|
if content.filename not in content_map:
|
|
content_map[content.filename] = {
|
|
'content': content,
|
|
'players': [],
|
|
'groups': []
|
|
}
|
|
|
|
# Add player info if assigned to a player
|
|
if content.player_id:
|
|
from app.models import Player
|
|
player = Player.query.get(content.player_id)
|
|
if player:
|
|
content_map[content.filename]['players'].append({
|
|
'id': player.id,
|
|
'name': player.name,
|
|
'group': player.group.name if player.group else None
|
|
})
|
|
|
|
# Convert to list for template
|
|
content_list = []
|
|
for filename, data in content_map.items():
|
|
content_list.append({
|
|
'filename': filename,
|
|
'content_type': data['content'].content_type,
|
|
'duration': data['content'].duration,
|
|
'file_size': data['content'].file_size_mb,
|
|
'uploaded_at': data['content'].uploaded_at,
|
|
'players': data['players'],
|
|
'player_count': len(data['players'])
|
|
})
|
|
|
|
# Sort by upload date
|
|
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
|
|
|
|
return render_template('content/content_list.html',
|
|
content_list=content_list)
|
|
except Exception as e:
|
|
log_action('error', f'Error loading content list: {str(e)}')
|
|
flash('Error loading content list.', 'danger')
|
|
return redirect(url_for('main.dashboard'))
|
|
|
|
|
|
@content_bp.route('/upload', methods=['GET', 'POST'])
|
|
@login_required
|
|
def upload_content():
|
|
"""Upload new content."""
|
|
if request.method == 'GET':
|
|
# Get parameters for return URL and pre-selection
|
|
player_id = request.args.get('player_id', type=int)
|
|
return_url = request.args.get('return_url', url_for('content.content_list'))
|
|
|
|
# Get all players for selection
|
|
from app.models import Player
|
|
players = Player.query.order_by(Player.name).all()
|
|
|
|
return render_template('content/upload_content.html',
|
|
players=players,
|
|
selected_player_id=player_id,
|
|
return_url=return_url)
|
|
|
|
try:
|
|
# Get form data
|
|
player_id = request.form.get('player_id', type=int)
|
|
media_type = request.form.get('media_type', 'image')
|
|
duration = request.form.get('duration', type=int, default=10)
|
|
session_id = request.form.get('session_id', os.urandom(8).hex())
|
|
return_url = request.form.get('return_url', url_for('content.content_list'))
|
|
|
|
# Get files
|
|
files = request.files.getlist('files')
|
|
|
|
if not files or files[0].filename == '':
|
|
flash('No files provided.', 'warning')
|
|
return redirect(url_for('content.upload_content'))
|
|
|
|
if not player_id:
|
|
flash('Please select a player.', 'warning')
|
|
return redirect(url_for('content.upload_content'))
|
|
|
|
# Initialize progress tracking using shared utility
|
|
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
|
|
|
|
# Process each file
|
|
upload_folder = current_app.config['UPLOAD_FOLDER']
|
|
os.makedirs(upload_folder, exist_ok=True)
|
|
|
|
processed_count = 0
|
|
total_files = len(files)
|
|
|
|
for idx, file in enumerate(files):
|
|
if file.filename == '':
|
|
continue
|
|
|
|
# Update progress
|
|
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
|
|
set_upload_progress(session_id, progress_pct,
|
|
f'Processing file {idx + 1} of {total_files}...', 'processing')
|
|
|
|
filename = secure_filename(file.filename)
|
|
filepath = os.path.join(upload_folder, filename)
|
|
|
|
# Save file
|
|
file.save(filepath)
|
|
|
|
# Determine content type
|
|
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
|
|
|
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
|
|
content_type = 'image'
|
|
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
|
|
content_type = 'video'
|
|
# Process video (convert to Raspberry Pi optimized format)
|
|
set_upload_progress(session_id, progress_pct + 5,
|
|
f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
|
|
success, message = process_video_file(filepath, session_id)
|
|
if not success:
|
|
log_action('error', f'Video optimization failed: {message}')
|
|
continue # Skip this file and move to next
|
|
elif file_ext == 'pdf':
|
|
content_type = 'pdf'
|
|
# Process PDF (convert to images)
|
|
set_upload_progress(session_id, progress_pct + 5,
|
|
f'Converting PDF {idx + 1}...', 'processing')
|
|
# process_pdf_file(filepath, session_id)
|
|
elif file_ext in ['ppt', 'pptx']:
|
|
content_type = 'presentation'
|
|
# Process presentation (convert to PDF then images)
|
|
set_upload_progress(session_id, progress_pct + 5,
|
|
f'Converting PowerPoint {idx + 1}...', 'processing')
|
|
# This would call pptx_converter utility
|
|
else:
|
|
content_type = 'other'
|
|
|
|
# Create content record linked to player
|
|
from app.models import Player
|
|
player = Player.query.get(player_id)
|
|
if player:
|
|
new_content = Content(
|
|
filename=filename,
|
|
content_type=content_type,
|
|
duration=duration,
|
|
file_size=os.path.getsize(filepath),
|
|
player_id=player_id
|
|
)
|
|
db.session.add(new_content)
|
|
|
|
# Increment playlist version
|
|
player.playlist_version += 1
|
|
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
|
|
|
|
processed_count += 1
|
|
|
|
# Commit all changes
|
|
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
|
|
db.session.commit()
|
|
|
|
# Complete
|
|
set_upload_progress(session_id, 100,
|
|
f'Successfully uploaded {processed_count} file(s)!', 'complete')
|
|
|
|
# Clear all playlist caches
|
|
cache.clear()
|
|
|
|
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
|
|
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
|
|
|
|
return redirect(return_url)
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
|
|
# Update progress to error state
|
|
if 'session_id' in locals():
|
|
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
|
|
|
|
log_action('error', f'Error uploading content: {str(e)}')
|
|
flash('Error uploading content. Please try again.', 'danger')
|
|
return redirect(url_for('content.upload_content'))
|
|
|
|
|
|
@content_bp.route('/<int:content_id>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
def edit_content(content_id: int):
|
|
"""Edit content metadata."""
|
|
content = Content.query.get_or_404(content_id)
|
|
|
|
if request.method == 'GET':
|
|
return render_template('content/edit_content.html', content=content)
|
|
|
|
try:
|
|
duration = request.form.get('duration', type=int)
|
|
description = request.form.get('description', '').strip()
|
|
|
|
# Update content
|
|
if duration is not None:
|
|
content.duration = duration
|
|
content.description = description or None
|
|
db.session.commit()
|
|
|
|
# Clear caches
|
|
cache.clear()
|
|
|
|
log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated')
|
|
flash(f'Content "{content.filename}" updated successfully.', 'success')
|
|
|
|
return redirect(url_for('content.content_list'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error updating content: {str(e)}')
|
|
flash('Error updating content. Please try again.', 'danger')
|
|
return redirect(url_for('content.edit_content', content_id=content_id))
|
|
|
|
|
|
@content_bp.route('/<int:content_id>/delete', methods=['POST'])
|
|
@login_required
|
|
def delete_content(content_id: int):
|
|
"""Delete content and associated file."""
|
|
try:
|
|
content = Content.query.get_or_404(content_id)
|
|
filename = content.filename
|
|
|
|
# Delete file from disk
|
|
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
|
if os.path.exists(filepath):
|
|
os.remove(filepath)
|
|
|
|
# Delete from database
|
|
db.session.delete(content)
|
|
db.session.commit()
|
|
|
|
# Clear caches
|
|
cache.clear()
|
|
|
|
log_action('info', f'Content "{filename}" (ID: {content_id}) deleted')
|
|
flash(f'Content "{filename}" deleted successfully.', 'success')
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error deleting content: {str(e)}')
|
|
flash('Error deleting content. Please try again.', 'danger')
|
|
|
|
return redirect(url_for('content.content_list'))
|
|
|
|
|
|
@content_bp.route('/delete-by-filename', methods=['POST'])
|
|
@login_required
|
|
def delete_by_filename():
|
|
"""Delete all content entries with a specific filename."""
|
|
try:
|
|
data = request.get_json()
|
|
filename = data.get('filename')
|
|
|
|
if not filename:
|
|
return jsonify({'success': False, 'message': 'No filename provided'}), 400
|
|
|
|
# Find all content entries with this filename
|
|
contents = Content.query.filter_by(filename=filename).all()
|
|
|
|
if not contents:
|
|
return jsonify({'success': False, 'message': 'Content not found'}), 404
|
|
|
|
deleted_count = len(contents)
|
|
|
|
# Delete file from disk (only once)
|
|
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
|
if os.path.exists(filepath):
|
|
os.remove(filepath)
|
|
log_action('info', f'Deleted file from disk: {filename}')
|
|
|
|
# Delete all database entries
|
|
for content in contents:
|
|
db.session.delete(content)
|
|
|
|
db.session.commit()
|
|
|
|
# Clear caches
|
|
cache.clear()
|
|
|
|
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)')
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Content deleted from {deleted_count} playlist(s)',
|
|
'deleted_count': deleted_count
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error deleting content by filename: {str(e)}')
|
|
return jsonify({'success': False, 'message': str(e)}), 500
|
|
|
|
|
|
@content_bp.route('/bulk/delete', methods=['POST'])
|
|
@login_required
|
|
def bulk_delete_content():
|
|
"""Delete multiple content items at once."""
|
|
try:
|
|
content_ids = request.json.get('content_ids', [])
|
|
|
|
if not content_ids:
|
|
return jsonify({'success': False, 'error': 'No content selected'}), 400
|
|
|
|
# Delete content
|
|
deleted_count = 0
|
|
for content_id in content_ids:
|
|
content = Content.query.get(content_id)
|
|
if content:
|
|
# Delete file
|
|
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
|
if os.path.exists(filepath):
|
|
os.remove(filepath)
|
|
|
|
db.session.delete(content)
|
|
deleted_count += 1
|
|
|
|
db.session.commit()
|
|
|
|
# Clear caches
|
|
cache.clear()
|
|
|
|
log_action('info', f'Bulk deleted {deleted_count} content items')
|
|
return jsonify({'success': True, 'deleted': deleted_count})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
log_action('error', f'Error bulk deleting content: {str(e)}')
|
|
return jsonify({'success': False, 'error': str(e)}), 500
|
|
|
|
|
|
@content_bp.route('/upload-progress/<upload_id>')
|
|
@login_required
|
|
def upload_progress_status(upload_id: str):
|
|
"""Get upload progress for a specific upload."""
|
|
progress = get_upload_progress(upload_id)
|
|
return jsonify(progress)
|
|
|
|
|
|
@content_bp.route('/preview/<int:content_id>')
|
|
@login_required
|
|
def preview_content(content_id: int):
|
|
"""Preview content in browser."""
|
|
try:
|
|
content = Content.query.get_or_404(content_id)
|
|
|
|
# Serve file from uploads folder
|
|
return send_from_directory(
|
|
current_app.config['UPLOAD_FOLDER'],
|
|
content.filename,
|
|
as_attachment=False
|
|
)
|
|
except Exception as e:
|
|
log_action('error', f'Error previewing content: {str(e)}')
|
|
return "Error loading content", 500
|
|
|
|
|
|
@content_bp.route('/<int:content_id>/download')
|
|
@login_required
|
|
def download_content(content_id: int):
|
|
"""Download content file."""
|
|
try:
|
|
content = Content.query.get_or_404(content_id)
|
|
|
|
log_action('info', f'Content "{content.filename}" downloaded')
|
|
|
|
return send_from_directory(
|
|
current_app.config['UPLOAD_FOLDER'],
|
|
content.filename,
|
|
as_attachment=True
|
|
)
|
|
except Exception as e:
|
|
log_action('error', f'Error downloading content: {str(e)}')
|
|
return "Error downloading content", 500
|
|
|
|
|
|
@content_bp.route('/statistics')
|
|
@login_required
|
|
def content_statistics():
|
|
"""Get content statistics."""
|
|
try:
|
|
total_content = Content.query.count()
|
|
|
|
# Count by type
|
|
type_counts = {}
|
|
for content_type in ['image', 'video', 'pdf', 'presentation', 'other']:
|
|
count = Content.query.filter_by(content_type=content_type).count()
|
|
type_counts[content_type] = count
|
|
|
|
# Calculate total storage
|
|
upload_folder = current_app.config['UPLOAD_FOLDER']
|
|
total_size = 0
|
|
if os.path.exists(upload_folder):
|
|
for dirpath, dirnames, filenames in os.walk(upload_folder):
|
|
for filename in filenames:
|
|
filepath = os.path.join(dirpath, filename)
|
|
if os.path.exists(filepath):
|
|
total_size += os.path.getsize(filepath)
|
|
|
|
return jsonify({
|
|
'total': total_content,
|
|
'by_type': type_counts,
|
|
'total_size_mb': round(total_size / (1024 * 1024), 2)
|
|
})
|
|
|
|
except Exception as e:
|
|
log_action('error', f'Error getting content statistics: {str(e)}')
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@content_bp.route('/check-duplicates')
|
|
@login_required
|
|
def check_duplicates():
|
|
"""Check for duplicate filenames."""
|
|
try:
|
|
# Get all filenames
|
|
all_content = Content.query.all()
|
|
filename_counts = {}
|
|
|
|
for content in all_content:
|
|
filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1
|
|
|
|
# Find duplicates
|
|
duplicates = {fname: count for fname, count in filename_counts.items() if count > 1}
|
|
|
|
return jsonify({
|
|
'has_duplicates': len(duplicates) > 0,
|
|
'duplicates': duplicates
|
|
})
|
|
|
|
except Exception as e:
|
|
log_action('error', f'Error checking duplicates: {str(e)}')
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
@content_bp.route('/<int:content_id>/groups')
|
|
@login_required
|
|
def content_groups_info(content_id: int):
|
|
"""Get groups that contain this content."""
|
|
try:
|
|
content = Content.query.get_or_404(content_id)
|
|
|
|
groups_data = []
|
|
for group in content.groups:
|
|
groups_data.append({
|
|
'id': group.id,
|
|
'name': group.name,
|
|
'description': group.description,
|
|
'player_count': group.players.count()
|
|
})
|
|
|
|
return jsonify({
|
|
'content_id': content_id,
|
|
'filename': content.filename,
|
|
'groups': groups_data
|
|
})
|
|
|
|
except Exception as e:
|
|
log_action('error', f'Error getting content groups: {str(e)}')
|
|
return jsonify({'error': str(e)}), 500
|