Compare commits
20 Commits
1326543418
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5456c0ec4 | ||
|
|
d0fbfe25b3 | ||
|
|
52344a27a6 | ||
|
|
9cb32da13c | ||
|
|
0f34a47fa9 | ||
|
|
a5ef5749b1 | ||
| 3cc703a7d1 | |||
|
|
505c8e268c | ||
|
|
6cefce81ef | ||
| 359e330758 | |||
| 9c124dbd7e | |||
|
|
7b24245ddb | ||
|
|
58694ff3f4 | ||
| 7f5991f60d | |||
|
|
5e4950563c | ||
| 091e985ff2 | |||
| 1eb0aa3658 | |||
| 4e5aff1c02 | |||
| 318f783de3 | |||
| 70d76f45e7 |
45
.dockerignore
Normal file
45
.dockerignore
Normal file
@@ -0,0 +1,45 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Data folders (these will be mounted as volumes)
|
||||
data/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.example
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
44
.env.example
44
.env.example
@@ -1,21 +1,41 @@
|
||||
# .env - Flask environment variables
|
||||
# DigiServer Environment Configuration
|
||||
# Copy this file to .env and modify the values as needed
|
||||
|
||||
# Flask secret key (change this to something secure in production)
|
||||
SECRET_KEY=Ana_Are_Multe_Mere-Si_Nu_Are_Pere
|
||||
# Flask Configuration
|
||||
FLASK_APP=app.py
|
||||
FLASK_RUN_HOST=0.0.0.0
|
||||
FLASK_ENV=production
|
||||
|
||||
# Flask environment: development or production
|
||||
FLASK_ENV=development
|
||||
# Security
|
||||
SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
|
||||
# Change this to a secure random string in production!
|
||||
|
||||
# Database location (optional, defaults to instance/dashboard.db)
|
||||
# Default Admin User
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=Initial01!
|
||||
# Change the default password after first login!
|
||||
|
||||
# Database Configuration
|
||||
# SQLite database file will be created in data/instance/dashboard.db
|
||||
# SQLALCHEMY_DATABASE_URI=sqlite:///instance/dashboard.db
|
||||
|
||||
# Default admin user credentials (used for auto-creation)
|
||||
DEFAULT_USER=admin
|
||||
DEFAULT_PASSWORD=1234
|
||||
# Application Settings
|
||||
MAX_CONTENT_LENGTH=2147483648 # 2GB in bytes
|
||||
UPLOAD_FOLDER=static/uploads
|
||||
UPLOAD_FOLDERLOGO=static/resurse
|
||||
|
||||
# Flask server settings
|
||||
# Server Information
|
||||
SERVER_VERSION=1.1.0
|
||||
BUILD_DATE=2025-06-29
|
||||
|
||||
# Docker Configuration (for docker-compose.yml)
|
||||
DIGISERVER_PORT=8880
|
||||
CONTAINER_NAME=digiserver
|
||||
|
||||
# Flask server settings (for development)
|
||||
HOST=0.0.0.0
|
||||
PORT=5000
|
||||
|
||||
# Maximum upload size (in bytes, 2GB)
|
||||
MAX_CONTENT_LENGTH=2147483648
|
||||
# Optional: External Database (for advanced users)
|
||||
# DATABASE_URL=postgresql://user:password@localhost/digiserver
|
||||
# DATABASE_URL=mysql://user:password@localhost/digiserver
|
||||
39
.gitignore
vendored
39
.gitignore
vendored
@@ -1,4 +1,41 @@
|
||||
digiscreen/
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
venv/
|
||||
# Data directories (persistent storage)
|
||||
data/
|
||||
instance/
|
||||
instance.bak/
|
||||
|
||||
# Legacy directories (can be removed after migration)
|
||||
digiscreen/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Backups
|
||||
backups/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
92
DEPLOYMENT.md
Normal file
92
DEPLOYMENT.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# DigiServer v1.1.0 - Production Deployment Guide
|
||||
|
||||
## 🎯 Ready for Deployment
|
||||
|
||||
Your DigiServer application has been cleaned and prepared for Docker deployment.
|
||||
|
||||
### ✅ What's Been Prepared
|
||||
|
||||
1. **Application Cleaned**
|
||||
- Python cache files removed (`__pycache__/`, `*.pyc`)
|
||||
- Development artifacts cleaned
|
||||
- Production-ready structure
|
||||
|
||||
2. **Docker Configuration**
|
||||
- Dockerfile optimized with LibreOffice and poppler-utils
|
||||
- docker-compose.yml configured for production
|
||||
- .dockerignore updated to exclude development files
|
||||
- Data persistence configured via volumes
|
||||
|
||||
3. **Deployment Scripts**
|
||||
- `deploy-docker.sh` - Automated deployment script
|
||||
- `cleanup-docker.sh` - Complete cleanup script
|
||||
- Both scripts use modern `docker compose` syntax
|
||||
|
||||
4. **Data Structure**
|
||||
- `./data/instance/` - Database files
|
||||
- `./data/uploads/` - Media uploads
|
||||
- `./data/resurse/` - System resources
|
||||
- All directories auto-created and volume-mounted
|
||||
|
||||
### 🚀 Quick Deployment
|
||||
|
||||
```bash
|
||||
# Deploy DigiServer
|
||||
./deploy-docker.sh
|
||||
|
||||
# Access at: http://localhost:8880
|
||||
# Username: admin
|
||||
# Password: Initial01!
|
||||
```
|
||||
|
||||
### 📋 Features Ready
|
||||
|
||||
- ✅ **Document Processing**: LibreOffice + poppler-utils integrated
|
||||
- ✅ **File Uploads**: PPTX → PDF → 4K JPG workflow
|
||||
- ✅ **Path Resolution**: Absolute path handling for containerized deployment
|
||||
- ✅ **File Management**: Bulk delete functions with physical file cleanup
|
||||
- ✅ **User Management**: Admin user auto-creation
|
||||
- ✅ **Data Persistence**: Volume-mounted data directories
|
||||
- ✅ **Health Checks**: Container health monitoring
|
||||
- ✅ **Production Logging**: Structured output and error handling
|
||||
|
||||
### 🔧 System Requirements
|
||||
|
||||
- Docker Engine 20.10+
|
||||
- Docker Compose v2 (plugin)
|
||||
- 2GB RAM minimum
|
||||
- 10GB disk space
|
||||
|
||||
### 📁 Deployment Structure
|
||||
|
||||
```
|
||||
digiserver/
|
||||
├── 📁 app/ # Application code
|
||||
├── 📁 data/ # Persistent data (auto-created)
|
||||
│ ├── 📁 instance/ # Database
|
||||
│ ├── 📁 uploads/ # Media files
|
||||
│ └── 📁 resurse/ # Resources
|
||||
├── 🐳 Dockerfile # Production image
|
||||
├── 🔧 docker-compose.yml # Container orchestration
|
||||
├── 🚀 deploy-docker.sh # Deployment script
|
||||
├── 🧹 cleanup-docker.sh # Cleanup script
|
||||
└── 📖 README.md # Documentation
|
||||
```
|
||||
|
||||
### 🔐 Security Notes
|
||||
|
||||
- Change default password after first login
|
||||
- SECRET_KEY configured for session security
|
||||
- File upload restrictions in place
|
||||
- Container runs with proper permissions
|
||||
|
||||
### 📊 Monitoring
|
||||
|
||||
- Health checks configured (30s intervals)
|
||||
- Container auto-restart on failure
|
||||
- Logs available via `docker compose logs -f`
|
||||
- Status monitoring with `docker compose ps`
|
||||
|
||||
---
|
||||
|
||||
**Next Step**: Run `./deploy-docker.sh` to deploy your DigiServer! 🚀
|
||||
56
Dockerfile
56
Dockerfile
@@ -1,31 +1,59 @@
|
||||
# Use Python 3.11 slim image
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies, including Rust and build tools
|
||||
# Install system dependencies including LibreOffice and poppler-utils
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libreoffice poppler-utils ffmpeg \
|
||||
libpoppler-cpp-dev libmagic1 \
|
||||
libffi-dev libssl-dev g++ curl libjpeg-dev zlib1g-dev \
|
||||
libxml2-dev libxslt-dev build-essential cargo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
poppler-utils \
|
||||
libreoffice \
|
||||
ffmpeg \
|
||||
libpoppler-cpp-dev \
|
||||
libmagic1 \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
g++ \
|
||||
curl \
|
||||
libjpeg-dev \
|
||||
zlib1g-dev \
|
||||
libxml2-dev \
|
||||
libxslt-dev \
|
||||
build-essential \
|
||||
cargo \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
# Debug: Verify Rust installation
|
||||
RUN rustc --version && cargo --version
|
||||
|
||||
# Copy application files
|
||||
COPY . /app
|
||||
# Verify LibreOffice and poppler-utils installation
|
||||
RUN libreoffice --version && pdftoppm -v
|
||||
|
||||
# Copy entrypoint script and make it executable
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
# Copy requirements first for better layer caching
|
||||
COPY app/requirements.txt .
|
||||
|
||||
# Upgrade pip and install Python dependencies (using piwheels for ARM)
|
||||
# Upgrade pip and install Python dependencies
|
||||
RUN python -m pip install --upgrade pip && \
|
||||
pip install --no-cache-dir --extra-index-url https://www.piwheels.org/simple -r requirements.txt
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app/ .
|
||||
|
||||
# Make entrypoint script executable
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Create necessary directories for volumes
|
||||
RUN mkdir -p /app/static/uploads /app/static/resurse /app/instance
|
||||
|
||||
# Expose the application port
|
||||
EXPOSE 5000
|
||||
|
||||
# Set environment variables
|
||||
ENV FLASK_APP=app.py
|
||||
ENV FLASK_RUN_HOST=0.0.0.0
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# Use entrypoint script
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
662
OPTIMIZATION_PROPOSAL.md
Normal file
662
OPTIMIZATION_PROPOSAL.md
Normal file
@@ -0,0 +1,662 @@
|
||||
# DigiServer Optimization Proposal
|
||||
|
||||
## Executive Summary
|
||||
|
||||
After analyzing the DigiServer project, I've identified several optimization opportunities across performance, architecture, security, and maintainability. The current system is functional but has areas for improvement.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Metrics
|
||||
- **Main Application**: 1,051 lines (app.py)
|
||||
- **Docker Image Size**: 3.53 GB ⚠️ (Very Large)
|
||||
- **Database Size**: 2.6 MB
|
||||
- **Media Storage**: 13 MB
|
||||
- **Routes**: 30+ endpoints
|
||||
- **Templates**: 14 HTML files
|
||||
|
||||
### Architecture
|
||||
- ✅ **Good**: Modular structure (models, utils, templates)
|
||||
- ✅ **Good**: Docker containerization
|
||||
- ✅ **Good**: Flask extensions properly used
|
||||
- ⚠️ **Issue**: Monolithic app.py (1,051 lines)
|
||||
- ⚠️ **Issue**: Large Docker image
|
||||
- ⚠️ **Issue**: No caching strategy
|
||||
- ⚠️ **Issue**: Synchronous video processing blocks requests
|
||||
|
||||
---
|
||||
|
||||
## Priority 1: Critical Optimizations
|
||||
|
||||
### 1. Reduce Docker Image Size (3.53 GB → ~800 MB)
|
||||
|
||||
**Current Issue**: Docker image is unnecessarily large due to build dependencies
|
||||
|
||||
**Solution**: Multi-stage build
|
||||
|
||||
```dockerfile
|
||||
# Stage 1: Build stage with heavy dependencies
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
g++ \
|
||||
cargo \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python packages with wheels
|
||||
COPY app/requirements.txt .
|
||||
RUN pip wheel --no-cache-dir --wheel-dir /build/wheels -r requirements.txt
|
||||
|
||||
# Stage 2: Runtime stage (smaller)
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
poppler-utils \
|
||||
libreoffice-writer \
|
||||
libreoffice-impress \
|
||||
ffmpeg \
|
||||
libmagic1 \
|
||||
curl \
|
||||
fonts-dejavu-core \
|
||||
--no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
# Copy wheels from builder
|
||||
COPY --from=builder /build/wheels /wheels
|
||||
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
|
||||
|
||||
# Copy application
|
||||
COPY app/ .
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Create volumes
|
||||
RUN mkdir -p /app/static/uploads /app/static/resurse /app/instance
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["./entrypoint.sh"]
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- ✅ Reduce image size by ~70% (3.53GB → ~800MB)
|
||||
- ✅ Faster deployment and startup
|
||||
- ✅ Less storage and bandwidth usage
|
||||
|
||||
---
|
||||
|
||||
### 2. Split Monolithic app.py into Blueprints
|
||||
|
||||
**Current Issue**: 1,051 lines in single file makes maintenance difficult
|
||||
|
||||
**Proposed Structure**:
|
||||
```
|
||||
app/
|
||||
├── app.py (main app initialization, ~100 lines)
|
||||
├── blueprints/
|
||||
│ ├── __init__.py
|
||||
│ ├── auth.py # Login, logout, register
|
||||
│ ├── admin.py # Admin routes
|
||||
│ ├── players.py # Player management
|
||||
│ ├── groups.py # Group management
|
||||
│ ├── content.py # Content upload/management
|
||||
│ └── api.py # API endpoints
|
||||
├── models/
|
||||
├── utils/
|
||||
└── templates/
|
||||
```
|
||||
|
||||
**Example Blueprint (auth.py)**:
|
||||
```python
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_user, logout_user, login_required
|
||||
from models import User
|
||||
from extensions import db, bcrypt
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
# Login logic here
|
||||
pass
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
# Register logic here
|
||||
pass
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ Better code organization
|
||||
- ✅ Easier to maintain and test
|
||||
- ✅ Multiple developers can work simultaneously
|
||||
- ✅ Clear separation of concerns
|
||||
|
||||
---
|
||||
|
||||
### 3. Implement Redis Caching
|
||||
|
||||
**Current Issue**: Database queries repeated on every request
|
||||
|
||||
**Solution**: Add Redis for caching
|
||||
|
||||
```python
|
||||
# Add to docker-compose.yml
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: digiserver-redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- digiserver-network
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
|
||||
# Add to requirements.txt
|
||||
redis==5.0.1
|
||||
Flask-Caching==2.1.0
|
||||
|
||||
# Configuration
|
||||
from flask_caching import Cache
|
||||
|
||||
cache = Cache(config={
|
||||
'CACHE_TYPE': 'redis',
|
||||
'CACHE_REDIS_HOST': 'redis',
|
||||
'CACHE_REDIS_PORT': 6379,
|
||||
'CACHE_DEFAULT_TIMEOUT': 300
|
||||
})
|
||||
|
||||
# Usage examples
|
||||
@cache.cached(timeout=60, key_prefix='dashboard')
|
||||
def dashboard():
|
||||
# Cached for 60 seconds
|
||||
pass
|
||||
|
||||
@cache.memoize(timeout=300)
|
||||
def get_player_content(player_id):
|
||||
# Cached per player_id for 5 minutes
|
||||
return Content.query.filter_by(player_id=player_id).all()
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- ✅ 50-80% faster page loads
|
||||
- ✅ Reduced database load
|
||||
- ✅ Better scalability
|
||||
|
||||
---
|
||||
|
||||
## Priority 2: Performance Optimizations
|
||||
|
||||
### 4. Implement Celery for Background Tasks
|
||||
|
||||
**Current Issue**: Video conversion blocks HTTP requests
|
||||
|
||||
**Solution**: Use Celery for async tasks
|
||||
|
||||
```python
|
||||
# docker-compose.yml
|
||||
services:
|
||||
worker:
|
||||
build: .
|
||||
image: digiserver:latest
|
||||
container_name: digiserver-worker
|
||||
command: celery -A celery_worker.celery worker --loglevel=info
|
||||
volumes:
|
||||
- ./app:/app
|
||||
- ./data/uploads:/app/static/uploads
|
||||
networks:
|
||||
- digiserver-network
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
# celery_worker.py
|
||||
from celery import Celery
|
||||
from app import app
|
||||
|
||||
celery = Celery(
|
||||
app.import_name,
|
||||
broker='redis://redis:6379/0',
|
||||
backend='redis://redis:6379/1'
|
||||
)
|
||||
|
||||
@celery.task
|
||||
def convert_video_task(file_path, filename, target_type, target_id, duration):
|
||||
with app.app_context():
|
||||
convert_video_and_update_playlist(
|
||||
app, file_path, filename, target_type, target_id, duration
|
||||
)
|
||||
return {'status': 'completed', 'filename': filename}
|
||||
|
||||
# Usage in upload route
|
||||
@app.route('/upload_content', methods=['POST'])
|
||||
def upload_content():
|
||||
# ... validation ...
|
||||
|
||||
for file in files:
|
||||
if media_type == 'video':
|
||||
# Queue video conversion
|
||||
convert_video_task.delay(file_path, filename, target_type, target_id, duration)
|
||||
flash('Video queued for processing', 'info')
|
||||
else:
|
||||
# Process immediately
|
||||
process_uploaded_files(...)
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ Non-blocking uploads
|
||||
- ✅ Better user experience
|
||||
- ✅ Can retry failed tasks
|
||||
- ✅ Monitor task status
|
||||
|
||||
---
|
||||
|
||||
### 5. Database Query Optimization
|
||||
|
||||
**Current Issues**: N+1 queries, no indexes
|
||||
|
||||
**Solutions**:
|
||||
|
||||
```python
|
||||
# Add indexes to models
|
||||
class Content(db.Model):
|
||||
__tablename__ = 'content'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_id = db.Column(db.Integer, db.ForeignKey('player.id'), index=True) # Add index
|
||||
position = db.Column(db.Integer, index=True) # Add index
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_player_position', 'player_id', 'position'), # Composite index
|
||||
)
|
||||
|
||||
# Use eager loading
|
||||
def get_group_content(group_id):
|
||||
# Bad: N+1 queries
|
||||
group = Group.query.get(group_id)
|
||||
content = [Content.query.filter_by(player_id=p.id).all() for p in group.players]
|
||||
|
||||
# Good: Single query with join
|
||||
content = db.session.query(Content)\
|
||||
.join(Player)\
|
||||
.join(Group, Player.groups)\
|
||||
.filter(Group.id == group_id)\
|
||||
.options(db.joinedload(Content.player))\
|
||||
.all()
|
||||
return content
|
||||
|
||||
# Use query result caching
|
||||
from sqlalchemy.orm import lazyload
|
||||
|
||||
@cache.memoize(timeout=300)
|
||||
def get_player_feedback_cached(player_name, limit=5):
|
||||
return PlayerFeedback.query\
|
||||
.filter_by(player_name=player_name)\
|
||||
.order_by(PlayerFeedback.timestamp.desc())\
|
||||
.limit(limit)\
|
||||
.all()
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- ✅ 40-60% faster database operations
|
||||
- ✅ Reduced database load
|
||||
|
||||
---
|
||||
|
||||
### 6. Optimize Static File Delivery
|
||||
|
||||
**Current**: Flask serves static files (slow)
|
||||
|
||||
**Solution**: Use nginx as reverse proxy
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: digiserver-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./data/uploads:/var/www/uploads:ro
|
||||
- ./data/resurse:/var/www/resurse:ro
|
||||
depends_on:
|
||||
- digiserver
|
||||
networks:
|
||||
- digiserver-network
|
||||
|
||||
digiserver:
|
||||
ports: [] # Remove external port exposure
|
||||
```
|
||||
|
||||
```nginx
|
||||
# nginx.conf
|
||||
http {
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
gzip_comp_level 6;
|
||||
|
||||
# Cache static files
|
||||
location /static/uploads/ {
|
||||
alias /var/www/uploads/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://digiserver:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ 3-5x faster static file delivery
|
||||
- ✅ Automatic gzip compression
|
||||
- ✅ Better caching
|
||||
- ✅ Load balancing ready
|
||||
|
||||
---
|
||||
|
||||
## Priority 3: Code Quality & Maintainability
|
||||
|
||||
### 7. Add Type Hints
|
||||
|
||||
```python
|
||||
# Before
|
||||
def get_player_content(player_id):
|
||||
return Content.query.filter_by(player_id=player_id).all()
|
||||
|
||||
# After
|
||||
from typing import List, Optional
|
||||
from models import Content
|
||||
|
||||
def get_player_content(player_id: int) -> List[Content]:
|
||||
"""Get all content for a specific player."""
|
||||
return Content.query.filter_by(player_id=player_id).all()
|
||||
|
||||
def update_playlist_version(player: Player, increment: int = 1) -> int:
|
||||
"""Update player playlist version and return new version."""
|
||||
player.playlist_version += increment
|
||||
db.session.commit()
|
||||
return player.playlist_version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. Add API Rate Limiting
|
||||
|
||||
```python
|
||||
# Add to requirements.txt
|
||||
Flask-Limiter==3.5.0
|
||||
|
||||
# Configuration
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
limiter = Limiter(
|
||||
app=app,
|
||||
key_func=get_remote_address,
|
||||
storage_uri="redis://redis:6379",
|
||||
default_limits=["200 per day", "50 per hour"]
|
||||
)
|
||||
|
||||
# Apply to routes
|
||||
@app.route('/api/player-feedback', methods=['POST'])
|
||||
@limiter.limit("10 per minute")
|
||||
def api_player_feedback():
|
||||
# Protected from abuse
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. Implement Health Checks & Monitoring
|
||||
|
||||
```python
|
||||
# Add health endpoint
|
||||
@app.route('/health')
|
||||
def health():
|
||||
try:
|
||||
# Check database
|
||||
db.session.execute(text('SELECT 1'))
|
||||
|
||||
# Check Redis
|
||||
cache.set('health_check', 'ok', timeout=5)
|
||||
|
||||
# Check disk space
|
||||
upload_stat = os.statvfs(UPLOAD_FOLDER)
|
||||
free_space_gb = (upload_stat.f_bavail * upload_stat.f_frsize) / (1024**3)
|
||||
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'database': 'ok',
|
||||
'cache': 'ok',
|
||||
'disk_space_gb': round(free_space_gb, 2)
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'status': 'unhealthy', 'error': str(e)}), 500
|
||||
|
||||
# Add metrics endpoint (Prometheus)
|
||||
from prometheus_flask_exporter import PrometheusMetrics
|
||||
|
||||
metrics = PrometheusMetrics(app)
|
||||
|
||||
# Automatic metrics:
|
||||
# - Request count
|
||||
# - Request duration
|
||||
# - Request size
|
||||
# - Response size
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. Environment-Based Configuration
|
||||
|
||||
```python
|
||||
# config.py
|
||||
import os
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'default-dev-key')
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
MAX_CONTENT_LENGTH = 2048 * 1024 * 1024
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
DEBUG = True
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///dev.db'
|
||||
CACHE_TYPE = 'simple'
|
||||
|
||||
class ProductionConfig(Config):
|
||||
DEBUG = False
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL')
|
||||
CACHE_TYPE = 'redis'
|
||||
CACHE_REDIS_HOST = 'redis'
|
||||
|
||||
class TestingConfig(Config):
|
||||
TESTING = True
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
|
||||
# Usage in app.py
|
||||
env = os.getenv('FLASK_ENV', 'development')
|
||||
if env == 'production':
|
||||
app.config.from_object('config.ProductionConfig')
|
||||
elif env == 'testing':
|
||||
app.config.from_object('config.TestingConfig')
|
||||
else:
|
||||
app.config.from_object('config.DevelopmentConfig')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Priority 4: Security Enhancements
|
||||
|
||||
### 11. Security Hardening
|
||||
|
||||
```python
|
||||
# Add to requirements.txt
|
||||
Flask-Talisman==1.1.0 # Already present
|
||||
Flask-SeaSurf==1.1.1 # CSRF protection
|
||||
|
||||
# Configuration
|
||||
from flask_talisman import Talisman
|
||||
from flask_seasurf import SeaSurf
|
||||
|
||||
# HTTPS enforcement (production only)
|
||||
if app.config['ENV'] == 'production':
|
||||
Talisman(app,
|
||||
force_https=True,
|
||||
strict_transport_security=True,
|
||||
content_security_policy={
|
||||
'default-src': "'self'",
|
||||
'img-src': ['*', 'data:'],
|
||||
'script-src': ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net'],
|
||||
'style-src': ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net']
|
||||
}
|
||||
)
|
||||
|
||||
# CSRF protection
|
||||
csrf = SeaSurf(app)
|
||||
|
||||
# Exempt API endpoints (use API keys instead)
|
||||
@csrf.exempt
|
||||
@app.route('/api/player-feedback', methods=['POST'])
|
||||
def api_player_feedback():
|
||||
# Verify API key
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
if not verify_api_key(api_key):
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
# ... rest of logic
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12. Input Validation & Sanitization
|
||||
|
||||
```python
|
||||
# Add to requirements.txt
|
||||
marshmallow==3.20.1
|
||||
Flask-Marshmallow==0.15.0
|
||||
|
||||
# schemas.py
|
||||
from marshmallow import Schema, fields, validate
|
||||
|
||||
class PlayerFeedbackSchema(Schema):
|
||||
player_name = fields.Str(required=True, validate=validate.Length(min=1, max=100))
|
||||
quickconnect_code = fields.Str(required=True, validate=validate.Length(min=6, max=20))
|
||||
message = fields.Str(required=True, validate=validate.Length(max=500))
|
||||
status = fields.Str(required=True, validate=validate.OneOf(['active', 'error', 'playing', 'stopped']))
|
||||
timestamp = fields.DateTime(required=True)
|
||||
playlist_version = fields.Int(allow_none=True)
|
||||
error_details = fields.Str(allow_none=True, validate=validate.Length(max=1000))
|
||||
|
||||
# Usage
|
||||
from schemas import PlayerFeedbackSchema
|
||||
|
||||
@app.route('/api/player-feedback', methods=['POST'])
|
||||
def api_player_feedback():
|
||||
schema = PlayerFeedbackSchema()
|
||||
try:
|
||||
data = schema.load(request.get_json())
|
||||
except ValidationError as err:
|
||||
return jsonify({'error': err.messages}), 400
|
||||
|
||||
# Data is now validated and sanitized
|
||||
feedback = PlayerFeedback(**data)
|
||||
db.session.add(feedback)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True}), 200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Quick Wins (1-2 days)
|
||||
1. ✅ Multi-stage Docker build (reduce image size)
|
||||
2. ✅ Add basic caching for dashboard
|
||||
3. ✅ Database indexes
|
||||
4. ✅ Type hints for main functions
|
||||
|
||||
### Phase 2: Architecture (3-5 days)
|
||||
1. ✅ Split app.py into blueprints
|
||||
2. ✅ Add Redis caching
|
||||
3. ✅ Implement Celery for background tasks
|
||||
4. ✅ Add nginx reverse proxy
|
||||
|
||||
### Phase 3: Polish (2-3 days)
|
||||
1. ✅ Security hardening
|
||||
2. ✅ Input validation
|
||||
3. ✅ Health checks & monitoring
|
||||
4. ✅ Environment-based config
|
||||
|
||||
### Phase 4: Testing & Documentation (2-3 days)
|
||||
1. ✅ Unit tests
|
||||
2. ✅ Integration tests
|
||||
3. ✅ API documentation
|
||||
4. ✅ Deployment guide
|
||||
|
||||
---
|
||||
|
||||
## Expected Results
|
||||
|
||||
### Performance
|
||||
- **Page Load Time**: 2-3s → 0.5-1s (50-75% faster)
|
||||
- **API Response**: 100-200ms → 20-50ms (75% faster)
|
||||
- **Video Upload**: Blocks request → Async (immediate response)
|
||||
- **Docker Image**: 3.53GB → 800MB (77% smaller)
|
||||
|
||||
### Scalability
|
||||
- **Concurrent Users**: 10-20 → 100-200 (10x)
|
||||
- **Request Handling**: 10 req/s → 100 req/s (10x)
|
||||
- **Database Load**: High → Low (caching)
|
||||
|
||||
### Maintainability
|
||||
- **Code Organization**: Monolithic → Modular (blueprints)
|
||||
- **Type Safety**: None → Type hints
|
||||
- **Testing**: Difficult → Easy (smaller modules)
|
||||
- **Documentation**: Scattered → Centralized
|
||||
|
||||
---
|
||||
|
||||
## Cost-Benefit Analysis
|
||||
|
||||
| Optimization | Effort | Impact | Priority |
|
||||
|--------------|--------|---------|----------|
|
||||
| Multi-stage Docker | Low | High | 🔴 Critical |
|
||||
| Split to Blueprints | Medium | High | 🔴 Critical |
|
||||
| Redis Caching | Low | High | 🔴 Critical |
|
||||
| Celery Background | Medium | High | 🟡 High |
|
||||
| Database Indexes | Low | Medium | 🟡 High |
|
||||
| nginx Proxy | Low | Medium | 🟡 High |
|
||||
| Type Hints | Low | Low | 🟢 Medium |
|
||||
| Rate Limiting | Low | Low | 🟢 Medium |
|
||||
| Security Hardening | Medium | Medium | 🟡 High |
|
||||
| Monitoring | Low | Medium | 🟢 Medium |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Review this proposal** with the team
|
||||
2. **Prioritize optimizations** based on current pain points
|
||||
3. **Create feature branches** for each optimization
|
||||
4. **Implement in phases** to minimize disruption
|
||||
5. **Test thoroughly** before deploying to production
|
||||
|
||||
Would you like me to start implementing any of these optimizations?
|
||||
134
OPTIMIZATION_SUMMARY.md
Normal file
134
OPTIMIZATION_SUMMARY.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# DigiServer Optimization - Quick Reference
|
||||
|
||||
## 🎯 Top 3 Critical Optimizations
|
||||
|
||||
### 1. Reduce Docker Image Size: 3.53GB → 800MB (77% smaller)
|
||||
**Impact**: Faster deployments, less storage
|
||||
**Effort**: 2 hours
|
||||
**File**: `Dockerfile` - implement multi-stage build
|
||||
|
||||
### 2. Split Monolithic app.py (1,051 lines) into Blueprints
|
||||
**Impact**: Better maintainability, easier testing
|
||||
**Effort**: 1 day
|
||||
**Structure**:
|
||||
```
|
||||
blueprints/
|
||||
├── auth.py # Login/Register
|
||||
├── admin.py # Admin panel
|
||||
├── players.py # Player management
|
||||
├── groups.py # Group management
|
||||
├── content.py # Upload/Media
|
||||
└── api.py # API endpoints
|
||||
```
|
||||
|
||||
### 3. Add Redis Caching
|
||||
**Impact**: 50-80% faster page loads
|
||||
**Effort**: 4 hours
|
||||
**Add**: Redis container + Flask-Caching
|
||||
|
||||
---
|
||||
|
||||
## 📊 Current State
|
||||
|
||||
| Metric | Value | Status |
|
||||
|--------|-------|--------|
|
||||
| Docker Image | 3.53 GB | ⚠️ Too large |
|
||||
| Main File (app.py) | 1,051 lines | ⚠️ Monolithic |
|
||||
| Routes | 30+ endpoints | ⚠️ No structure |
|
||||
| Caching | None | ❌ Missing |
|
||||
| Background Tasks | Synchronous | ❌ Blocks requests |
|
||||
| API Rate Limiting | None | ⚠️ Security risk |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Performance Wins
|
||||
|
||||
### Database Indexes (30 minutes)
|
||||
```python
|
||||
# Add to models
|
||||
class Content(db.Model):
|
||||
player_id = db.Column(db.Integer, index=True)
|
||||
position = db.Column(db.Integer, index=True)
|
||||
```
|
||||
|
||||
### Cache Dashboard (1 hour)
|
||||
```python
|
||||
from flask_caching import Cache
|
||||
cache = Cache(config={'CACHE_TYPE': 'simple'})
|
||||
|
||||
@cache.cached(timeout=60)
|
||||
def dashboard():
|
||||
# Cached for 60 seconds
|
||||
```
|
||||
|
||||
### Type Hints (2 hours)
|
||||
```python
|
||||
def get_player_content(player_id: int) -> List[Content]:
|
||||
return Content.query.filter_by(player_id=player_id).all()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Expected Results
|
||||
|
||||
| Metric | Before | After | Improvement |
|
||||
|--------|--------|-------|-------------|
|
||||
| Docker Image | 3.53 GB | 800 MB | 77% ↓ |
|
||||
| Page Load | 2-3s | 0.5-1s | 70% ↓ |
|
||||
| API Response | 100-200ms | 20-50ms | 75% ↓ |
|
||||
| Concurrent Users | 10-20 | 100-200 | 10x ↑ |
|
||||
| Maintainability | Low | High | ++ |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Implementation Order
|
||||
|
||||
### Week 1: Critical
|
||||
- [ ] Multi-stage Docker build
|
||||
- [ ] Database indexes
|
||||
- [ ] Basic caching
|
||||
|
||||
### Week 2: Architecture
|
||||
- [ ] Split to blueprints
|
||||
- [ ] Add Redis
|
||||
- [ ] Celery for video processing
|
||||
|
||||
### Week 3: Polish
|
||||
- [ ] nginx reverse proxy
|
||||
- [ ] Security hardening
|
||||
- [ ] Monitoring & health checks
|
||||
|
||||
---
|
||||
|
||||
## 💡 Quick Commands
|
||||
|
||||
### Rebuild Docker (smaller image)
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Check Image Size
|
||||
```bash
|
||||
docker images digiserver:latest
|
||||
```
|
||||
|
||||
### Monitor Performance
|
||||
```bash
|
||||
docker stats digiserver
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Files to Modify
|
||||
|
||||
1. **Dockerfile** - Multi-stage build
|
||||
2. **docker-compose.yml** - Add Redis, Celery, nginx
|
||||
3. **app.py** - Split into blueprints
|
||||
4. **requirements.txt** - Add redis, celery, flask-caching
|
||||
5. **models/*.py** - Add indexes
|
||||
|
||||
---
|
||||
|
||||
See `OPTIMIZATION_PROPOSAL.md` for detailed implementation guide.
|
||||
258
README.md
Normal file
258
README.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# DigiServer - Digital Signage Management Platform
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
DigiServer is a comprehensive digital signage management platform built with Flask. It allows you to manage digital displays, create playlists, organize content into groups, and control multiple players from a centralized dashboard.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **Multi-Player Management**: Control multiple digital signage players from a single dashboard
|
||||
- **Group Management**: Organize players into groups for synchronized content
|
||||
- **Content Management**: Upload and manage various media types (images, videos, PDFs, PowerPoint presentations)
|
||||
- **Real-time Updates**: Players automatically sync with the latest content
|
||||
- **User Management**: Admin and user role-based access control
|
||||
- **Orientation Support**: Configure display orientation (Landscape/Portrait) per player and group
|
||||
- **API Integration**: RESTful API for player authentication and playlist retrieval
|
||||
- **Docker Support**: Easy deployment with Docker containers
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
- Docker and Docker Compose
|
||||
- Python 3.11+ (if running without Docker)
|
||||
- FFmpeg (for video processing)
|
||||
- LibreOffice (for document conversion)
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
digiserver/
|
||||
├── app/ # Application code
|
||||
│ ├── models/ # Database models
|
||||
│ ├── templates/ # HTML templates
|
||||
│ ├── utils/ # Utility functions
|
||||
│ ├── app.py # Main Flask application
|
||||
│ ├── extensions.py # Flask extensions
|
||||
│ ├── requirements.txt # Python dependencies
|
||||
│ └── entrypoint.sh # Container entry point
|
||||
├── data/ # Persistent data (created on first run)
|
||||
│ ├── instance/ # Database files
|
||||
│ ├── uploads/ # Media uploads
|
||||
│ └── resurse/ # System resources (logos, etc.)
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
├── Dockerfile # Docker image definition
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## 🐳 Quick Start with Docker
|
||||
|
||||
### Automated Deployment (Recommended)
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd digiserver
|
||||
```
|
||||
|
||||
2. **Deploy with automated script**
|
||||
```bash
|
||||
./deploy-docker.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
- Check Docker requirements
|
||||
- Build the DigiServer image
|
||||
- Create necessary data directories
|
||||
- Start the containers
|
||||
- Display access information
|
||||
|
||||
3. **Access the application**
|
||||
- Open your browser and navigate to `http://localhost:8880`
|
||||
- Default admin credentials:
|
||||
- Username: `admin`
|
||||
- Password: `Initial01!`
|
||||
|
||||
### Manual Docker Commands
|
||||
|
||||
Alternatively, you can use Docker commands directly:
|
||||
|
||||
```bash
|
||||
# Build and start
|
||||
docker compose up -d
|
||||
|
||||
# Stop
|
||||
docker compose down
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Check status
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### Clean Up
|
||||
|
||||
To completely remove DigiServer containers and images:
|
||||
```bash
|
||||
./cleanup-docker.sh
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can customize the application by modifying the environment variables in `docker-compose.yml`:
|
||||
|
||||
- `ADMIN_USER`: Default admin username (default: admin)
|
||||
- `ADMIN_PASSWORD`: Default admin password (default: Initial01!)
|
||||
- `SECRET_KEY`: Flask secret key for session security
|
||||
- `FLASK_APP`: Flask application entry point
|
||||
- `FLASK_RUN_HOST`: Host to bind the Flask application
|
||||
|
||||
### Data Persistence
|
||||
|
||||
All persistent data is stored in the `data/` folder:
|
||||
- `data/instance/`: SQLite database files
|
||||
- `data/uploads/`: Uploaded media files
|
||||
- `data/resurse/`: System resources (logo, login images)
|
||||
|
||||
This folder will be created automatically on first run and persists between container restarts.
|
||||
|
||||
## 💻 Manual Installation (Development)
|
||||
|
||||
If you prefer to run without Docker:
|
||||
|
||||
1. **Install system dependencies**
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get update
|
||||
sudo apt-get install python3.11 python3-pip libreoffice ffmpeg
|
||||
|
||||
# CentOS/RHEL
|
||||
sudo yum install python3.11 python3-pip libreoffice ffmpeg
|
||||
```
|
||||
|
||||
2. **Install Python dependencies**
|
||||
```bash
|
||||
cd app/
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Run the application**
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
## 🎮 Usage
|
||||
|
||||
### Managing Players
|
||||
|
||||
1. **Add a Player**: Navigate to the dashboard and click "Add Player"
|
||||
2. **Configure Player**: Set username, hostname, passwords, and orientation
|
||||
3. **Upload Content**: Upload media files to the player's playlist
|
||||
4. **Player Authentication**: Players can authenticate using hostname and password/quickconnect code
|
||||
|
||||
### Managing Groups
|
||||
|
||||
1. **Create Group**: Group multiple players for synchronized content
|
||||
2. **Assign Players**: Add/remove players from groups
|
||||
3. **Upload Group Content**: Upload content that will be shared across all players in the group
|
||||
4. **Group Display**: View group content in fullscreen mode
|
||||
|
||||
### Content Types Supported
|
||||
|
||||
- **Images**: JPG, PNG, GIF
|
||||
- **Videos**: MP4, AVI, MOV (automatically converted to MP4)
|
||||
- **Documents**: PDF (converted to images)
|
||||
- **Presentations**: PPTX (converted to images)
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
### Player API
|
||||
|
||||
- `GET /api/playlists?hostname={hostname}&quickconnect_code={code}`: Get player playlist
|
||||
- `GET /api/playlist_version?hostname={hostname}&quickconnect_code={code}`: Get playlist version
|
||||
- `GET /media/{filename}`: Serve media files
|
||||
|
||||
### Authentication
|
||||
|
||||
Players authenticate using:
|
||||
- **Hostname**: Unique identifier for the player
|
||||
- **Password**: Primary authentication method
|
||||
- **Quickconnect Code**: Alternative authentication method
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Building the Docker Image
|
||||
|
||||
```bash
|
||||
docker build -t digiserver:latest .
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Install test dependencies
|
||||
pip install pytest pytest-flask
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
```
|
||||
|
||||
### Database Management
|
||||
|
||||
The application uses SQLite with Flask-Migrate for database management:
|
||||
|
||||
```bash
|
||||
# Initialize database
|
||||
flask db init
|
||||
|
||||
# Create migration
|
||||
flask db migrate -m "Description of changes"
|
||||
|
||||
# Apply migration
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
- **User Authentication**: Role-based access control (admin/user)
|
||||
- **Player Authentication**: Secure hostname and password-based authentication
|
||||
- **File Upload Security**: Secure filename handling and file type validation
|
||||
- **Session Management**: Secure session handling with configurable secret key
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
- **Server Logs**: View recent server activities from the dashboard
|
||||
- **Health Check**: Docker health check endpoint for monitoring
|
||||
- **Content Management**: Track content usage and cleanup unused files
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## 📝 License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
For support and questions:
|
||||
- Create an issue in the repository
|
||||
- Check the documentation in the `docs/` folder
|
||||
- Review the application logs for troubleshooting
|
||||
|
||||
## 🔄 Version History
|
||||
|
||||
- **1.1.0** (2025-06-29): Added orientation support, improved group management
|
||||
- **1.0.0**: Initial release with basic digital signage functionality
|
||||
|
||||
---
|
||||
|
||||
**Note**: Make sure to change the default admin password after first login for security purposes.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,17 @@
|
||||
# ...existing code...
|
||||
|
||||
# Player feedback API
|
||||
from models.player_feedback import PlayerFeedback
|
||||
|
||||
# --- API route to receive player feedback ---
|
||||
|
||||
import os
|
||||
import click
|
||||
from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, send_from_directory
|
||||
import psutil
|
||||
import shutil
|
||||
import zipfile
|
||||
import tempfile
|
||||
from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, send_from_directory, send_file
|
||||
from flask_migrate import Migrate
|
||||
import subprocess
|
||||
from werkzeug.utils import secure_filename
|
||||
@@ -46,7 +57,15 @@ from utils.uploads import (
|
||||
SERVER_VERSION = "1.1.0"
|
||||
BUILD_DATE = "2025-06-29"
|
||||
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
# Get the absolute path of the app directory
|
||||
app_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
template_dir = os.path.join(app_dir, 'templates')
|
||||
static_dir = os.path.join(app_dir, 'static')
|
||||
|
||||
app = Flask(__name__,
|
||||
instance_relative_config=True,
|
||||
template_folder=template_dir,
|
||||
static_folder=static_dir)
|
||||
|
||||
# Set the secret key from environment variable or use a default value
|
||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'Ana_Are_Multe_Mere-Si_Nu_Are_Pere')
|
||||
@@ -57,8 +76,12 @@ db_path = os.path.join(instance_dir, 'dashboard.db')
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
|
||||
# Set maximum content length to 1GB
|
||||
app.config['MAX_CONTENT_LENGTH'] = 2048 * 2048 * 2048 # 2GB, adjust as needed
|
||||
# Set maximum content length to 2GB
|
||||
app.config['MAX_CONTENT_LENGTH'] = 2048 * 1024 * 1024 # 2GB, adjust as needed
|
||||
|
||||
# Set longer timeouts for file processing
|
||||
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 300 # 5 minutes for static files
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = 1800 # 30 minutes for sessions
|
||||
|
||||
# Ensure the instance folder exists
|
||||
os.makedirs(app.instance_path, exist_ok=True)
|
||||
@@ -83,6 +106,58 @@ login_manager.login_view = 'login'
|
||||
|
||||
migrate = Migrate(app, db)
|
||||
|
||||
# Global dictionary to track upload progress
|
||||
# Format: {session_id: {'status': 'uploading/converting/complete', 'progress': 0-100, 'message': 'details', 'files_total': N, 'files_processed': N}}
|
||||
upload_progress = {}
|
||||
|
||||
@app.route('/api/player-feedback', methods=['POST'])
|
||||
def api_player_feedback():
|
||||
from datetime import datetime
|
||||
import dateutil.parser
|
||||
|
||||
data = request.get_json()
|
||||
required_fields = ['player_name', 'quickconnect_code', 'message', 'status', 'timestamp']
|
||||
if not all(field in data for field in required_fields):
|
||||
return jsonify({'error': 'Missing required fields'}), 400
|
||||
|
||||
# Convert timestamp string to datetime object
|
||||
try:
|
||||
if isinstance(data['timestamp'], str):
|
||||
timestamp = dateutil.parser.parse(data['timestamp'])
|
||||
else:
|
||||
timestamp = data['timestamp']
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'Invalid timestamp format'}), 400
|
||||
|
||||
feedback = PlayerFeedback(
|
||||
player_name=data['player_name'],
|
||||
quickconnect_code=data['quickconnect_code'],
|
||||
message=data['message'],
|
||||
status=data['status'],
|
||||
timestamp=timestamp,
|
||||
playlist_version=data.get('playlist_version'),
|
||||
error_details=data.get('error_details')
|
||||
)
|
||||
db.session.add(feedback)
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'feedback_id': feedback.id}), 200
|
||||
|
||||
# Add error handlers for better user experience
|
||||
@app.errorhandler(413)
|
||||
def request_entity_too_large(error):
|
||||
flash('File too large. Please upload files smaller than 2GB.', 'danger')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
@app.errorhandler(408)
|
||||
def request_timeout(error):
|
||||
flash('Request timed out. Please try uploading smaller files or try again later.', 'danger')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_server_error(error):
|
||||
flash('An internal server error occurred. Please try again or contact support.', 'danger')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return db.session.get(User, int(user_id))
|
||||
@@ -95,6 +170,52 @@ def admin_required(f):
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def get_system_info():
|
||||
"""Get system monitoring information"""
|
||||
try:
|
||||
# CPU information
|
||||
cpu_percent = psutil.cpu_percent(interval=1)
|
||||
cpu_count = psutil.cpu_count()
|
||||
|
||||
# Memory information
|
||||
memory = psutil.virtual_memory()
|
||||
memory_percent = memory.percent
|
||||
memory_used = round(memory.used / (1024**3), 2) # GB
|
||||
memory_total = round(memory.total / (1024**3), 2) # GB
|
||||
|
||||
# Disk information
|
||||
disk = psutil.disk_usage('/')
|
||||
disk_percent = round((disk.used / disk.total) * 100, 1)
|
||||
disk_used = round(disk.used / (1024**3), 2) # GB
|
||||
disk_total = round(disk.total / (1024**3), 2) # GB
|
||||
disk_free = round(disk.free / (1024**3), 2) # GB
|
||||
|
||||
# Upload folder size
|
||||
upload_folder_size = 0
|
||||
if os.path.exists(UPLOAD_FOLDER):
|
||||
for dirpath, dirnames, filenames in os.walk(UPLOAD_FOLDER):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
if os.path.exists(filepath):
|
||||
upload_folder_size += os.path.getsize(filepath)
|
||||
upload_folder_size_gb = round(upload_folder_size / (1024**3), 2)
|
||||
|
||||
return {
|
||||
'cpu_percent': cpu_percent,
|
||||
'cpu_count': cpu_count,
|
||||
'memory_percent': memory_percent,
|
||||
'memory_used': memory_used,
|
||||
'memory_total': memory_total,
|
||||
'disk_percent': disk_percent,
|
||||
'disk_used': disk_used,
|
||||
'disk_total': disk_total,
|
||||
'disk_free': disk_free,
|
||||
'upload_folder_size': upload_folder_size_gb
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error getting system info: {e}")
|
||||
return None
|
||||
|
||||
@app.route('/')
|
||||
@login_required
|
||||
def dashboard():
|
||||
@@ -142,21 +263,73 @@ def logout():
|
||||
@admin_required
|
||||
def upload_content():
|
||||
if request.method == 'POST':
|
||||
import uuid
|
||||
|
||||
target_type = request.form.get('target_type')
|
||||
target_id = request.form.get('target_id')
|
||||
files = request.files.getlist('files')
|
||||
duration = int(request.form['duration'])
|
||||
return_url = request.form.get('return_url')
|
||||
media_type = request.form['media_type']
|
||||
session_id = request.form.get('session_id', str(uuid.uuid4()))
|
||||
|
||||
print(f"Target Type: {target_type}, Target ID: {target_id}, Media Type: {media_type}")
|
||||
print(f"Target Type: {target_type}, Target ID: {target_id}, Media Type: {media_type}, Session ID: {session_id}")
|
||||
|
||||
if not target_type or not target_id:
|
||||
flash('Please select a target type and target ID.', 'danger')
|
||||
return redirect(url_for('upload_content'))
|
||||
|
||||
# Process uploaded files and get results
|
||||
results = process_uploaded_files(app, files, media_type, duration, target_type, target_id)
|
||||
# Initialize progress tracking
|
||||
upload_progress[session_id] = {
|
||||
'status': 'uploading',
|
||||
'progress': 0,
|
||||
'message': 'Starting upload...',
|
||||
'files_total': len(files),
|
||||
'files_processed': 0
|
||||
}
|
||||
|
||||
try:
|
||||
# Process uploaded files and get results
|
||||
results = process_uploaded_files(app, files, media_type, duration, target_type, target_id, upload_progress, session_id)
|
||||
|
||||
# Check if video conversion is happening in background
|
||||
if media_type == 'video':
|
||||
# For videos, don't mark as complete yet - background thread will do it
|
||||
# Status remains as "converting" set by the background thread
|
||||
flash('Video upload started. Conversion is in progress...', 'info')
|
||||
else:
|
||||
# For non-videos (images, PDF, PPT), mark as complete
|
||||
upload_progress[session_id] = {
|
||||
'status': 'complete',
|
||||
'progress': 100,
|
||||
'message': 'All files processed successfully!',
|
||||
'files_total': len(files),
|
||||
'files_processed': len(files)
|
||||
}
|
||||
|
||||
# Check for any failed uploads
|
||||
failed_files = [r for r in results if not r.get('success', True)]
|
||||
if failed_files:
|
||||
for failed in failed_files:
|
||||
flash(f"Error uploading {failed.get('filename', 'unknown file')}: {failed.get('message', 'Unknown error')}", 'warning')
|
||||
else:
|
||||
flash('All files uploaded and processed successfully!', 'success')
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in upload_content: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Mark as error
|
||||
upload_progress[session_id] = {
|
||||
'status': 'error',
|
||||
'progress': 0,
|
||||
'message': f'Upload failed: {str(e)}',
|
||||
'files_total': len(files),
|
||||
'files_processed': 0
|
||||
}
|
||||
|
||||
flash(f'Upload failed: {str(e)}', 'danger')
|
||||
|
||||
return redirect(return_url)
|
||||
|
||||
@@ -167,9 +340,12 @@ def upload_content():
|
||||
|
||||
players = [{'id': player.id, 'username': player.username} for player in Player.query.all()]
|
||||
groups = [{'id': group.id, 'name': group.name} for group in Group.query.all()]
|
||||
|
||||
# Get system information for monitoring
|
||||
system_info = get_system_info()
|
||||
|
||||
return render_template('upload_content.html', target_type=target_type, target_id=target_id,
|
||||
players=players, groups=groups, return_url=return_url)
|
||||
players=players, groups=groups, return_url=return_url, system_info=system_info)
|
||||
|
||||
@app.route('/admin')
|
||||
@login_required
|
||||
@@ -178,13 +354,18 @@ def admin():
|
||||
logo_exists = os.path.exists(os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'logo.png'))
|
||||
login_picture_exists = os.path.exists(os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'login_picture.png'))
|
||||
users = User.query.all()
|
||||
|
||||
# Get system information for monitoring
|
||||
system_info = get_system_info()
|
||||
|
||||
return render_template(
|
||||
'admin.html',
|
||||
users=users,
|
||||
logo_exists=logo_exists,
|
||||
login_picture_exists=login_picture_exists,
|
||||
server_version=SERVER_VERSION,
|
||||
build_date=BUILD_DATE
|
||||
build_date=BUILD_DATE,
|
||||
system_info=system_info
|
||||
)
|
||||
|
||||
@app.route('/admin/change_role/<int:user_id>', methods=['POST'])
|
||||
@@ -229,7 +410,49 @@ def create_user():
|
||||
def player_page(player_id):
|
||||
player = db.session.get(Player, player_id)
|
||||
content = get_player_content(player_id)
|
||||
return render_template('player_page.html', player=player, content=content)
|
||||
|
||||
# Get last 5 feedback entries for this player
|
||||
player_feedback = PlayerFeedback.query.filter_by(
|
||||
player_name=player.username
|
||||
).order_by(PlayerFeedback.timestamp.desc()).limit(5).all()
|
||||
|
||||
# Get server playlist version for this player
|
||||
server_playlist_version = get_server_playlist_version(player)
|
||||
|
||||
return render_template('player_page.html',
|
||||
player=player,
|
||||
content=content,
|
||||
player_feedback=player_feedback,
|
||||
server_playlist_version=server_playlist_version)
|
||||
|
||||
def get_server_playlist_version(player):
|
||||
"""Get the current server playlist version for a specific player"""
|
||||
# Check if player is locked to a group
|
||||
if player.locked_to_group_id:
|
||||
# Get content for all players in the group to ensure shared content
|
||||
group_players = player.locked_to_group.players
|
||||
player_ids = [p.id for p in group_players]
|
||||
|
||||
# Use the first occurrence of each file for the playlist
|
||||
content_query = (
|
||||
db.session.query(
|
||||
Content.file_name,
|
||||
db.func.min(Content.id).label('id'),
|
||||
db.func.min(Content.duration).label('duration')
|
||||
)
|
||||
.filter(Content.player_id.in_(player_ids))
|
||||
.group_by(Content.file_name)
|
||||
)
|
||||
|
||||
content = db.session.query(Content).filter(
|
||||
Content.id.in_([c.id for c in content_query])
|
||||
).all()
|
||||
else:
|
||||
# Get player's individual content
|
||||
content = Content.query.filter_by(player_id=player.id).all()
|
||||
|
||||
# Return the current playlist version for this player
|
||||
return player.playlist_version
|
||||
|
||||
@app.route('/player/<int:player_id>/upload', methods=['POST'])
|
||||
@login_required
|
||||
@@ -266,6 +489,61 @@ def delete_content(content_id):
|
||||
db.session.commit()
|
||||
return redirect(url_for('player_page', player_id=player_id))
|
||||
|
||||
@app.route('/player/<int:player_id>/bulk_delete', methods=['POST'])
|
||||
@login_required
|
||||
def bulk_delete_player_content(player_id):
|
||||
"""Bulk delete selected media files from player"""
|
||||
player = Player.query.get_or_404(player_id)
|
||||
|
||||
# Check if player is in a group (should be managed at group level)
|
||||
if player.groups:
|
||||
flash('Cannot delete media from players that are in groups. Manage media at the group level.', 'warning')
|
||||
return redirect(url_for('player_page', player_id=player_id))
|
||||
|
||||
selected_content_ids = request.form.getlist('selected_content')
|
||||
|
||||
if not selected_content_ids:
|
||||
flash('No media files selected for deletion.', 'warning')
|
||||
return redirect(url_for('player_page', player_id=player_id))
|
||||
|
||||
try:
|
||||
deleted_files = []
|
||||
deleted_count = 0
|
||||
|
||||
for content_id in selected_content_ids:
|
||||
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
|
||||
if content:
|
||||
# Delete file from filesystem using absolute path
|
||||
upload_folder = app.config['UPLOAD_FOLDER']
|
||||
if not os.path.isabs(upload_folder):
|
||||
upload_folder = os.path.abspath(upload_folder)
|
||||
file_path = os.path.join(upload_folder, content.file_name)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
deleted_files.append(content.file_name)
|
||||
print(f"Deleted file: {file_path}")
|
||||
except OSError as e:
|
||||
print(f"Error deleting file {file_path}: {e}")
|
||||
|
||||
# Delete from database
|
||||
db.session.delete(content)
|
||||
deleted_count += 1
|
||||
|
||||
# Update playlist version for the player
|
||||
player.playlist_version += 1
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Successfully deleted {deleted_count} media file(s). Playlist updated to version {player.playlist_version}.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error in bulk delete: {e}")
|
||||
flash('An error occurred while deleting media files.', 'danger')
|
||||
|
||||
return redirect(url_for('player_page', player_id=player_id))
|
||||
|
||||
@app.route('/player/<int:player_id>/fullscreen', methods=['GET', 'POST'])
|
||||
def player_fullscreen(player_id):
|
||||
player = Player.query.get_or_404(player_id)
|
||||
@@ -404,11 +682,15 @@ def clean_unused_files():
|
||||
print("Used files:", used_files)
|
||||
print("Unused files:", unused_files)
|
||||
|
||||
# Delete unused files
|
||||
# Delete unused files using absolute path
|
||||
upload_folder = app.config['UPLOAD_FOLDER']
|
||||
if not os.path.isabs(upload_folder):
|
||||
upload_folder = os.path.abspath(upload_folder)
|
||||
|
||||
for file_name in unused_files:
|
||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], file_name)
|
||||
file_path = os.path.join(upload_folder, file_name)
|
||||
if os.path.isfile(file_path):
|
||||
print(f"Deleting file: {file_path}") # Debugging: Print the file being deleted
|
||||
print(f"Deleting unused file: {file_path}")
|
||||
os.remove(file_path)
|
||||
|
||||
flash('Unused files have been cleaned.', 'success')
|
||||
@@ -473,6 +755,22 @@ def get_playlists():
|
||||
def media(filename):
|
||||
return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
|
||||
|
||||
@app.route('/api/upload_progress/<session_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_upload_progress(session_id):
|
||||
"""
|
||||
API endpoint to get upload/conversion progress for a session.
|
||||
Returns JSON with status, progress percentage, and current message.
|
||||
"""
|
||||
progress_data = upload_progress.get(session_id, {
|
||||
'status': 'unknown',
|
||||
'progress': 0,
|
||||
'message': 'No active upload found',
|
||||
'files_total': 0,
|
||||
'files_processed': 0
|
||||
})
|
||||
return jsonify(progress_data)
|
||||
|
||||
@app.context_processor
|
||||
def inject_theme():
|
||||
if current_user.is_authenticated:
|
||||
@@ -488,7 +786,8 @@ def create_group():
|
||||
if request.method == 'POST':
|
||||
group_name = request.form['name']
|
||||
player_ids = request.form.getlist('players')
|
||||
create_group_util(group_name, player_ids)
|
||||
orientation = request.form.get('orientation', 'Landscape')
|
||||
create_group_util(group_name, player_ids, orientation)
|
||||
flash(f'Group "{group_name}" created successfully.', 'success')
|
||||
return redirect(url_for('dashboard'))
|
||||
players = Player.query.all()
|
||||
@@ -498,13 +797,32 @@ def create_group():
|
||||
@login_required
|
||||
@admin_required
|
||||
def manage_group(group_id):
|
||||
from models.player_feedback import PlayerFeedback
|
||||
|
||||
group = Group.query.get_or_404(group_id)
|
||||
content = get_group_content(group_id)
|
||||
# Debug content ordering
|
||||
print("Group content positions before sorting:", [(c.id, c.file_name, c.position) for c in content])
|
||||
content = sorted(content, key=lambda c: c.position)
|
||||
print("Group content positions after sorting:", [(c.id, c.file_name, c.position) for c in content])
|
||||
return render_template('manage_group.html', group=group, content=content)
|
||||
|
||||
# Fetch player feedback for all players in the group
|
||||
players_status = []
|
||||
for player in group.players:
|
||||
player_feedback = PlayerFeedback.query.filter_by(player_name=player.username)\
|
||||
.order_by(PlayerFeedback.timestamp.desc())\
|
||||
.limit(5)\
|
||||
.all()
|
||||
players_status.append({
|
||||
'player': player,
|
||||
'feedback': player_feedback,
|
||||
'server_playlist_version': player.playlist_version
|
||||
})
|
||||
|
||||
return render_template('manage_group.html',
|
||||
group=group,
|
||||
content=content,
|
||||
players_status=players_status)
|
||||
|
||||
@app.route('/group/<int:group_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@@ -514,7 +832,8 @@ def edit_group(group_id):
|
||||
if request.method == 'POST':
|
||||
name = request.form['name']
|
||||
player_ids = request.form.getlist('players')
|
||||
edit_group_util(group_id, name, player_ids)
|
||||
orientation = request.form.get('orientation', group.orientation)
|
||||
edit_group_util(group_id, name, player_ids, orientation)
|
||||
flash(f'Group "{name}" updated successfully.', 'success')
|
||||
return redirect(url_for('dashboard'))
|
||||
players = Player.query.all()
|
||||
@@ -564,6 +883,63 @@ def delete_group_media_route(group_id, content_id):
|
||||
|
||||
return redirect(url_for('manage_group', group_id=group_id))
|
||||
|
||||
@app.route('/group/<int:group_id>/bulk_delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def bulk_delete_group_content(group_id):
|
||||
"""Bulk delete selected media files from group"""
|
||||
group = Group.query.get_or_404(group_id)
|
||||
selected_content_ids = request.form.getlist('selected_content')
|
||||
|
||||
if not selected_content_ids:
|
||||
flash('No media files selected for deletion.', 'warning')
|
||||
return redirect(url_for('manage_group', group_id=group_id))
|
||||
|
||||
try:
|
||||
deleted_files = []
|
||||
deleted_count = 0
|
||||
player_ids = [player.id for player in group.players]
|
||||
|
||||
for content_id in selected_content_ids:
|
||||
content = Content.query.filter(
|
||||
Content.id == content_id,
|
||||
Content.player_id.in_(player_ids)
|
||||
).first()
|
||||
|
||||
if content:
|
||||
# Delete file from filesystem using absolute path
|
||||
upload_folder = app.config['UPLOAD_FOLDER']
|
||||
if not os.path.isabs(upload_folder):
|
||||
upload_folder = os.path.abspath(upload_folder)
|
||||
file_path = os.path.join(upload_folder, content.file_name)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
deleted_files.append(content.file_name)
|
||||
print(f"Deleted file: {file_path}")
|
||||
except OSError as e:
|
||||
print(f"Error deleting file {file_path}: {e}")
|
||||
|
||||
# Delete from database
|
||||
db.session.delete(content)
|
||||
deleted_count += 1
|
||||
|
||||
# Update playlist version for all players in the group
|
||||
for player in group.players:
|
||||
player.playlist_version += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash(f'Successfully deleted {deleted_count} media file(s) from group. All player playlists updated.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error in group bulk delete: {e}")
|
||||
flash('An error occurred while deleting media files.', 'danger')
|
||||
|
||||
return redirect(url_for('manage_group', group_id=group_id))
|
||||
|
||||
@app.route('/api/playlist_version', methods=['GET'])
|
||||
def get_playlist_version():
|
||||
hostname = request.args.get('hostname')
|
||||
@@ -584,6 +960,17 @@ def get_playlist_version():
|
||||
'hashed_quickconnect': player.quickconnect_password
|
||||
})
|
||||
|
||||
@app.route('/api/system_info', methods=['GET'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_system_info():
|
||||
"""API endpoint to get real-time system information"""
|
||||
system_info = get_system_info()
|
||||
if system_info:
|
||||
return jsonify(system_info)
|
||||
else:
|
||||
return jsonify({'error': 'Could not retrieve system information'}), 500
|
||||
|
||||
@app.route('/player/<int:player_id>/update_order', methods=['POST'])
|
||||
@login_required
|
||||
def update_content_order(player_id):
|
||||
@@ -7,16 +7,7 @@ mkdir -p instance
|
||||
|
||||
# Check if database exists
|
||||
if [ ! -f instance/dashboard.db ]; then
|
||||
echo "No database found, initializing..."
|
||||
|
||||
# Remove and recreate migrations directory to ensure clean state
|
||||
rm -rf migrations
|
||||
mkdir -p migrations
|
||||
|
||||
# Initialize the database
|
||||
flask db init
|
||||
flask db migrate -m "Initial migration"
|
||||
flask db upgrade
|
||||
echo "No database found, creating fresh database..."
|
||||
|
||||
# Create admin user if environment variables are set
|
||||
if [ -n "$ADMIN_USER" ] && [ -n "$ADMIN_PASSWORD" ]; then
|
||||
@@ -26,8 +17,11 @@ if [ ! -f instance/dashboard.db ]; then
|
||||
echo "Warning: ADMIN_USER or ADMIN_PASSWORD not set, skipping admin creation"
|
||||
fi
|
||||
else
|
||||
echo "Existing database found, applying migrations..."
|
||||
flask db upgrade
|
||||
echo "Existing database found, skipping initialization..."
|
||||
echo "Creating admin user if needed..."
|
||||
if [ -n "$ADMIN_USER" ] && [ -n "$ADMIN_PASSWORD" ]; then
|
||||
flask create-admin --username "$ADMIN_USER" --password "$ADMIN_PASSWORD" 2>/dev/null || echo "Default user '$ADMIN_USER' already exists."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Starting DigiServer..."
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Add PlayerFeedback table
|
||||
|
||||
Revision ID: 217eab16e4e4
|
||||
Revises:
|
||||
Create Date: 2025-09-08 11:30:26.742813
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '217eab16e4e4'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('player_feedback',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('player_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('quickconnect_code', sa.String(length=255), nullable=False),
|
||||
sa.Column('message', sa.Text(), nullable=False),
|
||||
sa.Column('status', sa.String(length=50), nullable=False),
|
||||
sa.Column('timestamp', sa.DateTime(), nullable=False),
|
||||
sa.Column('playlist_version', sa.Integer(), nullable=True),
|
||||
sa.Column('error_details', sa.Text(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('player_feedback')
|
||||
# ### end Alembic commands ###
|
||||
@@ -2,4 +2,5 @@ from .user import User
|
||||
from .player import Player
|
||||
from .group import Group, group_player
|
||||
from .content import Content
|
||||
from .server_log import ServerLog
|
||||
from .server_log import ServerLog
|
||||
from .player_feedback import PlayerFeedback
|
||||
@@ -2,8 +2,9 @@
|
||||
import os
|
||||
|
||||
def create_default_user(db, User, bcrypt):
|
||||
username = os.getenv('DEFAULT_USER', 'admin')
|
||||
password = os.getenv('DEFAULT_PASSWORD', '1234')
|
||||
# Use ADMIN_USER and ADMIN_PASSWORD to match docker-compose environment variables
|
||||
username = os.getenv('ADMIN_USER', os.getenv('DEFAULT_USER', 'admin'))
|
||||
password = os.getenv('ADMIN_PASSWORD', os.getenv('DEFAULT_PASSWORD', '1234'))
|
||||
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
existing_user = User.query.filter_by(username=username).first()
|
||||
if not existing_user:
|
||||
11
app/models/player_feedback.py
Normal file
11
app/models/player_feedback.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from extensions import db
|
||||
|
||||
class PlayerFeedback(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
player_name = db.Column(db.String(255), nullable=False)
|
||||
quickconnect_code = db.Column(db.String(255), nullable=False)
|
||||
message = db.Column(db.Text, nullable=False)
|
||||
status = db.Column(db.String(50), nullable=False)
|
||||
timestamp = db.Column(db.DateTime, nullable=False)
|
||||
playlist_version = db.Column(db.Integer, nullable=True)
|
||||
error_details = db.Column(db.Text, nullable=True)
|
||||
@@ -18,10 +18,12 @@ alembic==1.14.1
|
||||
Mako==1.3.8
|
||||
greenlet==3.1.1
|
||||
|
||||
# Date parsing
|
||||
python-dateutil==2.9.0
|
||||
|
||||
# File Processing
|
||||
pdf2image==1.17.0
|
||||
PyPDF2==3.0.1
|
||||
python-pptx==0.6.21
|
||||
Pillow==10.0.1
|
||||
cairosvg==2.7.0
|
||||
ffmpeg-python==0.2.0
|
||||
@@ -39,6 +41,7 @@ gevent==23.9.1
|
||||
# Monitoring & Performance
|
||||
prometheus-flask-exporter==0.22.4
|
||||
sentry-sdk[flask]==1.40.0
|
||||
psutil==6.1.0
|
||||
|
||||
# Utilities
|
||||
typing_extensions==4.12.2
|
||||
@@ -207,6 +207,94 @@
|
||||
<p><strong>Date of Build:</strong> {{ build_date }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Monitoring Card -->
|
||||
{% if system_info %}
|
||||
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="card-header">
|
||||
<h2>📊 System Monitoring</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<!-- CPU Information -->
|
||||
<div class="col-md-3 col-6 text-center mb-3">
|
||||
<div class="h6">CPU Usage</div>
|
||||
<div class="progress mb-2" style="height: 25px;">
|
||||
<div class="progress-bar
|
||||
{% if system_info.cpu_percent < 50 %}bg-success
|
||||
{% elif system_info.cpu_percent < 80 %}bg-warning
|
||||
{% else %}bg-danger{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {{ system_info.cpu_percent }}%;">
|
||||
{{ system_info.cpu_percent }}%
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">{{ system_info.cpu_count }} cores available</small>
|
||||
</div>
|
||||
|
||||
<!-- Memory Information -->
|
||||
<div class="col-md-3 col-6 text-center mb-3">
|
||||
<div class="h6">Memory Usage</div>
|
||||
<div class="progress mb-2" style="height: 25px;">
|
||||
<div class="progress-bar
|
||||
{% if system_info.memory_percent < 60 %}bg-success
|
||||
{% elif system_info.memory_percent < 85 %}bg-warning
|
||||
{% else %}bg-danger{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {{ system_info.memory_percent }}%;">
|
||||
{{ system_info.memory_percent }}%
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">{{ system_info.memory_used }}GB / {{ system_info.memory_total }}GB</small>
|
||||
</div>
|
||||
|
||||
<!-- Disk Information -->
|
||||
<div class="col-md-3 col-6 text-center mb-3">
|
||||
<div class="h6">Disk Usage</div>
|
||||
<div class="progress mb-2" style="height: 25px;">
|
||||
<div class="progress-bar
|
||||
{% if system_info.disk_percent < 70 %}bg-success
|
||||
{% elif system_info.disk_percent < 90 %}bg-warning
|
||||
{% else %}bg-danger{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {{ system_info.disk_percent }}%;">
|
||||
{{ system_info.disk_percent }}%
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">{{ system_info.disk_used }}GB / {{ system_info.disk_total }}GB</small>
|
||||
</div>
|
||||
|
||||
<!-- Upload Folder Size -->
|
||||
<div class="col-md-3 col-6 text-center mb-3">
|
||||
<div class="h6">Media Storage</div>
|
||||
<div class="text-primary display-6">{{ system_info.upload_folder_size }}GB</div>
|
||||
<small class="text-muted">Total media files</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Details -->
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<hr>
|
||||
<div class="row text-center">
|
||||
<div class="col-md-4 col-12 mb-2">
|
||||
<strong>Available Disk Space:</strong><br>
|
||||
<span class="text-success">{{ system_info.disk_free }}GB free</span>
|
||||
</div>
|
||||
<div class="col-md-4 col-12 mb-2">
|
||||
<strong>Total Disk Space:</strong><br>
|
||||
<span class="text-info">{{ system_info.disk_total }}GB total</span>
|
||||
</div>
|
||||
<div class="col-md-4 col-12 mb-2">
|
||||
<strong>Last Updated:</strong><br>
|
||||
<span class="text-muted" id="last-update-admin">Just now</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -227,6 +315,77 @@
|
||||
popup.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Auto-refresh system monitoring every 15 seconds
|
||||
{% if system_info %}
|
||||
function updateAdminSystemInfo() {
|
||||
fetch('/api/system_info')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
console.warn('Could not fetch system info:', data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update progress bars and their colors
|
||||
const progressBars = document.querySelectorAll('.progress-bar');
|
||||
|
||||
if (progressBars.length >= 3) {
|
||||
// CPU Bar
|
||||
progressBars[0].style.width = data.cpu_percent + '%';
|
||||
progressBars[0].textContent = data.cpu_percent + '%';
|
||||
progressBars[0].className = 'progress-bar ' +
|
||||
(data.cpu_percent < 50 ? 'bg-success' :
|
||||
data.cpu_percent < 80 ? 'bg-warning' : 'bg-danger');
|
||||
|
||||
// Memory Bar
|
||||
progressBars[1].style.width = data.memory_percent + '%';
|
||||
progressBars[1].textContent = data.memory_percent + '%';
|
||||
progressBars[1].className = 'progress-bar ' +
|
||||
(data.memory_percent < 60 ? 'bg-success' :
|
||||
data.memory_percent < 85 ? 'bg-warning' : 'bg-danger');
|
||||
|
||||
// Disk Bar
|
||||
progressBars[2].style.width = data.disk_percent + '%';
|
||||
progressBars[2].textContent = data.disk_percent + '%';
|
||||
progressBars[2].className = 'progress-bar ' +
|
||||
(data.disk_percent < 70 ? 'bg-success' :
|
||||
data.disk_percent < 90 ? 'bg-warning' : 'bg-danger');
|
||||
}
|
||||
|
||||
// Update text values
|
||||
const smallTexts = document.querySelectorAll('.text-muted');
|
||||
smallTexts.forEach((text, index) => {
|
||||
if (index === 1) text.textContent = data.memory_used + 'GB / ' + data.memory_total + 'GB';
|
||||
if (index === 2) text.textContent = data.disk_used + 'GB / ' + data.disk_total + 'GB';
|
||||
});
|
||||
|
||||
// Update storage size
|
||||
const storageDisplay = document.querySelector('.display-6');
|
||||
if (storageDisplay) {
|
||||
storageDisplay.textContent = data.upload_folder_size + 'GB';
|
||||
}
|
||||
|
||||
// Update disk space info
|
||||
const diskFree = document.querySelector('.text-success');
|
||||
const diskTotal = document.querySelector('.text-info');
|
||||
if (diskFree) diskFree.textContent = data.disk_free + 'GB free';
|
||||
if (diskTotal) diskTotal.textContent = data.disk_total + 'GB total';
|
||||
|
||||
// Update timestamp
|
||||
const lastUpdate = document.getElementById('last-update-admin');
|
||||
if (lastUpdate) {
|
||||
lastUpdate.textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('Admin system monitoring update failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Update every 15 seconds
|
||||
setInterval(updateAdminSystemInfo, 15000);
|
||||
{% endif %}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -49,12 +49,24 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="orientation" class="form-label">Group Orientation</label>
|
||||
<select class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="orientation" name="orientation" required>
|
||||
<option value="Landscape" {% if group.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
|
||||
<option value="Portret" {% if group.orientation == 'Portret' %}selected{% endif %}>Portret</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add this above the player selection -->
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Warning:</strong> Adding new players to this group will delete their individual playlists.
|
||||
Removing players from the group will allow them to have their own playlists again.
|
||||
</div>
|
||||
<div id="orientation-warning" class="alert alert-danger d-none" role="alert">
|
||||
No players with the selected orientation are available.
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
|
||||
@@ -62,5 +74,45 @@
|
||||
</form>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// Get all players and their orientations from the backend
|
||||
const players = [
|
||||
{% for player in players %}
|
||||
{id: {{ player.id }}, username: "{{ player.username }}", orientation: "{{ player.orientation }}", inGroup: {% if player in group.players %}true{% else %}false{% endif %}},
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
const orientationSelect = document.getElementById('orientation');
|
||||
const playersSelect = document.getElementById('players');
|
||||
const orientationWarning = document.getElementById('orientation-warning');
|
||||
|
||||
function filterPlayers() {
|
||||
const selectedOrientation = orientationSelect.value;
|
||||
const currentSelection = Array.from(playersSelect.selectedOptions).map(option => option.value);
|
||||
playersSelect.innerHTML = '';
|
||||
let compatibleCount = 0;
|
||||
|
||||
players.forEach(player => {
|
||||
if (player.orientation === selectedOrientation) {
|
||||
const option = document.createElement('option');
|
||||
option.value = player.id;
|
||||
option.textContent = player.username;
|
||||
// Re-select if it was previously selected
|
||||
if (currentSelection.includes(player.id.toString()) || player.inGroup) {
|
||||
option.selected = true;
|
||||
}
|
||||
playersSelect.appendChild(option);
|
||||
compatibleCount++;
|
||||
}
|
||||
});
|
||||
|
||||
orientationWarning.classList.toggle('d-none', compatibleCount > 0);
|
||||
}
|
||||
|
||||
orientationSelect.addEventListener('change', filterPlayers);
|
||||
|
||||
// Initial filter on page load
|
||||
filterPlayers();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
495
app/templates/manage_group.html
Normal file
495
app/templates/manage_group.html
Normal file
@@ -0,0 +1,495 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Manage Group - {{ group.name }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body.dark-mode {
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
}
|
||||
.card.dark-mode {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
}
|
||||
.dark-mode label, .dark-mode th, .dark-mode td {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Logo styling */
|
||||
.logo {
|
||||
max-height: 80px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.logo {
|
||||
max-height: 50px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
h6 {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
.btn-sm {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.card {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.card-body {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
/* Stack buttons vertically on mobile */
|
||||
.action-buttons .btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
/* Smaller text on mobile */
|
||||
small {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
/* Reduce padding in tables */
|
||||
.list-group-item {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smaller screens - further optimization */
|
||||
@media (max-width: 576px) {
|
||||
.container-fluid {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.card-header h5 {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sortable-list li {
|
||||
cursor: move;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable-list li.dragging {
|
||||
opacity: 0.5;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: #aaa;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
border-top: 2px solid #0d6efd;
|
||||
}
|
||||
|
||||
/* Player status card compact design */
|
||||
.player-status-card {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.player-status-card {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="container-fluid py-3 py-md-4 py-lg-5">
|
||||
<!-- Header with Logo and Title -->
|
||||
<div class="d-flex justify-content-start align-items-center mb-3 mb-md-4">
|
||||
{% if logo_exists %}
|
||||
<img src="{{ url_for('static', filename='resurse/logo.png') }}" alt="Logo" class="logo">
|
||||
{% endif %}
|
||||
<div>
|
||||
<h1 class="mb-1">Manage Group</h1>
|
||||
<p class="text-muted mb-0 d-none d-md-block">{{ group.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Show group name if not shown in header -->
|
||||
<div class="d-md-none mb-3">
|
||||
<div class="badge bg-primary fs-6">{{ group.name }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Row with Group Info (left) and Players Status (right) -->
|
||||
<div class="row mb-3 mb-md-4">
|
||||
<!-- Group Information Card - Responsive width -->
|
||||
<div class="col-lg-3 col-md-4 col-12 mb-3">
|
||||
<div class="card h-100 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>Group Info</h5>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Group Name</small>
|
||||
<p class="mb-0"><strong>{{ group.name }}</strong></p>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Players</small>
|
||||
<p class="mb-0"><strong>{{ group.players|length }}</strong></p>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<small class="text-muted">Playlist Version</small>
|
||||
<p class="mb-0"><span class="badge bg-info mt-1">v{{ group.playlist_version }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Players Status Cards Container - 3/4 width on large screens -->
|
||||
<div class="col-lg-9 col-md-8 col-12">
|
||||
<div class="card {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0"><i class="bi bi-display me-2"></i>Players ({{ group.players|length }})</h5>
|
||||
</div>
|
||||
<div class="card-body p-2 p-md-3">
|
||||
{% if players_status %}
|
||||
<div class="row g-2 g-md-3">
|
||||
{% for player_status in players_status %}
|
||||
<div class="col-xl-4 col-lg-6 col-12 mb-2">
|
||||
<div class="card h-100 border-primary player-status-card {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center py-2">
|
||||
<h6 class="mb-0"><i class="bi bi-tv me-1"></i>{{ player_status.player.username }}</h6>
|
||||
<a href="{{ url_for('player_page', player_id=player_status.player.id) }}"
|
||||
class="btn btn-sm btn-light py-0 px-2" title="View Details">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div class="mb-2">
|
||||
<small class="text-muted"><i class="bi bi-hdd-network me-1"></i>Hostname:</small>
|
||||
<small class="d-block">{{ player_status.player.hostname }}</small>
|
||||
</div>
|
||||
|
||||
{% if player_status.feedback %}
|
||||
<div class="mb-2">
|
||||
<small class="text-muted"><i class="bi bi-activity me-1"></i>Status:</small>
|
||||
<span class="badge bg-{{ 'success' if player_status.feedback[0].status in ['active', 'playing'] else 'danger' }}">
|
||||
{{ player_status.feedback[0].status|title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<small class="text-muted"><i class="bi bi-clock me-1"></i>Last Activity:</small>
|
||||
<small class="d-block">{{ player_status.feedback[0].timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<small class="text-muted"><i class="bi bi-chat-dots me-1"></i>Message:</small>
|
||||
<small class="d-block text-muted">{{ player_status.feedback[0].message[:50] }}{% if player_status.feedback[0].message|length > 50 %}...{% endif %}</small>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<small class="text-muted"><i class="bi bi-list-check me-1"></i>Playlist:</small>
|
||||
{% if player_status.feedback[0].playlist_version %}
|
||||
{% if player_status.feedback[0].playlist_version|int == player_status.server_playlist_version %}
|
||||
<span class="badge bg-success">v{{ player_status.feedback[0].playlist_version }} ✓</span>
|
||||
<small class="text-success d-block">In sync</small>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">v{{ player_status.feedback[0].playlist_version }}</span>
|
||||
<small class="text-warning d-block">⚠ Out of sync (server: v{{ player_status.server_playlist_version }})</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Unknown</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-2">
|
||||
<p class="mb-1"><small>No status data</small></p>
|
||||
<small>Player hasn't reported yet</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-3">
|
||||
<i class="bi bi-inbox display-4 d-block mb-2"></i>
|
||||
<p>No players in this group</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manage Media Section -->
|
||||
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h2>Manage Media</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if content %}
|
||||
<!-- Bulk Actions Controls -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="selectAll">
|
||||
<label class="form-check-label" for="selectAll">
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<button type="button" class="btn btn-danger" id="bulkDeleteBtn" style="display:none;" onclick="confirmBulkDelete()">
|
||||
<i class="bi bi-trash"></i> Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="list-group sortable-list" id="groupMediaList">
|
||||
{% for media in content %}
|
||||
<li class="list-group-item d-flex align-items-center {{ 'dark-mode' if theme == 'dark' else '' }}"
|
||||
draggable="true"
|
||||
data-id="{{ media.id }}"
|
||||
data-position="{{ loop.index0 }}">
|
||||
<!-- Checkbox for bulk selection -->
|
||||
<div class="me-2">
|
||||
<input class="form-check-input media-checkbox"
|
||||
type="checkbox"
|
||||
name="selected_content"
|
||||
value="{{ media.id }}">
|
||||
</div>
|
||||
|
||||
<!-- Drag handle -->
|
||||
<div class="drag-handle me-2" title="Drag to reorder">
|
||||
<i class="bi bi-grip-vertical"></i>
|
||||
☰
|
||||
</div>
|
||||
|
||||
<!-- Media Thumbnail and Name -->
|
||||
<div class="flex-grow-1 mb-2 mb-md-0 d-flex align-items-center">
|
||||
{% set file_ext = media.file_name.lower().split('.')[-1] %}
|
||||
{% if file_ext in ['mp4', 'avi', 'mkv', 'mov', 'webm'] %}
|
||||
<!-- Video file - show generic video icon -->
|
||||
<div style="width: 48px; height: 48px; margin-right: 10px; border-radius: 4px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 5v14l11-7z" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Image file - show actual thumbnail -->
|
||||
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
|
||||
alt="thumbnail"
|
||||
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
|
||||
onerror="this.style.display='none';">
|
||||
{% endif %}
|
||||
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
|
||||
</div>
|
||||
<form action="{{ url_for('edit_group_media_route', group_id=group.id, content_id=media.id) }}" method="post" class="d-flex align-items-center">
|
||||
<div class="input-group me-2">
|
||||
<span class="input-group-text">seconds</span>
|
||||
<input type="number" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" name="duration" value="{{ media.duration }}" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning me-2">Edit</button>
|
||||
</form>
|
||||
<form action="{{ url_for('delete_group_media_route', group_id=group.id, content_id=media.id) }}" method="post" style="display:inline;">
|
||||
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<!-- Add a save button for the reordering -->
|
||||
<button id="saveGroupOrder" class="btn btn-success mt-3">Save Playlist Order</button>
|
||||
{% else %}
|
||||
<p class="text-center">No media uploaded for this group.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Media Button -->
|
||||
<div class="text-center mb-3 action-buttons">
|
||||
<a href="{{ url_for('upload_content', target_type='group', target_id=group.id, return_url=url_for('manage_group', group_id=group.id)) }}"
|
||||
class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-cloud-upload me-2"></i>Upload Media
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Back to Dashboard Button -->
|
||||
<div class="text-center mb-3">
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left me-2"></i>Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const groupMediaList = document.getElementById('groupMediaList');
|
||||
let draggedItem = null;
|
||||
|
||||
// Initialize drag events for all items
|
||||
const items = groupMediaList.querySelectorAll('li');
|
||||
items.forEach(item => {
|
||||
// Drag start
|
||||
item.addEventListener('dragstart', function(e) {
|
||||
draggedItem = item;
|
||||
setTimeout(() => {
|
||||
item.classList.add('dragging');
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Drag end
|
||||
item.addEventListener('dragend', function() {
|
||||
item.classList.remove('dragging');
|
||||
draggedItem = null;
|
||||
updatePositions();
|
||||
});
|
||||
|
||||
// Drag over
|
||||
item.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
if (item !== draggedItem) {
|
||||
const rect = item.getBoundingClientRect();
|
||||
const y = e.clientY - rect.top;
|
||||
const height = rect.height;
|
||||
|
||||
if (y < height / 2) {
|
||||
groupMediaList.insertBefore(draggedItem, item);
|
||||
} else {
|
||||
groupMediaList.insertBefore(draggedItem, item.nextSibling);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Save button click handler
|
||||
document.getElementById('saveGroupOrder').addEventListener('click', function() {
|
||||
// Collect new order
|
||||
const newOrder = [];
|
||||
groupMediaList.querySelectorAll('li').forEach((item, index) => {
|
||||
newOrder.push({
|
||||
id: item.dataset.id,
|
||||
position: index
|
||||
});
|
||||
});
|
||||
|
||||
// Send to server
|
||||
fetch('{{ url_for("update_group_content_order_route", group_id=group.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}'
|
||||
},
|
||||
body: JSON.stringify({items: newOrder})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('Playlist order updated successfully!');
|
||||
console.log('Group playlist update successful:', data);
|
||||
} else {
|
||||
alert('Error updating playlist order: ' + (data.error || 'Unknown error'));
|
||||
console.error('Failed to update group playlist:', data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while updating the playlist order.');
|
||||
});
|
||||
});
|
||||
|
||||
// Update positions in the UI
|
||||
function updatePositions() {
|
||||
groupMediaList.querySelectorAll('li').forEach((item, index) => {
|
||||
item.dataset.position = index;
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk selection functionality
|
||||
const selectAllCheckbox = document.getElementById('selectAll');
|
||||
const mediaCheckboxes = document.querySelectorAll('.media-checkbox');
|
||||
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
|
||||
|
||||
// Select all functionality
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
mediaCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = this.checked;
|
||||
});
|
||||
updateBulkDeleteButton();
|
||||
});
|
||||
}
|
||||
|
||||
// Individual checkbox change
|
||||
mediaCheckboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
updateSelectAllState();
|
||||
updateBulkDeleteButton();
|
||||
});
|
||||
});
|
||||
|
||||
function updateSelectAllState() {
|
||||
const checkedBoxes = Array.from(mediaCheckboxes).filter(cb => cb.checked);
|
||||
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = checkedBoxes.length === mediaCheckboxes.length && mediaCheckboxes.length > 0;
|
||||
selectAllCheckbox.indeterminate = checkedBoxes.length > 0 && checkedBoxes.length < mediaCheckboxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function updateBulkDeleteButton() {
|
||||
const checkedBoxes = Array.from(mediaCheckboxes).filter(cb => cb.checked);
|
||||
if (bulkDeleteBtn) {
|
||||
bulkDeleteBtn.style.display = checkedBoxes.length > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function confirmBulkDelete() {
|
||||
const checkedBoxes = Array.from(document.querySelectorAll('.media-checkbox:checked'));
|
||||
if (checkedBoxes.length === 0) {
|
||||
alert('No media files selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
const count = checkedBoxes.length;
|
||||
const message = `Are you sure you want to delete ${count} selected media file${count > 1 ? 's' : ''}? This action cannot be undone.`;
|
||||
|
||||
if (confirm(message)) {
|
||||
// Create a form with selected IDs
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '{{ url_for("bulk_delete_group_content", group_id=group.id) }}';
|
||||
|
||||
checkedBoxes.forEach(checkbox => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'selected_content';
|
||||
input.value = checkbox.value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
430
app/templates/player_page.html
Normal file
430
app/templates/player_page.html
Normal file
@@ -0,0 +1,430 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Player Schedule</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body.dark-mode {
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
}
|
||||
.card.dark-mode {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
}
|
||||
.dark-mode label, .dark-mode th, .dark-mode td {
|
||||
color: #ffffff;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.btn {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
.sortable-list li {
|
||||
cursor: move;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable-list li.dragging {
|
||||
opacity: 0.5;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: #aaa;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
border-top: 2px solid #0d6efd;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="{% if theme == 'dark' %}dark-mode{% endif %}">
|
||||
<div class="container py-5">
|
||||
<h1 class="text-center mb-4">Player Schedule for {{ player.username }}</h1>
|
||||
|
||||
<div class="row">
|
||||
<!-- Player Info Section -->
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h2>Player Info</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>Player Name:</strong> {{ player.username }}</p>
|
||||
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
|
||||
{% if current_user.role == 'admin' %}
|
||||
<a href="{{ url_for('edit_player', player_id=player.id, return_url=url_for('player_page', player_id=player.id)) }}" class="btn btn-warning">Update</a>
|
||||
<form action="{{ url_for('delete_player', player_id=player.id) }}" method="post" style="display:inline;">
|
||||
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this player?');">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Status Section -->
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h2>Player Status</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if player_feedback %}
|
||||
<div class="mb-3">
|
||||
<strong>Current Status:</strong>
|
||||
<span class="badge bg-{{ 'success' if player_feedback[0].status in ['active', 'playing'] else 'danger' }}">
|
||||
{{ player_feedback[0].status|title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Last Activity:</strong> {{ player_feedback[0].timestamp.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Latest Message:</strong> {{ player_feedback[0].message }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Server Playlist Version:</strong>
|
||||
<span class="badge bg-info">v{{ server_playlist_version }}</span>
|
||||
{% if player_feedback[0].playlist_version %}
|
||||
{% if player_feedback[0].playlist_version|int == server_playlist_version %}
|
||||
<small class="text-success ms-2">✓ Player in sync</small>
|
||||
{% else %}
|
||||
<small class="text-warning ms-2">⚠ Player v{{ player_feedback[0].playlist_version }} (out of sync)</small>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<small class="text-muted ms-2">Player version unknown</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity Log -->
|
||||
<details>
|
||||
<summary class="fw-bold mb-2">Recent Activity (Last 5)</summary>
|
||||
<div class="mt-2">
|
||||
{% for feedback in player_feedback %}
|
||||
<div class="border-bottom pb-2 mb-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="badge bg-{{ 'success' if feedback.status in ['active', 'playing'] else 'danger' }}">
|
||||
{{ feedback.status|title }}
|
||||
</span>
|
||||
<small class="text-muted">{{ feedback.timestamp.strftime('%m-%d %H:%M') }}</small>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<small>{{ feedback.message }}</small>
|
||||
{% if feedback.playlist_version %}
|
||||
<br><small class="text-muted">Playlist v{{ feedback.playlist_version }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="mb-3">
|
||||
<strong>Server Playlist Version:</strong>
|
||||
<span class="badge bg-info">v{{ server_playlist_version }}</span>
|
||||
<small class="text-muted ms-2">Player version unknown</small>
|
||||
</div>
|
||||
<div class="text-center text-muted">
|
||||
<p>No status information available</p>
|
||||
<small>Player hasn't sent any feedback yet</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Membership Section -->
|
||||
<div class="mb-4">
|
||||
{% if player.groups %}
|
||||
<h4 class="text-center">Member of Group(s):</h4>
|
||||
<ul class="list-group">
|
||||
{% for group in player.groups %}
|
||||
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}">{{ group.name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-center">This player is not a member of any groups.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Media Management Section -->
|
||||
{% if current_user.role == 'admin' %}
|
||||
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h2>Manage Media</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if content %}
|
||||
<!-- Bulk Actions Controls -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="selectAll" {% if player.groups %}disabled{% endif %}>
|
||||
<label class="form-check-label" for="selectAll">
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<button type="button" class="btn btn-danger" id="bulkDeleteBtn" {% if player.groups %}disabled{% endif %} style="display:none;" onclick="confirmBulkDelete()">
|
||||
<i class="bi bi-trash"></i> Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Delete Form -->
|
||||
<form id="bulkDeleteForm" action="{{ url_for('bulk_delete_player_content', player_id=player.id) }}" method="post" style="display:none;">
|
||||
<input type="hidden" name="selected_content_ids" id="selectedContentIds">
|
||||
</form>
|
||||
|
||||
<ul class="list-group sortable-list" id="mediaList">
|
||||
{% for media in content %}
|
||||
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}"
|
||||
draggable="true"
|
||||
data-id="{{ media.id }}"
|
||||
data-position="{{ loop.index0 }}">
|
||||
<div class="d-flex flex-column flex-md-row align-items-md-center">
|
||||
<!-- Checkbox for bulk selection -->
|
||||
<div class="me-2">
|
||||
<input class="form-check-input media-checkbox"
|
||||
type="checkbox"
|
||||
name="selected_content"
|
||||
value="{{ media.id }}"
|
||||
{% if player.groups %}disabled{% endif %}>
|
||||
</div>
|
||||
|
||||
<!-- Drag handle -->
|
||||
<div class="drag-handle me-2" title="Drag to reorder">
|
||||
<i class="bi bi-grip-vertical"></i>
|
||||
☰
|
||||
</div>
|
||||
|
||||
<!-- Media Thumbnail and Name -->
|
||||
<div class="flex-grow-1 mb-2 mb-md-0 d-flex align-items-center">
|
||||
{% set file_ext = media.file_name.lower().split('.')[-1] %}
|
||||
{% if file_ext in ['mp4', 'avi', 'mkv', 'mov', 'webm'] %}
|
||||
<!-- Video Icon for video files -->
|
||||
<div style="width: 48px; height: 48px; margin-right: 10px; border-radius: 4px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="white" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M6.271 5.055a.5.5 0 0 1 .52.038l3.5 2.5a.5.5 0 0 1 0 .814l-3.5 2.5A.5.5 0 0 1 6 10.5v-5a.5.5 0 0 1 .271-.445z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Image thumbnail for image files -->
|
||||
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
|
||||
alt="thumbnail"
|
||||
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
|
||||
onerror="this.style.display='none';">
|
||||
{% endif %}
|
||||
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="d-flex flex-wrap justify-content-start">
|
||||
<form action="{{ url_for('edit_content', content_id=media.id) }}" method="post" class="d-flex align-items-center me-2 mb-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">seconds</span>
|
||||
<input type="number" class="form-control {% if theme == 'dark' %}dark-mode{% endif %}" name="duration" value="{{ media.duration }}" {% if player.groups %}disabled{% endif %} required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning ms-2" {% if player.groups %}disabled{% endif %}>Edit</button>
|
||||
</form>
|
||||
<form action="{{ url_for('delete_content', content_id=media.id) }}" method="post" class="mb-2">
|
||||
<button type="submit" class="btn btn-danger" {% if player.groups %}disabled{% endif %} onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<!-- Add a save button for the reordering -->
|
||||
<button id="saveOrder" class="btn btn-success mt-3" {% if player.groups %}disabled{% endif %}>Save Playlist Order</button>
|
||||
{% else %}
|
||||
<p class="text-center">No media uploaded for this player.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
|
||||
<a href="{{ url_for('player_fullscreen', player_id=player.id) }}" class="btn btn-primary">Full Screen</a>
|
||||
<a href="{{ url_for('upload_content', target_type='player', target_id=player.id, return_url=url_for('player_page', player_id=player.id)) }}"
|
||||
class="btn btn-success"
|
||||
{% if player.groups %}disabled onclick="return false;"{% endif %}>
|
||||
{% if player.groups %}Manage Media by Group{% else %}Upload Media{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Only enable if the player is not in a group (if the buttons are not disabled)
|
||||
if (!document.querySelector('#saveOrder').hasAttribute('disabled')) {
|
||||
const mediaList = document.getElementById('mediaList');
|
||||
let draggedItem = null;
|
||||
|
||||
// Initialize drag events for all items
|
||||
const items = mediaList.querySelectorAll('li');
|
||||
items.forEach(item => {
|
||||
// Drag start
|
||||
item.addEventListener('dragstart', function(e) {
|
||||
draggedItem = item;
|
||||
setTimeout(() => {
|
||||
item.classList.add('dragging');
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Drag end
|
||||
item.addEventListener('dragend', function() {
|
||||
item.classList.remove('dragging');
|
||||
draggedItem = null;
|
||||
updatePositions();
|
||||
});
|
||||
|
||||
// Drag over
|
||||
item.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
if (item !== draggedItem) {
|
||||
const rect = item.getBoundingClientRect();
|
||||
const y = e.clientY - rect.top;
|
||||
const height = rect.height;
|
||||
|
||||
if (y < height / 2) {
|
||||
mediaList.insertBefore(draggedItem, item);
|
||||
} else {
|
||||
mediaList.insertBefore(draggedItem, item.nextSibling);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Save button click handler
|
||||
document.getElementById('saveOrder').addEventListener('click', function() {
|
||||
// Collect new order
|
||||
const newOrder = [];
|
||||
mediaList.querySelectorAll('li').forEach((item, index) => {
|
||||
newOrder.push({
|
||||
id: item.dataset.id,
|
||||
position: index
|
||||
});
|
||||
});
|
||||
|
||||
// Send to server
|
||||
fetch('{{ url_for("update_content_order", player_id=player.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}'
|
||||
},
|
||||
body: JSON.stringify({items: newOrder})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('Playlist order updated successfully!');
|
||||
console.log('Playlist version updated to:', data.new_version);
|
||||
} else {
|
||||
alert('Error updating playlist order: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while updating the playlist order.');
|
||||
});
|
||||
});
|
||||
|
||||
// Update positions in the UI
|
||||
function updatePositions() {
|
||||
mediaList.querySelectorAll('li').forEach((item, index) => {
|
||||
item.dataset.position = index;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk selection functionality
|
||||
const selectAllCheckbox = document.getElementById('selectAll');
|
||||
const mediaCheckboxes = document.querySelectorAll('.media-checkbox');
|
||||
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
|
||||
|
||||
// Select all functionality
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
mediaCheckboxes.forEach(checkbox => {
|
||||
if (!checkbox.disabled) {
|
||||
checkbox.checked = this.checked;
|
||||
}
|
||||
});
|
||||
updateBulkDeleteButton();
|
||||
});
|
||||
}
|
||||
|
||||
// Individual checkbox change
|
||||
mediaCheckboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
updateSelectAllState();
|
||||
updateBulkDeleteButton();
|
||||
});
|
||||
});
|
||||
|
||||
function updateSelectAllState() {
|
||||
const enabledCheckboxes = Array.from(mediaCheckboxes).filter(cb => !cb.disabled);
|
||||
const checkedBoxes = enabledCheckboxes.filter(cb => cb.checked);
|
||||
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = checkedBoxes.length === enabledCheckboxes.length && enabledCheckboxes.length > 0;
|
||||
selectAllCheckbox.indeterminate = checkedBoxes.length > 0 && checkedBoxes.length < enabledCheckboxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function updateBulkDeleteButton() {
|
||||
const checkedBoxes = Array.from(mediaCheckboxes).filter(cb => cb.checked);
|
||||
if (bulkDeleteBtn) {
|
||||
bulkDeleteBtn.style.display = checkedBoxes.length > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function confirmBulkDelete() {
|
||||
const checkedBoxes = Array.from(document.querySelectorAll('.media-checkbox:checked'));
|
||||
if (checkedBoxes.length === 0) {
|
||||
alert('No media files selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
const count = checkedBoxes.length;
|
||||
const message = `Are you sure you want to delete ${count} selected media file${count > 1 ? 's' : ''}? This action cannot be undone.`;
|
||||
|
||||
if (confirm(message)) {
|
||||
// Create a form with selected IDs
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '{{ url_for("bulk_delete_player_content", player_id=player.id) }}';
|
||||
|
||||
checkedBoxes.forEach(checkbox => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'selected_content';
|
||||
input.value = checkbox.value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
529
app/templates/upload_content.html
Normal file
529
app/templates/upload_content.html
Normal file
@@ -0,0 +1,529 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Upload Content</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body.dark-mode {
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
}
|
||||
.card.dark-mode {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
}
|
||||
.dark-mode label, .dark-mode th, .dark-mode td {
|
||||
color: #ffffff;
|
||||
}
|
||||
.logo {
|
||||
max-height: 100px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
/* Modal styling for dark mode */
|
||||
.modal-content.dark-mode {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
}
|
||||
.modal-header.dark-mode {
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
.modal-footer.dark-mode {
|
||||
border-top: 1px solid #444;
|
||||
}
|
||||
.progress-bar {
|
||||
background-color: #007bff;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.btn {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-start align-items-center mb-4">
|
||||
{% if logo_exists %}
|
||||
<img src="{{ url_for('static', filename='uploads/logo.png') }}" alt="Logo" class="logo">
|
||||
{% endif %}
|
||||
<h1 class="mb-0">Upload Content</h1>
|
||||
</div>
|
||||
<form id="upload-form" action="{{ url_for('upload_content') }}" method="post" enctype="multipart/form-data" onsubmit="handleFormSubmit(event)">
|
||||
<input type="hidden" name="return_url" value="{{ return_url }}">
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="target_type" class="form-label">Target Type:</label>
|
||||
<select name="target_type" id="target_type" class="form-select" required onchange="updateTargetIdOptions()">
|
||||
<option value="" disabled selected>Select Target Type</option>
|
||||
<option value="player" {% if target_type == 'player' %}selected{% endif %}>Player</option>
|
||||
<option value="group" {% if target_type == 'group' %}selected{% endif %}>Group</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="target_id" class="form-label">Target ID:</label>
|
||||
<select name="target_id" id="target_id" class="form-select" required>
|
||||
{% if target_type == 'player' %}
|
||||
<optgroup label="Players">
|
||||
{% for player in players %}
|
||||
<option value="{{ player.id }}" {% if target_id == player.id %}selected{% endif %}>{{ player.username }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% elif target_type == 'group' %}
|
||||
<optgroup label="Groups">
|
||||
{% for group in groups %}
|
||||
<option value="{{ group.id }}" {% if target_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% else %}
|
||||
<option value="" disabled selected>Select a Target ID</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="media_type" class="form-label">Media Type:</label>
|
||||
<select name="media_type" id="media_type" class="form-select" required>
|
||||
<option value="image">Image</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="ppt">PPT/PPTX</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="files" class="form-label">Files:</label>
|
||||
<input type="file" name="files" id="files" class="form-control" multiple required onchange="handleFileChange()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="duration" class="form-label">Duration (seconds):</label>
|
||||
<input type="number" name="duration" id="duration" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<button type="submit" id="submit-button" class="btn btn-primary">Upload</button>
|
||||
<a href="{{ return_url }}" class="btn btn-secondary mt-3">Back</a>
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Modal for Status Updates -->
|
||||
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="modal-header {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<h5 class="modal-title" id="statusModalLabel">Processing Files</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="status-message">Uploading and processing your files. Please wait...</p>
|
||||
|
||||
<!-- File Processing Progress -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">File Processing Progress</label>
|
||||
<div class="progress" style="height: 25px;">
|
||||
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Monitoring in Modal -->
|
||||
{% if system_info %}
|
||||
<div class="mt-4">
|
||||
<h6 class="mb-3">📊 Server Performance During Upload</h6>
|
||||
<div class="row">
|
||||
<!-- CPU Usage -->
|
||||
<div class="col-md-4 col-12 mb-3">
|
||||
<label class="form-label">CPU Usage</label>
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div id="cpu-progress" class="progress-bar
|
||||
{% if system_info.cpu_percent < 50 %}bg-success
|
||||
{% elif system_info.cpu_percent < 80 %}bg-warning
|
||||
{% else %}bg-danger{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {{ system_info.cpu_percent }}%;">
|
||||
{{ system_info.cpu_percent }}%
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">{{ system_info.cpu_count }} cores available</small>
|
||||
</div>
|
||||
|
||||
<!-- Memory Usage -->
|
||||
<div class="col-md-4 col-12 mb-3">
|
||||
<label class="form-label">Memory Usage</label>
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div id="memory-progress" class="progress-bar
|
||||
{% if system_info.memory_percent < 60 %}bg-success
|
||||
{% elif system_info.memory_percent < 85 %}bg-warning
|
||||
{% else %}bg-danger{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {{ system_info.memory_percent }}%;">
|
||||
{{ system_info.memory_percent }}%
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted" id="memory-text">{{ system_info.memory_used }}GB / {{ system_info.memory_total }}GB</small>
|
||||
</div>
|
||||
|
||||
<!-- Disk Usage -->
|
||||
<div class="col-md-4 col-12 mb-3">
|
||||
<label class="form-label">Disk Space</label>
|
||||
<div class="progress" style="height: 20px;">
|
||||
<div id="disk-progress" class="progress-bar
|
||||
{% if system_info.disk_percent < 70 %}bg-success
|
||||
{% elif system_info.disk_percent < 90 %}bg-warning
|
||||
{% else %}bg-danger{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {{ system_info.disk_percent }}%;">
|
||||
{{ system_info.disk_percent }}%
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted" id="disk-text">{{ system_info.disk_free }}GB free</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Summary -->
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6 col-12 text-center">
|
||||
<strong>Current Media Storage:</strong>
|
||||
<span class="text-primary" id="storage-size">{{ system_info.upload_folder_size }}GB</span>
|
||||
</div>
|
||||
<div class="col-md-6 col-12 text-center">
|
||||
<strong>Last Updated:</strong>
|
||||
<span class="text-muted" id="modal-last-update">Just now</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" disabled>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
let progressInterval = null;
|
||||
let sessionId = null;
|
||||
let statusModal = null;
|
||||
let returnUrl = '{{ return_url }}';
|
||||
|
||||
// Generate unique session ID for this upload
|
||||
function generateSessionId() {
|
||||
return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
function handleFormSubmit(event) {
|
||||
event.preventDefault(); // Prevent default form submission
|
||||
|
||||
// Generate session ID and add it to the form
|
||||
sessionId = generateSessionId();
|
||||
const form = document.getElementById('upload-form');
|
||||
let sessionInput = document.getElementById('session_id_input');
|
||||
if (!sessionInput) {
|
||||
sessionInput = document.createElement('input');
|
||||
sessionInput.type = 'hidden';
|
||||
sessionInput.name = 'session_id';
|
||||
sessionInput.id = 'session_id_input';
|
||||
form.appendChild(sessionInput);
|
||||
}
|
||||
sessionInput.value = sessionId;
|
||||
|
||||
// Show modal
|
||||
showStatusModal();
|
||||
|
||||
// Submit form via AJAX
|
||||
const formData = new FormData(form);
|
||||
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
console.log('Form submitted successfully');
|
||||
// Don't redirect yet - keep polling until status is complete
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Form submission error:', error);
|
||||
if (upload_progress && sessionId) {
|
||||
upload_progress[sessionId] = {
|
||||
'status': 'error',
|
||||
'progress': 0,
|
||||
'message': 'Upload failed: ' + error.message
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showStatusModal() {
|
||||
console.log("Processing popup triggered");
|
||||
|
||||
statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
|
||||
statusModal.show();
|
||||
|
||||
{% if system_info %}
|
||||
// Start system monitoring updates
|
||||
startModalSystemMonitoring();
|
||||
{% endif %}
|
||||
|
||||
// Start polling progress
|
||||
pollUploadProgress();
|
||||
}
|
||||
|
||||
function pollUploadProgress() {
|
||||
const statusMessage = document.getElementById('status-message');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
|
||||
// Poll every 500ms for real-time updates
|
||||
progressInterval = setInterval(() => {
|
||||
fetch(`/api/upload_progress/${sessionId}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('Progress update:', data);
|
||||
|
||||
// Update progress bar
|
||||
progressBar.style.width = `${data.progress}%`;
|
||||
progressBar.setAttribute('aria-valuenow', data.progress);
|
||||
|
||||
// Update status message
|
||||
statusMessage.textContent = data.message;
|
||||
|
||||
// If complete or error, stop polling and enable close button
|
||||
if (data.status === 'complete' || data.status === 'error') {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = null;
|
||||
|
||||
{% if system_info %}
|
||||
stopModalSystemMonitoring();
|
||||
{% endif %}
|
||||
|
||||
const closeBtn = document.querySelector('[data-bs-dismiss="modal"]');
|
||||
closeBtn.disabled = false;
|
||||
|
||||
// Change progress bar color based on status
|
||||
if (data.status === 'complete') {
|
||||
progressBar.classList.remove('progress-bar-animated');
|
||||
progressBar.classList.add('bg-success');
|
||||
|
||||
// Auto-close after 2 seconds and redirect
|
||||
setTimeout(() => {
|
||||
statusModal.hide();
|
||||
window.location.href = returnUrl;
|
||||
}, 2000);
|
||||
} else if (data.status === 'error') {
|
||||
progressBar.classList.remove('progress-bar-animated', 'progress-bar-striped');
|
||||
progressBar.classList.add('bg-danger');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching progress:', error);
|
||||
statusMessage.textContent = 'Error tracking upload progress';
|
||||
});
|
||||
}, 500); // Poll every 500ms
|
||||
}
|
||||
|
||||
{% if system_info %}
|
||||
let modalSystemInterval;
|
||||
|
||||
function updateModalSystemInfo() {
|
||||
fetch('/api/system_info')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.error) {
|
||||
console.warn('Could not fetch system info:', data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update CPU
|
||||
const cpuProgress = document.getElementById('cpu-progress');
|
||||
if (cpuProgress) {
|
||||
cpuProgress.style.width = data.cpu_percent + '%';
|
||||
cpuProgress.textContent = data.cpu_percent + '%';
|
||||
cpuProgress.className = 'progress-bar ' +
|
||||
(data.cpu_percent < 50 ? 'bg-success' :
|
||||
data.cpu_percent < 80 ? 'bg-warning' : 'bg-danger');
|
||||
}
|
||||
|
||||
// Update Memory
|
||||
const memoryProgress = document.getElementById('memory-progress');
|
||||
const memoryText = document.getElementById('memory-text');
|
||||
if (memoryProgress) {
|
||||
memoryProgress.style.width = data.memory_percent + '%';
|
||||
memoryProgress.textContent = data.memory_percent + '%';
|
||||
memoryProgress.className = 'progress-bar ' +
|
||||
(data.memory_percent < 60 ? 'bg-success' :
|
||||
data.memory_percent < 85 ? 'bg-warning' : 'bg-danger');
|
||||
}
|
||||
if (memoryText) {
|
||||
memoryText.textContent = data.memory_used + 'GB / ' + data.memory_total + 'GB';
|
||||
}
|
||||
|
||||
// Update Disk
|
||||
const diskProgress = document.getElementById('disk-progress');
|
||||
const diskText = document.getElementById('disk-text');
|
||||
if (diskProgress) {
|
||||
diskProgress.style.width = data.disk_percent + '%';
|
||||
diskProgress.textContent = data.disk_percent + '%';
|
||||
diskProgress.className = 'progress-bar ' +
|
||||
(data.disk_percent < 70 ? 'bg-success' :
|
||||
data.disk_percent < 90 ? 'bg-warning' : 'bg-danger');
|
||||
}
|
||||
if (diskText) {
|
||||
diskText.textContent = data.disk_free + 'GB free';
|
||||
}
|
||||
|
||||
// Update storage size
|
||||
const storageSize = document.getElementById('storage-size');
|
||||
if (storageSize) {
|
||||
storageSize.textContent = data.upload_folder_size + 'GB';
|
||||
}
|
||||
|
||||
// Update timestamp
|
||||
const lastUpdate = document.getElementById('modal-last-update');
|
||||
if (lastUpdate) {
|
||||
lastUpdate.textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('Modal system monitoring update failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function startModalSystemMonitoring() {
|
||||
// Update immediately
|
||||
updateModalSystemInfo();
|
||||
// Then update every 3 seconds for real-time monitoring during upload
|
||||
modalSystemInterval = setInterval(updateModalSystemInfo, 3000);
|
||||
}
|
||||
|
||||
function stopModalSystemMonitoring() {
|
||||
if (modalSystemInterval) {
|
||||
clearInterval(modalSystemInterval);
|
||||
modalSystemInterval = null;
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
function updateTargetIdOptions() {
|
||||
const targetType = document.getElementById('target_type').value;
|
||||
const targetIdSelect = document.getElementById('target_id');
|
||||
targetIdSelect.innerHTML = ''; // Clear existing options
|
||||
|
||||
if (targetType === 'player') {
|
||||
const players = {{ players|tojson }};
|
||||
const optgroup = document.createElement('optgroup');
|
||||
optgroup.label = 'Players';
|
||||
players.forEach(player => {
|
||||
const option = document.createElement('option');
|
||||
option.value = player.id;
|
||||
option.textContent = player.username;
|
||||
optgroup.appendChild(option);
|
||||
});
|
||||
targetIdSelect.appendChild(optgroup);
|
||||
} else if (targetType === 'group') {
|
||||
const groups = {{ groups|tojson }};
|
||||
const optgroup = document.createElement('optgroup');
|
||||
optgroup.label = 'Groups';
|
||||
groups.forEach(group => {
|
||||
const option = document.createElement('option');
|
||||
option.value = group.id;
|
||||
option.textContent = group.name;
|
||||
optgroup.appendChild(option);
|
||||
});
|
||||
targetIdSelect.appendChild(optgroup);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange() {
|
||||
const mediaType = document.getElementById('media_type').value;
|
||||
const filesInput = document.getElementById('files');
|
||||
const durationInput = document.getElementById('duration');
|
||||
|
||||
if (mediaType === 'video' && filesInput.files.length > 0) {
|
||||
const file = filesInput.files[0];
|
||||
const video = document.createElement('video');
|
||||
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = function () {
|
||||
window.URL.revokeObjectURL(video.src);
|
||||
const duration = Math.round(video.duration);
|
||||
durationInput.value = duration; // Set the duration in the input field
|
||||
};
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function showStatusModal() {
|
||||
console.log("Processing popup triggered");
|
||||
const statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
|
||||
statusModal.show();
|
||||
|
||||
// Update status message based on media type
|
||||
const mediaType = document.getElementById('media_type').value;
|
||||
const statusMessage = document.getElementById('status-message');
|
||||
|
||||
switch(mediaType) {
|
||||
case 'image':
|
||||
statusMessage.textContent = 'Uploading images...';
|
||||
break;
|
||||
case 'video':
|
||||
statusMessage.textContent = 'Uploading and processing video. This may take a while...';
|
||||
break;
|
||||
case 'pdf':
|
||||
statusMessage.textContent = 'Converting PDF to images. This may take a while...';
|
||||
break;
|
||||
case 'ppt':
|
||||
statusMessage.textContent = 'Converting PowerPoint to images. This may take a while...';
|
||||
break;
|
||||
default:
|
||||
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
|
||||
}
|
||||
|
||||
// Simulate progress updates
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {
|
||||
// For slow processes, increment more slowly
|
||||
const increment = (mediaType === 'image') ? 20 : 5;
|
||||
progress += increment;
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(interval);
|
||||
statusMessage.textContent = 'Files uploaded and processed successfully!';
|
||||
|
||||
// Enable the close button
|
||||
document.querySelector('[data-bs-dismiss="modal"]').disabled = false;
|
||||
} else {
|
||||
progressBar.style.width = `${progress}%`;
|
||||
progressBar.setAttribute('aria-valuenow', progress);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -35,15 +35,24 @@ def create_group(name, player_ids, orientation='Landscape'):
|
||||
log_group_created(name)
|
||||
return new_group
|
||||
|
||||
def edit_group(group_id, name, player_ids):
|
||||
def edit_group(group_id, name, player_ids, orientation=None):
|
||||
"""
|
||||
Edit an existing group, updating its name and players.
|
||||
Edit an existing group, updating its name, orientation, and players.
|
||||
Handles locking/unlocking players appropriately.
|
||||
"""
|
||||
group = Group.query.get_or_404(group_id)
|
||||
old_name = group.name # Store old name in case it changes
|
||||
group.name = name
|
||||
|
||||
# Update orientation if provided
|
||||
if orientation:
|
||||
group.orientation = orientation
|
||||
# Validate that all selected players have the matching orientation
|
||||
for player_id in player_ids:
|
||||
player = Player.query.get(player_id)
|
||||
if player and player.orientation != orientation:
|
||||
raise ValueError(f"Player '{player.username}' has orientation '{player.orientation}', which does not match group orientation '{orientation}'.")
|
||||
|
||||
# Get current players in the group
|
||||
current_player_ids = [player.id for player in group.players]
|
||||
|
||||
@@ -324,7 +333,7 @@ def edit_group_media(group_id, content_id, new_duration):
|
||||
|
||||
def delete_group_media(group_id, content_id):
|
||||
"""
|
||||
Delete a media item from all players in a group.
|
||||
Delete a media item from all players in a group and remove the physical file.
|
||||
|
||||
Args:
|
||||
group_id (int): ID of the group
|
||||
@@ -335,6 +344,8 @@ def delete_group_media(group_id, content_id):
|
||||
"""
|
||||
from models import Group, Content
|
||||
from extensions import db
|
||||
from flask import current_app
|
||||
import os
|
||||
|
||||
group = Group.query.get_or_404(group_id)
|
||||
content = Content.query.get(content_id)
|
||||
@@ -349,6 +360,19 @@ def delete_group_media(group_id, content_id):
|
||||
db.session.delete(content)
|
||||
count += 1
|
||||
|
||||
# Delete the physical file using absolute path
|
||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
||||
if not os.path.isabs(upload_folder):
|
||||
upload_folder = os.path.abspath(upload_folder)
|
||||
file_path = os.path.join(upload_folder, file_name)
|
||||
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
print(f"Deleted physical file: {file_path}")
|
||||
except OSError as e:
|
||||
print(f"Error deleting file {file_path}: {e}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Log the content deletion
|
||||
@@ -357,4 +381,5 @@ def delete_group_media(group_id, content_id):
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error in delete_group_media: {e}")
|
||||
return False
|
||||
111
app/utils/pptx_converter.py
Normal file
111
app/utils/pptx_converter.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
PPTX to PDF converter using LibreOffice for high-quality conversion
|
||||
This module provides the essential function to convert PowerPoint presentations to PDF
|
||||
using LibreOffice headless mode for professional-grade quality.
|
||||
|
||||
The converted PDF is then processed by the main upload workflow for 4K image generation.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import logging
|
||||
import signal
|
||||
import time
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cleanup_libreoffice_processes():
|
||||
"""Clean up any hanging LibreOffice processes"""
|
||||
try:
|
||||
subprocess.run(['pkill', '-f', 'soffice'], capture_output=True, timeout=10)
|
||||
time.sleep(1) # Give processes time to terminate
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup LibreOffice processes: {e}")
|
||||
|
||||
|
||||
def pptx_to_pdf_libreoffice(pptx_path, output_dir):
|
||||
"""
|
||||
Convert PPTX to PDF using LibreOffice for highest quality.
|
||||
|
||||
This function is the core component of the PPTX processing workflow:
|
||||
PPTX → PDF (this function) → 4K JPG images (handled in uploads.py)
|
||||
|
||||
Args:
|
||||
pptx_path (str): Path to the PPTX file
|
||||
output_dir (str): Directory to save the PDF
|
||||
|
||||
Returns:
|
||||
str: Path to the generated PDF file, or None if conversion failed
|
||||
"""
|
||||
try:
|
||||
# Clean up any existing LibreOffice processes
|
||||
cleanup_libreoffice_processes()
|
||||
|
||||
# Ensure output directory exists
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Use LibreOffice to convert PPTX to PDF
|
||||
cmd = [
|
||||
'libreoffice',
|
||||
'--headless',
|
||||
'--convert-to', 'pdf',
|
||||
'--outdir', output_dir,
|
||||
'--invisible', # Run without any UI
|
||||
'--nodefault', # Don't start with default template
|
||||
pptx_path
|
||||
]
|
||||
|
||||
logger.info(f"Converting PPTX to PDF using LibreOffice: {pptx_path}")
|
||||
# Increase timeout to 300 seconds (5 minutes) for large presentations
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"LibreOffice conversion failed: {result.stderr}")
|
||||
logger.error(f"LibreOffice stdout: {result.stdout}")
|
||||
cleanup_libreoffice_processes() # Clean up on failure
|
||||
return None
|
||||
|
||||
# Find the generated PDF file
|
||||
base_name = os.path.splitext(os.path.basename(pptx_path))[0]
|
||||
pdf_path = os.path.join(output_dir, f"{base_name}.pdf")
|
||||
|
||||
if os.path.exists(pdf_path):
|
||||
logger.info(f"PDF conversion successful: {pdf_path}")
|
||||
cleanup_libreoffice_processes() # Clean up after success
|
||||
return pdf_path
|
||||
else:
|
||||
logger.error(f"PDF file not found after conversion: {pdf_path}")
|
||||
cleanup_libreoffice_processes() # Clean up on failure
|
||||
return None
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("LibreOffice conversion timed out (300s)")
|
||||
cleanup_libreoffice_processes() # Clean up on timeout
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error in PPTX to PDF conversion: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
cleanup_libreoffice_processes() # Clean up on error
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the converter
|
||||
import sys
|
||||
if len(sys.argv) > 1:
|
||||
test_pptx = sys.argv[1]
|
||||
if os.path.exists(test_pptx):
|
||||
output_dir = "test_output"
|
||||
pdf_result = pptx_to_pdf_libreoffice(test_pptx, output_dir)
|
||||
if pdf_result:
|
||||
print(f"Successfully converted PPTX to PDF: {pdf_result}")
|
||||
else:
|
||||
print("PPTX to PDF conversion failed")
|
||||
else:
|
||||
print(f"File not found: {test_pptx}")
|
||||
else:
|
||||
print("Usage: python pptx_converter.py <pptx_file>")
|
||||
659
app/utils/uploads.py
Normal file
659
app/utils/uploads.py
Normal file
@@ -0,0 +1,659 @@
|
||||
import os
|
||||
import subprocess
|
||||
from flask import Flask
|
||||
from werkzeug.utils import secure_filename
|
||||
from pdf2image import convert_from_path
|
||||
from extensions import db
|
||||
from models import Content, Player, Group
|
||||
from utils.logger import log_content_added, log_upload, log_process
|
||||
|
||||
# Function to add image to playlist
|
||||
def add_image_to_playlist(app, file, filename, duration, target_type, target_id):
|
||||
"""
|
||||
Save the image file and add it to the playlist database.
|
||||
"""
|
||||
# Use simple path resolution for containerized environment
|
||||
upload_folder = app.config['UPLOAD_FOLDER']
|
||||
# In container, working directory is /app, so static/uploads resolves correctly
|
||||
print(f"Upload folder config: {upload_folder}")
|
||||
|
||||
# Ensure upload folder exists
|
||||
if not os.path.exists(upload_folder):
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
print(f"Created upload folder: {upload_folder}")
|
||||
|
||||
file_path = os.path.join(upload_folder, filename)
|
||||
print(f"Saving image to: {file_path}")
|
||||
|
||||
# Only save if file does not already exist
|
||||
if not os.path.exists(file_path):
|
||||
file.save(file_path)
|
||||
print(f"Image saved successfully: {file_path}")
|
||||
else:
|
||||
print(f"File already exists: {file_path}")
|
||||
|
||||
print(f"Adding image to playlist: {filename}, Target Type: {target_type}, Target ID: {target_id}")
|
||||
|
||||
if target_type == 'group':
|
||||
group = Group.query.get_or_404(target_id)
|
||||
for player in group.players:
|
||||
new_content = Content(file_name=filename, duration=duration, player_id=player.id)
|
||||
db.session.add(new_content)
|
||||
log_content_added(filename, target_type, group.name)
|
||||
elif target_type == 'player':
|
||||
player = Player.query.get_or_404(target_id)
|
||||
new_content = Content(file_name=filename, duration=duration, player_id=target_id)
|
||||
db.session.add(new_content)
|
||||
log_content_added(filename, target_type, player.username)
|
||||
|
||||
db.session.commit()
|
||||
log_upload('image', filename, target_type, target_id)
|
||||
return True
|
||||
|
||||
# Video conversion functions
|
||||
def convert_video(input_file, output_folder):
|
||||
print(f"Video conversion skipped for: {input_file}")
|
||||
return input_file
|
||||
|
||||
def convert_video_and_update_playlist(app, file_path, original_filename, target_type, target_id, duration, upload_progress=None, session_id=None, file_index=0, total_files=1):
|
||||
"""
|
||||
Convert video to Raspberry Pi optimized format, then add to playlist.
|
||||
This ensures players only download optimized videos.
|
||||
|
||||
Args:
|
||||
upload_progress (dict): Global progress tracking dictionary
|
||||
session_id (str): Unique session identifier for progress tracking
|
||||
file_index (int): Current file index being processed
|
||||
total_files (int): Total number of files being processed
|
||||
"""
|
||||
import shutil
|
||||
import tempfile
|
||||
print(f"Starting video optimization for Raspberry Pi: {file_path}")
|
||||
|
||||
# Update progress - conversion starting
|
||||
if upload_progress and session_id:
|
||||
print(f"[VIDEO CONVERSION] Setting initial progress for session {session_id}")
|
||||
upload_progress[session_id] = {
|
||||
'status': 'converting',
|
||||
'progress': 40,
|
||||
'message': f'Optimizing video for Raspberry Pi (30fps, H.264)...',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index
|
||||
}
|
||||
print(f"[VIDEO CONVERSION] Progress set: {upload_progress[session_id]}")
|
||||
else:
|
||||
print(f"[VIDEO CONVERSION] WARNING: upload_progress or session_id is None!")
|
||||
|
||||
# Only process video files
|
||||
if not file_path.lower().endswith(('.mp4', '.avi', '.mkv', '.mov', '.webm')):
|
||||
print(f"Skipping non-video file: {file_path}")
|
||||
return None
|
||||
|
||||
# Prepare temp output file
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_output = os.path.join(temp_dir, f"optimized_{os.path.basename(file_path)}")
|
||||
|
||||
# Enhanced ffmpeg command for Raspberry Pi optimization
|
||||
ffmpeg_cmd = [
|
||||
'ffmpeg', '-y', '-i', file_path,
|
||||
'-c:v', 'libx264', # H.264 codec
|
||||
'-preset', 'medium', # Balanced encoding speed/quality
|
||||
'-profile:v', 'main', # Main profile for compatibility
|
||||
'-crf', '23', # Constant quality (23 is good balance)
|
||||
'-maxrate', '8M', # Max bitrate 8Mbps
|
||||
'-bufsize', '12M', # Buffer size
|
||||
'-vf', 'scale=\'min(1920,iw)\':\'min(1080,ih)\':force_original_aspect_ratio=decrease,fps=30', # Scale down if needed, 30fps
|
||||
'-r', '30', # Output framerate 30fps
|
||||
'-c:a', 'aac', # AAC audio codec
|
||||
'-b:a', '128k', # Audio bitrate 128kbps
|
||||
'-movflags', '+faststart', # Enable fast start for web streaming
|
||||
temp_output
|
||||
]
|
||||
|
||||
print(f"Running ffmpeg optimization: {' '.join(ffmpeg_cmd)}")
|
||||
print(f"Settings: 1920x1080 max, 30fps, H.264, 8Mbps max bitrate")
|
||||
|
||||
# Update progress - conversion in progress
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id]['progress'] = 50
|
||||
upload_progress[session_id]['message'] = 'Converting video (this may take a few minutes)...'
|
||||
|
||||
try:
|
||||
result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True, timeout=1800)
|
||||
if result.returncode != 0:
|
||||
print(f"ffmpeg error: {result.stderr}")
|
||||
print(f"Video conversion failed for: {original_filename}")
|
||||
|
||||
# Update progress - error
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id] = {
|
||||
'status': 'error',
|
||||
'progress': 0,
|
||||
'message': f'Video conversion failed: {original_filename}',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index
|
||||
}
|
||||
|
||||
# Delete the unconverted file
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
print(f"Removed unconverted video file: {file_path}")
|
||||
return None
|
||||
|
||||
# Update progress - replacing file
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id]['progress'] = 80
|
||||
upload_progress[session_id]['message'] = 'Saving optimized video and adding to playlist...'
|
||||
|
||||
# Replace original file with optimized one
|
||||
shutil.move(temp_output, file_path)
|
||||
print(f"Video optimized and replaced: {file_path}")
|
||||
print(f"Video is now optimized for Raspberry Pi playback (30fps, max 1080p)")
|
||||
|
||||
# NOW add to playlist after successful conversion
|
||||
with app.app_context():
|
||||
if target_type == 'group':
|
||||
group = Group.query.get_or_404(target_id)
|
||||
for player in group.players:
|
||||
new_content = Content(file_name=original_filename, duration=duration, player_id=player.id)
|
||||
db.session.add(new_content)
|
||||
player.playlist_version += 1
|
||||
group.playlist_version += 1
|
||||
print(f"Video added to group '{group.name}' playlist after optimization")
|
||||
elif target_type == 'player':
|
||||
player = Player.query.get_or_404(target_id)
|
||||
new_content = Content(file_name=original_filename, duration=duration, player_id=target_id)
|
||||
db.session.add(new_content)
|
||||
player.playlist_version += 1
|
||||
print(f"Video added to player '{player.username}' playlist after optimization")
|
||||
|
||||
db.session.commit()
|
||||
print(f"Playlist updated with optimized video: {original_filename}")
|
||||
|
||||
# Update progress - complete
|
||||
if upload_progress and session_id:
|
||||
print(f"[VIDEO CONVERSION] Video conversion complete! Updating progress for session {session_id}")
|
||||
upload_progress[session_id] = {
|
||||
'status': 'complete',
|
||||
'progress': 100,
|
||||
'message': f'Video conversion complete! Added to playlist.',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index + 1
|
||||
}
|
||||
print(f"[VIDEO CONVERSION] Final progress: {upload_progress[session_id]}")
|
||||
else:
|
||||
print(f"[VIDEO CONVERSION] WARNING: Cannot update completion status - upload_progress or session_id is None!")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[VIDEO CONVERSION] ERROR during video optimization: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Update progress - error
|
||||
if upload_progress and session_id:
|
||||
print(f"[VIDEO CONVERSION] Setting error status for session {session_id}")
|
||||
upload_progress[session_id] = {
|
||||
'status': 'error',
|
||||
'progress': 0,
|
||||
'message': f'Error during video conversion: {str(e)}',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index
|
||||
}
|
||||
else:
|
||||
print(f"[VIDEO CONVERSION] WARNING: Cannot update error status - upload_progress or session_id is None!")
|
||||
|
||||
# Delete the unconverted file on error
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
print(f"Removed unconverted video file due to error: {file_path}")
|
||||
return None
|
||||
|
||||
# Filename remains the same
|
||||
return True
|
||||
|
||||
# PDF conversion functions
|
||||
def convert_pdf_to_images(pdf_file, output_folder, delete_pdf=True, dpi=300):
|
||||
"""
|
||||
Convert a PDF file to high-quality JPG images in sequential order.
|
||||
Uses standard 300 DPI for reliable conversion.
|
||||
"""
|
||||
print(f"Converting PDF to JPG images: {pdf_file} at {dpi} DPI")
|
||||
print(f"Output folder: {output_folder}")
|
||||
|
||||
try:
|
||||
# Ensure output folder exists
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder, exist_ok=True)
|
||||
print(f"Created output folder: {output_folder}")
|
||||
|
||||
# Convert PDF to images using pdf2image
|
||||
print("Starting PDF conversion...")
|
||||
images = convert_from_path(pdf_file, dpi=dpi)
|
||||
print(f"PDF converted to {len(images)} page(s)")
|
||||
|
||||
if not images:
|
||||
print("ERROR: No images generated from PDF")
|
||||
return []
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(pdf_file))[0]
|
||||
image_filenames = []
|
||||
|
||||
# Save each page as JPG image
|
||||
for i, image in enumerate(images):
|
||||
# Convert to RGB if necessary
|
||||
if image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
|
||||
# Simple naming with page numbers
|
||||
page_num = str(i + 1).zfill(3) # e.g., 001, 002, etc.
|
||||
image_filename = f"{base_name}_page_{page_num}.jpg"
|
||||
image_path = os.path.join(output_folder, image_filename)
|
||||
|
||||
# Save as JPG
|
||||
image.save(image_path, 'JPEG', quality=85, optimize=True)
|
||||
image_filenames.append(image_filename)
|
||||
print(f"Saved page {i + 1} to: {image_path}")
|
||||
|
||||
print(f"PDF conversion complete. {len(image_filenames)} JPG images saved to {output_folder}")
|
||||
|
||||
# Delete the PDF file if requested and conversion was successful
|
||||
if delete_pdf and os.path.exists(pdf_file) and image_filenames:
|
||||
os.remove(pdf_file)
|
||||
print(f"PDF file deleted: {pdf_file}")
|
||||
|
||||
return image_filenames
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error converting PDF to JPG images: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
def update_playlist_with_files(image_filenames, duration, target_type, target_id):
|
||||
"""
|
||||
Add files to a player or group playlist and update version numbers.
|
||||
|
||||
Args:
|
||||
image_filenames (list): List of filenames to add to playlist
|
||||
duration (int): Duration in seconds for each file
|
||||
target_type (str): 'player' or 'group'
|
||||
target_id (int): ID of the player or group
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if target_type == 'group':
|
||||
group = Group.query.get_or_404(target_id)
|
||||
for player in group.players:
|
||||
for image_filename in image_filenames:
|
||||
new_content = Content(file_name=image_filename, duration=duration, player_id=player.id)
|
||||
db.session.add(new_content)
|
||||
player.playlist_version += 1
|
||||
group.playlist_version += 1
|
||||
elif target_type == 'player':
|
||||
player = Player.query.get_or_404(target_id)
|
||||
for image_filename in image_filenames:
|
||||
new_content = Content(file_name=image_filename, duration=duration, player_id=target_id)
|
||||
db.session.add(new_content)
|
||||
player.playlist_version += 1
|
||||
else:
|
||||
print(f"Invalid target type: {target_type}")
|
||||
return False
|
||||
|
||||
db.session.commit()
|
||||
print(f"Added {len(image_filenames)} files to playlist")
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error updating playlist: {e}")
|
||||
return False
|
||||
|
||||
def process_pdf(input_file, output_folder, duration, target_type, target_id, upload_progress=None, session_id=None, file_index=0, total_files=1):
|
||||
"""
|
||||
Process a PDF file: convert to images and update playlist.
|
||||
|
||||
Args:
|
||||
input_file (str): Path to the PDF file
|
||||
output_folder (str): Path to save the images
|
||||
duration (int): Duration in seconds for each image
|
||||
target_type (str): 'player' or 'group'
|
||||
target_id (int): ID of the player or group
|
||||
upload_progress (dict): Global progress tracking dictionary
|
||||
session_id (str): Unique session identifier for progress tracking
|
||||
file_index (int): Current file index being processed
|
||||
total_files (int): Total number of files being processed
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
print(f"Processing PDF file: {input_file}")
|
||||
print(f"Output folder: {output_folder}")
|
||||
|
||||
# Update progress - starting PDF conversion
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id] = {
|
||||
'status': 'converting',
|
||||
'progress': 50,
|
||||
'message': f'Converting PDF to images (300 DPI)...',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index
|
||||
}
|
||||
|
||||
# Ensure output folder exists
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder, exist_ok=True)
|
||||
print(f"Created output folder: {output_folder}")
|
||||
|
||||
# Convert PDF to images using standard quality (delete PDF after successful conversion)
|
||||
image_filenames = convert_pdf_to_images(input_file, output_folder, delete_pdf=True, dpi=300)
|
||||
|
||||
# Update progress - adding to playlist
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id]['progress'] = 80
|
||||
upload_progress[session_id]['message'] = f'Adding {len(image_filenames)} images to playlist...'
|
||||
|
||||
# Update playlist with generated images
|
||||
if image_filenames:
|
||||
success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
|
||||
if success:
|
||||
print(f"Successfully processed PDF: {len(image_filenames)} images added to playlist")
|
||||
|
||||
# Update progress - complete
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id] = {
|
||||
'status': 'complete',
|
||||
'progress': 100,
|
||||
'message': f'PDF converted to {len(image_filenames)} images and added to playlist!',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index + 1
|
||||
}
|
||||
return success
|
||||
else:
|
||||
print("Failed to convert PDF to images")
|
||||
|
||||
# Update progress - error
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id] = {
|
||||
'status': 'error',
|
||||
'progress': 0,
|
||||
'message': 'Failed to convert PDF to images',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index
|
||||
}
|
||||
return False
|
||||
|
||||
def process_pptx(input_file, output_folder, duration, target_type, target_id, upload_progress=None, session_id=None, file_index=0, total_files=1):
|
||||
"""
|
||||
Process a PPTX file: convert to PDF first, then to JPG images (same workflow as PDF).
|
||||
|
||||
Args:
|
||||
input_file (str): Path to the PPTX file
|
||||
output_folder (str): Path to save the images
|
||||
duration (int): Duration in seconds for each image
|
||||
target_type (str): 'player' or 'group'
|
||||
target_id (int): ID of the player or group
|
||||
upload_progress (dict): Global progress tracking dictionary
|
||||
session_id (str): Unique session identifier for progress tracking
|
||||
file_index (int): Current file index being processed
|
||||
total_files (int): Total number of files being processed
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
print(f"Processing PPTX file using PDF workflow: {input_file}")
|
||||
print(f"Output folder: {output_folder}")
|
||||
|
||||
# Update progress - starting PPTX conversion (step 1)
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id] = {
|
||||
'status': 'converting',
|
||||
'progress': 40,
|
||||
'message': f'Converting PowerPoint to PDF (Step 1/3)...',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index
|
||||
}
|
||||
|
||||
# Ensure output folder exists
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder, exist_ok=True)
|
||||
print(f"Created output folder: {output_folder}")
|
||||
|
||||
try:
|
||||
# Step 1: Convert PPTX to PDF using LibreOffice for vector quality
|
||||
print("Step 1: Converting PPTX to PDF...")
|
||||
from utils.pptx_converter import pptx_to_pdf_libreoffice
|
||||
pdf_file = pptx_to_pdf_libreoffice(input_file, output_folder)
|
||||
|
||||
if not pdf_file:
|
||||
print("Error: Failed to convert PPTX to PDF")
|
||||
print("This could be due to:")
|
||||
print("- LibreOffice not properly installed")
|
||||
print("- Corrupted PPTX file")
|
||||
print("- Insufficient memory")
|
||||
print("- File permission issues")
|
||||
|
||||
# Update progress - error
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id] = {
|
||||
'status': 'error',
|
||||
'progress': 0,
|
||||
'message': 'Failed to convert PowerPoint to PDF',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index
|
||||
}
|
||||
return False
|
||||
|
||||
print(f"PPTX successfully converted to PDF: {pdf_file}")
|
||||
|
||||
# Update progress - step 2
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id]['progress'] = 60
|
||||
upload_progress[session_id]['message'] = 'Converting PDF to images (Step 2/3, 300 DPI)...'
|
||||
|
||||
# Step 2: Use the same PDF to images workflow as direct PDF uploads
|
||||
print("Step 2: Converting PDF to JPG images...")
|
||||
# Convert PDF to JPG images (300 DPI, same as PDF workflow)
|
||||
image_filenames = convert_pdf_to_images(pdf_file, output_folder, delete_pdf=True, dpi=300)
|
||||
|
||||
if not image_filenames:
|
||||
print("Error: Failed to convert PDF to images")
|
||||
print("This could be due to:")
|
||||
print("- poppler-utils not properly installed")
|
||||
print("- PDF corruption during conversion")
|
||||
print("- Insufficient disk space")
|
||||
print("- Memory issues during image processing")
|
||||
|
||||
# Update progress - error
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id] = {
|
||||
'status': 'error',
|
||||
'progress': 0,
|
||||
'message': 'Failed to convert PDF to images',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index
|
||||
}
|
||||
return False
|
||||
|
||||
print(f"Generated {len(image_filenames)} JPG images from PPTX → PDF")
|
||||
|
||||
# Step 3: Delete the original PPTX file after successful conversion
|
||||
if os.path.exists(input_file):
|
||||
os.remove(input_file)
|
||||
print(f"Original PPTX file deleted: {input_file}")
|
||||
|
||||
# Update progress - step 3
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id]['progress'] = 85
|
||||
upload_progress[session_id]['message'] = f'Adding {len(image_filenames)} images to playlist (Step 3/3)...'
|
||||
|
||||
# Step 4: Update playlist with generated images in sequential order
|
||||
print("Step 3: Adding images to playlist...")
|
||||
success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
|
||||
if success:
|
||||
print(f"Successfully processed PPTX: {len(image_filenames)} images added to playlist")
|
||||
|
||||
# Update progress - complete
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id] = {
|
||||
'status': 'complete',
|
||||
'progress': 100,
|
||||
'message': f'PowerPoint converted to {len(image_filenames)} images and added to playlist!',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index + 1
|
||||
}
|
||||
else:
|
||||
print("Error: Failed to add images to playlist database")
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing PPTX file: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def process_uploaded_files(app, files, media_type, duration, target_type, target_id, upload_progress=None, session_id=None):
|
||||
"""
|
||||
Process uploaded files based on media type and add them to playlists.
|
||||
|
||||
Args:
|
||||
upload_progress (dict): Global progress tracking dictionary
|
||||
session_id (str): Unique session identifier for progress tracking
|
||||
|
||||
Returns:
|
||||
list: List of result dictionaries with success status and messages
|
||||
"""
|
||||
results = []
|
||||
|
||||
# Get target name for logging
|
||||
target_name = ""
|
||||
if target_type == 'group':
|
||||
group = Group.query.get_or_404(target_id)
|
||||
target_name = group.name
|
||||
elif target_type == 'player':
|
||||
player = Player.query.get_or_404(target_id)
|
||||
target_name = player.username
|
||||
|
||||
total_files = len(files)
|
||||
|
||||
for file_index, file in enumerate(files):
|
||||
try:
|
||||
# Update progress - uploading phase
|
||||
if upload_progress and session_id:
|
||||
file_progress = int((file_index / total_files) * 30) # 0-30% for file uploads
|
||||
upload_progress[session_id] = {
|
||||
'status': 'uploading',
|
||||
'progress': file_progress,
|
||||
'message': f'Uploading file {file_index + 1}/{total_files}: {file.filename}...',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index
|
||||
}
|
||||
|
||||
# Generate a secure filename and save the file
|
||||
filename = secure_filename(file.filename)
|
||||
|
||||
# Use simple path resolution for containerized environment
|
||||
upload_folder = app.config['UPLOAD_FOLDER']
|
||||
print(f"Upload folder: {upload_folder}")
|
||||
|
||||
# Ensure upload folder exists
|
||||
if not os.path.exists(upload_folder):
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
print(f"Created upload folder: {upload_folder}")
|
||||
|
||||
file_path = os.path.join(upload_folder, filename)
|
||||
file.save(file_path)
|
||||
print(f"File saved to: {file_path}")
|
||||
|
||||
print(f"Processing file: {filename}, Media Type: {media_type}")
|
||||
result = {'filename': filename, 'success': True, 'message': ''}
|
||||
|
||||
if media_type == 'image':
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id]['message'] = f'Adding image {file_index + 1}/{total_files} to playlist...'
|
||||
upload_progress[session_id]['progress'] = int(30 + (file_index / total_files) * 70)
|
||||
|
||||
add_image_to_playlist(app, file, filename, duration, target_type, target_id)
|
||||
result['message'] = f"Image {filename} added to playlist"
|
||||
log_upload('image', filename, target_type, target_id)
|
||||
|
||||
elif media_type == 'video':
|
||||
# For videos, save file then start conversion in background
|
||||
# Video will be added to playlist AFTER conversion completes
|
||||
print(f"Video uploaded: {filename}")
|
||||
print(f"Starting background optimization - video will be added to playlist when ready")
|
||||
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id] = {
|
||||
'status': 'converting',
|
||||
'progress': 40,
|
||||
'message': f'Converting video {file_index + 1}/{total_files} to 30fps (this may take a few minutes)...',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index
|
||||
}
|
||||
|
||||
# Start background conversion using absolute path
|
||||
import threading
|
||||
print(f"[VIDEO UPLOAD] Starting background thread for video conversion. Session ID: {session_id}")
|
||||
print(f"[VIDEO UPLOAD] Parameters: file_path={file_path}, filename={filename}, target={target_type}/{target_id}")
|
||||
thread = threading.Thread(target=convert_video_and_update_playlist,
|
||||
args=(app, file_path, filename, target_type, target_id, duration, upload_progress, session_id, file_index, total_files))
|
||||
thread.daemon = True # Make thread daemon so it doesn't block shutdown
|
||||
thread.start()
|
||||
print(f"[VIDEO UPLOAD] Background thread started: {thread.name}")
|
||||
result['message'] = f"Video {filename} is being optimized for Raspberry Pi (30fps, max 1080p). It will be added to playlist when ready."
|
||||
log_upload('video', filename, target_type, target_id)
|
||||
|
||||
elif media_type == 'pdf':
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id] = {
|
||||
'status': 'converting',
|
||||
'progress': 40,
|
||||
'message': f'Converting PDF {file_index + 1}/{total_files} to images...',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index
|
||||
}
|
||||
|
||||
# For PDFs, convert to images and update playlist using absolute path
|
||||
success = process_pdf(file_path, upload_folder,
|
||||
duration, target_type, target_id, upload_progress, session_id, file_index, total_files)
|
||||
if success:
|
||||
result['message'] = f"PDF {filename} processed successfully"
|
||||
log_process('pdf', filename, target_type, target_id)
|
||||
else:
|
||||
result['success'] = False
|
||||
result['message'] = f"Error processing PDF file: {filename}"
|
||||
|
||||
elif media_type == 'ppt':
|
||||
if upload_progress and session_id:
|
||||
upload_progress[session_id] = {
|
||||
'status': 'converting',
|
||||
'progress': 30,
|
||||
'message': f'Converting PowerPoint {file_index + 1}/{total_files} to images (PPTX → PDF → Images, may take 2-5 minutes)...',
|
||||
'files_total': total_files,
|
||||
'files_processed': file_index
|
||||
}
|
||||
|
||||
# For PPT/PPTX, convert to PDF, then to images, and update playlist using absolute path
|
||||
success = process_pptx(file_path, upload_folder,
|
||||
duration, target_type, target_id, upload_progress, session_id, file_index, total_files)
|
||||
if success:
|
||||
result['message'] = f"PowerPoint {filename} processed successfully"
|
||||
log_process('ppt', filename, target_type, target_id)
|
||||
else:
|
||||
result['success'] = False
|
||||
result['message'] = f"Error processing PowerPoint file: {filename}"
|
||||
|
||||
results.append(result)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing file {file.filename}: {e}")
|
||||
results.append({
|
||||
'filename': file.filename,
|
||||
'success': False,
|
||||
'message': f"Error processing file {file.filename}: {str(e)}"
|
||||
})
|
||||
|
||||
return results
|
||||
64
cleanup-docker.sh
Executable file
64
cleanup-docker.sh
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
# DigiServer Docker Cleanup Script
|
||||
# Version: 1.1.0
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧹 DigiServer Docker Cleanup"
|
||||
echo "============================"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Confirm cleanup
|
||||
print_warning "This will stop and remove all DigiServer containers and images."
|
||||
print_warning "Your data in the ./data directory will be preserved."
|
||||
echo ""
|
||||
read -p "Are you sure you want to continue? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_status "Cleanup cancelled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Stop and remove containers
|
||||
print_status "Stopping DigiServer containers..."
|
||||
docker compose down
|
||||
|
||||
# Remove DigiServer images
|
||||
print_status "Removing DigiServer images..."
|
||||
docker rmi digiserver:latest 2>/dev/null || print_warning "DigiServer image not found"
|
||||
|
||||
# Clean up unused Docker resources
|
||||
print_status "Cleaning up unused Docker resources..."
|
||||
docker system prune -f
|
||||
|
||||
# Clean up development cache files
|
||||
print_status "Cleaning up development cache files..."
|
||||
find ./app -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
|
||||
find ./app -name "*.pyc" -delete 2>/dev/null || true
|
||||
|
||||
print_success "Cleanup completed!"
|
||||
print_status "Data directory preserved at: ./data"
|
||||
print_status "To redeploy, run: ./deploy-docker.sh"
|
||||
336
code player/get_playlists.py
Normal file
336
code player/get_playlists.py
Normal file
@@ -0,0 +1,336 @@
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
import bcrypt
|
||||
import re
|
||||
import datetime
|
||||
from logging_config import Logger
|
||||
|
||||
def send_player_feedback(config, message, status="active", playlist_version=None, error_details=None):
|
||||
"""
|
||||
Send feedback to the server about player status.
|
||||
|
||||
Args:
|
||||
config (dict): Configuration containing server details
|
||||
message (str): Main feedback message
|
||||
status (str): Player status - "active", "playing", "error", "restarting"
|
||||
playlist_version (int, optional): Current playlist version being played
|
||||
error_details (str, optional): Error details if status is "error"
|
||||
|
||||
Returns:
|
||||
bool: True if feedback sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
server = config.get("server_ip", "")
|
||||
host = config.get("screen_name", "")
|
||||
quick = config.get("quickconnect_key", "")
|
||||
port = config.get("port", "")
|
||||
|
||||
# Construct server URL
|
||||
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
|
||||
if re.match(ip_pattern, server):
|
||||
feedback_url = f'http://{server}:{port}/api/player-feedback'
|
||||
else:
|
||||
feedback_url = f'http://{server}/api/player-feedback'
|
||||
|
||||
# Prepare feedback data
|
||||
feedback_data = {
|
||||
'player_name': host,
|
||||
'quickconnect_code': quick,
|
||||
'message': message,
|
||||
'status': status,
|
||||
'timestamp': datetime.datetime.now().isoformat(),
|
||||
'playlist_version': playlist_version,
|
||||
'error_details': error_details
|
||||
}
|
||||
|
||||
Logger.info(f"Sending feedback to {feedback_url}: {feedback_data}")
|
||||
|
||||
# Send POST request
|
||||
response = requests.post(feedback_url, json=feedback_data, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
Logger.info(f"Feedback sent successfully: {message}")
|
||||
return True
|
||||
else:
|
||||
Logger.warning(f"Feedback failed with status {response.status_code}: {response.text}")
|
||||
return False
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
Logger.error(f"Failed to send feedback: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
Logger.error(f"Unexpected error sending feedback: {e}")
|
||||
return False
|
||||
|
||||
def send_playlist_check_feedback(config, playlist_version=None):
|
||||
"""
|
||||
Send feedback when playlist is checked for updates.
|
||||
|
||||
Args:
|
||||
config (dict): Configuration containing server details
|
||||
playlist_version (int, optional): Current playlist version
|
||||
|
||||
Returns:
|
||||
bool: True if feedback sent successfully, False otherwise
|
||||
"""
|
||||
player_name = config.get("screen_name", "unknown")
|
||||
version_info = f"v{playlist_version}" if playlist_version else "unknown"
|
||||
message = f"player {player_name}, is active, Playing {version_info}"
|
||||
|
||||
return send_player_feedback(
|
||||
config=config,
|
||||
message=message,
|
||||
status="active",
|
||||
playlist_version=playlist_version
|
||||
)
|
||||
|
||||
def send_playlist_restart_feedback(config, playlist_version=None):
|
||||
"""
|
||||
Send feedback when playlist loop ends and restarts.
|
||||
|
||||
Args:
|
||||
config (dict): Configuration containing server details
|
||||
playlist_version (int, optional): Current playlist version
|
||||
|
||||
Returns:
|
||||
bool: True if feedback sent successfully, False otherwise
|
||||
"""
|
||||
player_name = config.get("screen_name", "unknown")
|
||||
version_info = f"v{playlist_version}" if playlist_version else "unknown"
|
||||
message = f"player {player_name}, playlist loop completed, restarting {version_info}"
|
||||
|
||||
return send_player_feedback(
|
||||
config=config,
|
||||
message=message,
|
||||
status="restarting",
|
||||
playlist_version=playlist_version
|
||||
)
|
||||
|
||||
def send_player_error_feedback(config, error_message, playlist_version=None):
|
||||
"""
|
||||
Send feedback when an error occurs in the player.
|
||||
|
||||
Args:
|
||||
config (dict): Configuration containing server details
|
||||
error_message (str): Description of the error
|
||||
playlist_version (int, optional): Current playlist version
|
||||
|
||||
Returns:
|
||||
bool: True if feedback sent successfully, False otherwise
|
||||
"""
|
||||
player_name = config.get("screen_name", "unknown")
|
||||
message = f"player {player_name}, error occurred"
|
||||
|
||||
return send_player_feedback(
|
||||
config=config,
|
||||
message=message,
|
||||
status="error",
|
||||
playlist_version=playlist_version,
|
||||
error_details=error_message
|
||||
)
|
||||
|
||||
def send_playing_status_feedback(config, playlist_version=None, current_media=None):
|
||||
"""
|
||||
Send feedback about current playing status.
|
||||
|
||||
Args:
|
||||
config (dict): Configuration containing server details
|
||||
playlist_version (int, optional): Current playlist version
|
||||
current_media (str, optional): Currently playing media file
|
||||
|
||||
Returns:
|
||||
bool: True if feedback sent successfully, False otherwise
|
||||
"""
|
||||
player_name = config.get("screen_name", "unknown")
|
||||
version_info = f"v{playlist_version}" if playlist_version else "unknown"
|
||||
media_info = f" - {current_media}" if current_media else ""
|
||||
message = f"player {player_name}, is active, Playing {version_info}{media_info}"
|
||||
|
||||
return send_player_feedback(
|
||||
config=config,
|
||||
message=message,
|
||||
status="playing",
|
||||
playlist_version=playlist_version
|
||||
)
|
||||
|
||||
def is_playlist_up_to_date(local_playlist_path, config):
|
||||
"""
|
||||
Compare the version of the local playlist with the server playlist.
|
||||
Returns True if up-to-date, False otherwise.
|
||||
"""
|
||||
import json
|
||||
if not os.path.exists(local_playlist_path):
|
||||
Logger.info(f"Local playlist file not found: {local_playlist_path}")
|
||||
return False
|
||||
with open(local_playlist_path, 'r') as f:
|
||||
local_data = json.load(f)
|
||||
local_version = local_data.get('version', 0)
|
||||
server_data = fetch_server_playlist(config)
|
||||
server_version = server_data.get('version', 0)
|
||||
Logger.info(f"Local playlist version: {local_version}, Server playlist version: {server_version}")
|
||||
return local_version == server_version
|
||||
|
||||
def fetch_server_playlist(config):
|
||||
"""Fetch the updated playlist from the server using a config dict."""
|
||||
server = config.get("server_ip", "")
|
||||
host = config.get("screen_name", "")
|
||||
quick = config.get("quickconnect_key", "")
|
||||
port = config.get("port", "")
|
||||
try:
|
||||
ip_pattern = r'^\d+\.\d+\.\d+\.\d+$'
|
||||
if re.match(ip_pattern, server):
|
||||
server_url = f'http://{server}:{port}/api/playlists'
|
||||
else:
|
||||
server_url = f'http://{server}/api/playlists'
|
||||
params = {
|
||||
'hostname': host,
|
||||
'quickconnect_code': quick
|
||||
}
|
||||
Logger.info(f"Fetching playlist from URL: {server_url} with params: {params}")
|
||||
response = requests.get(server_url, params=params)
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
Logger.info(f"Server response: {response_data}")
|
||||
playlist = response_data.get('playlist', [])
|
||||
version = response_data.get('playlist_version', None)
|
||||
hashed_quickconnect = response_data.get('hashed_quickconnect', None)
|
||||
if version is not None and hashed_quickconnect is not None:
|
||||
if bcrypt.checkpw(quick.encode('utf-8'), hashed_quickconnect.encode('utf-8')):
|
||||
Logger.info("Fetched updated playlist from server.")
|
||||
return {'playlist': playlist, 'version': version}
|
||||
else:
|
||||
Logger.error("Quickconnect code validation failed.")
|
||||
else:
|
||||
Logger.error("Failed to retrieve playlist or hashed quickconnect from the response.")
|
||||
else:
|
||||
Logger.error(f"Failed to fetch playlist. Status Code: {response.status_code}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
Logger.error(f"Failed to fetch playlist: {e}")
|
||||
return {'playlist': [], 'version': 0}
|
||||
|
||||
def save_playlist_with_version(playlist_data, playlist_dir):
|
||||
version = playlist_data.get('version', 0)
|
||||
playlist_file = os.path.join(playlist_dir, f'server_playlist_v{version}.json')
|
||||
with open(playlist_file, 'w') as f:
|
||||
json.dump(playlist_data, f, indent=2)
|
||||
print(f"Playlist saved to {playlist_file}")
|
||||
return playlist_file
|
||||
|
||||
|
||||
def download_media_files(playlist, media_dir):
|
||||
"""Download media files from the server and save them to media_dir."""
|
||||
if not os.path.exists(media_dir):
|
||||
os.makedirs(media_dir)
|
||||
Logger.info(f"Created directory {media_dir} for media files.")
|
||||
|
||||
updated_playlist = []
|
||||
for media in playlist:
|
||||
file_name = media.get('file_name', '')
|
||||
file_url = media.get('url', '')
|
||||
duration = media.get('duration', 10)
|
||||
local_path = os.path.join(media_dir, file_name)
|
||||
Logger.info(f"Preparing to download {file_name} from {file_url}...")
|
||||
if os.path.exists(local_path):
|
||||
Logger.info(f"File {file_name} already exists. Skipping download.")
|
||||
else:
|
||||
try:
|
||||
response = requests.get(file_url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
with open(local_path, 'wb') as file:
|
||||
file.write(response.content)
|
||||
Logger.info(f"Successfully downloaded {file_name} to {local_path}")
|
||||
else:
|
||||
Logger.error(f"Failed to download {file_name}. Status Code: {response.status_code}")
|
||||
continue
|
||||
except requests.exceptions.RequestException as e:
|
||||
Logger.error(f"Error downloading {file_name}: {e}")
|
||||
continue
|
||||
updated_media = {
|
||||
'file_name': file_name,
|
||||
'url': os.path.relpath(local_path, os.path.dirname(media_dir)),
|
||||
'duration': duration
|
||||
}
|
||||
updated_playlist.append(updated_media)
|
||||
return updated_playlist
|
||||
|
||||
def delete_old_playlists_and_media(current_version, playlist_dir, media_dir, keep_versions=1):
|
||||
"""
|
||||
Delete old playlist files and media files not referenced by the latest playlist version.
|
||||
keep_versions: number of latest versions to keep (default 1)
|
||||
"""
|
||||
# Find all playlist files
|
||||
playlist_files = [f for f in os.listdir(playlist_dir) if f.startswith('server_playlist_v') and f.endswith('.json')]
|
||||
# Keep only the latest N versions
|
||||
versions = sorted([int(f.split('_v')[-1].split('.json')[0]) for f in playlist_files], reverse=True)
|
||||
keep = set(versions[:keep_versions])
|
||||
# Delete old playlist files
|
||||
for f in playlist_files:
|
||||
v = int(f.split('_v')[-1].split('.json')[0])
|
||||
if v not in keep:
|
||||
os.remove(os.path.join(playlist_dir, f))
|
||||
# Collect all media files referenced by the kept playlists
|
||||
referenced = set()
|
||||
for v in keep:
|
||||
path = os.path.join(playlist_dir, f'server_playlist_v{v}.json')
|
||||
if os.path.exists(path):
|
||||
with open(path, 'r') as f:
|
||||
data = json.load(f)
|
||||
for item in data.get('playlist', []):
|
||||
referenced.add(item.get('file_name'))
|
||||
# Delete media files not referenced
|
||||
for f in os.listdir(media_dir):
|
||||
if f not in referenced:
|
||||
try:
|
||||
os.remove(os.path.join(media_dir, f))
|
||||
except Exception as e:
|
||||
Logger.warning(f"Failed to delete media file {f}: {e}")
|
||||
|
||||
def update_playlist_if_needed(local_playlist_path, config, media_dir, playlist_dir):
|
||||
"""
|
||||
Fetch the server playlist once, compare versions, and update if needed.
|
||||
Returns True if updated, False if already up to date.
|
||||
Also sends feedback to server about playlist check.
|
||||
"""
|
||||
import json
|
||||
server_data = fetch_server_playlist(config)
|
||||
server_version = server_data.get('version', 0)
|
||||
if not os.path.exists(local_playlist_path):
|
||||
local_version = 0
|
||||
else:
|
||||
with open(local_playlist_path, 'r') as f:
|
||||
local_data = json.load(f)
|
||||
local_version = local_data.get('version', 0)
|
||||
|
||||
Logger.info(f"Local playlist version: {local_version}, Server playlist version: {server_version}")
|
||||
|
||||
# Send feedback about playlist check
|
||||
send_playlist_check_feedback(config, server_version if server_version > 0 else local_version)
|
||||
|
||||
if local_version != server_version:
|
||||
if server_data and server_data.get('playlist'):
|
||||
updated_playlist = download_media_files(server_data['playlist'], media_dir)
|
||||
server_data['playlist'] = updated_playlist
|
||||
save_playlist_with_version(server_data, playlist_dir)
|
||||
# Delete old playlists and unreferenced media
|
||||
delete_old_playlists_and_media(server_version, playlist_dir, media_dir)
|
||||
|
||||
# Send feedback about playlist update
|
||||
player_name = config.get("screen_name", "unknown")
|
||||
update_message = f"player {player_name}, playlist updated to v{server_version}"
|
||||
send_player_feedback(config, update_message, "active", server_version)
|
||||
|
||||
return True
|
||||
else:
|
||||
Logger.warning("No playlist data fetched from server or playlist is empty.")
|
||||
|
||||
# Send error feedback
|
||||
send_player_error_feedback(config, "No playlist data fetched from server or playlist is empty", local_version)
|
||||
|
||||
return False
|
||||
else:
|
||||
Logger.info("Local playlist is already up to date.")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
1091
code player/player.py
Normal file
1091
code player/player.py
Normal file
File diff suppressed because it is too large
Load Diff
109
deploy-docker.sh
Executable file
109
deploy-docker.sh
Executable file
@@ -0,0 +1,109 @@
|
||||
#!/bin/bash
|
||||
# DigiServer Docker Deployment Script
|
||||
# Version: 1.1.0
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 DigiServer Docker Deployment"
|
||||
echo "================================"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if Docker is running
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
print_error "Docker is not running. Please start Docker and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_status "Docker is running ✓"
|
||||
|
||||
# Check if docker compose is available
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
print_error "docker compose is not available. Please install Docker Compose and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_status "docker compose is available ✓"
|
||||
|
||||
# Stop existing containers if running
|
||||
print_status "Stopping existing containers..."
|
||||
docker compose down 2>/dev/null || true
|
||||
|
||||
# Remove old images (optional)
|
||||
read -p "Do you want to remove old DigiServer images? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_status "Removing old images..."
|
||||
docker image prune -f --filter label=app=digiserver 2>/dev/null || true
|
||||
docker rmi digiserver:latest 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create data directories if they don't exist
|
||||
print_status "Creating data directories..."
|
||||
mkdir -p data/instance data/uploads data/resurse
|
||||
|
||||
# Build the Docker image
|
||||
print_status "Building DigiServer Docker image..."
|
||||
docker compose build
|
||||
|
||||
# Check if build was successful
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Docker image built successfully!"
|
||||
else
|
||||
print_error "Docker build failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Start the containers
|
||||
print_status "Starting DigiServer containers..."
|
||||
docker compose up -d
|
||||
|
||||
# Wait a moment for containers to start
|
||||
sleep 10
|
||||
|
||||
# Check if containers are running
|
||||
if docker compose ps | grep -q "Up"; then
|
||||
print_success "DigiServer is now running!"
|
||||
echo ""
|
||||
echo "🌐 Access your DigiServer at: http://localhost:8880"
|
||||
echo "📊 Admin Panel: http://localhost:8880/admin"
|
||||
echo ""
|
||||
echo "Default credentials:"
|
||||
echo "Username: admin"
|
||||
echo "Password: Initial01!"
|
||||
echo ""
|
||||
print_warning "Please change the default password after first login!"
|
||||
echo ""
|
||||
echo "📝 To view logs: docker compose logs -f"
|
||||
echo "🛑 To stop: docker compose down"
|
||||
echo "📊 To check status: docker compose ps"
|
||||
else
|
||||
print_error "Failed to start DigiServer containers!"
|
||||
echo ""
|
||||
echo "Check logs with: docker compose logs"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_success "Deployment completed successfully! 🎉"
|
||||
32
docker-compose.dev.yml
Normal file
32
docker-compose.dev.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
# Development Docker Compose Configuration
|
||||
# Use this for development with hot reloading
|
||||
|
||||
services:
|
||||
digiserver-dev:
|
||||
build: .
|
||||
image: digiserver:dev
|
||||
container_name: digiserver-dev
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_RUN_HOST=0.0.0.0
|
||||
- FLASK_ENV=development
|
||||
- FLASK_DEBUG=1
|
||||
- ADMIN_USER=admin
|
||||
- ADMIN_PASSWORD=Initial01!
|
||||
- SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
|
||||
volumes:
|
||||
# Mount app code for hot reloading
|
||||
- ./app:/app
|
||||
# Persistent data volumes
|
||||
- ./data/instance:/app/instance
|
||||
- ./data/uploads:/app/static/uploads
|
||||
- ./data/resurse:/app/static/resurse
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- digiserver-dev-network
|
||||
|
||||
networks:
|
||||
digiserver-dev-network:
|
||||
driver: bridge
|
||||
32
docker-compose.yml
Executable file → Normal file
32
docker-compose.yml
Executable file → Normal file
@@ -1,16 +1,38 @@
|
||||
#version: '"1.1.0"'
|
||||
# Production Docker Compose Configuration
|
||||
# Use this for production deployment
|
||||
|
||||
services:
|
||||
web:
|
||||
digiserver:
|
||||
build: .
|
||||
image: digiserver:latest
|
||||
container_name: digiserver
|
||||
ports:
|
||||
- "8880:5000"
|
||||
- "80:5000"
|
||||
environment:
|
||||
- FLASK_APP=app.py
|
||||
- FLASK_RUN_HOST=0.0.0.0
|
||||
- FLASK_ENV=production
|
||||
- FLASK_DEBUG=0
|
||||
- ADMIN_USER=admin
|
||||
- ADMIN_PASSWORD=Initial01!
|
||||
- SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
|
||||
volumes:
|
||||
- /opt/digi-s/instance:/app/instance
|
||||
- /opt/digi-s/static/uploads:/app/static/uploads
|
||||
# Mount app code
|
||||
- ./app:/app
|
||||
# Persistent data volumes
|
||||
- ./data/instance:/app/instance
|
||||
- ./data/uploads:/app/static/uploads
|
||||
- ./data/resurse:/app/static/resurse
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- digiserver-network
|
||||
|
||||
networks:
|
||||
digiserver-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
python3 -m venv digiscreen
|
||||
|
||||
source digiscreen/bin/activate
|
||||
|
||||
pip install flask sqlalchemy flask-sqlalchemy
|
||||
|
||||
pip install flask-login flask-bcrypt
|
||||
|
||||
python3 setup.py sdist
|
||||
|
||||
python3 setup.py bdist_wheel flask
|
||||
|
||||
|
||||
for installing all the requirements
|
||||
pip install -r requirements.txt
|
||||
|
||||
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
ffmpeg \
|
||||
libpoppler-cpp-dev \
|
||||
poppler-utils \
|
||||
libreoffice \
|
||||
libmagic1
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,46 +0,0 @@
|
||||
"""Add position field to Content model
|
||||
|
||||
Revision ID: 54d8ece92767
|
||||
Revises:
|
||||
Create Date: 2025-06-29 15:32:30.794390
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '54d8ece92767'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('content', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('position', sa.Integer(), nullable=True))
|
||||
batch_op.alter_column('file_name',
|
||||
existing_type=sa.VARCHAR(length=120),
|
||||
type_=sa.String(length=255),
|
||||
existing_nullable=False)
|
||||
batch_op.alter_column('player_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('content', schema=None) as batch_op:
|
||||
batch_op.alter_column('player_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
batch_op.alter_column('file_name',
|
||||
existing_type=sa.String(length=255),
|
||||
type_=sa.VARCHAR(length=120),
|
||||
existing_nullable=False)
|
||||
batch_op.drop_column('position')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,62 +0,0 @@
|
||||
"""Add position field to Content model
|
||||
|
||||
Revision ID: c2aad6547472
|
||||
Revises: 54d8ece92767
|
||||
Create Date: 2025-06-29 15:58:57.678396
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c2aad6547472'
|
||||
down_revision = '54d8ece92767'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('player', schema=None) as batch_op:
|
||||
batch_op.alter_column('username',
|
||||
existing_type=sa.VARCHAR(length=100),
|
||||
type_=sa.String(length=255),
|
||||
existing_nullable=False)
|
||||
batch_op.alter_column('hostname',
|
||||
existing_type=sa.VARCHAR(length=100),
|
||||
type_=sa.String(length=255),
|
||||
existing_nullable=False)
|
||||
batch_op.alter_column('password',
|
||||
existing_type=sa.VARCHAR(length=200),
|
||||
type_=sa.String(length=255),
|
||||
existing_nullable=False)
|
||||
batch_op.alter_column('quickconnect_password',
|
||||
existing_type=sa.VARCHAR(length=200),
|
||||
type_=sa.String(length=255),
|
||||
existing_nullable=True)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('player', schema=None) as batch_op:
|
||||
batch_op.alter_column('quickconnect_password',
|
||||
existing_type=sa.String(length=255),
|
||||
type_=sa.VARCHAR(length=200),
|
||||
existing_nullable=True)
|
||||
batch_op.alter_column('password',
|
||||
existing_type=sa.String(length=255),
|
||||
type_=sa.VARCHAR(length=200),
|
||||
existing_nullable=False)
|
||||
batch_op.alter_column('hostname',
|
||||
existing_type=sa.String(length=255),
|
||||
type_=sa.VARCHAR(length=100),
|
||||
existing_nullable=False)
|
||||
batch_op.alter_column('username',
|
||||
existing_type=sa.String(length=255),
|
||||
type_=sa.VARCHAR(length=100),
|
||||
existing_nullable=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 153 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
@@ -1,225 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Manage Group</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body.dark-mode {
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
}
|
||||
.card.dark-mode {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
}
|
||||
.dark-mode label, .dark-mode th, .dark-mode td {
|
||||
color: #ffffff;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.btn {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sortable-list li {
|
||||
cursor: move;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable-list li.dragging {
|
||||
opacity: 0.5;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: #aaa;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
border-top: 2px solid #0d6efd;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="container py-5">
|
||||
<h1 class="text-center mb-4">Manage Group: {{ group.name }}</h1>
|
||||
|
||||
<!-- Group Information Card -->
|
||||
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h2>Group Info</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>Group Name:</strong> {{ group.name }}</p>
|
||||
<p><strong>Number of Players:</strong> {{ group.players|length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List of Players in the Group -->
|
||||
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="card-header bg-secondary text-white">
|
||||
<h2>Players in Group</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group">
|
||||
{% for player in group.players %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>{{ player.username }}</strong> ({{ player.hostname }})
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manage Media Section -->
|
||||
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h2>Manage Media</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if content %}
|
||||
<ul class="list-group sortable-list" id="groupMediaList">
|
||||
{% for media in content %}
|
||||
<li class="list-group-item d-flex align-items-center {{ 'dark-mode' if theme == 'dark' else '' }}"
|
||||
draggable="true"
|
||||
data-id="{{ media.id }}"
|
||||
data-position="{{ loop.index0 }}">
|
||||
<!-- Drag handle -->
|
||||
<div class="drag-handle me-2" title="Drag to reorder">
|
||||
<i class="bi bi-grip-vertical"></i>
|
||||
☰
|
||||
</div>
|
||||
|
||||
<div class="flex-grow-1">
|
||||
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
|
||||
</div>
|
||||
<form action="{{ url_for('edit_group_media', group_id=group.id, content_id=media.id) }}" method="post" class="d-flex align-items-center">
|
||||
<div class="input-group me-2">
|
||||
<span class="input-group-text">seconds</span>
|
||||
<input type="number" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" name="duration" value="{{ media.duration }}" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning me-2">Edit</button>
|
||||
</form>
|
||||
<form action="{{ url_for('delete_group_media', group_id=group.id, content_id=media.id) }}" method="post" style="display:inline;">
|
||||
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<!-- Add a save button for the reordering -->
|
||||
<button id="saveGroupOrder" class="btn btn-success mt-3">Save Playlist Order</button>
|
||||
{% else %}
|
||||
<p class="text-center">No media uploaded for this group.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Media Button -->
|
||||
<div class="text-center mb-4">
|
||||
<a href="{{ url_for('upload_content', target_type='group', target_id=group.id, return_url=url_for('manage_group', group_id=group.id)) }}" class="btn btn-primary btn-lg">Go to Upload Media</a>
|
||||
</div>
|
||||
|
||||
<!-- Back to Dashboard Button -->
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const groupMediaList = document.getElementById('groupMediaList');
|
||||
let draggedItem = null;
|
||||
|
||||
// Initialize drag events for all items
|
||||
const items = groupMediaList.querySelectorAll('li');
|
||||
items.forEach(item => {
|
||||
// Drag start
|
||||
item.addEventListener('dragstart', function(e) {
|
||||
draggedItem = item;
|
||||
setTimeout(() => {
|
||||
item.classList.add('dragging');
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Drag end
|
||||
item.addEventListener('dragend', function() {
|
||||
item.classList.remove('dragging');
|
||||
draggedItem = null;
|
||||
updatePositions();
|
||||
});
|
||||
|
||||
// Drag over
|
||||
item.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
if (item !== draggedItem) {
|
||||
const rect = item.getBoundingClientRect();
|
||||
const y = e.clientY - rect.top;
|
||||
const height = rect.height;
|
||||
|
||||
if (y < height / 2) {
|
||||
groupMediaList.insertBefore(draggedItem, item);
|
||||
} else {
|
||||
groupMediaList.insertBefore(draggedItem, item.nextSibling);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Save button click handler
|
||||
document.getElementById('saveGroupOrder').addEventListener('click', function() {
|
||||
// Collect new order
|
||||
const newOrder = [];
|
||||
groupMediaList.querySelectorAll('li').forEach((item, index) => {
|
||||
newOrder.push({
|
||||
id: item.dataset.id,
|
||||
position: index
|
||||
});
|
||||
});
|
||||
|
||||
// Send to server
|
||||
fetch('{{ url_for("update_group_content_order", group_id=group.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}'
|
||||
},
|
||||
body: JSON.stringify({items: newOrder})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('Playlist order updated successfully!');
|
||||
console.log('Group playlist update successful:', data);
|
||||
} else {
|
||||
alert('Error updating playlist order: ' + (data.error || 'Unknown error'));
|
||||
console.error('Failed to update group playlist:', data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while updating the playlist order.');
|
||||
});
|
||||
});
|
||||
|
||||
// Update positions in the UI
|
||||
function updatePositions() {
|
||||
groupMediaList.querySelectorAll('li').forEach((item, index) => {
|
||||
item.dataset.position = index;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,241 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Player Schedule</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body.dark-mode {
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
}
|
||||
.card.dark-mode {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
}
|
||||
.dark-mode label, .dark-mode th, .dark-mode td {
|
||||
color: #ffffff;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.btn {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
.sortable-list li {
|
||||
cursor: move;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sortable-list li.dragging {
|
||||
opacity: 0.5;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: #aaa;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
border-top: 2px solid #0d6efd;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="{% if theme == 'dark' %}dark-mode{% endif %}">
|
||||
<div class="container py-5">
|
||||
<h1 class="text-center mb-4">Player Schedule for {{ player.username }}</h1>
|
||||
|
||||
<!-- Player Info Section -->
|
||||
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h2>Player Info</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>Player Name:</strong> {{ player.username }}</p>
|
||||
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
|
||||
{% if current_user.role == 'admin' %}
|
||||
<a href="{{ url_for('edit_player', player_id=player.id, return_url=url_for('player_page', player_id=player.id)) }}" class="btn btn-warning">Update</a>
|
||||
<form action="{{ url_for('delete_player', player_id=player.id) }}" method="post" style="display:inline;">
|
||||
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this player?');">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Membership Section -->
|
||||
<div class="mb-4">
|
||||
{% if player.groups %}
|
||||
<h4 class="text-center">Member of Group(s):</h4>
|
||||
<ul class="list-group">
|
||||
{% for group in player.groups %}
|
||||
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}">{{ group.name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-center">This player is not a member of any groups.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Media Management Section -->
|
||||
{% if current_user.role == 'admin' %}
|
||||
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h2>Manage Media</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if content %}
|
||||
<ul class="list-group sortable-list" id="mediaList">
|
||||
{% for media in content %}
|
||||
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}"
|
||||
draggable="true"
|
||||
data-id="{{ media.id }}"
|
||||
data-position="{{ loop.index0 }}">
|
||||
<div class="d-flex flex-column flex-md-row align-items-md-center">
|
||||
<!-- Drag handle -->
|
||||
<div class="drag-handle me-2" title="Drag to reorder">
|
||||
<i class="bi bi-grip-vertical"></i>
|
||||
☰
|
||||
</div>
|
||||
|
||||
<!-- Media Thumbnail and Name -->
|
||||
<div class="flex-grow-1 mb-2 mb-md-0 d-flex align-items-center">
|
||||
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
|
||||
alt="thumbnail"
|
||||
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
|
||||
onerror="this.style.display='none';">
|
||||
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="d-flex flex-wrap justify-content-start">
|
||||
<form action="{{ url_for('edit_content', content_id=media.id) }}" method="post" class="d-flex align-items-center me-2 mb-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">seconds</span>
|
||||
<input type="number" class="form-control {% if theme == 'dark' %}dark-mode{% endif %}" name="duration" value="{{ media.duration }}" {% if player.groups %}disabled{% endif %} required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning ms-2" {% if player.groups %}disabled{% endif %}>Edit</button>
|
||||
</form>
|
||||
<form action="{{ url_for('delete_content', content_id=media.id) }}" method="post" class="mb-2">
|
||||
<button type="submit" class="btn btn-danger" {% if player.groups %}disabled{% endif %} onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<!-- Add a save button for the reordering -->
|
||||
<button id="saveOrder" class="btn btn-success mt-3" {% if player.groups %}disabled{% endif %}>Save Playlist Order</button>
|
||||
{% else %}
|
||||
<p class="text-center">No media uploaded for this player.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
|
||||
<a href="{{ url_for('player_fullscreen', player_id=player.id) }}" class="btn btn-primary">Full Screen</a>
|
||||
<a href="{{ url_for('upload_content', target_type='player', target_id=player.id, return_url=url_for('player_page', player_id=player.id)) }}"
|
||||
class="btn btn-success"
|
||||
{% if player.groups %}disabled onclick="return false;"{% endif %}>
|
||||
{% if player.groups %}Manage Media by Group{% else %}Upload Media{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Only enable if the player is not in a group (if the buttons are not disabled)
|
||||
if (!document.querySelector('#saveOrder').hasAttribute('disabled')) {
|
||||
const mediaList = document.getElementById('mediaList');
|
||||
let draggedItem = null;
|
||||
|
||||
// Initialize drag events for all items
|
||||
const items = mediaList.querySelectorAll('li');
|
||||
items.forEach(item => {
|
||||
// Drag start
|
||||
item.addEventListener('dragstart', function(e) {
|
||||
draggedItem = item;
|
||||
setTimeout(() => {
|
||||
item.classList.add('dragging');
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Drag end
|
||||
item.addEventListener('dragend', function() {
|
||||
item.classList.remove('dragging');
|
||||
draggedItem = null;
|
||||
updatePositions();
|
||||
});
|
||||
|
||||
// Drag over
|
||||
item.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
if (item !== draggedItem) {
|
||||
const rect = item.getBoundingClientRect();
|
||||
const y = e.clientY - rect.top;
|
||||
const height = rect.height;
|
||||
|
||||
if (y < height / 2) {
|
||||
mediaList.insertBefore(draggedItem, item);
|
||||
} else {
|
||||
mediaList.insertBefore(draggedItem, item.nextSibling);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Save button click handler
|
||||
document.getElementById('saveOrder').addEventListener('click', function() {
|
||||
// Collect new order
|
||||
const newOrder = [];
|
||||
mediaList.querySelectorAll('li').forEach((item, index) => {
|
||||
newOrder.push({
|
||||
id: item.dataset.id,
|
||||
position: index
|
||||
});
|
||||
});
|
||||
|
||||
// Send to server
|
||||
fetch('{{ url_for("update_content_order", player_id=player.id) }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}'
|
||||
},
|
||||
body: JSON.stringify({items: newOrder})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('Playlist order updated successfully!');
|
||||
console.log('Playlist version updated to:', data.new_version);
|
||||
} else {
|
||||
alert('Error updating playlist order: ' + (data.error || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while updating the playlist order.');
|
||||
});
|
||||
});
|
||||
|
||||
// Update positions in the UI
|
||||
function updatePositions() {
|
||||
mediaList.querySelectorAll('li').forEach((item, index) => {
|
||||
item.dataset.position = index;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,251 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Upload Content</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body.dark-mode {
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
}
|
||||
.card.dark-mode {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
}
|
||||
.dark-mode label, .dark-mode th, .dark-mode td {
|
||||
color: #ffffff;
|
||||
}
|
||||
.logo {
|
||||
max-height: 100px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
/* Modal styling for dark mode */
|
||||
.modal-content.dark-mode {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
}
|
||||
.modal-header.dark-mode {
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
.modal-footer.dark-mode {
|
||||
border-top: 1px solid #444;
|
||||
}
|
||||
.progress-bar {
|
||||
background-color: #007bff;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.btn {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-start align-items-center mb-4">
|
||||
{% if logo_exists %}
|
||||
<img src="{{ url_for('static', filename='uploads/logo.png') }}" alt="Logo" class="logo">
|
||||
{% endif %}
|
||||
<h1 class="mb-0">Upload Content</h1>
|
||||
</div>
|
||||
<form id="upload-form" action="{{ url_for('upload_content') }}" method="post" enctype="multipart/form-data" onsubmit="showStatusModal()">
|
||||
<input type="hidden" name="return_url" value="{{ return_url }}">
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="target_type" class="form-label">Target Type:</label>
|
||||
<select name="target_type" id="target_type" class="form-select" required onchange="updateTargetIdOptions()">
|
||||
<option value="" disabled selected>Select Target Type</option>
|
||||
<option value="player" {% if target_type == 'player' %}selected{% endif %}>Player</option>
|
||||
<option value="group" {% if target_type == 'group' %}selected{% endif %}>Group</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="target_id" class="form-label">Target ID:</label>
|
||||
<select name="target_id" id="target_id" class="form-select" required>
|
||||
{% if target_type == 'player' %}
|
||||
<optgroup label="Players">
|
||||
{% for player in players %}
|
||||
<option value="{{ player.id }}" {% if target_id == player.id %}selected{% endif %}>{{ player.username }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% elif target_type == 'group' %}
|
||||
<optgroup label="Groups">
|
||||
{% for group in groups %}
|
||||
<option value="{{ group.id }}" {% if target_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
{% else %}
|
||||
<option value="" disabled selected>Select a Target ID</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="media_type" class="form-label">Media Type:</label>
|
||||
<select name="media_type" id="media_type" class="form-select" required>
|
||||
<option value="image">Image</option>
|
||||
<option value="video">Video</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="ppt">PPT/PPTX</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="files" class="form-label">Files:</label>
|
||||
<input type="file" name="files" id="files" class="form-control" multiple required onchange="handleFileChange()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="duration" class="form-label">Duration (seconds):</label>
|
||||
<input type="number" name="duration" id="duration" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<button type="submit" id="submit-button" class="btn btn-primary">Upload</button>
|
||||
<a href="{{ return_url }}" class="btn btn-secondary mt-3">Back</a>
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Modal for Status Updates -->
|
||||
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<div class="modal-header {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<h5 class="modal-title" id="statusModalLabel">Processing Files</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="status-message">Uploading and processing your files. Please wait...</p>
|
||||
<div class="progress">
|
||||
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" disabled>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
function updateTargetIdOptions() {
|
||||
const targetType = document.getElementById('target_type').value;
|
||||
const targetIdSelect = document.getElementById('target_id');
|
||||
targetIdSelect.innerHTML = ''; // Clear existing options
|
||||
|
||||
if (targetType === 'player') {
|
||||
const players = {{ players|tojson }};
|
||||
const optgroup = document.createElement('optgroup');
|
||||
optgroup.label = 'Players';
|
||||
players.forEach(player => {
|
||||
const option = document.createElement('option');
|
||||
option.value = player.id;
|
||||
option.textContent = player.username;
|
||||
optgroup.appendChild(option);
|
||||
});
|
||||
targetIdSelect.appendChild(optgroup);
|
||||
} else if (targetType === 'group') {
|
||||
const groups = {{ groups|tojson }};
|
||||
const optgroup = document.createElement('optgroup');
|
||||
optgroup.label = 'Groups';
|
||||
groups.forEach(group => {
|
||||
const option = document.createElement('option');
|
||||
option.value = group.id;
|
||||
option.textContent = group.name;
|
||||
optgroup.appendChild(option);
|
||||
});
|
||||
targetIdSelect.appendChild(optgroup);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange() {
|
||||
const mediaType = document.getElementById('media_type').value;
|
||||
const filesInput = document.getElementById('files');
|
||||
const durationInput = document.getElementById('duration');
|
||||
|
||||
if (mediaType === 'video' && filesInput.files.length > 0) {
|
||||
const file = filesInput.files[0];
|
||||
const video = document.createElement('video');
|
||||
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = function () {
|
||||
window.URL.revokeObjectURL(video.src);
|
||||
const duration = Math.round(video.duration);
|
||||
durationInput.value = duration; // Set the duration in the input field
|
||||
};
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function showStatusModal() {
|
||||
console.log("Processing popup triggered");
|
||||
const statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
|
||||
statusModal.show();
|
||||
|
||||
// Update status message based on media type
|
||||
const mediaType = document.getElementById('media_type').value;
|
||||
const statusMessage = document.getElementById('status-message');
|
||||
|
||||
switch(mediaType) {
|
||||
case 'image':
|
||||
statusMessage.textContent = 'Uploading images...';
|
||||
break;
|
||||
case 'video':
|
||||
statusMessage.textContent = 'Uploading and processing video. This may take a while...';
|
||||
break;
|
||||
case 'pdf':
|
||||
statusMessage.textContent = 'Converting PDF to images. This may take a while...';
|
||||
break;
|
||||
case 'ppt':
|
||||
statusMessage.textContent = 'Converting PowerPoint to images. This may take a while...';
|
||||
break;
|
||||
default:
|
||||
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
|
||||
}
|
||||
|
||||
// Simulate progress updates
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {
|
||||
// For slow processes, increment more slowly
|
||||
const increment = (mediaType === 'image') ? 20 : 5;
|
||||
progress += increment;
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(interval);
|
||||
statusMessage.textContent = 'Files uploaded and processed successfully!';
|
||||
|
||||
// Enable the close button
|
||||
document.querySelector('[data-bs-dismiss="modal"]').disabled = false;
|
||||
} else {
|
||||
progressBar.style.width = `${progress}%`;
|
||||
progressBar.setAttribute('aria-valuenow', progress);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
358
utils/uploads.py
358
utils/uploads.py
@@ -1,358 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
from flask import Flask
|
||||
from werkzeug.utils import secure_filename
|
||||
from pdf2image import convert_from_path
|
||||
from extensions import db
|
||||
from models import Content, Player, Group
|
||||
from utils.logger import log_content_added, log_upload, log_process
|
||||
|
||||
# Function to add image to playlist
|
||||
def add_image_to_playlist(app, file, filename, duration, target_type, target_id):
|
||||
"""
|
||||
Save the image file and add it to the playlist database.
|
||||
"""
|
||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
# Only save if file does not already exist
|
||||
if not os.path.exists(file_path):
|
||||
file.save(file_path)
|
||||
|
||||
print(f"Adding image to playlist: {filename}, Target Type: {target_type}, Target ID: {target_id}")
|
||||
|
||||
if target_type == 'group':
|
||||
group = Group.query.get_or_404(target_id)
|
||||
for player in group.players:
|
||||
new_content = Content(file_name=filename, duration=duration, player_id=player.id)
|
||||
db.session.add(new_content)
|
||||
log_content_added(filename, target_type, group.name)
|
||||
elif target_type == 'player':
|
||||
player = Player.query.get_or_404(target_id)
|
||||
new_content = Content(file_name=filename, duration=duration, player_id=target_id)
|
||||
db.session.add(new_content)
|
||||
log_content_added(filename, target_type, player.username)
|
||||
|
||||
db.session.commit()
|
||||
log_upload('image', filename, target_type, target_id)
|
||||
return True
|
||||
|
||||
# Video conversion functions
|
||||
def convert_video(input_file, output_folder):
|
||||
"""
|
||||
Converts a video file to MP4 format with H.264 codec.
|
||||
"""
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder)
|
||||
|
||||
# Generate the output file path
|
||||
base_name = os.path.splitext(os.path.basename(input_file))[0]
|
||||
output_file = os.path.join(output_folder, f"{base_name}.mp4")
|
||||
|
||||
# FFmpeg command to convert the video
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-i", input_file, # Input file
|
||||
"-c:v", "libx264", # Video codec: H.264
|
||||
"-preset", "fast", # Encoding speed/quality tradeoff
|
||||
"-crf", "23", # Constant Rate Factor (quality, lower is better)
|
||||
"-vf", "scale=-1:1080", # Scale video to 1080p (preserve aspect ratio)
|
||||
"-r", "30", # Frame rate: 30 FPS
|
||||
"-c:a", "aac", # Audio codec: AAC
|
||||
"-b:a", "128k", # Audio bitrate
|
||||
output_file # Output file
|
||||
]
|
||||
|
||||
try:
|
||||
# Run the FFmpeg command
|
||||
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
print(f"Video converted successfully: {output_file}")
|
||||
return output_file
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error converting video: {e.stderr.decode()}")
|
||||
return None
|
||||
|
||||
def convert_video_and_update_playlist(app, file_path, original_filename, target_type, target_id, duration):
|
||||
"""
|
||||
Converts a video and updates the playlist database.
|
||||
"""
|
||||
print(f"Starting video conversion for: {file_path}")
|
||||
converted_file = convert_video(file_path, app.config['UPLOAD_FOLDER'])
|
||||
if converted_file:
|
||||
converted_filename = os.path.basename(converted_file)
|
||||
print(f"Video converted successfully: {converted_filename}")
|
||||
|
||||
# Use the application context to interact with the database
|
||||
with app.app_context():
|
||||
# Update the database with the converted filename
|
||||
if target_type == 'group':
|
||||
group = Group.query.get_or_404(target_id)
|
||||
for player in group.players:
|
||||
content = Content.query.filter_by(player_id=player.id, file_name=original_filename).first()
|
||||
if content:
|
||||
content.file_name = converted_filename
|
||||
elif target_type == 'player':
|
||||
content = Content.query.filter_by(player_id=target_id, file_name=original_filename).first()
|
||||
if content:
|
||||
content.file_name = converted_filename
|
||||
|
||||
db.session.commit()
|
||||
print(f"Database updated with converted video: {converted_filename}")
|
||||
|
||||
# Delete the original file only if it exists
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
print(f"Original file deleted: {file_path}")
|
||||
else:
|
||||
print(f"Video conversion failed for: {file_path}")
|
||||
|
||||
# PDF conversion functions
|
||||
def convert_pdf_to_images(pdf_file, output_folder, delete_pdf=True, dpi=600):
|
||||
"""
|
||||
Convert a PDF file to images in sequential order at high resolution (4K).
|
||||
"""
|
||||
print(f"Converting PDF to images: {pdf_file} at {dpi} DPI")
|
||||
try:
|
||||
# Convert PDF to images
|
||||
images = convert_from_path(pdf_file, dpi=dpi)
|
||||
base_name = os.path.splitext(os.path.basename(pdf_file))[0]
|
||||
image_filenames = []
|
||||
|
||||
# Save each page as an image with zero-padded page numbers for proper sorting
|
||||
for i, image in enumerate(images):
|
||||
# Use consistent naming with zero-padded page numbers (e.g., page_001.jpg)
|
||||
page_num = str(i + 1).zfill(3) # e.g., 001, 002, etc.
|
||||
image_filename = f"{base_name}_page_{page_num}.jpg"
|
||||
image_path = os.path.join(output_folder, image_filename)
|
||||
image.save(image_path, 'JPEG')
|
||||
image_filenames.append(image_filename)
|
||||
print(f"Saved page {i + 1} as image: {image_path}")
|
||||
|
||||
# Verify all pages were saved
|
||||
print(f"PDF conversion complete. {len(image_filenames)} pages saved.")
|
||||
print(f"Images in order: {image_filenames}")
|
||||
|
||||
# Delete the PDF file if requested
|
||||
if delete_pdf and os.path.exists(pdf_file):
|
||||
os.remove(pdf_file)
|
||||
print(f"PDF file deleted: {pdf_file}")
|
||||
|
||||
return image_filenames
|
||||
except Exception as e:
|
||||
print(f"Error converting PDF to images: {e}")
|
||||
return []
|
||||
|
||||
def update_playlist_with_files(image_filenames, duration, target_type, target_id):
|
||||
"""
|
||||
Add files to a player or group playlist and update version numbers.
|
||||
|
||||
Args:
|
||||
image_filenames (list): List of filenames to add to playlist
|
||||
duration (int): Duration in seconds for each file
|
||||
target_type (str): 'player' or 'group'
|
||||
target_id (int): ID of the player or group
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if target_type == 'group':
|
||||
group = Group.query.get_or_404(target_id)
|
||||
for player in group.players:
|
||||
for image_filename in image_filenames:
|
||||
new_content = Content(file_name=image_filename, duration=duration, player_id=player.id)
|
||||
db.session.add(new_content)
|
||||
player.playlist_version += 1
|
||||
group.playlist_version += 1
|
||||
elif target_type == 'player':
|
||||
player = Player.query.get_or_404(target_id)
|
||||
for image_filename in image_filenames:
|
||||
new_content = Content(file_name=image_filename, duration=duration, player_id=target_id)
|
||||
db.session.add(new_content)
|
||||
player.playlist_version += 1
|
||||
else:
|
||||
print(f"Invalid target type: {target_type}")
|
||||
return False
|
||||
|
||||
db.session.commit()
|
||||
print(f"Added {len(image_filenames)} files to playlist")
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error updating playlist: {e}")
|
||||
return False
|
||||
|
||||
def process_pdf(input_file, output_folder, duration, target_type, target_id):
|
||||
"""
|
||||
Process a PDF file: convert to images and update playlist.
|
||||
|
||||
Args:
|
||||
input_file (str): Path to the PDF file
|
||||
output_folder (str): Path to save the images
|
||||
duration (int): Duration in seconds for each image
|
||||
target_type (str): 'player' or 'group'
|
||||
target_id (int): ID of the player or group
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
# Ensure output folder exists
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder)
|
||||
|
||||
# Convert PDF to images
|
||||
image_filenames = convert_pdf_to_images(input_file, output_folder)
|
||||
|
||||
# Update playlist with generated images
|
||||
if image_filenames:
|
||||
return update_playlist_with_files(image_filenames, duration, target_type, target_id)
|
||||
return False
|
||||
|
||||
def process_pptx(input_file, output_folder, duration, target_type, target_id):
|
||||
"""
|
||||
Process a PPTX file: convert to PDF, then to images, and update playlist in sequential order.
|
||||
|
||||
Args:
|
||||
input_file (str): Path to the PPTX file
|
||||
output_folder (str): Path to save the images
|
||||
duration (int): Duration in seconds for each image
|
||||
target_type (str): 'player' or 'group'
|
||||
target_id (int): ID of the player or group
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
# Ensure output folder exists
|
||||
if not os.path.exists(output_folder):
|
||||
os.makedirs(output_folder)
|
||||
|
||||
# Step 1: Convert PPTX to PDF using LibreOffice
|
||||
pdf_file = os.path.join(output_folder, os.path.splitext(os.path.basename(input_file))[0] + ".pdf")
|
||||
command = [
|
||||
'libreoffice',
|
||||
'--headless',
|
||||
'--convert-to', 'pdf',
|
||||
'--outdir', output_folder,
|
||||
'--printer-resolution', '600',
|
||||
input_file
|
||||
]
|
||||
|
||||
print(f"Running LibreOffice command: {' '.join(command)}")
|
||||
try:
|
||||
result = subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
print(f"PPTX file converted to PDF: {pdf_file}")
|
||||
print(f"LibreOffice output: {result.stdout.decode()}")
|
||||
print(f"LibreOffice errors (if any): {result.stderr.decode()}")
|
||||
|
||||
# Step 2: Convert PDF to images and update playlist
|
||||
image_filenames = convert_pdf_to_images(pdf_file, output_folder, True, dpi=600)
|
||||
|
||||
# Verify we got images
|
||||
if not image_filenames:
|
||||
print("Error: No images were generated from the PDF")
|
||||
return False
|
||||
|
||||
print(f"Generated {len(image_filenames)} images for PPTX")
|
||||
|
||||
# Step 3: Delete the original PPTX file
|
||||
if os.path.exists(input_file):
|
||||
os.remove(input_file)
|
||||
print(f"Original PPTX file deleted: {input_file}")
|
||||
|
||||
# Step 4: Update playlist with generated images in sequential order
|
||||
return update_playlist_with_files(image_filenames, duration, target_type, target_id)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error converting PPTX to PDF: {e.stderr.decode() if hasattr(e, 'stderr') else str(e)}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error processing PPTX file: {e}")
|
||||
return False
|
||||
|
||||
def process_uploaded_files(app, files, media_type, duration, target_type, target_id):
|
||||
"""
|
||||
Process uploaded files based on media type and add them to playlists.
|
||||
|
||||
Returns:
|
||||
list: List of result dictionaries with success status and messages
|
||||
"""
|
||||
results = []
|
||||
|
||||
# Get target name for logging
|
||||
target_name = ""
|
||||
if target_type == 'group':
|
||||
group = Group.query.get_or_404(target_id)
|
||||
target_name = group.name
|
||||
elif target_type == 'player':
|
||||
player = Player.query.get_or_404(target_id)
|
||||
target_name = player.username
|
||||
|
||||
for file in files:
|
||||
try:
|
||||
# Generate a secure filename and save the file
|
||||
filename = secure_filename(file.filename)
|
||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
file.save(file_path)
|
||||
|
||||
print(f"Processing file: {filename}, Media Type: {media_type}")
|
||||
result = {'filename': filename, 'success': True, 'message': ''}
|
||||
|
||||
if media_type == 'image':
|
||||
add_image_to_playlist(app, file, filename, duration, target_type, target_id)
|
||||
result['message'] = f"Image {filename} added to playlist"
|
||||
log_upload('image', filename, target_type, target_id)
|
||||
|
||||
elif media_type == 'video':
|
||||
# For videos, add to playlist then start conversion in background
|
||||
if target_type == 'group':
|
||||
group = Group.query.get_or_404(target_id)
|
||||
for player in group.players:
|
||||
new_content = Content(file_name=filename, duration=duration, player_id=player.id)
|
||||
db.session.add(new_content)
|
||||
player.playlist_version += 1
|
||||
group.playlist_version += 1
|
||||
elif target_type == 'player':
|
||||
player = Player.query.get_or_404(target_id)
|
||||
new_content = Content(file_name=filename, duration=duration, player_id=target_id)
|
||||
db.session.add(new_content)
|
||||
player.playlist_version += 1
|
||||
|
||||
db.session.commit()
|
||||
# Start background conversion
|
||||
import threading
|
||||
threading.Thread(target=convert_video_and_update_playlist,
|
||||
args=(app, file_path, filename, target_type, target_id, duration)).start()
|
||||
result['message'] = f"Video {filename} added to playlist and being processed"
|
||||
log_upload('video', filename, target_type, target_id)
|
||||
|
||||
elif media_type == 'pdf':
|
||||
# For PDFs, convert to images and update playlist
|
||||
success = process_pdf(file_path, app.config['UPLOAD_FOLDER'],
|
||||
duration, target_type, target_id)
|
||||
if success:
|
||||
result['message'] = f"PDF {filename} processed successfully"
|
||||
log_process('pdf', filename, target_type, target_id)
|
||||
else:
|
||||
result['success'] = False
|
||||
result['message'] = f"Error processing PDF file: {filename}"
|
||||
|
||||
elif media_type == 'ppt':
|
||||
# For PPT/PPTX, convert to PDF, then to images, and update playlist
|
||||
success = process_pptx(file_path, app.config['UPLOAD_FOLDER'],
|
||||
duration, target_type, target_id)
|
||||
if success:
|
||||
result['message'] = f"PowerPoint {filename} processed successfully"
|
||||
log_process('ppt', filename, target_type, target_id)
|
||||
else:
|
||||
result['success'] = False
|
||||
result['message'] = f"Error processing PowerPoint file: {filename}"
|
||||
|
||||
results.append(result)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing file {file.filename}: {e}")
|
||||
results.append({
|
||||
'filename': file.filename,
|
||||
'success': False,
|
||||
'message': f"Error processing file {file.filename}: {str(e)}"
|
||||
})
|
||||
|
||||
return results
|
||||
Reference in New Issue
Block a user