Compare commits
21 Commits
2db0033bc0
...
digiserver
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
328edebe3c | ||
|
|
ff14e8defb | ||
|
|
8d52c0338f | ||
|
|
3921a09c4e | ||
|
|
8e43f2bd42 | ||
| d395240dce | |||
| 1fce23d3fd | |||
| 610227457c | |||
|
|
f88e332186 | ||
| 38929da929 | |||
|
|
b1dbacc679 | ||
|
|
69562fbf22 | ||
|
|
561b364022 | ||
|
|
b73e10cde7 | ||
|
|
f4df930d82 | ||
|
|
a2281e90e7 | ||
|
|
78c83579ee | ||
|
|
efb63f2b3f | ||
|
|
4d411b645d | ||
|
|
6d44542765 | ||
|
|
c16383ed75 |
@@ -13,20 +13,33 @@ ENV/
|
|||||||
.git/
|
.git/
|
||||||
.gitignore
|
.gitignore
|
||||||
*.md
|
*.md
|
||||||
*.sh
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Exclude shell scripts except Docker-related ones
|
||||||
|
*.sh
|
||||||
|
!docker-entrypoint.sh
|
||||||
|
!install_libreoffice.sh
|
||||||
|
!install_emoji_fonts.sh
|
||||||
|
|
||||||
# Database (will be created in volume)
|
# Database (will be created in volume)
|
||||||
instance/*.db
|
instance/
|
||||||
|
!instance/.gitkeep
|
||||||
|
|
||||||
# Uploads (will be in volume)
|
# Uploads (will be in volume)
|
||||||
app/static/uploads/*
|
app/static/uploads/*
|
||||||
|
!app/static/uploads/.gitkeep
|
||||||
static/uploads/*
|
static/uploads/*
|
||||||
|
!static/uploads/.gitkeep
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Development data
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
flask_session/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ DATABASE_URL=sqlite:///instance/dev.db
|
|||||||
REDIS_HOST=redis
|
REDIS_HOST=redis
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
# Admin User
|
# Admin User Credentials (used during initial Docker deployment)
|
||||||
ADMIN_USER=admin
|
# These credentials are set when the database is first created
|
||||||
ADMIN_PASSWORD=Initial01!
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=change-this-secure-password
|
||||||
|
|
||||||
# Optional: Sentry for error tracking
|
# Optional: Sentry for error tracking
|
||||||
# SENTRY_DSN=your-sentry-dsn-here
|
# SENTRY_DSN=your-sentry-dsn-here
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -5,11 +5,14 @@ FROM python:3.13-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
|
# Note: LibreOffice is excluded from the base image to reduce size (~500MB)
|
||||||
|
# It can be installed on-demand via the Admin Panel → System Dependencies
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
poppler-utils \
|
poppler-utils \
|
||||||
libreoffice \
|
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
libmagic1 \
|
libmagic1 \
|
||||||
|
sudo \
|
||||||
|
fonts-noto-color-emoji \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements first for better caching
|
# Copy requirements first for better caching
|
||||||
@@ -36,9 +39,14 @@ ENV FLASK_ENV=production
|
|||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
# Create a non-root user
|
# Create a non-root user and grant sudo access for dependency installation
|
||||||
RUN useradd -m -u 1000 appuser && \
|
RUN useradd -m -u 1000 appuser && \
|
||||||
chown -R appuser:appuser /app /docker-entrypoint.sh
|
chown -R appuser:appuser /app /docker-entrypoint.sh && \
|
||||||
|
echo "Defaults:appuser !requiretty, !use_pty" >> /etc/sudoers && \
|
||||||
|
echo "appuser ALL=(ALL) NOPASSWD: /usr/bin/apt-get" >> /etc/sudoers && \
|
||||||
|
echo "appuser ALL=(ALL) NOPASSWD: /app/install_libreoffice.sh" >> /etc/sudoers && \
|
||||||
|
echo "appuser ALL=(ALL) NOPASSWD: /app/install_emoji_fonts.sh" >> /etc/sudoers && \
|
||||||
|
chmod +x /app/install_libreoffice.sh /app/install_emoji_fonts.sh
|
||||||
|
|
||||||
USER appuser
|
USER appuser
|
||||||
|
|
||||||
|
|||||||
33
add_muted_column.py
Normal file
33
add_muted_column.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Add muted column to playlist_content table."""
|
||||||
|
from app.app import create_app
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
def add_muted_column():
|
||||||
|
"""Add muted column to playlist_content association table."""
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
# Check if column already exists
|
||||||
|
result = db.session.execute(db.text("PRAGMA table_info(playlist_content)")).fetchall()
|
||||||
|
columns = [row[1] for row in result]
|
||||||
|
|
||||||
|
if 'muted' in columns:
|
||||||
|
print("ℹ️ Column 'muted' already exists in playlist_content table")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add muted column with default value True (muted by default)
|
||||||
|
db.session.execute(db.text("""
|
||||||
|
ALTER TABLE playlist_content
|
||||||
|
ADD COLUMN muted BOOLEAN DEFAULT TRUE
|
||||||
|
"""))
|
||||||
|
db.session.commit()
|
||||||
|
print("✅ Successfully added 'muted' column to playlist_content table")
|
||||||
|
print(" Default: TRUE (videos will be muted by default)")
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"❌ Error adding column: {e}")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
add_muted_column()
|
||||||
29
app/app.py
29
app/app.py
@@ -52,6 +52,7 @@ def create_app(config_name=None):
|
|||||||
register_error_handlers(app)
|
register_error_handlers(app)
|
||||||
register_commands(app)
|
register_commands(app)
|
||||||
register_context_processors(app)
|
register_context_processors(app)
|
||||||
|
register_template_filters(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@@ -181,6 +182,34 @@ def register_context_processors(app):
|
|||||||
return {'theme': theme}
|
return {'theme': theme}
|
||||||
|
|
||||||
|
|
||||||
|
def register_template_filters(app):
|
||||||
|
"""Register custom Jinja2 template filters"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
@app.template_filter('localtime')
|
||||||
|
def localtime_filter(dt, format='%Y-%m-%d %H:%M'):
|
||||||
|
"""Convert UTC datetime to local time and format it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: datetime object in UTC
|
||||||
|
format: strftime format string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted datetime string in local timezone
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
# If datetime is naive (no timezone), assume it's UTC
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Convert to local time
|
||||||
|
local_dt = dt.astimezone()
|
||||||
|
|
||||||
|
return local_dt.strftime(format)
|
||||||
|
|
||||||
|
|
||||||
# For backwards compatibility and direct running
|
# For backwards compatibility and direct running
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ def delete_leftover_images():
|
|||||||
try:
|
try:
|
||||||
# Find all leftover image content
|
# Find all leftover image content
|
||||||
leftover_images = db.session.query(Content).filter(
|
leftover_images = db.session.query(Content).filter(
|
||||||
Content.media_type == 'image',
|
Content.content_type == 'image',
|
||||||
~Content.id.in_(
|
~Content.id.in_(
|
||||||
db.session.query(playlist_content.c.content_id)
|
db.session.query(playlist_content.c.content_id)
|
||||||
)
|
)
|
||||||
@@ -421,16 +421,26 @@ def delete_leftover_images():
|
|||||||
for content in leftover_images:
|
for content in leftover_images:
|
||||||
try:
|
try:
|
||||||
# Delete physical file
|
# Delete physical file
|
||||||
if content.file_path:
|
if content.filename:
|
||||||
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
|
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
|
|
||||||
|
# Delete edited media archive folder if it exists
|
||||||
|
import shutil
|
||||||
|
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
|
||||||
|
if os.path.exists(edited_media_dir):
|
||||||
|
shutil.rmtree(edited_media_dir)
|
||||||
|
|
||||||
|
# Delete associated player edit records first
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
PlayerEdit.query.filter_by(content_id=content.id).delete()
|
||||||
|
|
||||||
# Delete database record
|
# Delete database record
|
||||||
db.session.delete(content)
|
db.session.delete(content)
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"Error deleting {content.file_path}: {str(e)}")
|
errors.append(f"Error deleting {content.filename}: {str(e)}")
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -455,7 +465,7 @@ def delete_leftover_videos():
|
|||||||
try:
|
try:
|
||||||
# Find all leftover video content
|
# Find all leftover video content
|
||||||
leftover_videos = db.session.query(Content).filter(
|
leftover_videos = db.session.query(Content).filter(
|
||||||
Content.media_type == 'video',
|
Content.content_type == 'video',
|
||||||
~Content.id.in_(
|
~Content.id.in_(
|
||||||
db.session.query(playlist_content.c.content_id)
|
db.session.query(playlist_content.c.content_id)
|
||||||
)
|
)
|
||||||
@@ -467,16 +477,26 @@ def delete_leftover_videos():
|
|||||||
for content in leftover_videos:
|
for content in leftover_videos:
|
||||||
try:
|
try:
|
||||||
# Delete physical file
|
# Delete physical file
|
||||||
if content.file_path:
|
if content.filename:
|
||||||
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
|
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
|
|
||||||
|
# Delete edited media archive folder if it exists
|
||||||
|
import shutil
|
||||||
|
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
|
||||||
|
if os.path.exists(edited_media_dir):
|
||||||
|
shutil.rmtree(edited_media_dir)
|
||||||
|
|
||||||
|
# Delete associated player edit records first
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
PlayerEdit.query.filter_by(content_id=content.id).delete()
|
||||||
|
|
||||||
# Delete database record
|
# Delete database record
|
||||||
db.session.delete(content)
|
db.session.delete(content)
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"Error deleting {content.file_path}: {str(e)}")
|
errors.append(f"Error deleting {content.filename}: {str(e)}")
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@@ -500,19 +520,255 @@ def delete_single_leftover(content_id):
|
|||||||
content = Content.query.get_or_404(content_id)
|
content = Content.query.get_or_404(content_id)
|
||||||
|
|
||||||
# Delete physical file
|
# Delete physical file
|
||||||
if content.file_path:
|
if content.filename:
|
||||||
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.file_path)
|
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
||||||
if os.path.exists(file_path):
|
if os.path.exists(file_path):
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
|
|
||||||
|
# Delete edited media archive folder if it exists
|
||||||
|
import shutil
|
||||||
|
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(content.id))
|
||||||
|
if os.path.exists(edited_media_dir):
|
||||||
|
shutil.rmtree(edited_media_dir)
|
||||||
|
|
||||||
|
# Delete associated player edit records first
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
PlayerEdit.query.filter_by(content_id=content.id).delete()
|
||||||
|
|
||||||
# Delete database record
|
# Delete database record
|
||||||
db.session.delete(content)
|
db.session.delete(content)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash(f'Successfully deleted {content.file_path}', 'success')
|
flash(f'Successfully deleted {content.filename}', 'success')
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
flash(f'Error deleting file: {str(e)}', 'danger')
|
flash(f'Error deleting file: {str(e)}', 'danger')
|
||||||
|
|
||||||
return redirect(url_for('admin.leftover_media'))
|
return redirect(url_for('admin.leftover_media'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/dependencies')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def dependencies():
|
||||||
|
"""Show system dependencies status."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Check LibreOffice
|
||||||
|
libreoffice_installed = False
|
||||||
|
libreoffice_version = "Not installed"
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['libreoffice', '--version'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
libreoffice_installed = True
|
||||||
|
libreoffice_version = result.stdout.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check Poppler (for PDF)
|
||||||
|
poppler_installed = False
|
||||||
|
poppler_version = "Not installed"
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['pdftoppm', '-v'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5)
|
||||||
|
if result.returncode == 0 or 'pdftoppm' in result.stderr:
|
||||||
|
poppler_installed = True
|
||||||
|
poppler_version = "Installed"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check FFmpeg (for video)
|
||||||
|
ffmpeg_installed = False
|
||||||
|
ffmpeg_version = "Not installed"
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['ffmpeg', '-version'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5)
|
||||||
|
if result.returncode == 0:
|
||||||
|
ffmpeg_installed = True
|
||||||
|
ffmpeg_version = result.stdout.split('\n')[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check Emoji Fonts
|
||||||
|
emoji_installed = False
|
||||||
|
emoji_version = 'Not installed'
|
||||||
|
try:
|
||||||
|
result = subprocess.run(['dpkg', '-l', 'fonts-noto-color-emoji'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5)
|
||||||
|
if result.returncode == 0 and 'ii' in result.stdout:
|
||||||
|
emoji_installed = True
|
||||||
|
# Get version from dpkg output
|
||||||
|
lines = result.stdout.split('\n')
|
||||||
|
for line in lines:
|
||||||
|
if 'fonts-noto-color-emoji' in line and line.startswith('ii'):
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 3:
|
||||||
|
emoji_version = f'Noto Color Emoji {parts[2]}'
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return render_template('admin/dependencies.html',
|
||||||
|
libreoffice_installed=libreoffice_installed,
|
||||||
|
libreoffice_version=libreoffice_version,
|
||||||
|
poppler_installed=poppler_installed,
|
||||||
|
poppler_version=poppler_version,
|
||||||
|
ffmpeg_installed=ffmpeg_installed,
|
||||||
|
ffmpeg_version=ffmpeg_version,
|
||||||
|
emoji_installed=emoji_installed,
|
||||||
|
emoji_version=emoji_version)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/install-libreoffice', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def install_libreoffice():
|
||||||
|
"""Install LibreOffice for PPTX conversion."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run installation script
|
||||||
|
script_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||||
|
'install_libreoffice.sh')
|
||||||
|
|
||||||
|
if not os.path.exists(script_path):
|
||||||
|
flash('Installation script not found', 'danger')
|
||||||
|
return redirect(url_for('admin.dependencies'))
|
||||||
|
|
||||||
|
result = subprocess.run(['sudo', '-n', script_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
log_action('info', 'LibreOffice installed successfully')
|
||||||
|
flash('LibreOffice installed successfully! You can now convert PPTX files.', 'success')
|
||||||
|
else:
|
||||||
|
log_action('error', f'LibreOffice installation failed: {result.stderr}')
|
||||||
|
flash(f'Installation failed: {result.stderr}', 'danger')
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
flash('Installation timeout. Please try again.', 'warning')
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Error installing LibreOffice: {str(e)}')
|
||||||
|
flash(f'Error: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.dependencies'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/install-emoji-fonts', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def install_emoji_fonts():
|
||||||
|
"""Install Emoji Fonts for better UI display."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run installation script
|
||||||
|
script_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||||
|
'install_emoji_fonts.sh')
|
||||||
|
|
||||||
|
if not os.path.exists(script_path):
|
||||||
|
flash('Installation script not found', 'danger')
|
||||||
|
return redirect(url_for('admin.dependencies'))
|
||||||
|
|
||||||
|
result = subprocess.run(['sudo', '-n', script_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=180)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
log_action('info', 'Emoji fonts installed successfully')
|
||||||
|
flash('Emoji fonts installed successfully! Please restart your browser to see changes.', 'success')
|
||||||
|
else:
|
||||||
|
log_action('error', f'Emoji fonts installation failed: {result.stderr}')
|
||||||
|
flash(f'Installation failed: {result.stderr}', 'danger')
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
flash('Installation timeout. Please try again.', 'warning')
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Error installing emoji fonts: {str(e)}')
|
||||||
|
flash(f'Error: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.dependencies'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/customize-logos')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def customize_logos():
|
||||||
|
"""Logo customization page."""
|
||||||
|
import time
|
||||||
|
return render_template('admin/customize_logos.html', version=int(time.time()))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/upload-header-logo', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def upload_header_logo():
|
||||||
|
"""Upload header logo."""
|
||||||
|
try:
|
||||||
|
if 'header_logo' not in request.files:
|
||||||
|
flash('No file selected', 'warning')
|
||||||
|
return redirect(url_for('admin.customize_logos'))
|
||||||
|
|
||||||
|
file = request.files['header_logo']
|
||||||
|
if file.filename == '':
|
||||||
|
flash('No file selected', 'warning')
|
||||||
|
return redirect(url_for('admin.customize_logos'))
|
||||||
|
|
||||||
|
if file:
|
||||||
|
# Save as header_logo.png
|
||||||
|
filename = 'header_logo.png'
|
||||||
|
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||||
|
file.save(filepath)
|
||||||
|
|
||||||
|
log_action('info', f'Header logo uploaded: {filename}')
|
||||||
|
flash('Header logo uploaded successfully!', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Error uploading header logo: {str(e)}')
|
||||||
|
flash(f'Error uploading logo: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.customize_logos'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/upload-login-logo', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def upload_login_logo():
|
||||||
|
"""Upload login page logo."""
|
||||||
|
try:
|
||||||
|
if 'login_logo' not in request.files:
|
||||||
|
flash('No file selected', 'warning')
|
||||||
|
return redirect(url_for('admin.customize_logos'))
|
||||||
|
|
||||||
|
file = request.files['login_logo']
|
||||||
|
if file.filename == '':
|
||||||
|
flash('No file selected', 'warning')
|
||||||
|
return redirect(url_for('admin.customize_logos'))
|
||||||
|
|
||||||
|
if file:
|
||||||
|
# Save as login_logo.png
|
||||||
|
filename = 'login_logo.png'
|
||||||
|
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
||||||
|
file.save(filepath)
|
||||||
|
|
||||||
|
log_action('info', f'Login logo uploaded: {filename}')
|
||||||
|
flash('Login logo uploaded successfully!', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Error uploading login logo: {str(e)}')
|
||||||
|
flash(f'Error uploading logo: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.customize_logos'))
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ def health_check():
|
|||||||
|
|
||||||
|
|
||||||
@api_bp.route('/auth/player', methods=['POST'])
|
@api_bp.route('/auth/player', methods=['POST'])
|
||||||
@rate_limit(max_requests=10, window=60)
|
@rate_limit(max_requests=120, window=60)
|
||||||
def authenticate_player():
|
def authenticate_player():
|
||||||
"""Authenticate a player and return auth code and configuration.
|
"""Authenticate a player and return auth code and configuration.
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ def authenticate_player():
|
|||||||
|
|
||||||
|
|
||||||
@api_bp.route('/auth/verify', methods=['POST'])
|
@api_bp.route('/auth/verify', methods=['POST'])
|
||||||
@rate_limit(max_requests=30, window=60)
|
@rate_limit(max_requests=300, window=60)
|
||||||
def verify_auth_code():
|
def verify_auth_code():
|
||||||
"""Verify an auth code and return player information.
|
"""Verify an auth code and return player information.
|
||||||
|
|
||||||
@@ -240,8 +240,8 @@ def get_playlist_by_quickconnect():
|
|||||||
'playlist_version': 0
|
'playlist_version': 0
|
||||||
}), 403
|
}), 403
|
||||||
|
|
||||||
# Check if quickconnect matches
|
# Check if quickconnect matches (using bcrypt verification)
|
||||||
if player.quickconnect_code != quickconnect_code:
|
if not player.check_quickconnect_code(quickconnect_code):
|
||||||
log_action('warning', f'Invalid quickconnect code for player: {hostname}')
|
log_action('warning', f'Invalid quickconnect code for player: {hostname}')
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'error': 'Invalid quickconnect code',
|
'error': 'Invalid quickconnect code',
|
||||||
@@ -293,7 +293,7 @@ def get_playlist_by_quickconnect():
|
|||||||
|
|
||||||
|
|
||||||
@api_bp.route('/playlists/<int:player_id>', methods=['GET'])
|
@api_bp.route('/playlists/<int:player_id>', methods=['GET'])
|
||||||
@rate_limit(max_requests=30, window=60)
|
@rate_limit(max_requests=300, window=60)
|
||||||
@verify_player_auth
|
@verify_player_auth
|
||||||
def get_player_playlist(player_id: int):
|
def get_player_playlist(player_id: int):
|
||||||
"""Get playlist for a specific player.
|
"""Get playlist for a specific player.
|
||||||
@@ -401,14 +401,15 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
|
|||||||
'duration': content._playlist_duration or content.duration or 10,
|
'duration': content._playlist_duration or content.duration or 10,
|
||||||
'position': content._playlist_position or idx,
|
'position': content._playlist_position or idx,
|
||||||
'url': content_url, # Full URL for downloads
|
'url': content_url, # Full URL for downloads
|
||||||
'description': content.description
|
'description': content.description,
|
||||||
|
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
|
||||||
})
|
})
|
||||||
|
|
||||||
return playlist_data
|
return playlist_data
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/player-feedback', methods=['POST'])
|
@api_bp.route('/player-feedback', methods=['POST'])
|
||||||
@rate_limit(max_requests=100, window=60)
|
@rate_limit(max_requests=600, window=60)
|
||||||
def receive_player_feedback():
|
def receive_player_feedback():
|
||||||
"""Receive feedback/status updates from players (Kivy player compatible).
|
"""Receive feedback/status updates from players (Kivy player compatible).
|
||||||
|
|
||||||
@@ -427,24 +428,50 @@ def receive_player_feedback():
|
|||||||
data = request.json
|
data = request.json
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
|
log_action('warning', 'Player feedback received with no data')
|
||||||
return jsonify({'error': 'No data provided'}), 400
|
return jsonify({'error': 'No data provided'}), 400
|
||||||
|
|
||||||
player_name = data.get('player_name')
|
player_name = data.get('player_name')
|
||||||
|
hostname = data.get('hostname') # Also accept hostname
|
||||||
quickconnect_code = data.get('quickconnect_code')
|
quickconnect_code = data.get('quickconnect_code')
|
||||||
|
|
||||||
if not player_name or not quickconnect_code:
|
# Find player by hostname first (more reliable), then by name
|
||||||
return jsonify({'error': 'player_name and quickconnect_code required'}), 400
|
player = None
|
||||||
|
if hostname:
|
||||||
|
player = Player.query.filter_by(hostname=hostname).first()
|
||||||
|
if not player and player_name:
|
||||||
|
player = Player.query.filter_by(name=player_name).first()
|
||||||
|
|
||||||
# Find player by name and validate quickconnect
|
# If player not found and no credentials provided, try to infer from IP and recent auth
|
||||||
player = Player.query.filter_by(name=player_name).first()
|
if not player and (not quickconnect_code or (not player_name and not hostname)):
|
||||||
|
# Try to find player by recent authentication from same IP
|
||||||
|
client_ip = request.remote_addr
|
||||||
|
# Look for players with matching IP in recent activity (last 5 minutes)
|
||||||
|
recent_time = datetime.utcnow() - timedelta(minutes=5)
|
||||||
|
possible_player = Player.query.filter(
|
||||||
|
Player.last_seen >= recent_time
|
||||||
|
).order_by(Player.last_seen.desc()).first()
|
||||||
|
|
||||||
|
if possible_player:
|
||||||
|
player = possible_player
|
||||||
|
log_action('info', f'Inferred player feedback from {player.name} ({player.hostname}) based on recent activity')
|
||||||
|
|
||||||
|
# Still require quickconnect validation if provided
|
||||||
|
if not player:
|
||||||
|
if not player_name and not hostname:
|
||||||
|
log_action('warning', f'Player feedback missing required fields. Data: {data}')
|
||||||
|
return jsonify({'error': 'player_name/hostname and quickconnect_code required'}), 400
|
||||||
|
else:
|
||||||
|
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
|
||||||
|
return jsonify({'error': 'Player not found'}), 404
|
||||||
|
|
||||||
if not player:
|
if not player:
|
||||||
log_action('warning', f'Player feedback from unknown player: {player_name}')
|
log_action('warning', f'Player feedback from unknown player: {player_name or hostname}')
|
||||||
return jsonify({'error': 'Player not found'}), 404
|
return jsonify({'error': 'Player not found'}), 404
|
||||||
|
|
||||||
# Validate quickconnect code
|
# Validate quickconnect code if provided (using bcrypt verification)
|
||||||
if player.quickconnect_code != quickconnect_code:
|
if quickconnect_code and not player.check_quickconnect_code(quickconnect_code):
|
||||||
log_action('warning', f'Invalid quickconnect in feedback from: {player_name}')
|
log_action('warning', f'Invalid quickconnect in feedback from: {player.name} ({player.hostname})')
|
||||||
return jsonify({'error': 'Invalid quickconnect code'}), 403
|
return jsonify({'error': 'Invalid quickconnect code'}), 403
|
||||||
|
|
||||||
# Create feedback record
|
# Create feedback record
|
||||||
@@ -466,7 +493,7 @@ def receive_player_feedback():
|
|||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
log_action('info', f'Feedback received from {player_name}: {status} - {message}')
|
log_action('info', f'Feedback received from {player.name} ({player.hostname}): {status} - {message}')
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
@@ -673,6 +700,143 @@ def get_logs():
|
|||||||
return jsonify({'error': 'Internal server error'}), 500
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/player-edit-media', methods=['POST'])
|
||||||
|
@rate_limit(max_requests=60, window=60)
|
||||||
|
@verify_player_auth
|
||||||
|
def receive_edited_media():
|
||||||
|
"""Receive edited media from player.
|
||||||
|
|
||||||
|
Expected multipart/form-data:
|
||||||
|
- image_file: The edited image file
|
||||||
|
- metadata: JSON string with metadata
|
||||||
|
|
||||||
|
Metadata JSON structure:
|
||||||
|
{
|
||||||
|
"time_of_modification": "ISO timestamp",
|
||||||
|
"original_name": "original_file.jpg",
|
||||||
|
"new_name": "original_file_v1.jpg",
|
||||||
|
"version": 1,
|
||||||
|
"user": "player_user"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
player = request.player
|
||||||
|
|
||||||
|
# Check if file is present
|
||||||
|
if 'image_file' not in request.files:
|
||||||
|
return jsonify({'error': 'No image file provided'}), 400
|
||||||
|
|
||||||
|
file = request.files['image_file']
|
||||||
|
if file.filename == '':
|
||||||
|
return jsonify({'error': 'No file selected'}), 400
|
||||||
|
|
||||||
|
# Get metadata
|
||||||
|
import json
|
||||||
|
metadata_str = request.form.get('metadata')
|
||||||
|
if not metadata_str:
|
||||||
|
return jsonify({'error': 'No metadata provided'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata = json.loads(metadata_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return jsonify({'error': 'Invalid metadata JSON'}), 400
|
||||||
|
|
||||||
|
# Validate required metadata fields
|
||||||
|
required_fields = ['time_of_modification', 'original_name', 'new_name', 'version']
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in metadata:
|
||||||
|
return jsonify({'error': f'Missing required field: {field}'}), 400
|
||||||
|
|
||||||
|
# Import required modules
|
||||||
|
import os
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
|
||||||
|
# Find the original content by filename
|
||||||
|
original_name = metadata['original_name']
|
||||||
|
content = Content.query.filter_by(filename=original_name).first()
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
log_action('warning', f'Player {player.name} tried to edit non-existent content: {original_name}')
|
||||||
|
return jsonify({'error': f'Original content not found: {original_name}'}), 404
|
||||||
|
|
||||||
|
# Create versioned folder structure: edited_media/<content_id>/
|
||||||
|
base_upload_dir = os.path.join(current_app.root_path, 'static', 'uploads')
|
||||||
|
edited_media_dir = os.path.join(base_upload_dir, 'edited_media', str(content.id))
|
||||||
|
os.makedirs(edited_media_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Save the edited file with version suffix
|
||||||
|
version = metadata['version']
|
||||||
|
new_filename = metadata['new_name']
|
||||||
|
edited_file_path = os.path.join(edited_media_dir, new_filename)
|
||||||
|
file.save(edited_file_path)
|
||||||
|
|
||||||
|
# Save metadata JSON file
|
||||||
|
metadata_filename = f"{os.path.splitext(new_filename)[0]}_metadata.json"
|
||||||
|
metadata_path = os.path.join(edited_media_dir, metadata_filename)
|
||||||
|
with open(metadata_path, 'w') as f:
|
||||||
|
json.dump(metadata, f, indent=2)
|
||||||
|
|
||||||
|
# Copy the versioned image to the main uploads folder
|
||||||
|
import shutil
|
||||||
|
versioned_upload_path = os.path.join(base_upload_dir, new_filename)
|
||||||
|
shutil.copy2(edited_file_path, versioned_upload_path)
|
||||||
|
|
||||||
|
# Update the content record to reference the new versioned filename
|
||||||
|
old_filename = content.filename
|
||||||
|
content.filename = new_filename
|
||||||
|
|
||||||
|
# Create edit record
|
||||||
|
time_of_mod = None
|
||||||
|
if metadata.get('time_of_modification'):
|
||||||
|
try:
|
||||||
|
time_of_mod = datetime.fromisoformat(metadata['time_of_modification'].replace('Z', '+00:00'))
|
||||||
|
except:
|
||||||
|
time_of_mod = datetime.utcnow()
|
||||||
|
|
||||||
|
edit_record = PlayerEdit(
|
||||||
|
player_id=player.id,
|
||||||
|
content_id=content.id,
|
||||||
|
original_name=original_name,
|
||||||
|
new_name=new_filename,
|
||||||
|
version=version,
|
||||||
|
user=metadata.get('user'),
|
||||||
|
time_of_modification=time_of_mod,
|
||||||
|
metadata_path=metadata_path,
|
||||||
|
edited_file_path=edited_file_path
|
||||||
|
)
|
||||||
|
db.session.add(edit_record)
|
||||||
|
|
||||||
|
# Update playlist version to force player refresh
|
||||||
|
if player.playlist_id:
|
||||||
|
from app.models.playlist import Playlist
|
||||||
|
playlist = db.session.get(Playlist, player.playlist_id)
|
||||||
|
if playlist:
|
||||||
|
playlist.version += 1
|
||||||
|
|
||||||
|
# Clear playlist cache
|
||||||
|
cache.delete_memoized(get_cached_playlist, player.id)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action('info', f'Player {player.name} uploaded edited media: {old_filename} -> {new_filename} (v{version})')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Edited media received and processed',
|
||||||
|
'edit_id': edit_record.id,
|
||||||
|
'version': version,
|
||||||
|
'old_filename': old_filename,
|
||||||
|
'new_filename': new_filename,
|
||||||
|
'new_playlist_version': playlist.version if player.playlist_id and playlist else None
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
log_action('error', f'Error receiving edited media: {str(e)}')
|
||||||
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
@api_bp.errorhandler(404)
|
@api_bp.errorhandler(404)
|
||||||
def api_not_found(error):
|
def api_not_found(error):
|
||||||
"""Handle 404 errors in API."""
|
"""Handle 404 errors in API."""
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ from flask import (Blueprint, render_template, request, redirect, url_for,
|
|||||||
flash, jsonify, current_app)
|
flash, jsonify, current_app)
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
from typing import Optional
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
from app.extensions import db, cache
|
from app.extensions import db, cache
|
||||||
from app.models import Content, Playlist, Player
|
from app.models import Content, Playlist, Player
|
||||||
@@ -11,6 +13,9 @@ from app.models.playlist import playlist_content
|
|||||||
from app.utils.logger import log_action
|
from app.utils.logger import log_action
|
||||||
from app.utils.uploads import process_video_file, set_upload_progress
|
from app.utils.uploads import process_video_file, set_upload_progress
|
||||||
|
|
||||||
|
# Store for background processing status
|
||||||
|
_background_tasks = {}
|
||||||
|
|
||||||
content_bp = Blueprint('content', __name__, url_prefix='/content')
|
content_bp = Blueprint('content', __name__, url_prefix='/content')
|
||||||
|
|
||||||
|
|
||||||
@@ -19,15 +24,109 @@ content_bp = Blueprint('content', __name__, url_prefix='/content')
|
|||||||
def content_list():
|
def content_list():
|
||||||
"""Main playlist management page."""
|
"""Main playlist management page."""
|
||||||
playlists = Playlist.query.order_by(Playlist.created_at.desc()).all()
|
playlists = Playlist.query.order_by(Playlist.created_at.desc()).all()
|
||||||
media_files = Content.query.order_by(Content.uploaded_at.desc()).all()
|
media_files = Content.query.order_by(Content.uploaded_at.desc()).limit(3).all() # Only last 3
|
||||||
|
total_media_count = Content.query.count() # Total count for display
|
||||||
players = Player.query.order_by(Player.name).all()
|
players = Player.query.order_by(Player.name).all()
|
||||||
|
|
||||||
return render_template('content/content_list_new.html',
|
return render_template('content/content_list_new.html',
|
||||||
playlists=playlists,
|
playlists=playlists,
|
||||||
media_files=media_files,
|
media_files=media_files,
|
||||||
|
total_media_count=total_media_count,
|
||||||
players=players)
|
players=players)
|
||||||
|
|
||||||
|
|
||||||
|
@content_bp.route('/media-library')
|
||||||
|
@login_required
|
||||||
|
def media_library():
|
||||||
|
"""View all media files in the library."""
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
|
||||||
|
media_files = Content.query.order_by(Content.uploaded_at.desc()).all()
|
||||||
|
|
||||||
|
# Add edit count to each media item
|
||||||
|
for media in media_files:
|
||||||
|
media.edit_count = PlayerEdit.query.filter_by(content_id=media.id).count()
|
||||||
|
|
||||||
|
# Group by content type
|
||||||
|
images = [m for m in media_files if m.content_type == 'image']
|
||||||
|
videos = [m for m in media_files if m.content_type == 'video']
|
||||||
|
pdfs = [m for m in media_files if m.content_type == 'pdf']
|
||||||
|
presentations = [m for m in media_files if m.content_type == 'pptx']
|
||||||
|
others = [m for m in media_files if m.content_type not in ['image', 'video', 'pdf', 'pptx']]
|
||||||
|
|
||||||
|
return render_template('content/media_library.html',
|
||||||
|
media_files=media_files,
|
||||||
|
images=images,
|
||||||
|
videos=videos,
|
||||||
|
pdfs=pdfs,
|
||||||
|
presentations=presentations,
|
||||||
|
others=others)
|
||||||
|
|
||||||
|
|
||||||
|
@content_bp.route('/media/<int:media_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def delete_media(media_id: int):
|
||||||
|
"""Delete a media file and remove it from all playlists."""
|
||||||
|
try:
|
||||||
|
media = Content.query.get_or_404(media_id)
|
||||||
|
filename = media.filename
|
||||||
|
|
||||||
|
# Get all playlists containing this media
|
||||||
|
affected_playlists = list(media.playlists.all())
|
||||||
|
|
||||||
|
# Delete physical file
|
||||||
|
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], media.filename)
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
log_action('info', f'Deleted physical file: {filename}')
|
||||||
|
|
||||||
|
# Delete edited media archive folder if it exists
|
||||||
|
import shutil
|
||||||
|
edited_media_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'edited_media', str(media.id))
|
||||||
|
if os.path.exists(edited_media_dir):
|
||||||
|
shutil.rmtree(edited_media_dir)
|
||||||
|
log_action('info', f'Deleted edited media archive for content ID {media.id}')
|
||||||
|
|
||||||
|
# Delete associated player edit records first
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
edit_records = PlayerEdit.query.filter_by(content_id=media.id).all()
|
||||||
|
if edit_records:
|
||||||
|
for edit in edit_records:
|
||||||
|
db.session.delete(edit)
|
||||||
|
log_action('info', f'Deleted {len(edit_records)} edit record(s) for content: {filename}')
|
||||||
|
|
||||||
|
# Remove from all playlists (this will cascade properly)
|
||||||
|
db.session.delete(media)
|
||||||
|
|
||||||
|
# Increment version for all affected playlists
|
||||||
|
for playlist in affected_playlists:
|
||||||
|
playlist.version += 1
|
||||||
|
log_action('info', f'Playlist "{playlist.name}" version updated to {playlist.version} (media removed)')
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Clear cache for affected playlists
|
||||||
|
from app.blueprints.players import get_player_playlist
|
||||||
|
from app.extensions import cache
|
||||||
|
for playlist in affected_playlists:
|
||||||
|
for player in playlist.players:
|
||||||
|
cache.delete_memoized(get_player_playlist, player.id)
|
||||||
|
|
||||||
|
if affected_playlists:
|
||||||
|
flash(f'Deleted "{filename}" and removed from {len(affected_playlists)} playlist(s). Playlist versions updated.', 'success')
|
||||||
|
else:
|
||||||
|
flash(f'Deleted "{filename}" successfully.', 'success')
|
||||||
|
|
||||||
|
log_action('info', f'Media deleted: {filename} (affected {len(affected_playlists)} playlists)')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
log_action('error', f'Error deleting media: {str(e)}')
|
||||||
|
flash(f'Error deleting media: {str(e)}', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('content.media_library'))
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/playlist/create', methods=['POST'])
|
@content_bp.route('/playlist/create', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def create_playlist():
|
def create_playlist():
|
||||||
@@ -194,6 +293,48 @@ def remove_content_from_playlist(playlist_id: int, content_id: int):
|
|||||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||||
|
|
||||||
|
|
||||||
|
@content_bp.route('/playlist/<int:playlist_id>/bulk-remove', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def bulk_remove_from_playlist(playlist_id: int):
|
||||||
|
"""Remove multiple content items from playlist."""
|
||||||
|
playlist = Playlist.query.get_or_404(playlist_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
content_ids = data.get('content_ids', [])
|
||||||
|
|
||||||
|
if not content_ids:
|
||||||
|
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
|
||||||
|
|
||||||
|
from app.models.playlist import playlist_content
|
||||||
|
|
||||||
|
# Remove all selected items
|
||||||
|
stmt = playlist_content.delete().where(
|
||||||
|
(playlist_content.c.playlist_id == playlist_id) &
|
||||||
|
(playlist_content.c.content_id.in_(content_ids))
|
||||||
|
)
|
||||||
|
result = db.session.execute(stmt)
|
||||||
|
|
||||||
|
# Increment version
|
||||||
|
playlist.increment_version()
|
||||||
|
db.session.commit()
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
removed_count = result.rowcount if hasattr(result, 'rowcount') else len(content_ids)
|
||||||
|
log_action('info', f'Bulk removed {removed_count} items from playlist "{playlist.name}"')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Removed {removed_count} item(s) from playlist',
|
||||||
|
'removed_count': removed_count
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
log_action('error', f'Error bulk removing from playlist: {str(e)}')
|
||||||
|
return jsonify({'success': False, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/playlist/<int:playlist_id>/reorder', methods=['POST'])
|
@content_bp.route('/playlist/<int:playlist_id>/reorder', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def reorder_playlist_content(playlist_id: int):
|
def reorder_playlist_content(playlist_id: int):
|
||||||
@@ -235,6 +376,88 @@ def reorder_playlist_content(playlist_id: int):
|
|||||||
return jsonify({'success': False, 'message': str(e)}), 500
|
return jsonify({'success': False, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@content_bp.route('/playlist/<int:playlist_id>/update-muted/<int:content_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def update_playlist_content_muted(playlist_id: int, content_id: int):
|
||||||
|
"""Update content muted setting in playlist."""
|
||||||
|
playlist = Playlist.query.get_or_404(playlist_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
muted = request.form.get('muted', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
from app.models.playlist import playlist_content
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
# Update muted in association table
|
||||||
|
stmt = update(playlist_content).where(
|
||||||
|
(playlist_content.c.playlist_id == playlist_id) &
|
||||||
|
(playlist_content.c.content_id == content_id)
|
||||||
|
).values(muted=muted)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
# Increment playlist version
|
||||||
|
playlist.increment_version()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
log_action('info', f'Updated muted={muted} for "{content.filename}" in playlist "{playlist.name}"')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Audio setting updated',
|
||||||
|
'muted': muted,
|
||||||
|
'version': playlist.version
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
log_action('error', f'Error updating muted setting: {str(e)}')
|
||||||
|
return jsonify({'success': False, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@content_bp.route('/playlist/<int:playlist_id>/update-edit-enabled/<int:content_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def update_playlist_content_edit_enabled(playlist_id: int, content_id: int):
|
||||||
|
"""Update content edit_on_player_enabled setting in playlist."""
|
||||||
|
playlist = Playlist.query.get_or_404(playlist_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
edit_enabled = request.form.get('edit_enabled', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
from app.models.playlist import playlist_content
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
# Update edit_on_player_enabled in association table
|
||||||
|
stmt = update(playlist_content).where(
|
||||||
|
(playlist_content.c.playlist_id == playlist_id) &
|
||||||
|
(playlist_content.c.content_id == content_id)
|
||||||
|
).values(edit_on_player_enabled=edit_enabled)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
# Increment playlist version
|
||||||
|
playlist.increment_version()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
log_action('info', f'Updated edit_on_player_enabled={edit_enabled} for "{content.filename}" in playlist "{playlist.name}"')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Edit setting updated',
|
||||||
|
'edit_enabled': edit_enabled,
|
||||||
|
'version': playlist.version
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
log_action('error', f'Error updating edit setting: {str(e)}')
|
||||||
|
return jsonify({'success': False, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/upload-media-page')
|
@content_bp.route('/upload-media-page')
|
||||||
@login_required
|
@login_required
|
||||||
def upload_media_page():
|
def upload_media_page():
|
||||||
@@ -365,6 +588,182 @@ def process_pdf_file(filepath: str, filename: str) -> tuple[bool, str]:
|
|||||||
return False, f"PDF processing error: {str(e)}"
|
return False, f"PDF processing error: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
|
||||||
|
duration: int, playlist_id: Optional[int], task_id: str, edit_on_player_enabled: bool = False):
|
||||||
|
"""Process large files (PDF, PPTX, Video) in background thread."""
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
_background_tasks[task_id] = {'status': 'processing', 'message': f'Processing {filename}...'}
|
||||||
|
|
||||||
|
upload_folder = app.config['UPLOAD_FOLDER']
|
||||||
|
processing_success = True
|
||||||
|
processing_message = ""
|
||||||
|
detected_type = file_ext
|
||||||
|
|
||||||
|
# Process based on file type
|
||||||
|
if file_ext == 'pdf':
|
||||||
|
processing_success, processing_message = process_pdf_file(filepath, filename)
|
||||||
|
|
||||||
|
if processing_success and "converted to" in processing_message.lower():
|
||||||
|
base_name = os.path.splitext(filename)[0]
|
||||||
|
page_pattern = f"{base_name}_page*.png"
|
||||||
|
import glob
|
||||||
|
page_files = sorted(glob.glob(os.path.join(upload_folder, page_pattern)))
|
||||||
|
|
||||||
|
if page_files:
|
||||||
|
max_position = 0
|
||||||
|
if playlist_id:
|
||||||
|
playlist = Playlist.query.get(playlist_id)
|
||||||
|
max_position = db.session.query(db.func.max(playlist_content.c.position))\
|
||||||
|
.filter(playlist_content.c.playlist_id == playlist_id)\
|
||||||
|
.scalar() or 0
|
||||||
|
|
||||||
|
for page_file in page_files:
|
||||||
|
page_filename = os.path.basename(page_file)
|
||||||
|
page_content = Content(
|
||||||
|
filename=page_filename,
|
||||||
|
content_type='image',
|
||||||
|
duration=duration,
|
||||||
|
file_size=os.path.getsize(page_file)
|
||||||
|
)
|
||||||
|
db.session.add(page_content)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
if playlist_id:
|
||||||
|
max_position += 1
|
||||||
|
stmt = playlist_content.insert().values(
|
||||||
|
playlist_id=playlist_id,
|
||||||
|
content_id=page_content.id,
|
||||||
|
position=max_position,
|
||||||
|
duration=duration,
|
||||||
|
edit_on_player_enabled=edit_on_player_enabled
|
||||||
|
)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
if playlist_id and page_files:
|
||||||
|
playlist = Playlist.query.get(playlist_id)
|
||||||
|
if playlist:
|
||||||
|
playlist.version += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
os.remove(filepath)
|
||||||
|
|
||||||
|
_background_tasks[task_id] = {
|
||||||
|
'status': 'complete',
|
||||||
|
'message': f'PDF converted to {len(page_files)} images successfully!'
|
||||||
|
}
|
||||||
|
log_action('info', f'Background: PDF {filename} converted to {len(page_files)} pages')
|
||||||
|
return
|
||||||
|
|
||||||
|
elif file_ext in ['ppt', 'pptx']:
|
||||||
|
processing_success, processing_message = process_presentation_file(filepath, filename)
|
||||||
|
|
||||||
|
if processing_success and "converted to" in processing_message.lower():
|
||||||
|
base_name = os.path.splitext(filename)[0]
|
||||||
|
slide_pattern = f"{base_name}_slide_*.png"
|
||||||
|
import glob
|
||||||
|
slide_files = sorted(glob.glob(os.path.join(upload_folder, slide_pattern)))
|
||||||
|
|
||||||
|
if slide_files:
|
||||||
|
max_position = 0
|
||||||
|
if playlist_id:
|
||||||
|
playlist = Playlist.query.get(playlist_id)
|
||||||
|
max_position = db.session.query(db.func.max(playlist_content.c.position))\
|
||||||
|
.filter(playlist_content.c.playlist_id == playlist_id)\
|
||||||
|
.scalar() or 0
|
||||||
|
|
||||||
|
for slide_file in slide_files:
|
||||||
|
slide_filename = os.path.basename(slide_file)
|
||||||
|
slide_content = Content(
|
||||||
|
filename=slide_filename,
|
||||||
|
content_type='image',
|
||||||
|
duration=duration,
|
||||||
|
file_size=os.path.getsize(slide_file)
|
||||||
|
)
|
||||||
|
db.session.add(slide_content)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
if playlist_id:
|
||||||
|
max_position += 1
|
||||||
|
stmt = playlist_content.insert().values(
|
||||||
|
playlist_id=playlist_id,
|
||||||
|
content_id=slide_content.id,
|
||||||
|
position=max_position,
|
||||||
|
duration=duration,
|
||||||
|
edit_on_player_enabled=edit_on_player_enabled
|
||||||
|
)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
if playlist_id and slide_files:
|
||||||
|
playlist = Playlist.query.get(playlist_id)
|
||||||
|
if playlist:
|
||||||
|
playlist.version += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
os.remove(filepath)
|
||||||
|
|
||||||
|
_background_tasks[task_id] = {
|
||||||
|
'status': 'complete',
|
||||||
|
'message': f'Presentation converted to {len(slide_files)} slides successfully!'
|
||||||
|
}
|
||||||
|
log_action('info', f'Background: PPTX {filename} converted to {len(slide_files)} slides')
|
||||||
|
return
|
||||||
|
|
||||||
|
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']:
|
||||||
|
processing_success, processing_message = process_video_file_extended(filepath, filename)
|
||||||
|
detected_type = 'video'
|
||||||
|
|
||||||
|
# If file still exists, add as regular content
|
||||||
|
if processing_success and os.path.exists(filepath):
|
||||||
|
content = Content(
|
||||||
|
filename=filename,
|
||||||
|
content_type=detected_type,
|
||||||
|
duration=duration,
|
||||||
|
file_size=os.path.getsize(filepath)
|
||||||
|
)
|
||||||
|
db.session.add(content)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
if playlist_id:
|
||||||
|
playlist = Playlist.query.get(playlist_id)
|
||||||
|
if playlist:
|
||||||
|
max_position = db.session.query(db.func.max(playlist_content.c.position))\
|
||||||
|
.filter(playlist_content.c.playlist_id == playlist_id)\
|
||||||
|
.scalar() or 0
|
||||||
|
|
||||||
|
stmt = playlist_content.insert().values(
|
||||||
|
playlist_id=playlist_id,
|
||||||
|
content_id=content.id,
|
||||||
|
position=max_position + 1,
|
||||||
|
duration=duration,
|
||||||
|
edit_on_player_enabled=edit_on_player_enabled
|
||||||
|
)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
playlist.version += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
cache.clear()
|
||||||
|
_background_tasks[task_id] = {'status': 'complete', 'message': f'{filename} processed successfully!'}
|
||||||
|
log_action('info', f'Background: {filename} processed successfully')
|
||||||
|
else:
|
||||||
|
_background_tasks[task_id] = {
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Failed to process {filename}: {processing_message}'
|
||||||
|
}
|
||||||
|
log_action('error', f'Background: Failed to process {filename}')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
_background_tasks[task_id] = {'status': 'error', 'message': f'Error: {str(e)}'}
|
||||||
|
log_action('error', f'Background processing error for {filename}: {str(e)}')
|
||||||
|
|
||||||
|
|
||||||
def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
|
def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
|
||||||
"""Process PowerPoint presentation files by converting slides to images."""
|
"""Process PowerPoint presentation files by converting slides to images."""
|
||||||
try:
|
try:
|
||||||
@@ -401,8 +800,8 @@ def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if not libreoffice_cmd:
|
if not libreoffice_cmd:
|
||||||
log_action('warning', f'LibreOffice not found, skipping slide conversion for: {filename}')
|
log_action('warning', f'LibreOffice not found, cannot convert: {filename}')
|
||||||
return True, "Presentation accepted without conversion (LibreOffice unavailable)"
|
return False, "LibreOffice is not installed. Please install it from the Admin Panel → System Dependencies to upload PowerPoint files."
|
||||||
|
|
||||||
# Create temporary directory for conversion
|
# Create temporary directory for conversion
|
||||||
with tempfile.TemporaryDirectory() as temp_dir:
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
@@ -412,17 +811,16 @@ def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
|
|||||||
temp_ppt = temp_path / filename
|
temp_ppt = temp_path / filename
|
||||||
shutil.copy2(filepath, temp_ppt)
|
shutil.copy2(filepath, temp_ppt)
|
||||||
|
|
||||||
# Convert presentation to images (PNG format)
|
# Convert presentation to PDF first (for better quality)
|
||||||
# Using LibreOffice headless mode with custom resolution
|
|
||||||
convert_cmd = [
|
convert_cmd = [
|
||||||
libreoffice_cmd,
|
libreoffice_cmd,
|
||||||
'--headless',
|
'--headless',
|
||||||
'--convert-to', 'png',
|
'--convert-to', 'pdf',
|
||||||
'--outdir', str(temp_path),
|
'--outdir', str(temp_path),
|
||||||
str(temp_ppt)
|
str(temp_ppt)
|
||||||
]
|
]
|
||||||
|
|
||||||
log_action('info', f'Converting presentation to images: {filename}')
|
log_action('info', f'Converting presentation to PDF: {filename}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -436,8 +834,40 @@ def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
|
|||||||
log_action('error', f'LibreOffice conversion failed: {result.stderr}')
|
log_action('error', f'LibreOffice conversion failed: {result.stderr}')
|
||||||
return True, "Presentation accepted without conversion (conversion failed)"
|
return True, "Presentation accepted without conversion (conversion failed)"
|
||||||
|
|
||||||
|
# Find generated PDF file
|
||||||
|
pdf_files = list(temp_path.glob('*.pdf'))
|
||||||
|
if not pdf_files:
|
||||||
|
log_action('warning', f'No PDF generated from presentation: {filename}')
|
||||||
|
return True, "Presentation accepted without conversion"
|
||||||
|
|
||||||
|
pdf_file = pdf_files[0]
|
||||||
|
log_action('info', f'Converting PDF to images at Full HD resolution: {pdf_file.name}')
|
||||||
|
|
||||||
|
# Convert PDF to images using pdftoppm at Full HD resolution (1920x1080)
|
||||||
|
# Calculate DPI for Full HD output (assuming standard presentation is 10x7.5 inches)
|
||||||
|
# 1920/10 = 192 DPI for width, use 192 DPI for best quality
|
||||||
|
pdftoppm_cmd = [
|
||||||
|
'pdftoppm',
|
||||||
|
'-png',
|
||||||
|
'-r', '300', # High DPI for quality
|
||||||
|
'-scale-to', '1920', # Scale width to 1920px
|
||||||
|
str(pdf_file),
|
||||||
|
str(temp_path / 'slide')
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
pdftoppm_cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
log_action('error', f'pdftoppm conversion failed: {result.stderr}')
|
||||||
|
return True, "Presentation accepted without conversion (image conversion failed)"
|
||||||
|
|
||||||
# Find generated PNG files
|
# Find generated PNG files
|
||||||
png_files = sorted(temp_path.glob('*.png'))
|
png_files = sorted(temp_path.glob('slide-*.png'))
|
||||||
|
|
||||||
if not png_files:
|
if not png_files:
|
||||||
log_action('warning', f'No images generated from presentation: {filename}')
|
log_action('warning', f'No images generated from presentation: {filename}')
|
||||||
@@ -447,7 +877,7 @@ def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
|
|||||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||||
base_name = os.path.splitext(filename)[0]
|
base_name = os.path.splitext(filename)[0]
|
||||||
|
|
||||||
# Move converted images to upload folder
|
# Move converted images to upload folder and resize to exact Full HD
|
||||||
slide_count = 0
|
slide_count = 0
|
||||||
for idx, png_file in enumerate(png_files, start=1):
|
for idx, png_file in enumerate(png_files, start=1):
|
||||||
# Create descriptive filename
|
# Create descriptive filename
|
||||||
@@ -456,8 +886,8 @@ def process_presentation_file(filepath: str, filename: str) -> tuple[bool, str]:
|
|||||||
|
|
||||||
shutil.move(str(png_file), destination)
|
shutil.move(str(png_file), destination)
|
||||||
|
|
||||||
# Optimize the image to Full HD (1920x1080)
|
# Resize to exact Full HD dimensions (1920x1080) maintaining aspect ratio
|
||||||
optimize_image_to_fullhd(destination)
|
resize_image_to_fullhd(destination)
|
||||||
|
|
||||||
slide_count += 1
|
slide_count += 1
|
||||||
|
|
||||||
@@ -502,6 +932,64 @@ def create_fullhd_image(img):
|
|||||||
return fullhd_img
|
return fullhd_img
|
||||||
|
|
||||||
|
|
||||||
|
def resize_image_to_fullhd(filepath: str) -> bool:
|
||||||
|
"""Resize image to exactly Full HD (1920x1080) maintaining aspect ratio with centered crop or padding."""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
img = Image.open(filepath)
|
||||||
|
target_width = 1920
|
||||||
|
target_height = 1080
|
||||||
|
|
||||||
|
# Calculate aspect ratios
|
||||||
|
img_aspect = img.width / img.height
|
||||||
|
target_aspect = target_width / target_height
|
||||||
|
|
||||||
|
if abs(img_aspect - target_aspect) < 0.01:
|
||||||
|
# Aspect ratio is very close, just resize
|
||||||
|
img_resized = img.resize((target_width, target_height), Image.Resampling.LANCZOS)
|
||||||
|
elif img_aspect > target_aspect:
|
||||||
|
# Image is wider than target, fit height and crop/pad width
|
||||||
|
new_height = target_height
|
||||||
|
new_width = int(target_height * img_aspect)
|
||||||
|
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Crop to center if wider
|
||||||
|
if new_width > target_width:
|
||||||
|
left = (new_width - target_width) // 2
|
||||||
|
img_resized = img_resized.crop((left, 0, left + target_width, target_height))
|
||||||
|
else:
|
||||||
|
# Pad with white if narrower
|
||||||
|
result = Image.new('RGB', (target_width, target_height), (255, 255, 255))
|
||||||
|
offset = (target_width - new_width) // 2
|
||||||
|
result.paste(img_resized, (offset, 0))
|
||||||
|
img_resized = result
|
||||||
|
else:
|
||||||
|
# Image is taller than target, fit width and crop/pad height
|
||||||
|
new_width = target_width
|
||||||
|
new_height = int(target_width / img_aspect)
|
||||||
|
img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Crop to center if taller
|
||||||
|
if new_height > target_height:
|
||||||
|
top = (new_height - target_height) // 2
|
||||||
|
img_resized = img_resized.crop((0, top, target_width, top + target_height))
|
||||||
|
else:
|
||||||
|
# Pad with white if shorter
|
||||||
|
result = Image.new('RGB', (target_width, target_height), (255, 255, 255))
|
||||||
|
offset = (target_height - new_height) // 2
|
||||||
|
result.paste(img_resized, (0, offset))
|
||||||
|
img_resized = result
|
||||||
|
|
||||||
|
# Save optimized image
|
||||||
|
img_resized.save(filepath, 'PNG', optimize=True, quality=95)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Image resize error: {str(e)}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def optimize_image_to_fullhd(filepath: str) -> bool:
|
def optimize_image_to_fullhd(filepath: str) -> bool:
|
||||||
"""Optimize and resize image file to Full HD (1920x1080) maintaining aspect ratio."""
|
"""Optimize and resize image file to Full HD (1920x1080) maintaining aspect ratio."""
|
||||||
try:
|
try:
|
||||||
@@ -526,6 +1014,7 @@ def upload_media():
|
|||||||
content_type = request.form.get('content_type', 'image')
|
content_type = request.form.get('content_type', 'image')
|
||||||
duration = request.form.get('duration', type=int, default=10)
|
duration = request.form.get('duration', type=int, default=10)
|
||||||
playlist_id = request.form.get('playlist_id', type=int)
|
playlist_id = request.form.get('playlist_id', type=int)
|
||||||
|
edit_on_player_enabled = request.form.get('edit_on_player_enabled', '0') == '1'
|
||||||
|
|
||||||
if not files or files[0].filename == '':
|
if not files or files[0].filename == '':
|
||||||
flash('No files provided.', 'warning')
|
flash('No files provided.', 'warning')
|
||||||
@@ -535,6 +1024,7 @@ def upload_media():
|
|||||||
os.makedirs(upload_folder, exist_ok=True)
|
os.makedirs(upload_folder, exist_ok=True)
|
||||||
|
|
||||||
uploaded_count = 0
|
uploaded_count = 0
|
||||||
|
background_count = 0
|
||||||
processing_errors = []
|
processing_errors = []
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
@@ -556,6 +1046,26 @@ def upload_media():
|
|||||||
# Determine content type from extension
|
# Determine content type from extension
|
||||||
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||||
|
|
||||||
|
# Check if file needs background processing (large files)
|
||||||
|
needs_background = file_ext in ['pdf', 'ppt', 'pptx', 'mp4', 'avi', 'mov', 'mkv', 'webm', 'flv', 'wmv']
|
||||||
|
|
||||||
|
if needs_background:
|
||||||
|
# Process in background thread
|
||||||
|
import uuid
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
_background_tasks[task_id] = {'status': 'queued', 'message': f'Queued {filename} for processing...'}
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=process_file_in_background,
|
||||||
|
args=(current_app._get_current_object(), filepath, filename, file_ext, duration, playlist_id, task_id, edit_on_player_enabled)
|
||||||
|
)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
background_count += 1
|
||||||
|
log_action('info', f'Queued {filename} for background processing (task: {task_id})')
|
||||||
|
continue
|
||||||
|
|
||||||
# Process file based on type
|
# Process file based on type
|
||||||
processing_success = True
|
processing_success = True
|
||||||
processing_message = ""
|
processing_message = ""
|
||||||
@@ -610,7 +1120,8 @@ def upload_media():
|
|||||||
playlist_id=playlist_id,
|
playlist_id=playlist_id,
|
||||||
content_id=page_content.id,
|
content_id=page_content.id,
|
||||||
position=max_position,
|
position=max_position,
|
||||||
duration=duration
|
duration=duration,
|
||||||
|
edit_on_player_enabled=edit_on_player_enabled
|
||||||
)
|
)
|
||||||
db.session.execute(stmt)
|
db.session.execute(stmt)
|
||||||
|
|
||||||
@@ -618,7 +1129,7 @@ def upload_media():
|
|||||||
|
|
||||||
# Increment playlist version if pages were added
|
# Increment playlist version if pages were added
|
||||||
if playlist_id and page_files:
|
if playlist_id and page_files:
|
||||||
playlist.version += 1
|
playlist.increment_version()
|
||||||
|
|
||||||
# Delete original PDF file
|
# Delete original PDF file
|
||||||
if os.path.exists(filepath):
|
if os.path.exists(filepath):
|
||||||
@@ -669,7 +1180,8 @@ def upload_media():
|
|||||||
playlist_id=playlist_id,
|
playlist_id=playlist_id,
|
||||||
content_id=slide_content.id,
|
content_id=slide_content.id,
|
||||||
position=max_position,
|
position=max_position,
|
||||||
duration=duration
|
duration=duration,
|
||||||
|
edit_on_player_enabled=edit_on_player_enabled
|
||||||
)
|
)
|
||||||
db.session.execute(stmt)
|
db.session.execute(stmt)
|
||||||
|
|
||||||
@@ -677,7 +1189,7 @@ def upload_media():
|
|||||||
|
|
||||||
# Increment playlist version if slides were added
|
# Increment playlist version if slides were added
|
||||||
if playlist_id and slide_files:
|
if playlist_id and slide_files:
|
||||||
playlist.version += 1
|
playlist.increment_version()
|
||||||
|
|
||||||
# Delete original PPTX file
|
# Delete original PPTX file
|
||||||
if os.path.exists(filepath):
|
if os.path.exists(filepath):
|
||||||
@@ -721,19 +1233,20 @@ def upload_media():
|
|||||||
playlist_id=playlist_id,
|
playlist_id=playlist_id,
|
||||||
content_id=content.id,
|
content_id=content.id,
|
||||||
position=max_position + 1,
|
position=max_position + 1,
|
||||||
duration=duration
|
duration=duration,
|
||||||
|
edit_on_player_enabled=edit_on_player_enabled
|
||||||
)
|
)
|
||||||
db.session.execute(stmt)
|
db.session.execute(stmt)
|
||||||
|
|
||||||
# Increment playlist version
|
# Increment playlist version
|
||||||
playlist.version += 1
|
playlist.increment_version()
|
||||||
|
|
||||||
uploaded_count += 1
|
uploaded_count += 1
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
cache.clear()
|
cache.clear()
|
||||||
|
|
||||||
log_action('info', f'Uploaded {uploaded_count} media files')
|
log_action('info', f'Uploaded {uploaded_count} media files, {background_count} files processing in background')
|
||||||
|
|
||||||
# Show appropriate flash message
|
# Show appropriate flash message
|
||||||
if processing_errors:
|
if processing_errors:
|
||||||
@@ -741,6 +1254,14 @@ def upload_media():
|
|||||||
if len(processing_errors) > 3:
|
if len(processing_errors) > 3:
|
||||||
error_summary += f' and {len(processing_errors) - 3} more...'
|
error_summary += f' and {len(processing_errors) - 3} more...'
|
||||||
flash(f'Uploaded {uploaded_count} file(s). Errors: {error_summary}', 'warning')
|
flash(f'Uploaded {uploaded_count} file(s). Errors: {error_summary}', 'warning')
|
||||||
|
elif background_count > 0:
|
||||||
|
bg_msg = f'{background_count} file(s) are being processed in the background (PDF, PPTX, or large videos). '
|
||||||
|
bg_msg += 'They will appear in the media library/playlist automatically when conversion completes. '
|
||||||
|
bg_msg += 'This may take a few minutes.'
|
||||||
|
if uploaded_count > 0:
|
||||||
|
flash(f'✅ Uploaded {uploaded_count} file(s) immediately. ⏳ {bg_msg}', 'info')
|
||||||
|
else:
|
||||||
|
flash(f'⏳ {bg_msg}', 'info')
|
||||||
elif playlist_id:
|
elif playlist_id:
|
||||||
playlist = Playlist.query.get(playlist_id)
|
playlist = Playlist.query.get(playlist_id)
|
||||||
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')
|
flash(f'Successfully uploaded {uploaded_count} file(s) to playlist "{playlist.name}"!', 'success')
|
||||||
|
|||||||
@@ -228,21 +228,48 @@ def manage_player(player_id: int):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if action == 'update_credentials':
|
if action == 'update_credentials':
|
||||||
# Update player name, location, orientation
|
# Update player name, location, orientation, and authentication
|
||||||
name = request.form.get('name', '').strip()
|
name = request.form.get('name', '').strip()
|
||||||
location = request.form.get('location', '').strip()
|
location = request.form.get('location', '').strip()
|
||||||
orientation = request.form.get('orientation', 'Landscape')
|
orientation = request.form.get('orientation', 'Landscape')
|
||||||
|
hostname = request.form.get('hostname', '').strip()
|
||||||
|
password = request.form.get('password', '').strip()
|
||||||
|
quickconnect_code = request.form.get('quickconnect_code', '').strip()
|
||||||
|
|
||||||
if not name or len(name) < 3:
|
if not name or len(name) < 3:
|
||||||
flash('Player name must be at least 3 characters long.', 'warning')
|
flash('Player name must be at least 3 characters long.', 'warning')
|
||||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||||
|
|
||||||
|
if not hostname or len(hostname) < 3:
|
||||||
|
flash('Hostname must be at least 3 characters long.', 'warning')
|
||||||
|
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||||
|
|
||||||
|
# Check if hostname is taken by another player
|
||||||
|
if hostname != player.hostname:
|
||||||
|
existing = Player.query.filter_by(hostname=hostname).first()
|
||||||
|
if existing:
|
||||||
|
flash(f'Hostname "{hostname}" is already in use by another player.', 'warning')
|
||||||
|
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||||
|
|
||||||
|
# Update basic info
|
||||||
player.name = name
|
player.name = name
|
||||||
|
player.hostname = hostname
|
||||||
player.location = location or None
|
player.location = location or None
|
||||||
player.orientation = orientation
|
player.orientation = orientation
|
||||||
|
|
||||||
|
# Update password if provided
|
||||||
|
if password:
|
||||||
|
player.set_password(password)
|
||||||
|
log_action('info', f'Password updated for player "{name}"')
|
||||||
|
|
||||||
|
# Update quickconnect code if provided
|
||||||
|
if quickconnect_code:
|
||||||
|
player.set_quickconnect_code(quickconnect_code)
|
||||||
|
log_action('info', f'QuickConnect code updated for player "{name}" to: {quickconnect_code}')
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
log_action('info', f'Player "{name}" credentials updated')
|
log_action('info', f'Player "{name}" (hostname: {hostname}) credentials updated')
|
||||||
flash(f'Player "{name}" updated successfully.', 'success')
|
flash(f'Player "{name}" updated successfully.', 'success')
|
||||||
|
|
||||||
elif action == 'assign_playlist':
|
elif action == 'assign_playlist':
|
||||||
@@ -288,6 +315,13 @@ def manage_player(player_id: int):
|
|||||||
.limit(20)\
|
.limit(20)\
|
||||||
.all()
|
.all()
|
||||||
|
|
||||||
|
# Get edited media history from player
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
|
||||||
|
.order_by(PlayerEdit.created_at.desc())\
|
||||||
|
.limit(20)\
|
||||||
|
.all()
|
||||||
|
|
||||||
# Get player status
|
# Get player status
|
||||||
status_info = get_player_status_info(player_id)
|
status_info = get_player_status_info(player_id)
|
||||||
|
|
||||||
@@ -296,9 +330,41 @@ def manage_player(player_id: int):
|
|||||||
playlists=playlists,
|
playlists=playlists,
|
||||||
current_playlist=current_playlist,
|
current_playlist=current_playlist,
|
||||||
recent_logs=recent_logs,
|
recent_logs=recent_logs,
|
||||||
|
edited_media=edited_media,
|
||||||
status_info=status_info)
|
status_info=status_info)
|
||||||
|
|
||||||
|
|
||||||
|
@players_bp.route('/<int:player_id>/edited-media')
|
||||||
|
@login_required
|
||||||
|
def edited_media(player_id: int):
|
||||||
|
"""Display all edited media files from this player."""
|
||||||
|
try:
|
||||||
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
|
# Get all edited media history from player
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
|
||||||
|
.order_by(PlayerEdit.created_at.desc())\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
# Get original content files for each edited media
|
||||||
|
content_files = {}
|
||||||
|
for edit in edited_media:
|
||||||
|
if edit.content_id not in content_files:
|
||||||
|
content = Content.query.get(edit.content_id)
|
||||||
|
if content:
|
||||||
|
content_files[edit.content_id] = content
|
||||||
|
|
||||||
|
return render_template('players/edited_media.html',
|
||||||
|
player=player,
|
||||||
|
edited_media=edited_media,
|
||||||
|
content_files=content_files)
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Error loading edited media for player {player_id}: {str(e)}')
|
||||||
|
flash('Error loading edited media.', 'danger')
|
||||||
|
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||||
|
|
||||||
|
|
||||||
@players_bp.route('/<int:player_id>/fullscreen')
|
@players_bp.route('/<int:player_id>/fullscreen')
|
||||||
def player_fullscreen(player_id: int):
|
def player_fullscreen(player_id: int):
|
||||||
"""Display player fullscreen view (no authentication required for players)."""
|
"""Display player fullscreen view (no authentication required for players)."""
|
||||||
@@ -353,6 +419,7 @@ def get_player_playlist(player_id: int) -> List[dict]:
|
|||||||
'type': content.content_type,
|
'type': content.content_type,
|
||||||
'duration': getattr(content, '_playlist_duration', content.duration or 10),
|
'duration': getattr(content, '_playlist_duration', content.duration or 10),
|
||||||
'position': getattr(content, '_playlist_position', 0),
|
'position': getattr(content, '_playlist_position', 0),
|
||||||
|
'muted': getattr(content, '_playlist_muted', True),
|
||||||
'filename': content.filename
|
'filename': content.filename
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -239,6 +239,49 @@ def update_duration(player_id: int, content_id: int):
|
|||||||
return jsonify({'success': False, 'message': str(e)}), 500
|
return jsonify({'success': False, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@playlist_bp.route('/<int:player_id>/update-muted/<int:content_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def update_muted(player_id: int, content_id: int):
|
||||||
|
"""Update content muted setting in playlist."""
|
||||||
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
|
if not player.playlist_id:
|
||||||
|
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
playlist = Playlist.query.get(player.playlist_id)
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
|
||||||
|
muted = request.form.get('muted', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# Update muted in association table
|
||||||
|
stmt = update(playlist_content).where(
|
||||||
|
(playlist_content.c.playlist_id == playlist.id) &
|
||||||
|
(playlist_content.c.content_id == content_id)
|
||||||
|
).values(muted=muted)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
# Increment playlist version
|
||||||
|
playlist.increment_version()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
log_action('info', f'Updated muted={muted} for "{content.filename}" in player "{player.name}" playlist')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Audio setting updated',
|
||||||
|
'muted': muted,
|
||||||
|
'version': playlist.version
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
log_action('error', f'Error updating muted setting: {str(e)}')
|
||||||
|
return jsonify({'success': False, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@playlist_bp.route('/<int:player_id>/clear', methods=['POST'])
|
@playlist_bp.route('/<int:player_id>/clear', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def clear_playlist(player_id: int):
|
def clear_playlist(player_id: int):
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class Config:
|
|||||||
|
|
||||||
# File Upload - use absolute paths
|
# File Upload - use absolute paths
|
||||||
MAX_CONTENT_LENGTH = 2048 * 1024 * 1024 # 2GB
|
MAX_CONTENT_LENGTH = 2048 * 1024 * 1024 # 2GB
|
||||||
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
_basedir = os.path.abspath(os.path.dirname(__file__))
|
||||||
UPLOAD_FOLDER = os.path.join(_basedir, 'static', 'uploads')
|
UPLOAD_FOLDER = os.path.join(_basedir, 'static', 'uploads')
|
||||||
UPLOAD_FOLDERLOGO = os.path.join(_basedir, 'static', 'resurse')
|
UPLOAD_FOLDERLOGO = os.path.join(_basedir, 'static', 'resurse')
|
||||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'mp4', 'avi', 'mkv', 'mov', 'webm', 'pdf', 'ppt', 'pptx'}
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'mp4', 'avi', 'mkv', 'mov', 'webm', 'pdf', 'ppt', 'pptx'}
|
||||||
@@ -71,6 +71,7 @@ class ProductionConfig(Config):
|
|||||||
|
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
TESTING = False
|
TESTING = False
|
||||||
|
TEMPLATES_AUTO_RELOAD = True # Force template reload
|
||||||
|
|
||||||
# Database - construct absolute path
|
# Database - construct absolute path
|
||||||
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
_basedir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||||
@@ -79,11 +80,8 @@ class ProductionConfig(Config):
|
|||||||
f'sqlite:///{os.path.join(_basedir, "instance", "dashboard.db")}'
|
f'sqlite:///{os.path.join(_basedir, "instance", "dashboard.db")}'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Redis Cache
|
# Cache - use simple cache instead of Redis
|
||||||
CACHE_TYPE = 'redis'
|
CACHE_TYPE = 'simple'
|
||||||
CACHE_REDIS_HOST = os.getenv('REDIS_HOST', 'redis')
|
|
||||||
CACHE_REDIS_PORT = int(os.getenv('REDIS_PORT', 6379))
|
|
||||||
CACHE_REDIS_DB = 0
|
|
||||||
CACHE_DEFAULT_TIMEOUT = 300
|
CACHE_DEFAULT_TIMEOUT = 300
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from app.models.playlist import Playlist, playlist_content
|
|||||||
from app.models.content import Content
|
from app.models.content import Content
|
||||||
from app.models.server_log import ServerLog
|
from app.models.server_log import ServerLog
|
||||||
from app.models.player_feedback import PlayerFeedback
|
from app.models.player_feedback import PlayerFeedback
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'User',
|
'User',
|
||||||
@@ -15,6 +16,7 @@ __all__ = [
|
|||||||
'Content',
|
'Content',
|
||||||
'ServerLog',
|
'ServerLog',
|
||||||
'PlayerFeedback',
|
'PlayerFeedback',
|
||||||
|
'PlayerEdit',
|
||||||
'group_content',
|
'group_content',
|
||||||
'playlist_content',
|
'playlist_content',
|
||||||
]
|
]
|
||||||
|
|||||||
60
app/models/player_edit.py
Normal file
60
app/models/player_edit.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Player edit model for tracking media edited on players."""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerEdit(db.Model):
|
||||||
|
"""Player edit model for tracking media files edited on player devices.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: Primary key
|
||||||
|
player_id: Foreign key to player
|
||||||
|
content_id: Foreign key to content that was edited
|
||||||
|
original_name: Original filename
|
||||||
|
new_name: New filename after editing
|
||||||
|
version: Edit version number (v1, v2, etc.)
|
||||||
|
user: User who made the edit (from player)
|
||||||
|
time_of_modification: When the edit was made
|
||||||
|
metadata_path: Path to the metadata JSON file
|
||||||
|
edited_file_path: Path to the edited file
|
||||||
|
created_at: Record creation timestamp
|
||||||
|
"""
|
||||||
|
__tablename__ = 'player_edit'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
player_id = db.Column(db.Integer, db.ForeignKey('player.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||||
|
content_id = db.Column(db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||||
|
original_name = db.Column(db.String(255), nullable=False)
|
||||||
|
new_name = db.Column(db.String(255), nullable=False)
|
||||||
|
version = db.Column(db.Integer, default=1, nullable=False)
|
||||||
|
user = db.Column(db.String(255), nullable=True)
|
||||||
|
time_of_modification = db.Column(db.DateTime, nullable=True)
|
||||||
|
metadata_path = db.Column(db.String(512), nullable=True)
|
||||||
|
edited_file_path = db.Column(db.String(512), nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
player = db.relationship('Player', backref=db.backref('edits', lazy='dynamic', cascade='all, delete-orphan'))
|
||||||
|
content = db.relationship('Content', backref=db.backref('edits', lazy='dynamic', cascade='all, delete-orphan'))
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""String representation of PlayerEdit."""
|
||||||
|
return f'<PlayerEdit {self.original_name} v{self.version} by {self.user or "unknown"}>'
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary for API responses."""
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'player_id': self.player_id,
|
||||||
|
'player_name': self.player.name if self.player else None,
|
||||||
|
'content_id': self.content_id,
|
||||||
|
'original_name': self.original_name,
|
||||||
|
'new_name': self.new_name,
|
||||||
|
'version': self.version,
|
||||||
|
'user': self.user,
|
||||||
|
'time_of_modification': self.time_of_modification.isoformat() if self.time_of_modification else None,
|
||||||
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||||
|
'edited_file_path': self.edited_file_path
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ playlist_content = db.Table('playlist_content',
|
|||||||
db.Column('playlist_id', db.Integer, db.ForeignKey('playlist.id', ondelete='CASCADE'), primary_key=True),
|
db.Column('playlist_id', db.Integer, db.ForeignKey('playlist.id', ondelete='CASCADE'), primary_key=True),
|
||||||
db.Column('content_id', db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), primary_key=True),
|
db.Column('content_id', db.Integer, db.ForeignKey('content.id', ondelete='CASCADE'), primary_key=True),
|
||||||
db.Column('position', db.Integer, default=0),
|
db.Column('position', db.Integer, default=0),
|
||||||
db.Column('duration', db.Integer, default=10)
|
db.Column('duration', db.Integer, default=10),
|
||||||
|
db.Column('muted', db.Boolean, default=True),
|
||||||
|
db.Column('edit_on_player_enabled', db.Boolean, default=False)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -76,7 +78,9 @@ class Playlist(db.Model):
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
stmt = select(playlist_content.c.content_id,
|
stmt = select(playlist_content.c.content_id,
|
||||||
playlist_content.c.position,
|
playlist_content.c.position,
|
||||||
playlist_content.c.duration).where(
|
playlist_content.c.duration,
|
||||||
|
playlist_content.c.muted,
|
||||||
|
playlist_content.c.edit_on_player_enabled).where(
|
||||||
playlist_content.c.playlist_id == self.id
|
playlist_content.c.playlist_id == self.id
|
||||||
).order_by(playlist_content.c.position)
|
).order_by(playlist_content.c.position)
|
||||||
|
|
||||||
@@ -88,6 +92,8 @@ class Playlist(db.Model):
|
|||||||
if content:
|
if content:
|
||||||
content._playlist_position = row.position
|
content._playlist_position = row.position
|
||||||
content._playlist_duration = row.duration
|
content._playlist_duration = row.duration
|
||||||
|
content._playlist_muted = row.muted if len(row) > 3 else True
|
||||||
|
content._playlist_edit_on_player_enabled = row.edit_on_player_enabled if len(row) > 4 else False
|
||||||
ordered_content.append(content)
|
ordered_content.append(content)
|
||||||
|
|
||||||
return ordered_content
|
return ordered_content
|
||||||
|
|||||||
@@ -70,6 +70,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- System Dependencies Card -->
|
||||||
|
<div class="card management-card" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
|
||||||
|
<h2>🔧 System Dependencies</h2>
|
||||||
|
<p>Check and install required software dependencies</p>
|
||||||
|
<div class="card-actions">
|
||||||
|
<a href="{{ url_for('admin.dependencies') }}" class="btn btn-primary">
|
||||||
|
View Dependencies
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logo Customization Card -->
|
||||||
|
<div class="card management-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
|
||||||
|
<h2>🎨 Logo Customization</h2>
|
||||||
|
<p>Upload custom logos for header and login page</p>
|
||||||
|
<div class="card-actions">
|
||||||
|
<a href="{{ url_for('admin.customize_logos') }}" class="btn btn-primary">
|
||||||
|
Customize Logos
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Quick Actions Card -->
|
<!-- Quick Actions Card -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>⚡ Quick Actions</h2>
|
<h2>⚡ Quick Actions</h2>
|
||||||
|
|||||||
101
app/templates/admin/customize_logos.html
Normal file
101
app/templates/admin/customize_logos.html
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Logo Customization - DigiServer{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container" style="max-width: 900px;">
|
||||||
|
<h1 style="margin-bottom: 25px;">🎨 Logo Customization</h1>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom: 20px;">
|
||||||
|
<h2 style="margin-bottom: 20px;">📸 Upload Custom Logos</h2>
|
||||||
|
|
||||||
|
<!-- Header Logo -->
|
||||||
|
<div style="margin-bottom: 30px; padding: 20px; background: #f8f9fa; border-radius: 8px;">
|
||||||
|
<h3 style="margin-bottom: 15px;">Header Logo (Small)</h3>
|
||||||
|
<p style="color: #666; margin-bottom: 15px;">
|
||||||
|
This logo appears in the top header next to "DigiServer" text.<br>
|
||||||
|
<strong>Recommended:</strong> 150x40 pixels (or similar aspect ratio), transparent background PNG
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 20px; margin-bottom: 15px;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<img src="{{ url_for('static', filename='uploads/header_logo.png') }}?v={{ version }}"
|
||||||
|
alt="Current Header Logo"
|
||||||
|
style="max-height: 50px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 10px; border-radius: 4px;"
|
||||||
|
onerror="this.src='{{ url_for('static', filename='icons/monitor.svg') }}'; this.style.filter='brightness(0) invert(1)'; this.style.maxWidth='50px';">
|
||||||
|
<p style="margin-top: 5px; font-size: 0.9rem; color: #888;">Current Header Logo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('admin.upload_header_logo') }}" enctype="multipart/form-data">
|
||||||
|
<div style="margin-bottom: 10px;">
|
||||||
|
<input type="file" name="header_logo" accept="image/png,image/jpeg,image/svg+xml" required
|
||||||
|
style="padding: 10px; border: 2px solid #ddd; border-radius: 4px; width: 100%;">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">📤 Upload Header Logo</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Logo -->
|
||||||
|
<div style="margin-bottom: 30px; padding: 20px; background: #f8f9fa; border-radius: 8px;">
|
||||||
|
<h3 style="margin-bottom: 15px;">Login Page Logo (Large)</h3>
|
||||||
|
<p style="color: #666; margin-bottom: 15px;">
|
||||||
|
This logo appears on the left side of the login page (2/3 of screen).<br>
|
||||||
|
<strong>Recommended:</strong> 800x600 pixels (or similar), transparent background PNG
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 20px; margin-bottom: 15px;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<img src="{{ url_for('static', filename='uploads/login_logo.png') }}?v={{ version }}"
|
||||||
|
alt="Current Login Logo"
|
||||||
|
style="max-width: 300px; max-height: 200px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px;"
|
||||||
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
||||||
|
<p style="margin-top: 10px; font-size: 0.9rem; color: #888; display: none;">No login logo uploaded yet</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('admin.upload_login_logo') }}" enctype="multipart/form-data">
|
||||||
|
<div style="margin-bottom: 10px;">
|
||||||
|
<input type="file" name="login_logo" accept="image/png,image/jpeg,image/svg+xml" required
|
||||||
|
style="padding: 10px; border: 2px solid #ddd; border-radius: 4px; width: 100%;">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">📤 Upload Login Logo</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding: 15px; background: #e7f3ff; border-radius: 8px; border-left: 4px solid #0066cc;">
|
||||||
|
<h4 style="margin: 0 0 10px 0;">ℹ️ Logo Guidelines</h4>
|
||||||
|
<ul style="margin: 5px 0; padding-left: 25px; color: #555;">
|
||||||
|
<li><strong>Header Logo:</strong> Keep it simple and small (max 200px width recommended)</li>
|
||||||
|
<li><strong>Login Logo:</strong> Can be larger and more detailed (800x600px works great)</li>
|
||||||
|
<li><strong>Format:</strong> PNG with transparent background recommended, or JPG/SVG</li>
|
||||||
|
<li><strong>File Size:</strong> Keep under 2MB for optimal performance</li>
|
||||||
|
<li>Logos are cached - clear browser cache if changes don't appear immediately</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
|
||||||
|
← Back to Admin Panel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body.dark-mode div[style*="background: #f8f9fa"] {
|
||||||
|
background: #2d3748 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode div[style*="background: #e7f3ff"] {
|
||||||
|
background: #1e3a5f !important;
|
||||||
|
border-left-color: #64b5f6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode p[style*="color: #666"],
|
||||||
|
body.dark-mode p[style*="color: #888"],
|
||||||
|
body.dark-mode ul[style*="color: #555"] {
|
||||||
|
color: #cbd5e0 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
176
app/templates/admin/dependencies.html
Normal file
176
app/templates/admin/dependencies.html
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}System Dependencies - DigiServer v2{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container" style="max-width: 1000px;">
|
||||||
|
<h1 style="margin-bottom: 25px;">🔧 System Dependencies</h1>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom: 20px;">
|
||||||
|
<h2 style="margin-bottom: 20px;">📦 Installed Dependencies</h2>
|
||||||
|
|
||||||
|
<!-- LibreOffice -->
|
||||||
|
<div class="dependency-card" style="background: {% if libreoffice_installed %}#d4edda{% else %}#f8d7da{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if libreoffice_installed %}#28a745{% else %}#dc3545{% endif %};">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
|
||||||
|
{% if libreoffice_installed %}
|
||||||
|
<span style="font-size: 24px;">✅</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-size: 24px;">❌</span>
|
||||||
|
{% endif %}
|
||||||
|
LibreOffice
|
||||||
|
</h3>
|
||||||
|
<p style="margin: 5px 0; color: #555;">
|
||||||
|
<strong>Purpose:</strong> Required for PowerPoint (PPTX/PPT) to image conversion
|
||||||
|
</p>
|
||||||
|
<p style="margin: 5px 0; color: #555;">
|
||||||
|
<strong>Status:</strong> {{ libreoffice_version }}
|
||||||
|
</p>
|
||||||
|
{% if not libreoffice_installed %}
|
||||||
|
<p style="margin: 10px 0 0 0; color: #721c24;">
|
||||||
|
⚠️ Without LibreOffice, you cannot upload or convert PowerPoint presentations.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if not libreoffice_installed %}
|
||||||
|
<form method="POST" action="{{ url_for('admin.install_libreoffice') }}" style="margin-left: 20px;">
|
||||||
|
<button type="submit" class="btn btn-success" onclick="return confirm('Install LibreOffice? This may take 2-5 minutes.');">
|
||||||
|
📥 Install LibreOffice
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Poppler Utils -->
|
||||||
|
<div class="dependency-card" style="background: {% if poppler_installed %}#d4edda{% else %}#fff3cd{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if poppler_installed %}#28a745{% else %}#ffc107{% endif %};">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
|
||||||
|
{% if poppler_installed %}
|
||||||
|
<span style="font-size: 24px;">✅</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-size: 24px;">⚠️</span>
|
||||||
|
{% endif %}
|
||||||
|
Poppler Utils
|
||||||
|
</h3>
|
||||||
|
<p style="margin: 5px 0; color: #555;">
|
||||||
|
<strong>Purpose:</strong> Required for PDF to image conversion
|
||||||
|
</p>
|
||||||
|
<p style="margin: 5px 0; color: #555;">
|
||||||
|
<strong>Status:</strong> {{ poppler_version }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FFmpeg -->
|
||||||
|
<div class="dependency-card" style="background: {% if ffmpeg_installed %}#d4edda{% else %}#fff3cd{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if ffmpeg_installed %}#28a745{% else %}#ffc107{% endif %};">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
|
||||||
|
{% if ffmpeg_installed %}
|
||||||
|
<span style="font-size: 24px;">✅</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-size: 24px;">⚠️</span>
|
||||||
|
{% endif %}
|
||||||
|
FFmpeg
|
||||||
|
</h3>
|
||||||
|
<p style="margin: 5px 0; color: #555;">
|
||||||
|
<strong>Purpose:</strong> Required for video processing and validation
|
||||||
|
</p>
|
||||||
|
<p style="margin: 5px 0; color: #555;">
|
||||||
|
<strong>Status:</strong> {{ ffmpeg_version }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Emoji Fonts -->
|
||||||
|
<div class="dependency-card" style="background: {% if emoji_installed %}#d4edda{% else %}#fff3cd{% endif %}; padding: 20px; border-radius: 8px; margin-bottom: 15px; border-left: 4px solid {% if emoji_installed %}#28a745{% else %}#ffc107{% endif %};">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<h3 style="margin: 0 0 10px 0; display: flex; align-items: center; gap: 10px;">
|
||||||
|
{% if emoji_installed %}
|
||||||
|
<span style="font-size: 24px;">✅</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-size: 24px;">⚠️</span>
|
||||||
|
{% endif %}
|
||||||
|
Emoji Fonts
|
||||||
|
</h3>
|
||||||
|
<p style="margin: 5px 0; color: #555;">
|
||||||
|
<strong>Purpose:</strong> Better emoji display in UI (optional, mainly for Raspberry Pi)
|
||||||
|
</p>
|
||||||
|
<p style="margin: 5px 0; color: #555;">
|
||||||
|
<strong>Status:</strong> {{ emoji_version }}
|
||||||
|
</p>
|
||||||
|
{% if not emoji_installed %}
|
||||||
|
<p style="margin: 10px 0 0 0; color: #856404;">
|
||||||
|
ℹ️ Optional: Improves emoji rendering on systems without native emoji support.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if not emoji_installed %}
|
||||||
|
<form method="POST" action="{{ url_for('admin.install_emoji_fonts') }}" style="margin-left: 20px;">
|
||||||
|
<button type="submit" class="btn btn-warning" onclick="return confirm('Install emoji fonts? This may take 1-2 minutes.');">
|
||||||
|
📥 Install Emoji Fonts
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 25px; padding: 15px; background: #e7f3ff; border-radius: 8px; border-left: 4px solid #0066cc;">
|
||||||
|
<h4 style="margin: 0 0 10px 0;">ℹ️ Installation Notes</h4>
|
||||||
|
<ul style="margin: 5px 0; padding-left: 25px; color: #555;">
|
||||||
|
<li>LibreOffice can be installed using the button above (requires sudo access)</li>
|
||||||
|
<li>Emoji fonts improve UI display, especially on Raspberry Pi systems</li>
|
||||||
|
<li>Installation may take 1-5 minutes depending on your internet connection</li>
|
||||||
|
<li>After installation, refresh this page to verify the status</li>
|
||||||
|
<li>Docker containers may require rebuilding to include dependencies</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-secondary">
|
||||||
|
← Back to Admin Panel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body.dark-mode .dependency-card {
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .dependency-card p {
|
||||||
|
color: #cbd5e0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .dependency-card[style*="#d4edda"] {
|
||||||
|
background: #1e4620 !important;
|
||||||
|
border-left-color: #48bb78 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .dependency-card[style*="#f8d7da"] {
|
||||||
|
background: #5a1e1e !important;
|
||||||
|
border-left-color: #ef5350 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .dependency-card[style*="#fff3cd"] {
|
||||||
|
background: #5a4a1e !important;
|
||||||
|
border-left-color: #ffc107 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode div[style*="#e7f3ff"] {
|
||||||
|
background: #1e3a5f !important;
|
||||||
|
border-left-color: #64b5f6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode div[style*="#e7f3ff"] ul {
|
||||||
|
color: #cbd5e0 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
<td style="padding: 10px;">📷 {{ img.filename }}</td>
|
<td style="padding: 10px;">📷 {{ img.filename }}</td>
|
||||||
<td style="padding: 10px;">{{ "%.2f"|format(img.file_size_mb) }} MB</td>
|
<td style="padding: 10px;">{{ "%.2f"|format(img.file_size_mb) }} MB</td>
|
||||||
<td style="padding: 10px;">{{ img.duration }}s</td>
|
<td style="padding: 10px;">{{ img.duration }}s</td>
|
||||||
<td style="padding: 10px;">{{ img.uploaded_at.strftime('%Y-%m-%d %H:%M') if img.uploaded_at else 'N/A' }}</td>
|
<td style="padding: 10px;">{{ img.uploaded_at | localtime if img.uploaded_at else 'N/A' }}</td>
|
||||||
<td style="padding: 10px;">
|
<td style="padding: 10px;">
|
||||||
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=img.id) }}" style="display: inline;" onsubmit="return confirm('Delete this image?');">
|
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=img.id) }}" style="display: inline;" onsubmit="return confirm('Delete this image?');">
|
||||||
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
|
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
<td style="padding: 10px;">🎥 {{ video.filename }}</td>
|
<td style="padding: 10px;">🎥 {{ video.filename }}</td>
|
||||||
<td style="padding: 10px;">{{ "%.2f"|format(video.file_size_mb) }} MB</td>
|
<td style="padding: 10px;">{{ "%.2f"|format(video.file_size_mb) }} MB</td>
|
||||||
<td style="padding: 10px;">{{ video.duration }}s</td>
|
<td style="padding: 10px;">{{ video.duration }}s</td>
|
||||||
<td style="padding: 10px;">{{ video.uploaded_at.strftime('%Y-%m-%d %H:%M') if video.uploaded_at else 'N/A' }}</td>
|
<td style="padding: 10px;">{{ video.uploaded_at | localtime if video.uploaded_at else 'N/A' }}</td>
|
||||||
<td style="padding: 10px;">
|
<td style="padding: 10px;">
|
||||||
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=video.id) }}" style="display: inline;" onsubmit="return confirm('Delete this video?');">
|
<form method="POST" action="{{ url_for('admin.delete_single_leftover', content_id=video.id) }}" style="display: inline;" onsubmit="return confirm('Delete this video?');">
|
||||||
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
|
<button type="submit" class="btn btn-danger btn-sm" style="padding: 4px 8px; font-size: 12px;">🗑️</button>
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
<td style="padding: 10px;">📄 {{ pdf.filename }}</td>
|
<td style="padding: 10px;">📄 {{ pdf.filename }}</td>
|
||||||
<td style="padding: 10px;">{{ "%.2f"|format(pdf.file_size_mb) }} MB</td>
|
<td style="padding: 10px;">{{ "%.2f"|format(pdf.file_size_mb) }} MB</td>
|
||||||
<td style="padding: 10px;">{{ pdf.duration }}s</td>
|
<td style="padding: 10px;">{{ pdf.duration }}s</td>
|
||||||
<td style="padding: 10px;">{{ pdf.uploaded_at.strftime('%Y-%m-%d %H:%M') if pdf.uploaded_at else 'N/A' }}</td>
|
<td style="padding: 10px;">{{ pdf.uploaded_at | localtime if pdf.uploaded_at else 'N/A' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -37,8 +37,8 @@
|
|||||||
{{ user.role|capitalize }}
|
{{ user.role|capitalize }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else 'N/A' }}</td>
|
<td>{{ user.created_at | localtime if user.created_at else 'N/A' }}</td>
|
||||||
<td>{{ user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else 'Never' }}</td>
|
<td>{{ user.last_login | localtime if user.last_login else 'Never' }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
{% if user.id != current_user.id %}
|
{% if user.id != current_user.id %}
|
||||||
<button class="btn btn-sm btn-warning" onclick="showEditUserModal({{ user.id }}, '{{ user.username }}', '{{ user.role }}')">
|
<button class="btn btn-sm btn-warning" onclick="showEditUserModal({{ user.id }}, '{{ user.username }}', '{{ user.role }}')">
|
||||||
|
|||||||
@@ -1,31 +1,229 @@
|
|||||||
{% extends "base.html" %}
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
{% block title %}Login - DigiServer v2{% endblock %}
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
{% block content %}
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<div class="card" style="max-width: 400px; margin: 2rem auto;">
|
<title>Login - DigiServer</title>
|
||||||
<h2>Login</h2>
|
<style>
|
||||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<label for="username" style="display: block; margin-bottom: 0.5rem;">Username</label>
|
body {
|
||||||
<input type="text" id="username" name="username" required
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-section {
|
||||||
|
flex: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-section img {
|
||||||
|
max-width: 70%;
|
||||||
|
max-height: 70%;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 10px 30px rgba(0, 0, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form h2 {
|
||||||
|
color: #2d3748;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #4a5568;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"]:focus,
|
||||||
|
.form-group input[type="password"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-me {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-me input {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remember-me label {
|
||||||
|
color: #4a5568;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-messages {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: #fed7d7;
|
||||||
|
color: #c53030;
|
||||||
|
border: 1px solid #fc8181;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: #c6f6d5;
|
||||||
|
color: #2f855a;
|
||||||
|
border: 1px solid #68d391;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
background: #feebc8;
|
||||||
|
color: #c05621;
|
||||||
|
border: 1px solid #f6ad55;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.login-container {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<!-- Logo Section (Left - 2/3) -->
|
||||||
|
<div class="logo-section">
|
||||||
|
<img src="{{ url_for('static', filename='uploads/login_logo.png') }}?v={{ range(1, 999999) | random }}"
|
||||||
|
alt="DigiServer Logo"
|
||||||
|
onerror="this.style.display='none';">
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<label for="password" style="display: block; margin-bottom: 0.5rem;">Password</label>
|
<!-- Form Section (Right - 1/3) -->
|
||||||
<input type="password" id="password" name="password" required
|
<div class="form-section">
|
||||||
style="width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;">
|
<div class="login-form">
|
||||||
|
<h2>Welcome Back</h2>
|
||||||
|
|
||||||
|
<!-- Flash Messages -->
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash-messages">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }}">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="remember-me">
|
||||||
|
<input type="checkbox" id="remember" name="remember" value="yes">
|
||||||
|
<label for="remember">Remember me</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-login">Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-bottom: 1rem;">
|
</div>
|
||||||
<label>
|
</body>
|
||||||
<input type="checkbox" name="remember" value="yes">
|
</html>
|
||||||
Remember me
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn" style="width: 100%;">Login</button>
|
|
||||||
</form>
|
|
||||||
<p style="margin-top: 1rem; text-align: center;">
|
|
||||||
Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
@@ -69,6 +69,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
header h1 {
|
header h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
@@ -80,6 +82,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
nav a {
|
nav a {
|
||||||
color: white;
|
color: white;
|
||||||
@@ -102,6 +105,69 @@
|
|||||||
filter: brightness(0) invert(1);
|
filter: brightness(0) invert(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
header .container {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode-toggle {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 15px;
|
||||||
|
margin: -1rem -1rem 1rem -1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dark-mode-toggle {
|
.dark-mode-toggle {
|
||||||
background: rgba(255,255,255,0.2);
|
background: rgba(255,255,255,0.2);
|
||||||
border: 2px solid rgba(255,255,255,0.3);
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
@@ -310,8 +376,8 @@
|
|||||||
<header>
|
<header>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>
|
<h1>
|
||||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="DigiServer" style="width: 28px; height: 28px; filter: brightness(0) invert(1);">
|
<img src="{{ url_for('static', filename='uploads/header_logo.png') }}" alt="DigiServer" style="height: 32px; width: auto;" onerror="this.src='{{ url_for('static', filename='icons/monitor.svg') }}'; this.style.filter='brightness(0) invert(1)'; this.style.width='28px'; this.style.height='28px';">
|
||||||
DigiServer v2
|
DigiServer
|
||||||
</h1>
|
</h1>
|
||||||
<nav>
|
<nav>
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 12px;">
|
<td style="padding: 12px;">
|
||||||
<small style="color: #6c757d;">{{ item.uploaded_at.strftime('%Y-%m-%d %H:%M') }}</small>
|
<small style="color: #6c757d;">{{ item.uploaded_at | localtime }}</small>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 12px;">
|
<td style="padding: 12px;">
|
||||||
{% if item.player_count > 0 %}
|
{% if item.player_count > 0 %}
|
||||||
|
|||||||
@@ -249,6 +249,12 @@
|
|||||||
background: #1a202c !important;
|
background: #1a202c !important;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode for upload section */
|
||||||
|
body.dark-mode .card > div[style*="background: #f8f9fa"] {
|
||||||
|
background: #2d3748 !important;
|
||||||
|
border: 1px solid #4a5568;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container" style="max-width: 1400px;">
|
<div class="container" style="max-width: 1400px;">
|
||||||
@@ -258,18 +264,17 @@
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="main-grid">
|
<div class="main-grid">
|
||||||
<!-- Create/Manage Playlists Card -->
|
<!-- Create Playlist Card -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
|
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
|
||||||
Playlists
|
Create New Playlist
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create New Playlist Form -->
|
<!-- Create New Playlist Form -->
|
||||||
<form method="POST" action="{{ url_for('content.create_playlist') }}" style="margin-bottom: 25px;">
|
<form method="POST" action="{{ url_for('content.create_playlist') }}">
|
||||||
<h3 style="margin-bottom: 15px;">Create New Playlist</h3>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="playlist_name">Playlist Name *</label>
|
<label for="playlist_name">Playlist Name *</label>
|
||||||
<input type="text" name="name" id="playlist_name" class="form-control" required
|
<input type="text" name="name" id="playlist_name" class="form-control" required
|
||||||
@@ -294,47 +299,6 @@
|
|||||||
➕ Create Playlist
|
➕ Create Playlist
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<hr style="margin: 25px 0;">
|
|
||||||
|
|
||||||
<!-- Existing Playlists -->
|
|
||||||
<h3 style="margin-bottom: 15px;">Existing Playlists</h3>
|
|
||||||
<div class="playlist-list">
|
|
||||||
{% if playlists %}
|
|
||||||
{% for playlist in playlists %}
|
|
||||||
<div class="playlist-item">
|
|
||||||
<div class="playlist-info">
|
|
||||||
<h3>{{ playlist.name }}</h3>
|
|
||||||
<div class="playlist-stats">
|
|
||||||
📊 {{ playlist.content_count }} items |
|
|
||||||
👥 {{ playlist.player_count }} players |
|
|
||||||
🔄 v{{ playlist.version }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="playlist-actions">
|
|
||||||
<a href="{{ url_for('content.manage_playlist_content', playlist_id=playlist.id) }}"
|
|
||||||
class="btn btn-primary btn-sm">
|
|
||||||
✏️ Manage
|
|
||||||
</a>
|
|
||||||
<form method="POST"
|
|
||||||
action="{{ url_for('content.delete_playlist', playlist_id=playlist.id) }}"
|
|
||||||
style="display: inline;"
|
|
||||||
onsubmit="return confirm('Delete playlist {{ playlist.name }}?');">
|
|
||||||
<button type="submit" class="btn btn-danger btn-sm" style="display: flex; align-items: center; gap: 0.3rem;">
|
|
||||||
<img src="{{ url_for('static', filename='icons/trash.svg') }}" alt="" style="width: 14px; height: 14px; filter: brightness(0) invert(1);">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<div style="text-align: center; padding: 40px; color: #999;">
|
|
||||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 64px; height: 64px; opacity: 0.3; margin-bottom: 10px;">
|
|
||||||
<p>No playlists yet. Create your first playlist above!</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload Media Card -->
|
<!-- Upload Media Card -->
|
||||||
@@ -356,9 +320,10 @@
|
|||||||
|
|
||||||
<!-- Media Library with Thumbnails -->
|
<!-- Media Library with Thumbnails -->
|
||||||
<h3 style="margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between;">
|
<h3 style="margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between;">
|
||||||
<span>📚 Available Media ({{ media_files|length }})</span>
|
<span>📚 Last 3 Added Media</span>
|
||||||
|
<small style="color: #6c757d; font-size: 0.85rem;">Total: {{ total_media_count }}</small>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="media-library" style="max-height: 500px; overflow-y: auto;">
|
<div class="media-library" style="max-height: 350px; overflow-y: auto;">
|
||||||
{% if media_files %}
|
{% if media_files %}
|
||||||
{% for media in media_files %}
|
{% for media in media_files %}
|
||||||
<div class="media-item" title="{{ media.filename }}">
|
<div class="media-item" title="{{ media.filename }}">
|
||||||
@@ -393,6 +358,63 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- View All Media Button -->
|
||||||
|
{% if total_media_count > 3 %}
|
||||||
|
<div style="text-align: center; padding: 15px; border-top: 1px solid #dee2e6; margin-top: 10px;">
|
||||||
|
<a href="{{ url_for('content.media_library') }}" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span>📚</span>
|
||||||
|
View All Media ({{ total_media_count }} files)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Playlists Card -->
|
||||||
|
<div class="card full-width">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px; filter: brightness(0) invert(1);">
|
||||||
|
Existing Playlists
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="playlist-list">
|
||||||
|
{% if playlists %}
|
||||||
|
{% for playlist in playlists %}
|
||||||
|
<div class="playlist-item">
|
||||||
|
<div class="playlist-info">
|
||||||
|
<h3>{{ playlist.name }}</h3>
|
||||||
|
<div class="playlist-stats">
|
||||||
|
📊 {{ playlist.content_count }} items |
|
||||||
|
👥 {{ playlist.player_count }} players |
|
||||||
|
🔄 v{{ playlist.version }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-actions">
|
||||||
|
<a href="{{ url_for('content.manage_playlist_content', playlist_id=playlist.id) }}"
|
||||||
|
class="btn btn-primary btn-sm">
|
||||||
|
✏️ Manage
|
||||||
|
</a>
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('content.delete_playlist', playlist_id=playlist.id) }}"
|
||||||
|
style="display: inline;"
|
||||||
|
onsubmit="return confirm('Delete playlist {{ playlist.name }}?');">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm" style="display: flex; align-items: center; gap: 0.3rem;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/trash.svg') }}" alt="" style="width: 14px; height: 14px; filter: brightness(0) invert(1);">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; padding: 40px; color: #999;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 64px; height: 64px; opacity: 0.3; margin-bottom: 10px;">
|
||||||
|
<p>No playlists yet. Create your first playlist above!</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,72 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Audio toggle styles */
|
||||||
|
.audio-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-checkbox {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-label {
|
||||||
|
font-size: 20px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-checkbox + .audio-label .audio-on {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-checkbox + .audio-label .audio-off {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-checkbox:checked + .audio-label .audio-on {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-checkbox:checked + .audio-label .audio-off {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-label:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
body.dark-mode .playlist-table th {
|
||||||
|
background: #1a202c;
|
||||||
|
color: #cbd5e0;
|
||||||
|
border-bottom-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .playlist-table td {
|
||||||
|
border-bottom-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .draggable-row:hover {
|
||||||
|
background: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .drag-handle {
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .content-item {
|
||||||
|
background: #1a202c;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .available-content {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container" style="max-width: 1400px;">
|
<div class="container" style="max-width: 1400px;">
|
||||||
@@ -125,23 +191,36 @@
|
|||||||
|
|
||||||
<div class="content-grid">
|
<div class="content-grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-bottom: 20px;">📋 Playlist Content (Drag to Reorder)</h2>
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
|
<h2 style="margin: 0;">📋 Playlist Content (Drag to Reorder)</h2>
|
||||||
|
<button id="bulk-delete-btn" class="btn btn-danger" style="display: none;" onclick="bulkDeleteSelected()">
|
||||||
|
🗑️ Delete Selected (<span id="selected-count">0</span>)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if playlist_content %}
|
{% if playlist_content %}
|
||||||
<table class="playlist-table" id="playlist-table">
|
<table class="playlist-table" id="playlist-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width: 40px;">
|
||||||
|
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" title="Select all">
|
||||||
|
</th>
|
||||||
<th style="width: 40px;"></th>
|
<th style="width: 40px;"></th>
|
||||||
<th style="width: 50px;">#</th>
|
<th style="width: 50px;">#</th>
|
||||||
<th>Filename</th>
|
<th>Filename</th>
|
||||||
<th style="width: 100px;">Type</th>
|
<th style="width: 100px;">Type</th>
|
||||||
<th style="width: 100px;">Duration</th>
|
<th style="width: 100px;">Duration</th>
|
||||||
|
<th style="width: 80px;">Audio</th>
|
||||||
|
<th style="width: 80px;">Edit</th>
|
||||||
<th style="width: 100px;">Actions</th>
|
<th style="width: 100px;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="playlist-tbody">
|
<tbody id="playlist-tbody">
|
||||||
{% for content in playlist_content %}
|
{% for content in playlist_content %}
|
||||||
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
|
<tr class="draggable-row" draggable="true" data-content-id="{{ content.id }}">
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" class="content-checkbox" data-content-id="{{ content.id }}" onchange="updateBulkDeleteButton()">
|
||||||
|
</td>
|
||||||
<td><span class="drag-handle">⋮⋮</span></td>
|
<td><span class="drag-handle">⋮⋮</span></td>
|
||||||
<td>{{ loop.index }}</td>
|
<td>{{ loop.index }}</td>
|
||||||
<td>{{ content.filename }}</td>
|
<td>{{ content.filename }}</td>
|
||||||
@@ -152,6 +231,40 @@
|
|||||||
{% else %}📁 Other{% endif %}
|
{% else %}📁 Other{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ content._playlist_duration or content.duration }}s</td>
|
<td>{{ content._playlist_duration or content.duration }}s</td>
|
||||||
|
<td>
|
||||||
|
{% if content.content_type == 'video' %}
|
||||||
|
<label class="audio-toggle">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="audio-checkbox"
|
||||||
|
data-content-id="{{ content.id }}"
|
||||||
|
{{ 'checked' if not content._playlist_muted else '' }}
|
||||||
|
onchange="toggleAudio({{ content.id }}, this.checked)">
|
||||||
|
<span class="audio-label">
|
||||||
|
<span class="audio-on">🔊</span>
|
||||||
|
<span class="audio-off">🔇</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #999;">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if content.content_type in ['image', 'pdf'] %}
|
||||||
|
<label class="audio-toggle">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="edit-checkbox"
|
||||||
|
data-content-id="{{ content.id }}"
|
||||||
|
{{ 'checked' if content._playlist_edit_on_player_enabled else '' }}
|
||||||
|
onchange="toggleEdit({{ content.id }}, this.checked)">
|
||||||
|
<span class="audio-label">
|
||||||
|
<span class="audio-on">✏️</span>
|
||||||
|
<span class="audio-off">🔒</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #999;">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="POST"
|
<form method="POST"
|
||||||
action="{{ url_for('content.remove_content_from_playlist', playlist_id=playlist.id, content_id=content.id) }}"
|
action="{{ url_for('content.remove_content_from_playlist', playlist_id=playlist.id, content_id=content.id) }}"
|
||||||
@@ -299,6 +412,145 @@ function saveOrder() {
|
|||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleAudio(contentId, enabled) {
|
||||||
|
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
|
||||||
|
const playlistId = {{ playlist.id }};
|
||||||
|
const url = `/content/playlist/${playlistId}/update-muted/${contentId}`;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('muted', muted ? 'true' : 'false');
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
console.log('Audio setting updated:', enabled ? 'Enabled' : 'Muted');
|
||||||
|
} else {
|
||||||
|
alert('Error updating audio setting: ' + data.message);
|
||||||
|
// Revert checkbox on error
|
||||||
|
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
|
||||||
|
if (checkbox) checkbox.checked = !enabled;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Error updating audio setting');
|
||||||
|
// Revert checkbox on error
|
||||||
|
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
|
||||||
|
if (checkbox) checkbox.checked = !enabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEdit(contentId, enabled) {
|
||||||
|
const playlistId = {{ playlist.id }};
|
||||||
|
const url = `/content/playlist/${playlistId}/update-edit-enabled/${contentId}`;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('edit_enabled', enabled ? 'true' : 'false');
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
console.log('Edit setting updated:', enabled ? 'Enabled' : 'Disabled');
|
||||||
|
} else {
|
||||||
|
alert('Error updating edit setting: ' + data.message);
|
||||||
|
// Revert checkbox on error
|
||||||
|
const checkbox = document.querySelector(`.edit-checkbox[data-content-id="${contentId}"]`);
|
||||||
|
if (checkbox) checkbox.checked = !enabled;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Error updating edit setting');
|
||||||
|
// Revert checkbox on error
|
||||||
|
const checkbox = document.querySelector(`.edit-checkbox[data-content-id="${contentId}"]`);
|
||||||
|
if (checkbox) checkbox.checked = !enabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll(checkbox) {
|
||||||
|
const checkboxes = document.querySelectorAll('.content-checkbox');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.checked = checkbox.checked;
|
||||||
|
});
|
||||||
|
updateBulkDeleteButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkDeleteButton() {
|
||||||
|
const checkboxes = document.querySelectorAll('.content-checkbox:checked');
|
||||||
|
const count = checkboxes.length;
|
||||||
|
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
|
||||||
|
const selectedCount = document.getElementById('selected-count');
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
bulkDeleteBtn.style.display = 'block';
|
||||||
|
selectedCount.textContent = count;
|
||||||
|
} else {
|
||||||
|
bulkDeleteBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update select-all checkbox state
|
||||||
|
const allCheckboxes = document.querySelectorAll('.content-checkbox');
|
||||||
|
const selectAllCheckbox = document.getElementById('select-all');
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
selectAllCheckbox.checked = allCheckboxes.length > 0 && count === allCheckboxes.length;
|
||||||
|
selectAllCheckbox.indeterminate = count > 0 && count < allCheckboxes.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bulkDeleteSelected() {
|
||||||
|
const checkboxes = document.querySelectorAll('.content-checkbox:checked');
|
||||||
|
const contentIds = Array.from(checkboxes).map(cb => parseInt(cb.dataset.contentId));
|
||||||
|
|
||||||
|
if (contentIds.length === 0) {
|
||||||
|
alert('No items selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmMsg = `Are you sure you want to remove ${contentIds.length} item(s) from this playlist?`;
|
||||||
|
if (!confirm(confirmMsg)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const bulkDeleteBtn = document.getElementById('bulk-delete-btn');
|
||||||
|
const originalText = bulkDeleteBtn.innerHTML;
|
||||||
|
bulkDeleteBtn.disabled = true;
|
||||||
|
bulkDeleteBtn.innerHTML = '⏳ Removing...';
|
||||||
|
|
||||||
|
fetch('{{ url_for("content.bulk_remove_from_playlist", playlist_id=playlist.id) }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content_ids: contentIds })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Reload page to show updated playlist
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Error removing items: ' + data.message);
|
||||||
|
bulkDeleteBtn.disabled = false;
|
||||||
|
bulkDeleteBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Error removing items from playlist');
|
||||||
|
bulkDeleteBtn.disabled = false;
|
||||||
|
bulkDeleteBtn.innerHTML = originalText;
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
806
app/templates/content/media_library.html
Normal file
806
app/templates/content/media_library.html
Normal file
@@ -0,0 +1,806 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Media Library - DigiServer v2{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .media-card {
|
||||||
|
background: #1a202c;
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .media-thumbnail {
|
||||||
|
background: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .media-info {
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-filename {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .media-filename {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-card:hover .delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background: #a02834;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-badge.in-use {
|
||||||
|
background: #ffc107;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-badge.unused {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .playlist-badge.in-use {
|
||||||
|
background: #856404;
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .upload-section {
|
||||||
|
background: #2d3748 !important;
|
||||||
|
border: 1px solid #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .playlist-badge.unused {
|
||||||
|
background: #1a4d2e;
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge.image { background: #d4edda; color: #155724; }
|
||||||
|
.type-badge.video { background: #cce5ff; color: #004085; }
|
||||||
|
.type-badge.pdf { background: #fff3cd; color: #856404; }
|
||||||
|
.type-badge.pptx { background: #f8d7da; color: #721c24; }
|
||||||
|
|
||||||
|
body.dark-mode .type-badge.image { background: #1a4d2e; color: #86efac; }
|
||||||
|
body.dark-mode .type-badge.video { background: #1e3a5f; color: #93c5fd; }
|
||||||
|
body.dark-mode .type-badge.pdf { background: #4a3800; color: #fbbf24; }
|
||||||
|
body.dark-mode .type-badge.pptx { background: #4a1a1a; color: #fca5a5; }
|
||||||
|
|
||||||
|
.stats-box {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .stat-item {
|
||||||
|
background: #1a202c;
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .stat-value {
|
||||||
|
color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .stat-label {
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 30px 0 15px 0;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .section-header {
|
||||||
|
border-bottom-color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .section-header h2 {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<h1 style="display: inline-block; margin-right: 1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||||
|
📚 Media Library
|
||||||
|
</h1>
|
||||||
|
<a href="{{ url_for('content.content_list') }}" class="btn" style="float: right; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||||
|
Back to Playlists
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics -->
|
||||||
|
<div class="stats-box">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ media_files|length }}</div>
|
||||||
|
<div class="stat-label">Total Files</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ images|length }}</div>
|
||||||
|
<div class="stat-label">Images</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ videos|length }}</div>
|
||||||
|
<div class="stat-label">Videos</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ pdfs|length }}</div>
|
||||||
|
<div class="stat-label">PDFs</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">{{ presentations|length }}</div>
|
||||||
|
<div class="stat-label">Presentations</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Button -->
|
||||||
|
<div class="upload-section" style="text-align: center; padding: 20px; background: #f8f9fa; border-radius: 8px; margin-bottom: 30px;">
|
||||||
|
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||||
|
Upload New Media
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Images Section -->
|
||||||
|
{% if images %}
|
||||||
|
<div class="section-header">
|
||||||
|
<span style="font-size: 32px;">📷</span>
|
||||||
|
<h2>Images ({{ images|length }})</h2>
|
||||||
|
</div>
|
||||||
|
<div class="media-grid">
|
||||||
|
{% for media in images %}
|
||||||
|
<div class="media-card">
|
||||||
|
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||||
|
<div class="media-thumbnail">
|
||||||
|
<img src="{{ url_for('static', filename='uploads/' + media.filename) }}"
|
||||||
|
alt="{{ media.filename }}"
|
||||||
|
onerror="this.style.display='none'; this.parentElement.innerHTML='<span class=\'media-icon\'>📷</span>'">
|
||||||
|
</div>
|
||||||
|
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
||||||
|
<div class="media-info">
|
||||||
|
<span class="type-badge image">Image</span>
|
||||||
|
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||||
|
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
|
||||||
|
{% if media.playlists.count() > 0 %}
|
||||||
|
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="playlist-badge unused">✓ Not in use</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Videos Section -->
|
||||||
|
{% if videos %}
|
||||||
|
<div class="section-header">
|
||||||
|
<span style="font-size: 32px;">🎥</span>
|
||||||
|
<h2>Videos ({{ videos|length }})</h2>
|
||||||
|
</div>
|
||||||
|
<div class="media-grid">
|
||||||
|
{% for media in videos %}
|
||||||
|
<div class="media-card">
|
||||||
|
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||||
|
<div class="media-thumbnail">
|
||||||
|
<span class="media-icon">🎥</span>
|
||||||
|
</div>
|
||||||
|
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
||||||
|
<div class="media-info">
|
||||||
|
<span class="type-badge video">Video</span>
|
||||||
|
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||||
|
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
|
||||||
|
{% if media.playlists.count() > 0 %}
|
||||||
|
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="playlist-badge unused">✓ Not in use</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- PDFs Section -->
|
||||||
|
{% if pdfs %}
|
||||||
|
<div class="section-header">
|
||||||
|
<span style="font-size: 32px;">📄</span>
|
||||||
|
<h2>PDFs ({{ pdfs|length }})</h2>
|
||||||
|
</div>
|
||||||
|
<div class="media-grid">
|
||||||
|
{% for media in pdfs %}
|
||||||
|
<div class="media-card">
|
||||||
|
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||||
|
<div class="media-thumbnail">
|
||||||
|
<span class="media-icon">📄</span>
|
||||||
|
</div>
|
||||||
|
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
||||||
|
<div class="media-info">
|
||||||
|
<span class="type-badge pdf">PDF</span>
|
||||||
|
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||||
|
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
|
||||||
|
{% if media.playlists.count() > 0 %}
|
||||||
|
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="playlist-badge unused">✓ Not in use</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Presentations Section -->
|
||||||
|
{% if presentations %}
|
||||||
|
<div class="section-header">
|
||||||
|
<span style="font-size: 32px;">📊</span>
|
||||||
|
<h2>Presentations ({{ presentations|length }})</h2>
|
||||||
|
</div>
|
||||||
|
<div class="media-grid">
|
||||||
|
{% for media in presentations %}
|
||||||
|
<div class="media-card">
|
||||||
|
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||||
|
<div class="media-thumbnail">
|
||||||
|
<span class="media-icon">📊</span>
|
||||||
|
</div>
|
||||||
|
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
||||||
|
<div class="media-info">
|
||||||
|
<span class="type-badge pptx">PPTX</span>
|
||||||
|
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||||
|
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
|
||||||
|
{% if media.playlists.count() > 0 %}
|
||||||
|
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="playlist-badge unused">✓ Not in use</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Others Section -->
|
||||||
|
{% if others %}
|
||||||
|
<div class="section-header">
|
||||||
|
<span style="font-size: 32px;">📁</span>
|
||||||
|
<h2>Other Files ({{ others|length }})</h2>
|
||||||
|
</div>
|
||||||
|
<div class="media-grid">
|
||||||
|
{% for media in others %}
|
||||||
|
<div class="media-card">
|
||||||
|
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||||
|
<div class="media-thumbnail">
|
||||||
|
<span class="media-icon">📁</span>
|
||||||
|
</div>
|
||||||
|
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
||||||
|
<div class="media-info">
|
||||||
|
<span class="type-badge">{{ media.content_type }}</span>
|
||||||
|
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||||
|
<div style="font-size: 10px; margin-top: 3px;">{{ media.uploaded_at | localtime('%Y-%m-%d') }}</div>
|
||||||
|
{% if media.playlists.count() > 0 %}
|
||||||
|
<div class="playlist-badge in-use">📋 In {{ media.playlists.count() }} playlist(s)</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="playlist-badge unused">✓ Not in use</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not media_files %}
|
||||||
|
<div style="text-align: center; padding: 80px 20px;">
|
||||||
|
<div style="font-size: 96px; margin-bottom: 20px;">📭</div>
|
||||||
|
<h2 style="color: #6c757d;">No Media Files Yet</h2>
|
||||||
|
<p style="color: #999; margin-bottom: 30px;">Start by uploading your first media file!</p>
|
||||||
|
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-success" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||||
|
Upload Media
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div id="deleteModal" class="delete-modal">
|
||||||
|
<div class="delete-modal-content">
|
||||||
|
<div class="delete-modal-header">
|
||||||
|
<span class="delete-icon">⚠️</span>
|
||||||
|
<h2>Confirm Delete</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delete-modal-body">
|
||||||
|
<p class="delete-question">
|
||||||
|
Are you sure you want to delete <strong id="deleteFilename" class="delete-filename"></strong>?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="playlistWarning" class="warning-box warning-playlist">
|
||||||
|
<div class="warning-header">
|
||||||
|
<span>⚠️</span>
|
||||||
|
<strong>Playlist Warning</strong>
|
||||||
|
</div>
|
||||||
|
<p>This file is used in <strong id="playlistCount"></strong> playlist(s). Deleting it will remove it from all playlists and increment their version numbers.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="editWarning" class="warning-box warning-edit">
|
||||||
|
<div class="warning-header">
|
||||||
|
<span>✏️</span>
|
||||||
|
<strong>Edited Versions</strong>
|
||||||
|
</div>
|
||||||
|
<p>This file has <strong id="editCount"></strong> edited version(s) from player devices. All edited versions and their metadata will also be permanently deleted.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delete-final-warning">
|
||||||
|
<strong>⚠️ This action cannot be undone!</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="delete-modal-footer">
|
||||||
|
<button onclick="closeDeleteModal()" class="btn btn-cancel">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<form id="deleteForm" method="POST">
|
||||||
|
<button type="submit" class="btn btn-delete">
|
||||||
|
Yes, Delete File
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Delete Modal - Light Mode Styles */
|
||||||
|
.delete-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-content {
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||||
|
margin: 8% auto;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 550px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: slideDown 0.3s ease-out;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
transform: translateY(-50px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-header {
|
||||||
|
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 24px 30px;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-body {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-question {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
color: #2d3748;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-filename {
|
||||||
|
color: #dc3545;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-box {
|
||||||
|
display: none;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 16px 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 4px solid;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-box:hover {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-header span {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-header strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-box p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-playlist {
|
||||||
|
background: linear-gradient(135deg, #fff8e1 0%, #fff3cd 100%);
|
||||||
|
border-color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-playlist .warning-header,
|
||||||
|
.warning-playlist p {
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-edit {
|
||||||
|
background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%);
|
||||||
|
border-color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-edit .warning-header,
|
||||||
|
.warning-edit p {
|
||||||
|
color: #5b21b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-final-warning {
|
||||||
|
background: linear-gradient(135deg, #fee 0%, #fdd 100%);
|
||||||
|
border: 2px solid #dc3545;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 20px 0 0 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-final-warning strong {
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-footer {
|
||||||
|
padding: 20px 30px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 0 0 16px 16px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-footer form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: linear-gradient(135deg, #5a6268 0%, #4e555b 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 6px rgba(220, 53, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background: linear-gradient(135deg, #c82333 0%, #bd2130 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.5);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete Modal - Dark Mode Styles */
|
||||||
|
body.dark-mode .delete-modal {
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .delete-modal-content {
|
||||||
|
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
|
||||||
|
border: 1px solid #4a5568;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .delete-modal-header {
|
||||||
|
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(185, 28, 28, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .delete-question {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .delete-filename {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .warning-playlist {
|
||||||
|
background: linear-gradient(135deg, #422006 0%, #713f12 100%);
|
||||||
|
border-color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .warning-playlist .warning-header,
|
||||||
|
body.dark-mode .warning-playlist p {
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .warning-edit {
|
||||||
|
background: linear-gradient(135deg, #3b0764 0%, #581c87 100%);
|
||||||
|
border-color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .warning-edit .warning-header,
|
||||||
|
body.dark-mode .warning-edit p {
|
||||||
|
color: #ddd6fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .delete-final-warning {
|
||||||
|
background: linear-gradient(135deg, #450a0a 0%, #7f1d1d 100%);
|
||||||
|
border-color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .delete-final-warning strong {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .delete-modal-footer {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-top: 1px solid #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .btn-cancel {
|
||||||
|
background: linear-gradient(135deg, #374151 0%, #1f2937 100%);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .btn-cancel:hover {
|
||||||
|
background: linear-gradient(135deg, #4b5563 0%, #374151 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .btn-delete {
|
||||||
|
background: linear-gradient(135deg, #b91c1c 0%, #991b1b 100%);
|
||||||
|
box-shadow: 0 2px 6px rgba(185, 28, 28, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .btn-delete:hover {
|
||||||
|
background: linear-gradient(135deg, #991b1b 0%, #7f1d1d 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(185, 28, 28, 0.6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let deleteMediaId = null;
|
||||||
|
|
||||||
|
function confirmDelete(mediaId, filename, playlistCount, editCount) {
|
||||||
|
deleteMediaId = mediaId;
|
||||||
|
document.getElementById('deleteFilename').textContent = filename;
|
||||||
|
document.getElementById('deleteForm').action = "{{ url_for('content.delete_media', media_id=0) }}".replace('/0', '/' + mediaId);
|
||||||
|
|
||||||
|
// Show playlist warning if file is in use
|
||||||
|
if (playlistCount > 0) {
|
||||||
|
document.getElementById('playlistWarning').style.display = 'block';
|
||||||
|
document.getElementById('playlistCount').textContent = playlistCount;
|
||||||
|
} else {
|
||||||
|
document.getElementById('playlistWarning').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show edit versions warning if file has been edited
|
||||||
|
if (editCount > 0) {
|
||||||
|
document.getElementById('editWarning').style.display = 'block';
|
||||||
|
document.getElementById('editCount').textContent = editCount;
|
||||||
|
} else {
|
||||||
|
document.getElementById('editWarning').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('deleteModal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
document.getElementById('deleteModal').style.display = 'none';
|
||||||
|
deleteMediaId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const modal = document.getElementById('deleteModal');
|
||||||
|
if (event.target == modal) {
|
||||||
|
closeDeleteModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal with ESC key
|
||||||
|
document.addEventListener('keydown', function(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeDeleteModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -296,6 +296,17 @@
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; cursor: pointer; user-select: none;">
|
||||||
|
<input type="checkbox" name="edit_on_player_enabled" id="edit_on_player_enabled"
|
||||||
|
value="1" style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
|
||||||
|
<span>Allow editing on player (PDF, Images, PPTX)</span>
|
||||||
|
</label>
|
||||||
|
<small style="color: #6c757d; display: block; margin-top: 4px; font-size: 11px;">
|
||||||
|
✏️ Enable local editing of this media on the player device
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Upload Button -->
|
<!-- Upload Button -->
|
||||||
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 20px; padding: 12px 30px;">
|
<button type="submit" class="btn-upload" id="upload-btn" disabled style="display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-top: 20px; padding: 12px 30px;">
|
||||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ body.dark-mode .log-item {
|
|||||||
[{{ log.level.upper() }}]
|
[{{ log.level.upper() }}]
|
||||||
</span>
|
</span>
|
||||||
{{ log.message }}
|
{{ log.message }}
|
||||||
<small class="secondary-text" style="float: right;">{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
<small class="secondary-text" style="float: right;">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
525
app/templates/players/edited_media.html
Normal file
525
app/templates/players/edited_media.html
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Edited Media - {{ player.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.expandable-card {
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||||
|
border: 2px solid #cbd5e1;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandable-card:hover {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandable-card.expanded {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandable-card:not(.expanded) .card-header:hover {
|
||||||
|
background: rgba(124, 58, 237, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandable-card.expanded .card-header-icon {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandable-card.expanded .card-content {
|
||||||
|
max-height: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 350px 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info-item {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-info-value {
|
||||||
|
color: #1a202c;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #7c3aed;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.versions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item {
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item:hover {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item.active {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
background: #000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-label {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a202c;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item.active .version-label {
|
||||||
|
background: #7c3aed;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-latest {
|
||||||
|
background: #7c3aed;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-original {
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(124, 58, 237, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles */
|
||||||
|
body.dark-mode .expandable-card {
|
||||||
|
background: linear-gradient(135deg, #1a202c 0%, #2d3748 100%);
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .expandable-card:hover,
|
||||||
|
body.dark-mode .expandable-card.expanded {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card-header-title {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .expandable-card:not(.expanded) .card-header:hover {
|
||||||
|
background: rgba(124, 58, 237, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .preview-info {
|
||||||
|
background: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .preview-info-item {
|
||||||
|
border-bottom-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .preview-info-label {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .preview-info-value {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .versions-title {
|
||||||
|
color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .version-item {
|
||||||
|
background: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .version-item:hover,
|
||||||
|
body.dark-mode .version-item.active {
|
||||||
|
border-color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .version-label {
|
||||||
|
background: #1a202c;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .version-item.active .version-label {
|
||||||
|
background: #7c3aed;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 2rem;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
|
||||||
|
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||||
|
class="btn"
|
||||||
|
style="background: #6c757d; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||||
|
← Back to Player
|
||||||
|
</a>
|
||||||
|
<h1 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||||
|
Edited Media - {{ player.name }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p style="color: #6c757d; font-size: 1rem;">Complete history of media files edited on this player</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if edited_media %}
|
||||||
|
{% set edited_by_content = {} %}
|
||||||
|
{% for edit in edited_media %}
|
||||||
|
{% if edit.content_id not in edited_by_content %}
|
||||||
|
{% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'versions': []}}) %}
|
||||||
|
{% endif %}
|
||||||
|
{% set _ = edited_by_content[edit.content_id]['versions'].append(edit) %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Expandable Cards -->
|
||||||
|
{% for content_id, data in edited_by_content.items() %}
|
||||||
|
{% set original_content = content_files.get(content_id) %}
|
||||||
|
<div class="expandable-card" id="card-{{ content_id }}" onclick="toggleCard({{ content_id }})">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-header-title">
|
||||||
|
<span class="card-header-icon">▶</span>
|
||||||
|
<span>📄 {{ original_content.filename if original_content else data.original_name }}</span>
|
||||||
|
<span style="font-size: 0.9rem; color: #64748b; font-weight: normal;">
|
||||||
|
({{ data.versions|length + 1 }} version{{ 's' if (data.versions|length + 1) > 1 else '' }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Preview Area (Left Column) -->
|
||||||
|
<div class="preview-area">
|
||||||
|
<div class="preview-container" id="preview-{{ content_id }}">
|
||||||
|
{% set latest = data.versions|sort(attribute='version', reverse=True)|first %}
|
||||||
|
{% if latest.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||||
|
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ latest.content_id ~ '/' ~ latest.new_name) }}"
|
||||||
|
alt="{{ latest.new_name }}"
|
||||||
|
id="preview-img-{{ content_id }}">
|
||||||
|
{% else %}
|
||||||
|
<div style="color: white; text-align: center;">
|
||||||
|
<div style="font-size: 3rem; margin-bottom: 0.5rem;">📄</div>
|
||||||
|
<div>No preview available</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ latest.content_id ~ '/' ~ latest.new_name) }}"
|
||||||
|
download
|
||||||
|
class="download-btn"
|
||||||
|
id="download-btn-{{ content_id }}">
|
||||||
|
💾 Download File
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="preview-info" id="info-{{ content_id }}">
|
||||||
|
<div class="preview-info-item">
|
||||||
|
<span class="preview-info-label">📄 Filename:</span>
|
||||||
|
<span class="preview-info-value" id="info-filename-{{ content_id }}">{{ latest.new_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-info-item">
|
||||||
|
<span class="preview-info-label">📦 Version:</span>
|
||||||
|
<span class="preview-info-value" id="info-version-{{ content_id }}">v{{ latest.version }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-info-item">
|
||||||
|
<span class="preview-info-label">👤 Edited by:</span>
|
||||||
|
<span class="preview-info-value" id="info-user-{{ content_id }}">{{ latest.user or 'Unknown' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-info-item">
|
||||||
|
<span class="preview-info-label">🕒 Modified:</span>
|
||||||
|
<span class="preview-info-value" id="info-date-{{ content_id }}">{{ latest.time_of_modification | localtime('%Y-%m-%d %H:%M') if latest.time_of_modification else 'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-info-item">
|
||||||
|
<span class="preview-info-label">📅 Uploaded:</span>
|
||||||
|
<span class="preview-info-value" id="info-created-{{ content_id }}">{{ latest.created_at | localtime('%Y-%m-%d %H:%M') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Versions Area (Right Column) -->
|
||||||
|
<div class="versions-area">
|
||||||
|
<div class="versions-title">
|
||||||
|
📚 All Versions
|
||||||
|
</div>
|
||||||
|
<div class="versions-grid">
|
||||||
|
{% for edit in data.versions|sort(attribute='version', reverse=True) %}
|
||||||
|
<div class="version-item {% if loop.first %}active{% endif %}"
|
||||||
|
id="version-{{ content_id }}-{{ edit.version }}"
|
||||||
|
onclick="event.stopPropagation(); selectVersion({{ content_id }}, {{ edit.version }}, '{{ edit.new_name }}', '{{ edit.user or 'Unknown' }}', '{{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') if edit.time_of_modification else 'N/A' }}', '{{ edit.created_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}')">
|
||||||
|
<div class="version-thumbnail">
|
||||||
|
{% if edit.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||||
|
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||||
|
alt="Version {{ edit.version }}">
|
||||||
|
{% else %}
|
||||||
|
<div style="color: white; font-size: 2rem;">📄</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="version-label">
|
||||||
|
v{{ edit.version }}
|
||||||
|
{% if loop.first %}
|
||||||
|
<span class="version-badge badge-latest">Latest</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Original File -->
|
||||||
|
{% if original_content %}
|
||||||
|
<div class="version-item"
|
||||||
|
id="version-{{ content_id }}-original"
|
||||||
|
onclick="event.stopPropagation(); selectVersion({{ content_id }}, 'original', '{{ original_content.filename }}', 'System', 'N/A', '{{ original_content.uploaded_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/' ~ original_content.filename) }}')">
|
||||||
|
<div class="version-thumbnail">
|
||||||
|
{% if original_content.filename.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||||
|
<img src="{{ url_for('static', filename='uploads/' ~ original_content.filename) }}"
|
||||||
|
alt="Original">
|
||||||
|
{% else %}
|
||||||
|
<div style="color: white; font-size: 2rem;">📄</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="version-label">
|
||||||
|
Original
|
||||||
|
<span class="version-badge badge-original">Source</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Summary Statistics -->
|
||||||
|
<div class="card" style="margin-top: 2rem; background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%); color: white; border-radius: 12px; overflow: hidden;">
|
||||||
|
<div style="padding: 1.5rem; display: flex; justify-content: space-around; align-items: center; flex-wrap: wrap; gap: 2rem;">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ edited_by_content|length }}</div>
|
||||||
|
<div style="font-size: 0.9rem; opacity: 0.9;">Total Files Edited</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ edited_media|length }}</div>
|
||||||
|
<div style="font-size: 0.9rem; opacity: 0.9;">Total Versions</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div style="font-size: 2.5rem; font-weight: bold; margin-bottom: 0.5rem;">{{ ((edited_media|length / edited_by_content|length) | round(1)) if edited_by_content else 0 }}</div>
|
||||||
|
<div style="font-size: 0.9rem; opacity: 0.9;">Avg Versions per File</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="card" style="text-align: center; padding: 4rem 2rem;">
|
||||||
|
<div style="font-size: 4rem; margin-bottom: 1rem; opacity: 0.5;">📭</div>
|
||||||
|
<h2 style="color: #6c757d; margin-bottom: 1rem;">No Edited Media Yet</h2>
|
||||||
|
<p style="color: #6c757d; font-size: 1.1rem;">
|
||||||
|
This player hasn't edited any media files yet. When the player edits content,<br>
|
||||||
|
all versions will be tracked and displayed here.
|
||||||
|
</p>
|
||||||
|
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||||
|
class="btn"
|
||||||
|
style="margin-top: 2rem; display: inline-block; background: #7c3aed; color: white; padding: 0.75rem 1.5rem; text-decoration: none; border-radius: 6px; font-size: 1rem;">
|
||||||
|
← Back to Player Management
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleCard(contentId) {
|
||||||
|
const card = document.getElementById('card-' + contentId);
|
||||||
|
const wasExpanded = card.classList.contains('expanded');
|
||||||
|
|
||||||
|
// Close all cards
|
||||||
|
document.querySelectorAll('.expandable-card').forEach(c => {
|
||||||
|
c.classList.remove('expanded');
|
||||||
|
});
|
||||||
|
|
||||||
|
// If this card wasn't expanded, expand it
|
||||||
|
if (!wasExpanded) {
|
||||||
|
card.classList.add('expanded');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectVersion(contentId, version, filename, user, modifiedDate, createdDate, fileUrl) {
|
||||||
|
// Update preview image
|
||||||
|
const previewImg = document.getElementById('preview-img-' + contentId);
|
||||||
|
if (previewImg) {
|
||||||
|
previewImg.src = fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update download button
|
||||||
|
const downloadBtn = document.getElementById('download-btn-' + contentId);
|
||||||
|
if (downloadBtn) {
|
||||||
|
downloadBtn.href = fileUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
|
document.getElementById('info-filename-' + contentId).textContent = filename;
|
||||||
|
document.getElementById('info-version-' + contentId).textContent = 'v' + version;
|
||||||
|
document.getElementById('info-user-' + contentId).textContent = user;
|
||||||
|
document.getElementById('info-date-' + contentId).textContent = modifiedDate;
|
||||||
|
document.getElementById('info-created-' + contentId).textContent = createdDate;
|
||||||
|
|
||||||
|
// Update active state on version items
|
||||||
|
document.querySelectorAll('.version-item').forEach(item => {
|
||||||
|
if (item.id.startsWith('version-' + contentId + '-')) {
|
||||||
|
item.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('version-' + contentId + '-' + version).classList.add('active');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -211,18 +211,155 @@
|
|||||||
body.dark-mode .playlist-stats > div > div:first-child {
|
body.dark-mode .playlist-stats > div > div:first-child {
|
||||||
color: #a0aec0;
|
color: #a0aec0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Player Logs Dark Mode */
|
||||||
|
body.dark-mode .card p[style*="color: #6c757d"] {
|
||||||
|
color: #a0aec0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] {
|
||||||
|
background: #2d3748 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] p {
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] p[style*="color: #6c757d"] {
|
||||||
|
color: #a0aec0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] small {
|
||||||
|
color: #a0aec0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] details summary {
|
||||||
|
color: #f87171 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card > div[style*="max-height: 500px"] > div[style*="padding: 0.75rem"] pre {
|
||||||
|
background: #1a202c !important;
|
||||||
|
border-color: #4a5568 !important;
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card > div[style*="text-align: center"] {
|
||||||
|
color: #a0aec0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edited Media Cards Dark Mode */
|
||||||
|
body.dark-mode .card[style*="border: 2px solid #7c3aed"] {
|
||||||
|
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%) !important;
|
||||||
|
border-color: #8b5cf6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card[style*="border: 2px solid #7c3aed"] h3 {
|
||||||
|
color: #a78bfa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card[style*="border: 2px solid #7c3aed"] div[style*="background: white"] {
|
||||||
|
background: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card[style*="border: 2px solid #7c3aed"] strong {
|
||||||
|
color: #a78bfa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #475569"],
|
||||||
|
body.dark-mode .card[style*="border: 2px solid #7c3aed"] p[style*="color: #64748b"] {
|
||||||
|
color: #cbd5e1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode div[style*="background: #f8f9fa; border-radius: 8px"] {
|
||||||
|
background: #2d3748 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .card > div[style*="text-align: center"] p {
|
||||||
|
color: #a0aec0 !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<div style="margin-bottom: 2rem;">
|
<div style="margin-bottom: 2rem; display: flex; justify-content: space-between; align-items: flex-start;">
|
||||||
<h1 style="display: inline-block; margin-right: 1rem; display: flex; align-items: center; gap: 0.5rem;">
|
<h1 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 32px; height: 32px;">
|
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 32px; height: 32px;">
|
||||||
Manage Player: {{ player.name }}
|
Manage Player: {{ player.name }}
|
||||||
</h1>
|
</h1>
|
||||||
<a href="{{ url_for('players.list') }}" class="btn" style="float: right; display: inline-flex; align-items: center; gap: 0.5rem;">
|
<a href="{{ url_for('players.list') }}" class="btn" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
||||||
Back to Players
|
Back to Players
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div id="deleteModal" style="display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.4);">
|
||||||
|
<div style="background-color: #fefefe; margin: 15% auto; padding: 20px; border: 1px solid #888; border-radius: 8px; width: 90%; max-width: 500px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||||
|
<h2 style="margin-top: 0; color: #dc3545; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="font-size: 1.5rem;">⚠️</span>
|
||||||
|
Confirm Delete
|
||||||
|
</h2>
|
||||||
|
<p style="font-size: 1.1rem; margin: 1.5rem 0;">
|
||||||
|
Are you sure you want to delete player <strong>"{{ player.name }}"</strong>?
|
||||||
|
</p>
|
||||||
|
<p style="color: #dc3545; margin: 1rem 0;">
|
||||||
|
<strong>Warning:</strong> This action cannot be undone. All feedback logs for this player will also be deleted.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 2rem;">
|
||||||
|
<button onclick="closeDeleteModal()" class="btn" style="background: #6c757d;">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<form method="POST" action="{{ url_for('players.delete_player', player_id=player.id) }}" style="margin: 0;">
|
||||||
|
<button type="submit" class="btn" style="background: #dc3545;">
|
||||||
|
Yes, Delete Player
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body.dark-mode #deleteModal > div {
|
||||||
|
background-color: #1a202c;
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode #deleteModal h2 {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode #deleteModal p {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode #deleteModal p strong {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function confirmDelete() {
|
||||||
|
document.getElementById('deleteModal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
document.getElementById('deleteModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside of it
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const modal = document.getElementById('deleteModal');
|
||||||
|
if (event.target == modal) {
|
||||||
|
closeDeleteModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal with ESC key
|
||||||
|
document.addEventListener('keydown', function(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeDeleteModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Player Status Overview -->
|
<!-- Player Status Overview -->
|
||||||
<div class="card status-card {% if player.status == 'online' %}online{% elif player.status == 'offline' %}offline{% else %}other{% endif %}">
|
<div class="card status-card {% if player.status == 'online' %}online{% elif player.status == 'offline' %}offline{% else %}other{% endif %}">
|
||||||
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
|
<h3 style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
@@ -247,7 +384,7 @@
|
|||||||
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
|
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
|
||||||
<p><strong>Last Seen:</strong>
|
<p><strong>Last Seen:</strong>
|
||||||
{% if player.last_seen %}
|
{% if player.last_seen %}
|
||||||
{{ player.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}
|
{{ player.last_seen | localtime('%Y-%m-%d %H:%M:%S') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
Never
|
Never
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -279,7 +416,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Last Updated</div>
|
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Last Updated</div>
|
||||||
<div style="font-size: 1rem; font-weight: bold;">{{ current_playlist.updated_at.strftime('%Y-%m-%d %H:%M') }}</div>
|
<div style="font-size: 1rem; font-weight: bold;">{{ current_playlist.updated_at | localtime }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Orientation</div>
|
<div style="font-size: 0.85rem; color: #6c757d; margin-bottom: 0.25rem;">Orientation</div>
|
||||||
@@ -334,23 +471,35 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-box neutral">
|
<div style="border-top: 1px solid #ddd; margin: 1.5rem 0; padding-top: 1.5rem;">
|
||||||
<h4 style="margin: 0 0 1rem 0; font-size: 0.95rem; color: #495057;">🔑 Player Credentials</h4>
|
<h4 style="margin: 0 0 1rem 0; font-size: 0.95rem;">🔑 Authentication Settings</h4>
|
||||||
|
|
||||||
<div class="credential-item">
|
<div class="form-group">
|
||||||
<span class="credential-label">Hostname</span>
|
<label for="hostname">Hostname *</label>
|
||||||
<div class="credential-value">{{ player.hostname }}</div>
|
<input type="text" id="hostname" name="hostname" value="{{ player.hostname }}"
|
||||||
|
required minlength="3" class="form-control"
|
||||||
|
placeholder="e.g., tv-terasa">
|
||||||
|
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
|
||||||
|
ℹ️ This is the unique identifier for the player
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="credential-item">
|
<div class="form-group">
|
||||||
<span class="credential-label">Auth Code</span>
|
<label for="password">New Password (leave blank to keep current)</label>
|
||||||
<div class="credential-value">{{ player.auth_code }}</div>
|
<input type="password" id="password" name="password" class="form-control"
|
||||||
|
placeholder="Enter new password">
|
||||||
|
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
|
||||||
|
🔒 Optional: Set a new password for player authentication
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="credential-item">
|
<div class="form-group">
|
||||||
<span class="credential-label">Quick Connect Code (Hashed)</span>
|
<label for="quickconnect_code">Quick Connect Code</label>
|
||||||
<div class="credential-value" style="font-size: 0.75rem;">{{ player.quickconnect_code or 'Not set' }}</div>
|
<input type="text" id="quickconnect_code" name="quickconnect_code" class="form-control"
|
||||||
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">⚠️ This is the hashed version for security</small>
|
placeholder="e.g., 8887779">
|
||||||
|
<small style="display: block; margin-top: 0.25rem; color: #6c757d;">
|
||||||
|
🔗 Enter the plain text code (e.g., 8887779) - will be hashed automatically
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -390,7 +539,7 @@
|
|||||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Version: {{ current_playlist.version }}</p>
|
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Version: {{ current_playlist.version }}</p>
|
||||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Content Items: {{ current_playlist.contents.count() }}</p>
|
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem;">Content Items: {{ current_playlist.contents.count() }}</p>
|
||||||
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem; color: #6c757d;">
|
<p style="margin: 0.25rem 0 0 0; font-size: 0.9rem; color: #6c757d;">
|
||||||
Updated: {{ current_playlist.updated_at.strftime('%Y-%m-%d %H:%M') }}
|
Updated: {{ current_playlist.updated_at | localtime }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -421,6 +570,10 @@
|
|||||||
Edit Current Playlist
|
Edit Current Playlist
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<button onclick="confirmDelete()" class="btn" style="width: 100%; margin-top: 0.5rem; background: #dc3545; display: flex; align-items: center; justify-content: center; gap: 0.5rem;">
|
||||||
|
<span style="font-size: 1.2rem;">🗑️</span>
|
||||||
|
Delete Player
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -469,7 +622,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<small style="color: #6c757d; white-space: nowrap; margin-left: 1rem;">
|
<small style="color: #6c757d; white-space: nowrap; margin-left: 1rem;">
|
||||||
{{ log.timestamp.strftime('%m/%d %H:%M') }}
|
{{ log.timestamp | localtime('%m/%d %H:%M') }}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -485,13 +638,118 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edited Media Section - Full Width -->
|
||||||
|
<div class="card" style="margin-top: 2rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
|
||||||
|
<h2 style="display: flex; align-items: center; gap: 0.5rem; margin: 0;">
|
||||||
|
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||||
|
Edited Media on the Player
|
||||||
|
</h2>
|
||||||
|
{% if edited_media %}
|
||||||
|
<a href="{{ url_for('players.edited_media', player_id=player.id) }}"
|
||||||
|
class="btn"
|
||||||
|
style="background: #7c3aed; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; transition: background 0.2s;"
|
||||||
|
onmouseover="this.style.background='#6d28d9'"
|
||||||
|
onmouseout="this.style.background='#7c3aed'">
|
||||||
|
📋 View All Edited Media
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.5rem;">Latest 3 edited files with their most recent versions</p>
|
||||||
|
|
||||||
|
{% if edited_media %}
|
||||||
|
{% set edited_by_content = {} %}
|
||||||
|
{% for edit in edited_media %}
|
||||||
|
{% if edit.content_id not in edited_by_content %}
|
||||||
|
{% set _ = edited_by_content.update({edit.content_id: {'original_name': edit.original_name, 'content_id': edit.content_id, 'latest_version': edit}}) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;">
|
||||||
|
{% for content_id, data in edited_by_content.items() %}
|
||||||
|
{% if loop.index <= 3 %}
|
||||||
|
<div class="card" style="background: linear-gradient(135deg, #f8f9fa 0%, #fff 100%); border: 2px solid #7c3aed; box-shadow: 0 2px 8px rgba(124, 58, 237, 0.1);">
|
||||||
|
{% set edit = data.latest_version %}
|
||||||
|
|
||||||
|
<!-- Image Preview if it's an image -->
|
||||||
|
{% if edit.new_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||||
|
<div style="width: 100%; height: 200px; overflow: hidden; border-radius: 8px 8px 0 0; background: #000;">
|
||||||
|
<img src="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||||
|
alt="{{ edit.new_name }}"
|
||||||
|
style="width: 100%; height: 100%; object-fit: contain;">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="padding: 1rem;">
|
||||||
|
<h3 style="margin: 0 0 0.75rem 0; color: #7c3aed; font-size: 1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
✏️ {{ data.original_name }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style="padding: 0.75rem; background: white; border-radius: 6px; border-left: 4px solid #7c3aed; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
|
||||||
|
<strong style="color: #7c3aed; font-size: 0.95rem;">
|
||||||
|
Version {{ edit.version }}
|
||||||
|
<span style="background: #7c3aed; color: white; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; margin-left: 0.5rem;">Latest</span>
|
||||||
|
</strong>
|
||||||
|
<small style="color: #6c757d; white-space: nowrap;">
|
||||||
|
{{ edit.created_at | localtime('%m/%d %H:%M') }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin: 0.25rem 0; font-size: 0.85rem; color: #475569;">
|
||||||
|
📄 {{ edit.new_name }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if edit.user %}
|
||||||
|
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
|
||||||
|
👤 {{ edit.user }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if edit.time_of_modification %}
|
||||||
|
<p style="margin: 0.25rem 0; font-size: 0.8rem; color: #64748b;">
|
||||||
|
🕒 {{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem;">
|
||||||
|
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||||
|
target="_blank"
|
||||||
|
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #7c3aed; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
|
||||||
|
onmouseover="this.style.background='#6d28d9'"
|
||||||
|
onmouseout="this.style.background='#7c3aed'">
|
||||||
|
📥 View File
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||||
|
download
|
||||||
|
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.4rem 0.75rem; background: #64748b; color: white; text-decoration: none; border-radius: 4px; font-size: 0.8rem; transition: background 0.2s;"
|
||||||
|
onmouseover="this.style.background='#475569'"
|
||||||
|
onmouseout="this.style.background='#64748b'">
|
||||||
|
💾 Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; padding: 3rem; color: #6c757d; background: #f8f9fa; border-radius: 8px; margin-top: 1.5rem;">
|
||||||
|
<p style="font-size: 2rem; margin: 0;">📝</p>
|
||||||
|
<p style="margin: 0.5rem 0 0 0; font-size: 1.1rem; font-weight: 500;">No edited media yet</p>
|
||||||
|
<p style="font-size: 0.9rem; margin: 0.5rem 0 0 0;">Media edits will appear here once the player sends edited files</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Additional Info Section -->
|
<!-- Additional Info Section -->
|
||||||
<div class="card" style="margin-top: 2rem;">
|
<div class="card" style="margin-top: 2rem;">
|
||||||
<h2>ℹ️ Player Information</h2>
|
<h2>ℹ️ Player Information</h2>
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-top: 1rem;">
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-top: 1rem;">
|
||||||
<div>
|
<div>
|
||||||
<p><strong>Player ID:</strong> {{ player.id }}</p>
|
<p><strong>Player ID:</strong> {{ player.id }}</p>
|
||||||
<p><strong>Created:</strong> {{ player.created_at.strftime('%Y-%m-%d %H:%M') if player.created_at else 'N/A' }}</p>
|
<p><strong>Created:</strong> {{ (player.created_at | localtime) if player.created_at else 'N/A' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p><strong>Orientation:</strong> {{ player.orientation }}</p>
|
<p><strong>Orientation:</strong> {{ player.orientation }}</p>
|
||||||
@@ -500,7 +758,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p><strong>Last Heartbeat:</strong>
|
<p><strong>Last Heartbeat:</strong>
|
||||||
{% if player.last_heartbeat %}
|
{% if player.last_heartbeat %}
|
||||||
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M:%S') }}
|
{{ player.last_heartbeat | localtime('%Y-%m-%d %H:%M:%S') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
Never
|
Never
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
<div id="player-container">
|
<div id="player-container">
|
||||||
<div class="loading" id="loading">Loading playlist...</div>
|
<div class="loading" id="loading">Loading playlist...</div>
|
||||||
<img id="media-display" alt="Content">
|
<img id="media-display" alt="Content">
|
||||||
<video id="video-display" style="display: none; max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain;"></video>
|
<video id="video-display" muted autoplay playsinline style="display: none; max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain;"></video>
|
||||||
<div class="no-content" id="no-content" style="display: none;">
|
<div class="no-content" id="no-content" style="display: none;">
|
||||||
<p>💭 No content in playlist</p>
|
<p>💭 No content in playlist</p>
|
||||||
<p style="font-size: 16px; margin-top: 10px; opacity: 0.7;">Add content to the playlist to preview</p>
|
<p style="font-size: 16px; margin-top: 10px; opacity: 0.7;">Add content to the playlist to preview</p>
|
||||||
@@ -149,6 +149,7 @@
|
|||||||
|
|
||||||
if (item.type === 'video') {
|
if (item.type === 'video') {
|
||||||
videoDisplay.src = item.url;
|
videoDisplay.src = item.url;
|
||||||
|
videoDisplay.muted = item.muted !== false; // Muted unless explicitly set to false
|
||||||
videoDisplay.style.display = 'block';
|
videoDisplay.style.display = 'block';
|
||||||
videoDisplay.play();
|
videoDisplay.play();
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 10px; font-weight: bold;">Created:</td>
|
<td style="padding: 10px; font-weight: bold;">Created:</td>
|
||||||
<td style="padding: 10px;">{{ player.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
<td style="padding: 10px;">{{ player.created_at | localtime }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
{% for feedback in recent_feedback %}
|
{% for feedback in recent_feedback %}
|
||||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
<tr style="border-bottom: 1px solid #dee2e6;">
|
||||||
<td style="padding: 10px; white-space: nowrap;">
|
<td style="padding: 10px; white-space: nowrap;">
|
||||||
<small style="color: #6c757d;">{{ feedback.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
<small style="color: #6c757d;">{{ feedback.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 10px;">
|
<td style="padding: 10px;">
|
||||||
{% if feedback.status == 'playing' %}
|
{% if feedback.status == 'playing' %}
|
||||||
|
|||||||
@@ -162,16 +162,15 @@
|
|||||||
{{ player.orientation or 'Landscape' }}
|
{{ player.orientation or 'Landscape' }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% set status = player_statuses.get(player.id, {}) %}
|
{% if player.is_online %}
|
||||||
{% if status.get('is_online') %}
|
|
||||||
<span class="status-badge online">Online</span>
|
<span class="status-badge online">Online</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="status-badge offline">Offline</span>
|
<span class="status-badge offline">Offline</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if player.last_heartbeat %}
|
{% if player.last_seen %}
|
||||||
{{ player.last_heartbeat.strftime('%Y-%m-%d %H:%M') }}
|
{{ player.last_seen | localtime }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">Never</span>
|
<span class="text-muted">Never</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -369,6 +369,43 @@
|
|||||||
background: #5a1e1e;
|
background: #5a1e1e;
|
||||||
color: #ef5350;
|
color: #ef5350;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Audio toggle styles */
|
||||||
|
.audio-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-checkbox {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-label {
|
||||||
|
font-size: 20px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-checkbox + .audio-label .audio-on {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-checkbox + .audio-label .audio-off {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-checkbox:checked + .audio-label .audio-on {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-checkbox:checked + .audio-label .audio-off {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-label:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="playlist-container">
|
<div class="playlist-container">
|
||||||
@@ -433,6 +470,7 @@
|
|||||||
<th>Filename</th>
|
<th>Filename</th>
|
||||||
<th style="width: 100px;">Type</th>
|
<th style="width: 100px;">Type</th>
|
||||||
<th style="width: 120px;">Duration (s)</th>
|
<th style="width: 120px;">Duration (s)</th>
|
||||||
|
<th style="width: 80px;">Audio</th>
|
||||||
<th style="width: 100px;">Size</th>
|
<th style="width: 100px;">Size</th>
|
||||||
<th style="width: 150px;">Actions</th>
|
<th style="width: 150px;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -479,6 +517,24 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if content.content_type == 'video' %}
|
||||||
|
<label class="audio-toggle" onclick="event.stopPropagation()">
|
||||||
|
<input type="checkbox"
|
||||||
|
class="audio-checkbox"
|
||||||
|
data-content-id="{{ content.id }}"
|
||||||
|
{{ 'checked' if not content._playlist_muted else '' }}
|
||||||
|
onchange="toggleAudio({{ content.id }}, this.checked)"
|
||||||
|
onclick="event.stopPropagation()">
|
||||||
|
<span class="audio-label">
|
||||||
|
<span class="audio-on">🔊</span>
|
||||||
|
<span class="audio-off">🔇</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #999;">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
|
<td>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="POST"
|
<form method="POST"
|
||||||
@@ -764,6 +820,38 @@ function updateTotalDuration() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleAudio(contentId, enabled) {
|
||||||
|
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
|
||||||
|
const playerId = {{ player.id }};
|
||||||
|
const url = `/playlist/${playerId}/update-muted/${contentId}`;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('muted', muted ? 'true' : 'false');
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
console.log('Audio setting updated:', enabled ? 'Enabled' : 'Muted');
|
||||||
|
} else {
|
||||||
|
alert('Error updating audio setting: ' + data.message);
|
||||||
|
// Revert checkbox on error
|
||||||
|
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
|
||||||
|
if (checkbox) checkbox.checked = !enabled;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Error updating audio setting');
|
||||||
|
// Revert checkbox on error
|
||||||
|
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
|
||||||
|
if (checkbox) checkbox.checked = !enabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
49
check_fix_player.py
Normal file
49
check_fix_player.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check and fix player quickconnect code."""
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from app.models import Player
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
# Find player by hostname
|
||||||
|
player = Player.query.filter_by(hostname='tv-terasa').first()
|
||||||
|
|
||||||
|
if not player:
|
||||||
|
print("❌ Player 'tv-terasa' NOT FOUND in database!")
|
||||||
|
print("\nAll registered players:")
|
||||||
|
all_players = Player.query.all()
|
||||||
|
for p in all_players:
|
||||||
|
print(f" - ID={p.id}, Name='{p.name}', Hostname='{p.hostname}'")
|
||||||
|
else:
|
||||||
|
print(f"✅ Player found:")
|
||||||
|
print(f" ID: {player.id}")
|
||||||
|
print(f" Name: {player.name}")
|
||||||
|
print(f" Hostname: {player.hostname}")
|
||||||
|
print(f" Playlist ID: {player.playlist_id}")
|
||||||
|
print(f" Status: {player.status}")
|
||||||
|
print(f" QuickConnect Hash: {player.quickconnect_code[:60] if player.quickconnect_code else 'Not set'}...")
|
||||||
|
|
||||||
|
# Test the quickconnect code
|
||||||
|
test_code = "8887779"
|
||||||
|
print(f"\n🔐 Testing quickconnect code: '{test_code}'")
|
||||||
|
|
||||||
|
if player.check_quickconnect_code(test_code):
|
||||||
|
print(f"✅ Code '{test_code}' is VALID!")
|
||||||
|
else:
|
||||||
|
print(f"❌ Code '{test_code}' is INVALID - Hash doesn't match!")
|
||||||
|
|
||||||
|
# Update it
|
||||||
|
print(f"\n🔧 Updating quickconnect code to: '{test_code}'")
|
||||||
|
player.set_quickconnect_code(test_code)
|
||||||
|
db.session.commit()
|
||||||
|
print("✅ QuickConnect code updated successfully!")
|
||||||
|
print(f" New hash: {player.quickconnect_code[:60]}...")
|
||||||
|
|
||||||
|
# Verify the update
|
||||||
|
if player.check_quickconnect_code(test_code):
|
||||||
|
print(f"✅ Verification successful - code '{test_code}' now works!")
|
||||||
|
else:
|
||||||
|
print(f"❌ Verification failed - something went wrong!")
|
||||||
85
clean_for_deployment.sh
Executable file
85
clean_for_deployment.sh
Executable file
@@ -0,0 +1,85 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Clean development data before Docker deployment
|
||||||
|
# This script removes all development data to ensure a fresh start
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🧹 Cleaning DigiServer v2 for deployment..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Confirm action
|
||||||
|
read -p "This will delete ALL data (database, uploads, logs). Continue? (y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "❌ Cancelled"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "📦 Cleaning development data..."
|
||||||
|
|
||||||
|
# Remove database files
|
||||||
|
if [ -d "instance" ]; then
|
||||||
|
echo " 🗄️ Removing database files..."
|
||||||
|
rm -rf instance/*.db
|
||||||
|
rm -rf instance/*.db-*
|
||||||
|
echo " ✅ Database cleaned"
|
||||||
|
else
|
||||||
|
echo " ℹ️ No instance directory found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove uploaded media
|
||||||
|
if [ -d "app/static/uploads" ]; then
|
||||||
|
echo " 📁 Removing uploaded media files..."
|
||||||
|
find app/static/uploads -type f -not -name '.gitkeep' -delete 2>/dev/null || true
|
||||||
|
find app/static/uploads -type d -empty -not -path "app/static/uploads" -delete 2>/dev/null || true
|
||||||
|
echo " ✅ Uploads cleaned"
|
||||||
|
else
|
||||||
|
echo " ℹ️ No uploads directory found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove additional upload directory if exists
|
||||||
|
if [ -d "static/uploads" ]; then
|
||||||
|
echo " 📁 Removing static uploads..."
|
||||||
|
find static/uploads -type f -not -name '.gitkeep' -delete 2>/dev/null || true
|
||||||
|
find static/uploads -type d -empty -not -path "static/uploads" -delete 2>/dev/null || true
|
||||||
|
echo " ✅ Static uploads cleaned"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove log files
|
||||||
|
echo " 📝 Removing log files..."
|
||||||
|
find . -name "*.log" -type f -delete 2>/dev/null || true
|
||||||
|
echo " ✅ Logs cleaned"
|
||||||
|
|
||||||
|
# Remove Python cache
|
||||||
|
echo " 🐍 Removing Python cache..."
|
||||||
|
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||||
|
find . -type f -name "*.pyo" -delete 2>/dev/null || true
|
||||||
|
echo " ✅ Python cache cleaned"
|
||||||
|
|
||||||
|
# Remove Flask session files if any
|
||||||
|
if [ -d "flask_session" ]; then
|
||||||
|
echo " 🔐 Removing session files..."
|
||||||
|
rm -rf flask_session
|
||||||
|
echo " ✅ Sessions cleaned"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo ""
|
||||||
|
echo "✨ Cleanup complete!"
|
||||||
|
echo ""
|
||||||
|
echo "📊 Summary:"
|
||||||
|
echo " - Database: Removed"
|
||||||
|
echo " - Uploaded media: Removed"
|
||||||
|
echo " - Logs: Removed"
|
||||||
|
echo " - Python cache: Removed"
|
||||||
|
echo ""
|
||||||
|
echo "🚀 Ready for deployment!"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. Build Docker image: docker compose build"
|
||||||
|
echo " 2. Start container: docker compose up -d"
|
||||||
|
echo " 3. Access at: http://localhost:80"
|
||||||
|
echo " 4. Login with: admin / admin123"
|
||||||
|
echo ""
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
version: '3.8'
|
#version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
digiserver:
|
digiserver:
|
||||||
build: .
|
build: .
|
||||||
container_name: digiserver-v2
|
container_name: digiserver-v2
|
||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "80:5000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./instance:/app/instance
|
- ./instance:/app/instance
|
||||||
- ./app/static/uploads:/app/app/static/uploads
|
- ./app/static/uploads:/app/app/static/uploads
|
||||||
environment:
|
environment:
|
||||||
- FLASK_ENV=production
|
- FLASK_ENV=production
|
||||||
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this}
|
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this}
|
||||||
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/').read()"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -8,27 +8,35 @@ mkdir -p /app/instance
|
|||||||
mkdir -p /app/app/static/uploads
|
mkdir -p /app/app/static/uploads
|
||||||
|
|
||||||
# Initialize database if it doesn't exist
|
# Initialize database if it doesn't exist
|
||||||
if [ ! -f /app/instance/digiserver.db ]; then
|
if [ ! -f /app/instance/dashboard.db ]; then
|
||||||
echo "Initializing database..."
|
echo "Initializing database..."
|
||||||
python -c "
|
python -c "
|
||||||
from app.app import create_app
|
from app.app import create_app
|
||||||
from app.extensions import db, bcrypt
|
from app.extensions import db, bcrypt
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
|
||||||
app = create_app()
|
app = create_app('production')
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
# Create admin user
|
# Create or update admin user from environment variables
|
||||||
admin = User.query.filter_by(username='admin').first()
|
import os
|
||||||
|
admin_username = os.getenv('ADMIN_USERNAME', 'admin')
|
||||||
|
admin_password = os.getenv('ADMIN_PASSWORD', 'admin123')
|
||||||
|
|
||||||
|
admin = User.query.filter_by(username=admin_username).first()
|
||||||
if not admin:
|
if not admin:
|
||||||
hashed = bcrypt.generate_password_hash('admin123').decode('utf-8')
|
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
|
||||||
admin = User(username='admin', password=hashed, role='admin')
|
admin = User(username=admin_username, password=hashed, role='admin')
|
||||||
db.session.add(admin)
|
db.session.add(admin)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
print('✅ Admin user created (admin/admin123)')
|
print(f'✅ Admin user created ({admin_username})')
|
||||||
else:
|
else:
|
||||||
print('✅ Admin user already exists')
|
# Update password if it exists
|
||||||
|
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
|
||||||
|
admin.password = hashed
|
||||||
|
db.session.commit()
|
||||||
|
print(f'✅ Admin user password updated ({admin_username})')
|
||||||
"
|
"
|
||||||
echo "Database initialized!"
|
echo "Database initialized!"
|
||||||
fi
|
fi
|
||||||
@@ -41,4 +49,4 @@ exec gunicorn \
|
|||||||
--timeout 120 \
|
--timeout 120 \
|
||||||
--access-logfile - \
|
--access-logfile - \
|
||||||
--error-logfile - \
|
--error-logfile - \
|
||||||
"app.app:create_app()"
|
"app.app:create_app('production')"
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
# Install emoji fonts for Raspberry Pi
|
# Install emoji fonts for Raspberry Pi
|
||||||
echo "Installing emoji font support for Raspberry Pi..."
|
echo "Installing emoji font support for Raspberry Pi..."
|
||||||
|
|
||||||
sudo apt-get update
|
apt-get update -qq
|
||||||
sudo apt-get install -y fonts-noto-color-emoji fonts-noto-emoji
|
apt-get install -y fonts-noto-color-emoji fonts-noto-emoji
|
||||||
|
|
||||||
echo "✅ Emoji fonts installed!"
|
echo "✅ Emoji fonts installed!"
|
||||||
echo "Please restart your browser to see the changes."
|
echo "Please restart your browser to see the changes."
|
||||||
|
|||||||
42
install_libreoffice.sh
Executable file
42
install_libreoffice.sh
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# LibreOffice installation script for DigiServer v2
|
||||||
|
# This script installs LibreOffice for PPTX to image conversion
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "======================================"
|
||||||
|
echo "LibreOffice Installation Script"
|
||||||
|
echo "======================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if already installed
|
||||||
|
if command -v libreoffice &> /dev/null; then
|
||||||
|
VERSION=$(libreoffice --version 2>/dev/null || echo "Unknown")
|
||||||
|
echo "✅ LibreOffice is already installed: $VERSION"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 Installing LibreOffice..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Update package list
|
||||||
|
echo "Updating package list..."
|
||||||
|
apt-get update -qq
|
||||||
|
|
||||||
|
# Install LibreOffice
|
||||||
|
echo "Installing LibreOffice (this may take a few minutes)..."
|
||||||
|
apt-get install -y libreoffice libreoffice-impress
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
if command -v libreoffice &> /dev/null; then
|
||||||
|
VERSION=$(libreoffice --version 2>/dev/null || echo "Installed")
|
||||||
|
echo ""
|
||||||
|
echo "✅ LibreOffice successfully installed: $VERSION"
|
||||||
|
echo ""
|
||||||
|
echo "You can now upload and convert PowerPoint presentations (PPTX files)."
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "❌ LibreOffice installation failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
47
migrate_add_edit_enabled.py
Normal file
47
migrate_add_edit_enabled.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Migration: Add edit_on_player_enabled column to playlist_content table."""
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = 'instance/dashboard.db'
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
"""Add edit_on_player_enabled column to playlist_content."""
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if column already exists
|
||||||
|
cursor.execute("PRAGMA table_info(playlist_content)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'edit_on_player_enabled' in columns:
|
||||||
|
print("Column 'edit_on_player_enabled' already exists!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Add the new column with default value False
|
||||||
|
print("Adding 'edit_on_player_enabled' column to playlist_content table...")
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE playlist_content
|
||||||
|
ADD COLUMN edit_on_player_enabled BOOLEAN DEFAULT 0
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("✅ Migration completed successfully!")
|
||||||
|
print("Column 'edit_on_player_enabled' added with default value False (0)")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"❌ Migration failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
migrate()
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# Docker Deployment Guide
|
# Docker Deployment Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
DigiServer v2 Docker image features:
|
||||||
|
- **Base image size**: ~400MB (optimized)
|
||||||
|
- **Full HD media support**: Images, videos, PDFs
|
||||||
|
- **Optional LibreOffice**: Install on-demand for PPTX support (+500MB)
|
||||||
|
- **Auto-initialization**: Database and admin user created on first run
|
||||||
|
- **Non-root user**: Runs as `appuser` (UID 1000) for security
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Build and Run with Docker Compose
|
### 1. Build and Run with Docker Compose
|
||||||
@@ -171,6 +180,24 @@ docker-compose exec digiserver bash
|
|||||||
docker exec -it digiserver bash
|
docker exec -it digiserver bash
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Installing Optional Dependencies
|
||||||
|
|
||||||
|
**LibreOffice for PowerPoint Support:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Method 1: Via Web UI (Recommended)
|
||||||
|
# Navigate to Admin Panel → System Dependencies
|
||||||
|
# Click "Install LibreOffice" button
|
||||||
|
|
||||||
|
# Method 2: Via Docker exec
|
||||||
|
docker exec -it digiserver bash
|
||||||
|
sudo /app/install_libreoffice.sh
|
||||||
|
exit
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
docker exec digiserver libreoffice --version
|
||||||
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Port Already in Use
|
### Port Already in Use
|
||||||
@@ -210,10 +237,15 @@ docker-compose up -d
|
|||||||
|
|
||||||
## System Requirements
|
## System Requirements
|
||||||
|
|
||||||
|
### Base Image
|
||||||
- Docker 20.10+
|
- Docker 20.10+
|
||||||
- Docker Compose 2.0+
|
- Docker Compose 2.0+
|
||||||
- 2GB RAM minimum
|
- 1GB RAM minimum (2GB recommended)
|
||||||
- 10GB disk space for media files
|
- 5GB disk space (base + uploads)
|
||||||
|
|
||||||
|
### With LibreOffice (Optional)
|
||||||
|
- 2GB RAM recommended
|
||||||
|
- 10GB disk space (includes LibreOffice + media)
|
||||||
|
|
||||||
## Security Recommendations
|
## Security Recommendations
|
||||||
|
|
||||||
265
old_code_documentation/IMPLEMENTATION_OPTIONAL_LIBREOFFICE.md
Normal file
265
old_code_documentation/IMPLEMENTATION_OPTIONAL_LIBREOFFICE.md
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
# Optional LibreOffice Installation - Implementation Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Implemented a system to install LibreOffice on-demand instead of including it in the base Docker image, reducing image size by 56% (~900MB → ~400MB).
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
### 1. Backend Implementation
|
||||||
|
|
||||||
|
#### `/srv/digiserver-v2/app/blueprints/admin.py`
|
||||||
|
Added two new routes:
|
||||||
|
|
||||||
|
- **`/admin/dependencies`** - Display dependency status page
|
||||||
|
- Checks LibreOffice, Poppler, FFmpeg installation status
|
||||||
|
- Uses subprocess to run version commands with 5s timeout
|
||||||
|
- Passes status variables to template
|
||||||
|
|
||||||
|
- **`/admin/install_libreoffice`** (POST) - Install LibreOffice
|
||||||
|
- Executes `install_libreoffice.sh` with sudo
|
||||||
|
- 300s timeout for installation
|
||||||
|
- Logs installation output
|
||||||
|
- Flash messages for success/failure
|
||||||
|
|
||||||
|
#### `/srv/digiserver-v2/app/blueprints/content.py`
|
||||||
|
Modified presentation file processing:
|
||||||
|
|
||||||
|
- **Changed behavior**: Now returns error instead of accepting PPTX without LibreOffice
|
||||||
|
- **Error message**: "LibreOffice is not installed. Please install it from the Admin Panel → System Dependencies to upload PowerPoint files."
|
||||||
|
- **User experience**: Clear guidance on how to enable PPTX support
|
||||||
|
|
||||||
|
### 2. Installation Script
|
||||||
|
|
||||||
|
#### `/srv/digiserver-v2/install_libreoffice.sh`
|
||||||
|
Bash script to install LibreOffice:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Checks root privileges
|
||||||
|
# Verifies if already installed
|
||||||
|
# Updates package cache
|
||||||
|
# Installs libreoffice and libreoffice-impress
|
||||||
|
# Verifies installation success
|
||||||
|
# Reports version
|
||||||
|
```
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Idempotent (safe to run multiple times)
|
||||||
|
- Error handling and validation
|
||||||
|
- Success/failure reporting
|
||||||
|
- Version verification
|
||||||
|
|
||||||
|
### 3. Frontend Templates
|
||||||
|
|
||||||
|
#### `/srv/digiserver-v2/app/templates/admin/dependencies.html`
|
||||||
|
New template showing:
|
||||||
|
- LibreOffice status (✅ installed or ❌ not installed)
|
||||||
|
- Poppler Utils status (always present)
|
||||||
|
- FFmpeg status (always present)
|
||||||
|
- Install button for LibreOffice when not present
|
||||||
|
- Installation notes and guidance
|
||||||
|
- Dark mode support
|
||||||
|
|
||||||
|
#### `/srv/digiserver-v2/app/templates/admin/admin.html`
|
||||||
|
Added new card:
|
||||||
|
- "System Dependencies" card with gradient background
|
||||||
|
- Links to `/admin/dependencies` route
|
||||||
|
- Matches existing admin panel styling
|
||||||
|
|
||||||
|
### 4. Docker Configuration
|
||||||
|
|
||||||
|
#### `/srv/digiserver-v2/Dockerfile`
|
||||||
|
Key changes:
|
||||||
|
- **Removed**: `libreoffice` from apt-get install
|
||||||
|
- **Added**: `sudo` for installation script execution
|
||||||
|
- **Added**: Sudoers entry for appuser to run installation script
|
||||||
|
- **Added**: Script permissions (`chmod +x`)
|
||||||
|
- **Added**: Comments explaining optional LibreOffice
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- Base image: ~400MB (down from ~900MB)
|
||||||
|
- LibreOffice can be installed post-deployment
|
||||||
|
- Maintains security with non-root user
|
||||||
|
|
||||||
|
### 5. Documentation
|
||||||
|
|
||||||
|
#### `/srv/digiserver-v2/OPTIONAL_DEPENDENCIES.md` (NEW)
|
||||||
|
Comprehensive guide covering:
|
||||||
|
- Why optional dependencies?
|
||||||
|
- Installation methods (Web UI, Docker exec, direct)
|
||||||
|
- Checking dependency status
|
||||||
|
- File type support matrix
|
||||||
|
- Upload behavior with/without LibreOffice
|
||||||
|
- Technical details
|
||||||
|
- Installation times
|
||||||
|
- Troubleshooting
|
||||||
|
- Production recommendations
|
||||||
|
- FAQ
|
||||||
|
|
||||||
|
#### `/srv/digiserver-v2/README.md`
|
||||||
|
Updated sections:
|
||||||
|
- Features: Added "Optional Dependencies" bullet
|
||||||
|
- Prerequisites: Marked LibreOffice as optional
|
||||||
|
- Installation: Separated required vs optional dependencies
|
||||||
|
- Troubleshooting: Enhanced PPTX troubleshooting with Web UI method
|
||||||
|
- Documentation: Added links to OPTIONAL_DEPENDENCIES.md
|
||||||
|
- Version History: Added v2.1 with optional LibreOffice feature
|
||||||
|
|
||||||
|
#### `/srv/digiserver-v2/DOCKER.md`
|
||||||
|
Updated sections:
|
||||||
|
- Overview: Added base image size and optional LibreOffice info
|
||||||
|
- Maintenance: Added "Installing Optional Dependencies" section
|
||||||
|
- System Requirements: Split into base vs with LibreOffice
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### Image Size Reduction
|
||||||
|
- **Before**: ~900MB (Python + Poppler + FFmpeg + LibreOffice)
|
||||||
|
- **After**: ~400MB (Python + Poppler + FFmpeg only)
|
||||||
|
- **Savings**: 500MB (56% reduction)
|
||||||
|
|
||||||
|
### Deployment Speed
|
||||||
|
- Faster Docker pulls
|
||||||
|
- Faster container starts
|
||||||
|
- Lower bandwidth usage
|
||||||
|
- Lower storage requirements
|
||||||
|
|
||||||
|
### Flexibility
|
||||||
|
- Users without PPTX needs: smaller, faster image
|
||||||
|
- Users with PPTX needs: install on-demand
|
||||||
|
- Can be installed/uninstalled as needed
|
||||||
|
- No rebuild required
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- Clear error messages when PPTX upload attempted
|
||||||
|
- Easy installation via Web UI
|
||||||
|
- Visual status indicators
|
||||||
|
- Guided troubleshooting
|
||||||
|
|
||||||
|
## Technical Architecture
|
||||||
|
|
||||||
|
### Dependency Detection
|
||||||
|
```python
|
||||||
|
# Uses subprocess to check installation
|
||||||
|
subprocess.run(['libreoffice', '--version'],
|
||||||
|
capture_output=True, timeout=5)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installation Flow
|
||||||
|
1. User clicks "Install LibreOffice" button
|
||||||
|
2. POST request to `/admin/install_libreoffice`
|
||||||
|
3. Server runs `sudo /app/install_libreoffice.sh`
|
||||||
|
4. Script installs packages via apt-get
|
||||||
|
5. Server logs output and flashes message
|
||||||
|
6. User refreshes to see updated status
|
||||||
|
|
||||||
|
### Upload Validation
|
||||||
|
```python
|
||||||
|
# In process_presentation_file()
|
||||||
|
if not libreoffice_cmd:
|
||||||
|
return False, "LibreOffice is not installed..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Docker image builds successfully
|
||||||
|
- [ ] Base image size is ~400MB
|
||||||
|
- [ ] Server starts without LibreOffice
|
||||||
|
- [ ] Dependencies page shows correct status
|
||||||
|
- [ ] Install button appears when LibreOffice not present
|
||||||
|
- [ ] PPTX upload fails with clear error message
|
||||||
|
- [ ] Installation script runs successfully
|
||||||
|
- [ ] PPTX upload works after installation
|
||||||
|
- [ ] PDF uploads work without LibreOffice
|
||||||
|
- [ ] Image/video uploads work without LibreOffice
|
||||||
|
- [ ] Dark mode styling works on dependencies page
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Sudoers Configuration
|
||||||
|
```dockerfile
|
||||||
|
# Only allows running installation script, not arbitrary commands
|
||||||
|
echo "appuser ALL=(ALL) NOPASSWD: /app/install_libreoffice.sh" >> /etc/sudoers
|
||||||
|
```
|
||||||
|
|
||||||
|
### Installation Script
|
||||||
|
- Requires root privileges
|
||||||
|
- Validates installation success
|
||||||
|
- Uses official apt repositories
|
||||||
|
- No external downloads
|
||||||
|
|
||||||
|
### Application Security
|
||||||
|
- Installation requires authenticated admin access
|
||||||
|
- Non-root user for runtime
|
||||||
|
- Timeouts prevent hanging processes
|
||||||
|
|
||||||
|
## Maintenance Notes
|
||||||
|
|
||||||
|
### Future Enhancements
|
||||||
|
- Add uninstall functionality
|
||||||
|
- Support for other optional dependencies
|
||||||
|
- Installation progress indicator
|
||||||
|
- Automatic dependency detection on upload
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
- Installation requires sudo access
|
||||||
|
- Docker containers need sudo configured
|
||||||
|
- No progress feedback during installation (2-5 min wait)
|
||||||
|
- Requires internet connection for apt packages
|
||||||
|
|
||||||
|
## Rollback Procedure
|
||||||
|
|
||||||
|
If optional installation causes issues:
|
||||||
|
|
||||||
|
1. **Restore LibreOffice to base image:**
|
||||||
|
```dockerfile
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
poppler-utils \
|
||||||
|
libreoffice \
|
||||||
|
ffmpeg \
|
||||||
|
libmagic1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Remove sudo configuration:**
|
||||||
|
```dockerfile
|
||||||
|
# Remove this line
|
||||||
|
echo "appuser ALL=(ALL) NOPASSWD: /app/install_libreoffice.sh" >> /etc/sudoers
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Revert content.py error behavior:**
|
||||||
|
```python
|
||||||
|
if not libreoffice_cmd:
|
||||||
|
return True, "Presentation accepted without conversion..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. `app/blueprints/admin.py` - Added dependency routes
|
||||||
|
2. `app/blueprints/content.py` - Changed PPTX error handling
|
||||||
|
3. `app/templates/admin/dependencies.html` - New status page
|
||||||
|
4. `app/templates/admin/admin.html` - Added dependencies card
|
||||||
|
5. `Dockerfile` - Removed LibreOffice, added sudo
|
||||||
|
6. `install_libreoffice.sh` - New installation script
|
||||||
|
7. `OPTIONAL_DEPENDENCIES.md` - New comprehensive guide
|
||||||
|
8. `README.md` - Updated with optional dependency info
|
||||||
|
9. `DOCKER.md` - Updated with installation instructions
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
To complete the implementation:
|
||||||
|
1. Test Docker build: `docker-compose build`
|
||||||
|
2. Verify image size: `docker images | grep digiserver`
|
||||||
|
3. Test installation flow in running container
|
||||||
|
4. Update production deployment docs if needed
|
||||||
|
5. Consider adding installation progress indicator
|
||||||
|
6. Add metrics for tracking LibreOffice usage
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
- ✅ Docker image size reduced by >50%
|
||||||
|
- ✅ All file types work without LibreOffice (except PPTX)
|
||||||
|
- ✅ Clear error messages guide users to installation
|
||||||
|
- ✅ Installation works via Web UI
|
||||||
|
- ✅ Installation works via Docker exec
|
||||||
|
- ✅ Comprehensive documentation provided
|
||||||
258
old_code_documentation/OPTIONAL_DEPENDENCIES.md
Normal file
258
old_code_documentation/OPTIONAL_DEPENDENCIES.md
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
# Optional Dependencies Guide
|
||||||
|
|
||||||
|
DigiServer v2 uses an optimized dependency installation strategy to minimize Docker image size while maintaining full functionality.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The base Docker image (~400MB) includes only essential dependencies:
|
||||||
|
- **Poppler Utils** - PDF to image conversion
|
||||||
|
- **FFmpeg** - Video processing and validation
|
||||||
|
- **Python 3.13** - Application runtime
|
||||||
|
|
||||||
|
Optional dependencies can be installed on-demand:
|
||||||
|
- **LibreOffice** (~500MB) - PowerPoint (PPTX/PPT) to image conversion
|
||||||
|
|
||||||
|
## Why Optional Dependencies?
|
||||||
|
|
||||||
|
By excluding LibreOffice from the base image, we reduce:
|
||||||
|
- **Initial image size**: From ~900MB to ~400MB (56% reduction)
|
||||||
|
- **Download time**: Faster deployments
|
||||||
|
- **Storage requirements**: Lower disk usage on hosts
|
||||||
|
|
||||||
|
Users who don't need PowerPoint conversion benefit from a smaller, faster image.
|
||||||
|
|
||||||
|
## Installation Methods
|
||||||
|
|
||||||
|
### 1. Web UI (Recommended)
|
||||||
|
|
||||||
|
The easiest way to install LibreOffice:
|
||||||
|
|
||||||
|
1. Log in to DigiServer admin panel
|
||||||
|
2. Navigate to **Admin Panel** → **System Dependencies**
|
||||||
|
3. Click **"Install LibreOffice"** button
|
||||||
|
4. Wait 2-5 minutes for installation
|
||||||
|
5. Refresh the page to verify installation
|
||||||
|
|
||||||
|
The web interface provides:
|
||||||
|
- Real-time installation status
|
||||||
|
- Version verification
|
||||||
|
- Error reporting
|
||||||
|
- No terminal access needed
|
||||||
|
|
||||||
|
### 2. Docker Exec (Manual)
|
||||||
|
|
||||||
|
For Docker deployments, use `docker exec`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enter the container
|
||||||
|
docker exec -it digiserver bash
|
||||||
|
|
||||||
|
# Run the installation script
|
||||||
|
sudo /app/install_libreoffice.sh
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
libreoffice --version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Direct Installation (Non-Docker)
|
||||||
|
|
||||||
|
For bare-metal or VM deployments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make script executable (if not already)
|
||||||
|
chmod +x /srv/digiserver-v2/install_libreoffice.sh
|
||||||
|
|
||||||
|
# Run the installation script
|
||||||
|
sudo /srv/digiserver-v2/install_libreoffice.sh
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
libreoffice --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checking Dependency Status
|
||||||
|
|
||||||
|
### Web Interface
|
||||||
|
|
||||||
|
Navigate to **Admin Panel** → **System Dependencies** to see:
|
||||||
|
- ✅ LibreOffice: Installed or ❌ Not installed
|
||||||
|
- ✅ Poppler Utils: Installed (always present)
|
||||||
|
- ✅ FFmpeg: Installed (always present)
|
||||||
|
|
||||||
|
### Command Line
|
||||||
|
|
||||||
|
Check individual dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# LibreOffice
|
||||||
|
libreoffice --version
|
||||||
|
|
||||||
|
# Poppler
|
||||||
|
pdftoppm -v
|
||||||
|
|
||||||
|
# FFmpeg
|
||||||
|
ffmpeg -version
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Type Support Matrix
|
||||||
|
|
||||||
|
| File Type | Required Dependency | Status |
|
||||||
|
|-----------|-------------------|---------|
|
||||||
|
| **Images** (JPG, PNG, GIF) | None | Always supported |
|
||||||
|
| **PDF** | Poppler Utils | Always available |
|
||||||
|
| **Videos** (MP4, AVI, MOV) | FFmpeg | Always available |
|
||||||
|
| **PowerPoint** (PPTX, PPT) | LibreOffice | Optional install |
|
||||||
|
|
||||||
|
## Upload Behavior
|
||||||
|
|
||||||
|
### Without LibreOffice
|
||||||
|
|
||||||
|
When you try to upload a PowerPoint file without LibreOffice:
|
||||||
|
- Upload will be **rejected**
|
||||||
|
- Error message: *"LibreOffice is not installed. Please install it from the Admin Panel → System Dependencies to upload PowerPoint files."*
|
||||||
|
- Other file types (PDF, images, videos) work normally
|
||||||
|
|
||||||
|
### With LibreOffice
|
||||||
|
|
||||||
|
After installation:
|
||||||
|
- PowerPoint files are converted to high-quality PNG images
|
||||||
|
- Each slide becomes a separate media item
|
||||||
|
- Slides maintain aspect ratio and resolution
|
||||||
|
- Original PPTX file is deleted after conversion
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Installation Script
|
||||||
|
|
||||||
|
The `install_libreoffice.sh` script:
|
||||||
|
1. Checks for root/sudo privileges
|
||||||
|
2. Verifies if LibreOffice is already installed
|
||||||
|
3. Updates apt package cache
|
||||||
|
4. Installs `libreoffice` and `libreoffice-impress`
|
||||||
|
5. Verifies successful installation
|
||||||
|
6. Reports version and status
|
||||||
|
|
||||||
|
### Docker Implementation
|
||||||
|
|
||||||
|
The Dockerfile includes:
|
||||||
|
- Sudo access for `appuser` to run installation script
|
||||||
|
- Script permissions set during build
|
||||||
|
- No LibreOffice in base layers (smaller image)
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
- Installation requires sudo/root access
|
||||||
|
- In Docker, `appuser` has limited sudo rights (only for installation script)
|
||||||
|
- Installation script validates LibreOffice binary after install
|
||||||
|
- No external downloads except from official apt repositories
|
||||||
|
|
||||||
|
## Installation Time
|
||||||
|
|
||||||
|
Typical installation times:
|
||||||
|
- **Fast network** (100+ Mbps): 2-3 minutes
|
||||||
|
- **Average network** (10-100 Mbps): 3-5 minutes
|
||||||
|
- **Slow network** (<10 Mbps): 5-10 minutes
|
||||||
|
|
||||||
|
The installation downloads approximately 450-500MB of packages.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Installation Fails
|
||||||
|
|
||||||
|
**Error**: "Permission denied"
|
||||||
|
- **Solution**: Ensure script has execute permissions (`chmod +x`)
|
||||||
|
- **Docker**: Check sudoers configuration in Dockerfile
|
||||||
|
|
||||||
|
**Error**: "Unable to locate package"
|
||||||
|
- **Solution**: Run `sudo apt-get update` first
|
||||||
|
- **Docker**: Rebuild image with fresh apt cache
|
||||||
|
|
||||||
|
### Installation Hangs
|
||||||
|
|
||||||
|
- Check internet connectivity
|
||||||
|
- Verify apt repositories are accessible
|
||||||
|
- In Docker, check container has network access
|
||||||
|
- Increase timeout if on slow connection
|
||||||
|
|
||||||
|
### Verification Fails
|
||||||
|
|
||||||
|
**Symptom**: Installation completes but LibreOffice not found
|
||||||
|
- **Solution**: Check LibreOffice was installed to expected path
|
||||||
|
- Run: `which libreoffice` to locate binary
|
||||||
|
- Verify with: `libreoffice --version`
|
||||||
|
|
||||||
|
### Upload Still Fails After Installation
|
||||||
|
|
||||||
|
1. Verify installation: Admin Panel → System Dependencies
|
||||||
|
2. Check server logs for conversion errors
|
||||||
|
3. Restart application: `docker restart digiserver` (Docker) or restart Flask
|
||||||
|
4. Try uploading a simple PPTX file to test
|
||||||
|
|
||||||
|
## Uninstallation
|
||||||
|
|
||||||
|
To remove LibreOffice and reclaim space:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In container or host
|
||||||
|
sudo apt-get remove --purge libreoffice libreoffice-impress
|
||||||
|
sudo apt-get autoremove
|
||||||
|
sudo apt-get clean
|
||||||
|
```
|
||||||
|
|
||||||
|
This frees approximately 500MB of disk space.
|
||||||
|
|
||||||
|
## Production Recommendations
|
||||||
|
|
||||||
|
### When to Install LibreOffice
|
||||||
|
|
||||||
|
Install LibreOffice if:
|
||||||
|
- Users need to upload PowerPoint presentations
|
||||||
|
- You have >1GB free disk space
|
||||||
|
- Network bandwidth supports 500MB download
|
||||||
|
|
||||||
|
### When to Skip LibreOffice
|
||||||
|
|
||||||
|
Skip LibreOffice if:
|
||||||
|
- Only using PDF, images, and videos
|
||||||
|
- Disk space is constrained (<2GB)
|
||||||
|
- Want minimal installation footprint
|
||||||
|
- Can convert PPTX to PDF externally
|
||||||
|
|
||||||
|
### Multi-Container Deployments
|
||||||
|
|
||||||
|
For multiple instances:
|
||||||
|
- **Option A**: Create custom image with LibreOffice pre-installed
|
||||||
|
- **Option B**: Install on each container individually
|
||||||
|
- **Option C**: Use shared volume for LibreOffice binaries
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: Will removing LibreOffice break existing media?**
|
||||||
|
A: No, converted slides remain as PNG images after conversion.
|
||||||
|
|
||||||
|
**Q: Can I pre-install LibreOffice in the Docker image?**
|
||||||
|
A: Yes, uncomment the `libreoffice` line in Dockerfile and rebuild.
|
||||||
|
|
||||||
|
**Q: How much space does LibreOffice use?**
|
||||||
|
A: Approximately 450-500MB installed.
|
||||||
|
|
||||||
|
**Q: Does LibreOffice run during conversion?**
|
||||||
|
A: Yes, in headless mode. It converts slides to PNG without GUI.
|
||||||
|
|
||||||
|
**Q: Can I use other presentation converters?**
|
||||||
|
A: The code currently only supports LibreOffice. Custom converters require code changes.
|
||||||
|
|
||||||
|
**Q: Is LibreOffice safe for production?**
|
||||||
|
A: Yes, LibreOffice is widely used in production environments for document conversion.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues with optional dependencies:
|
||||||
|
1. Check the **System Dependencies** page in Admin Panel
|
||||||
|
2. Review server logs: `docker logs digiserver`
|
||||||
|
3. Verify system requirements (disk space, memory)
|
||||||
|
4. Consult DOCKER.md for container-specific guidance
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
- **v2.0**: Introduced optional LibreOffice installation
|
||||||
|
- **v1.0**: LibreOffice included in base image (larger size)
|
||||||
181
old_code_documentation/PLAYER_EDIT_MEDIA_API.md
Normal file
181
old_code_documentation/PLAYER_EDIT_MEDIA_API.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# Player Edit Media API
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This API allows players to upload edited media files back to the server, maintaining version history and automatically updating playlists.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
### POST `/api/player-edit-media`
|
||||||
|
|
||||||
|
Upload an edited media file from a player device.
|
||||||
|
|
||||||
|
**Authentication Required:** Yes (Bearer token)
|
||||||
|
|
||||||
|
**Rate Limit:** 60 requests per 60 seconds
|
||||||
|
|
||||||
|
**Content-Type:** `multipart/form-data`
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
### Form Data
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `image_file` | File | Yes | The edited image file |
|
||||||
|
| `metadata` | JSON String | Yes | Metadata about the edit (see below) |
|
||||||
|
|
||||||
|
### Metadata JSON Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"time_of_modification": "2025-12-05T20:30:00Z",
|
||||||
|
"original_name": "image.jpg",
|
||||||
|
"new_name": "image_v1.jpg",
|
||||||
|
"version": 1,
|
||||||
|
"user": "player_user_name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `time_of_modification` | ISO 8601 DateTime | Yes | When the edit was made |
|
||||||
|
| `original_name` | String | Yes | Original filename (must exist in content) |
|
||||||
|
| `new_name` | String | Yes | New filename with version suffix |
|
||||||
|
| `version` | Integer | Yes | Version number (1, 2, 3, etc.) |
|
||||||
|
| `user` | String | No | User who made the edit |
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
### Success (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Edited media received and processed",
|
||||||
|
"edit_id": 123,
|
||||||
|
"version": 1,
|
||||||
|
"new_playlist_version": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
|
||||||
|
#### 400 Bad Request
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "No image file provided"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 404 Not Found
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Original content not found: image.jpg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 500 Internal Server Error
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Internal server error"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. **Player edits media** - User edits an image/PDF/PPTX on the player device
|
||||||
|
2. **Player uploads** - Player sends edited file + metadata to this endpoint
|
||||||
|
3. **Server processes**:
|
||||||
|
- Saves edited file to `/static/uploads/edited_media/<content_id>/<new_name>`
|
||||||
|
- Saves metadata JSON to `/static/uploads/edited_media/<content_id>/<new_name>_metadata.json`
|
||||||
|
- Replaces original file in `/static/uploads/` with edited version
|
||||||
|
- Creates database record in `player_edit` table
|
||||||
|
- Increments playlist version to trigger player refresh
|
||||||
|
- Clears playlist cache
|
||||||
|
4. **Player refreshes** - Next playlist check shows updated media
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
Each edit is saved with a version number:
|
||||||
|
- `image.jpg` → `image_v1.jpg` (first edit)
|
||||||
|
- `image.jpg` → `image_v2.jpg` (second edit)
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
All versions are preserved in the `edited_media/<content_id>/` folder.
|
||||||
|
|
||||||
|
## Example cURL Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# First, authenticate to get token
|
||||||
|
TOKEN=$(curl -X POST http://server/api/auth/authenticate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"hostname": "player-1", "password": "password123"}' \
|
||||||
|
| jq -r '.token')
|
||||||
|
|
||||||
|
# Upload edited media
|
||||||
|
curl -X POST http://server/api/player-edit-media \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-F "image_file=@edited_image_v1.jpg" \
|
||||||
|
-F 'metadata={"time_of_modification":"2025-12-05T20:30:00Z","original_name":"image.jpg","new_name":"image_v1.jpg","version":1,"user":"john"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python Example
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Authenticate
|
||||||
|
auth_response = requests.post(
|
||||||
|
'http://server/api/auth/authenticate',
|
||||||
|
json={'hostname': 'player-1', 'password': 'password123'}
|
||||||
|
)
|
||||||
|
token = auth_response.json()['token']
|
||||||
|
|
||||||
|
# Prepare metadata
|
||||||
|
metadata = {
|
||||||
|
'time_of_modification': '2025-12-05T20:30:00Z',
|
||||||
|
'original_name': 'image.jpg',
|
||||||
|
'new_name': 'image_v1.jpg',
|
||||||
|
'version': 1,
|
||||||
|
'user': 'john'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upload edited file
|
||||||
|
with open('edited_image_v1.jpg', 'rb') as f:
|
||||||
|
response = requests.post(
|
||||||
|
'http://server/api/player-edit-media',
|
||||||
|
headers={'Authorization': f'Bearer {token}'},
|
||||||
|
files={'image_file': f},
|
||||||
|
data={'metadata': json.dumps(metadata)}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### player_edit Table
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | INTEGER | Primary key |
|
||||||
|
| player_id | INTEGER | Foreign key to player |
|
||||||
|
| content_id | INTEGER | Foreign key to content |
|
||||||
|
| original_name | VARCHAR(255) | Original filename |
|
||||||
|
| new_name | VARCHAR(255) | New filename with version |
|
||||||
|
| version | INTEGER | Version number |
|
||||||
|
| user | VARCHAR(255) | User who made the edit |
|
||||||
|
| time_of_modification | DATETIME | When edit was made |
|
||||||
|
| metadata_path | VARCHAR(512) | Path to metadata JSON |
|
||||||
|
| edited_file_path | VARCHAR(512) | Path to edited file |
|
||||||
|
| created_at | DATETIME | Record creation time |
|
||||||
|
|
||||||
|
## UI Display
|
||||||
|
|
||||||
|
Edited media history is displayed on the player management page under the "Edited Media on the Player" card, showing:
|
||||||
|
- Original filename
|
||||||
|
- Version number
|
||||||
|
- Editor name
|
||||||
|
- Modification time
|
||||||
|
- Link to view edited file
|
||||||
@@ -8,11 +8,12 @@ Digital Signage Management System - A modern Flask-based application for managin
|
|||||||
- 🎬 **Playlist System** - Create and manage content playlists with drag-and-drop reordering
|
- 🎬 **Playlist System** - Create and manage content playlists with drag-and-drop reordering
|
||||||
- 📁 **Media Library** - Upload and organize images, videos, PDFs, and presentations
|
- 📁 **Media Library** - Upload and organize images, videos, PDFs, and presentations
|
||||||
- 📄 **PDF to Image Conversion** - Automatic conversion of PDF pages to Full HD images (300 DPI)
|
- 📄 **PDF to Image Conversion** - Automatic conversion of PDF pages to Full HD images (300 DPI)
|
||||||
- 📊 **PowerPoint Support** - Convert PPTX slides to images automatically
|
- 📊 **PowerPoint Support** - Convert PPTX slides to images automatically (optional LibreOffice install)
|
||||||
- 🖼️ **Live Preview** - Real-time content preview for each player
|
- 🖼️ **Live Preview** - Real-time content preview for each player
|
||||||
- ⚡ **Real-time Updates** - Players automatically sync with playlist changes
|
- ⚡ **Real-time Updates** - Players automatically sync with playlist changes
|
||||||
- 🌓 **Dark Mode** - Full dark mode support across all interfaces
|
- 🌓 **Dark Mode** - Full dark mode support across all interfaces
|
||||||
- 🗑️ **Media Management** - Clean up unused media files with leftover media manager
|
- 🗑️ **Media Management** - Clean up unused media files with leftover media manager
|
||||||
|
- 🔧 **Optional Dependencies** - Install LibreOffice on-demand to reduce base image size by 56%
|
||||||
- 🔒 **User Authentication** - Secure admin access with role-based permissions
|
- 🔒 **User Authentication** - Secure admin access with role-based permissions
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -35,16 +36,20 @@ See [DOCKER.md](DOCKER.md) for detailed Docker documentation.
|
|||||||
#### Prerequisites
|
#### Prerequisites
|
||||||
|
|
||||||
- Python 3.13+
|
- Python 3.13+
|
||||||
- LibreOffice (for PPTX conversion)
|
- Poppler Utils (for PDF conversion) - **Required**
|
||||||
- Poppler Utils (for PDF conversion)
|
- FFmpeg (for video processing) - **Required**
|
||||||
- FFmpeg (for video processing)
|
- LibreOffice (for PPTX conversion) - **Optional** (can be installed via Admin Panel)
|
||||||
|
|
||||||
#### Installation
|
#### Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install system dependencies (Debian/Ubuntu)
|
# Install required system dependencies (Debian/Ubuntu)
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y poppler-utils libreoffice ffmpeg libmagic1
|
sudo apt-get install -y poppler-utils ffmpeg libmagic1
|
||||||
|
|
||||||
|
# Optional: Install LibreOffice for PowerPoint conversion
|
||||||
|
# OR install later via Admin Panel → System Dependencies
|
||||||
|
sudo apt-get install -y libreoffice
|
||||||
|
|
||||||
# Create virtual environment
|
# Create virtual environment
|
||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
@@ -199,11 +204,20 @@ sudo apt-get install poppler-utils
|
|||||||
```
|
```
|
||||||
|
|
||||||
### PPTX Conversion Fails
|
### PPTX Conversion Fails
|
||||||
Install LibreOffice:
|
**Method 1: Via Web UI (Recommended)**
|
||||||
|
1. Go to Admin Panel → System Dependencies
|
||||||
|
2. Click "Install LibreOffice"
|
||||||
|
3. Wait 2-5 minutes for installation
|
||||||
|
|
||||||
|
**Method 2: Manual Install**
|
||||||
```bash
|
```bash
|
||||||
sudo apt-get install libreoffice
|
sudo apt-get install libreoffice
|
||||||
|
# OR use the provided script
|
||||||
|
sudo ./install_libreoffice.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See [OPTIONAL_DEPENDENCIES.md](OPTIONAL_DEPENDENCIES.md) for details.
|
||||||
|
|
||||||
### Upload Fails
|
### Upload Fails
|
||||||
Check folder permissions:
|
Check folder permissions:
|
||||||
```bash
|
```bash
|
||||||
@@ -248,21 +262,35 @@ flask db upgrade
|
|||||||
|
|
||||||
This project is proprietary software. All rights reserved.
|
This project is proprietary software. All rights reserved.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [DOCKER.md](DOCKER.md) - Docker deployment guide
|
||||||
|
- [OPTIONAL_DEPENDENCIES.md](OPTIONAL_DEPENDENCIES.md) - Optional dependency installation
|
||||||
|
- [PROGRESS.md](PROGRESS.md) - Development progress tracker
|
||||||
|
- [KIVY_PLAYER_COMPATIBILITY.md](KIVY_PLAYER_COMPATIBILITY.md) - Player integration guide
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
For issues and questions:
|
For issues and questions:
|
||||||
- Check [DOCKER.md](DOCKER.md) for deployment help
|
- Check [DOCKER.md](DOCKER.md) for deployment help
|
||||||
|
- Review [OPTIONAL_DEPENDENCIES.md](OPTIONAL_DEPENDENCIES.md) for LibreOffice setup
|
||||||
- Review troubleshooting section
|
- Review troubleshooting section
|
||||||
- Check application logs
|
- Check application logs
|
||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
|
- **v2.1** - Optional LibreOffice installation
|
||||||
|
- Reduced base Docker image by 56% (~900MB → ~400MB)
|
||||||
|
- On-demand LibreOffice installation via Admin Panel
|
||||||
|
- System Dependencies management page
|
||||||
|
- Enhanced error messages for PPTX without LibreOffice
|
||||||
|
|
||||||
- **v2.0** - Complete rewrite with playlist-centric architecture
|
- **v2.0** - Complete rewrite with playlist-centric architecture
|
||||||
- PDF to image conversion (300 DPI)
|
- PDF to image conversion (300 DPI)
|
||||||
- PPTX slide conversion
|
- PPTX slide conversion
|
||||||
- Leftover media management
|
- Leftover media management
|
||||||
- Enhanced dark mode
|
- Enhanced dark mode
|
||||||
- Duration editing for all content types
|
- Duration editing for all content types
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Binary file not shown.
Reference in New Issue
Block a user