diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6f78cc3 --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/.env.example b/.env.example index 703e8a4..ea755a4 100644 --- a/.env.example +++ b/.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 \ No newline at end of file +# Optional: External Database (for advanced users) +# DATABASE_URL=postgresql://user:password@localhost/digiserver +# DATABASE_URL=mysql://user:password@localhost/digiserver \ No newline at end of file diff --git a/.gitignore b/.gitignore index 374a490..ef346a7 100755 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,41 @@ -digiscreen/ -.env +# 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/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..0d5bfa1 --- /dev/null +++ b/DEPLOYMENT.md @@ -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! ๐Ÿš€ diff --git a/Dockerfile b/Dockerfile index 7b78d42..7256892 100644 --- a/Dockerfile +++ b/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"] \ No newline at end of file +ENTRYPOINT ["./entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3aaf712 --- /dev/null +++ b/README.md @@ -0,0 +1,258 @@ +# DigiServer - Digital Signage Management Platform + +![Version](https://img.shields.io/badge/version-1.1.0-blue.svg) +![Python](https://img.shields.io/badge/python-3.11-green.svg) +![Flask](https://img.shields.io/badge/flask-3.0-red.svg) +![Docker](https://img.shields.io/badge/docker-supported-blue.svg) + +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 + 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. diff --git a/__pycache__/app.cpython-311.pyc b/__pycache__/app.cpython-311.pyc deleted file mode 100644 index b0108ec..0000000 Binary files a/__pycache__/app.cpython-311.pyc and /dev/null differ diff --git a/__pycache__/app.cpython-312.pyc b/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 7cfd5c3..0000000 Binary files a/__pycache__/app.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/extensions.cpython-311.pyc b/__pycache__/extensions.cpython-311.pyc deleted file mode 100644 index 05e2225..0000000 Binary files a/__pycache__/extensions.cpython-311.pyc and /dev/null differ diff --git a/__pycache__/extensions.cpython-312.pyc b/__pycache__/extensions.cpython-312.pyc deleted file mode 100644 index b6757c2..0000000 Binary files a/__pycache__/extensions.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/models.cpython-311.pyc b/__pycache__/models.cpython-311.pyc deleted file mode 100644 index 30219bd..0000000 Binary files a/__pycache__/models.cpython-311.pyc and /dev/null differ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc deleted file mode 100644 index eb7ec95..0000000 Binary files a/__pycache__/models.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/pptx_to_images.cpython-311.pyc b/__pycache__/pptx_to_images.cpython-311.pyc deleted file mode 100644 index 519560f..0000000 Binary files a/__pycache__/pptx_to_images.cpython-311.pyc and /dev/null differ diff --git a/__pycache__/pptx_to_images.cpython-312.pyc b/__pycache__/pptx_to_images.cpython-312.pyc deleted file mode 100644 index 4ae4875..0000000 Binary files a/__pycache__/pptx_to_images.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/server_logger.cpython-311.pyc b/__pycache__/server_logger.cpython-311.pyc deleted file mode 100644 index a9993f9..0000000 Binary files a/__pycache__/server_logger.cpython-311.pyc and /dev/null differ diff --git a/__pycache__/upload_utils.cpython-311.pyc b/__pycache__/upload_utils.cpython-311.pyc deleted file mode 100644 index 9f30863..0000000 Binary files a/__pycache__/upload_utils.cpython-311.pyc and /dev/null differ diff --git a/app.py b/app/app.py similarity index 76% rename from app.py rename to app/app.py index 8f886ea..48d2119 100755 --- a/app.py +++ b/app/app.py @@ -1,6 +1,10 @@ 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 +50,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') @@ -95,6 +107,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(): @@ -167,9 +225,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 +239,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/', methods=['POST']) @@ -266,6 +332,61 @@ def delete_content(content_id): db.session.commit() return redirect(url_for('player_page', player_id=player_id)) +@app.route('/player//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//fullscreen', methods=['GET', 'POST']) def player_fullscreen(player_id): player = Player.query.get_or_404(player_id) @@ -404,11 +525,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') @@ -566,6 +691,63 @@ def delete_group_media_route(group_id, content_id): return redirect(url_for('manage_group', group_id=group_id)) +@app.route('/group//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') @@ -586,6 +768,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//update_order', methods=['POST']) @login_required def update_content_order(player_id): diff --git a/entrypoint.sh b/app/entrypoint.sh similarity index 100% rename from entrypoint.sh rename to app/entrypoint.sh diff --git a/extensions.py b/app/extensions.py similarity index 100% rename from extensions.py rename to app/extensions.py diff --git a/models/__init__.py b/app/models/__init__.py similarity index 100% rename from models/__init__.py rename to app/models/__init__.py diff --git a/models/clear_db.py b/app/models/clear_db.py similarity index 100% rename from models/clear_db.py rename to app/models/clear_db.py diff --git a/models/content.py b/app/models/content.py similarity index 100% rename from models/content.py rename to app/models/content.py diff --git a/models/create_default_user.py b/app/models/create_default_user.py similarity index 100% rename from models/create_default_user.py rename to app/models/create_default_user.py diff --git a/models/group.py b/app/models/group.py similarity index 100% rename from models/group.py rename to app/models/group.py diff --git a/models/init_db.py b/app/models/init_db.py similarity index 100% rename from models/init_db.py rename to app/models/init_db.py diff --git a/models/player.py b/app/models/player.py similarity index 100% rename from models/player.py rename to app/models/player.py diff --git a/models/server_log.py b/app/models/server_log.py similarity index 100% rename from models/server_log.py rename to app/models/server_log.py diff --git a/models/user.py b/app/models/user.py similarity index 100% rename from models/user.py rename to app/models/user.py diff --git a/requirements.txt b/app/requirements.txt similarity index 97% rename from requirements.txt rename to app/requirements.txt index 3e41b2d..c236af1 100755 --- a/requirements.txt +++ b/app/requirements.txt @@ -21,7 +21,6 @@ greenlet==3.1.1 # 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 +38,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 diff --git a/static/resurse/login_picture.png b/app/static/resurse/login_picture.png similarity index 100% rename from static/resurse/login_picture.png rename to app/static/resurse/login_picture.png diff --git a/static/resurse/logo.png b/app/static/resurse/logo.png similarity index 100% rename from static/resurse/logo.png rename to app/static/resurse/logo.png diff --git a/templates/add_player.html b/app/templates/add_player.html similarity index 100% rename from templates/add_player.html rename to app/templates/add_player.html diff --git a/templates/admin.html b/app/templates/admin.html similarity index 54% rename from templates/admin.html rename to app/templates/admin.html index 7d5e627..bcb7f5b 100644 --- a/templates/admin.html +++ b/app/templates/admin.html @@ -207,6 +207,94 @@

Date of Build: {{ build_date }}

+ + + {% if system_info %} +
+
+

๐Ÿ“Š System Monitoring

+
+
+
+ +
+
CPU Usage
+
+
+ {{ system_info.cpu_percent }}% +
+
+ {{ system_info.cpu_count }} cores available +
+ + +
+
Memory Usage
+
+
+ {{ system_info.memory_percent }}% +
+
+ {{ system_info.memory_used }}GB / {{ system_info.memory_total }}GB +
+ + +
+
Disk Usage
+
+
+ {{ system_info.disk_percent }}% +
+
+ {{ system_info.disk_used }}GB / {{ system_info.disk_total }}GB +
+ + +
+
Media Storage
+
{{ system_info.upload_folder_size }}GB
+ Total media files +
+
+ + +
+
+
+
+
+ Available Disk Space:
+ {{ system_info.disk_free }}GB free +
+
+ Total Disk Space:
+ {{ system_info.disk_total }}GB total +
+
+ Last Updated:
+ Just now +
+
+
+
+
+
+ {% endif %} @@ -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 %} diff --git a/templates/create_group.html b/app/templates/create_group.html similarity index 100% rename from templates/create_group.html rename to app/templates/create_group.html diff --git a/templates/dashboard.html b/app/templates/dashboard.html similarity index 100% rename from templates/dashboard.html rename to app/templates/dashboard.html diff --git a/templates/edit_group.html b/app/templates/edit_group.html similarity index 100% rename from templates/edit_group.html rename to app/templates/edit_group.html diff --git a/templates/edit_player.html b/app/templates/edit_player.html similarity index 100% rename from templates/edit_player.html rename to app/templates/edit_player.html diff --git a/templates/group_fullscreen.html b/app/templates/group_fullscreen.html similarity index 100% rename from templates/group_fullscreen.html rename to app/templates/group_fullscreen.html diff --git a/templates/login.html b/app/templates/login.html similarity index 100% rename from templates/login.html rename to app/templates/login.html diff --git a/templates/manage_group.html b/app/templates/manage_group.html similarity index 70% rename from templates/manage_group.html rename to app/templates/manage_group.html index aaaf8a8..6bf4cb6 100644 --- a/templates/manage_group.html +++ b/app/templates/manage_group.html @@ -91,12 +91,37 @@
{% if content %} + +
+
+
+ + +
+
+
+ +
+
+
    {% for media in content %}
  • + +
    + +
    +
    @@ -219,7 +244,75 @@ document.addEventListener('DOMContentLoaded', function() { 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(); + } +} \ No newline at end of file diff --git a/templates/player_auth.html b/app/templates/player_auth.html similarity index 100% rename from templates/player_auth.html rename to app/templates/player_auth.html diff --git a/templates/player_fullscreen.html b/app/templates/player_fullscreen.html similarity index 100% rename from templates/player_fullscreen.html rename to app/templates/player_fullscreen.html diff --git a/templates/player_page.html b/app/templates/player_page.html similarity index 70% rename from templates/player_page.html rename to app/templates/player_page.html index 3ba5814..5bbee16 100644 --- a/templates/player_page.html +++ b/app/templates/player_page.html @@ -93,6 +93,28 @@
    {% if content %} + +
    +
    +
    + + +
    +
    +
    + +
    +
    + + + +
      {% for media in content %}
    • + +
      + +
      +
      @@ -235,7 +266,78 @@ document.addEventListener('DOMContentLoaded', function() { }); } } + + // 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(); + } +} \ No newline at end of file diff --git a/templates/register.html b/app/templates/register.html similarity index 100% rename from templates/register.html rename to app/templates/register.html diff --git a/app/templates/upload_content.html b/app/templates/upload_content.html new file mode 100644 index 0000000..8f30906 --- /dev/null +++ b/app/templates/upload_content.html @@ -0,0 +1,463 @@ + + + + + + Upload Content + + + + +
      +
      + {% if logo_exists %} + + {% endif %} +

      Upload Content

      +
      +
      + +
      +
      +
      + + +
      +
      +
      +
      + + +
      +
      +
      +
      +
      +
      + + +
      +
      +
      +
      + + +
      +
      +
      +
      +
      +
      + + +
      +
      +
      +
      + + Back + Back to Dashboard +
      +
      + + + +
      + + + + + diff --git a/utils/__init__.py b/app/utils/__init__.py similarity index 100% rename from utils/__init__.py rename to app/utils/__init__.py diff --git a/utils/group_player_management.py b/app/utils/group_player_management.py similarity index 94% rename from utils/group_player_management.py rename to app/utils/group_player_management.py index b0e34b7..b87a248 100644 --- a/utils/group_player_management.py +++ b/app/utils/group_player_management.py @@ -333,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 @@ -344,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) @@ -358,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 @@ -366,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 \ No newline at end of file diff --git a/utils/logger.py b/app/utils/logger.py similarity index 100% rename from utils/logger.py rename to app/utils/logger.py diff --git a/app/utils/pptx_converter.py b/app/utils/pptx_converter.py new file mode 100644 index 0000000..e00e31c --- /dev/null +++ b/app/utils/pptx_converter.py @@ -0,0 +1,86 @@ +""" +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 + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +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: + # 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, + pptx_path + ] + + logger.info(f"Converting PPTX to PDF using LibreOffice: {pptx_path}") + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + + if result.returncode != 0: + logger.error(f"LibreOffice conversion failed: {result.stderr}") + 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}") + return pdf_path + else: + logger.error(f"PDF file not found after conversion: {pdf_path}") + return None + + except subprocess.TimeoutExpired: + logger.error("LibreOffice conversion timed out (120s)") + return None + except Exception as e: + logger.error(f"Error in PPTX to PDF conversion: {e}") + 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 ") diff --git a/utils/uploads.py b/app/utils/uploads.py similarity index 63% rename from utils/uploads.py rename to app/utils/uploads.py index 2d0ab53..5dc95bc 100644 --- a/utils/uploads.py +++ b/app/utils/uploads.py @@ -12,10 +12,25 @@ 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) + # Ensure we use absolute path for upload folder + upload_folder = app.config['UPLOAD_FOLDER'] + if not os.path.isabs(upload_folder): + upload_folder = os.path.abspath(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}") @@ -40,12 +55,19 @@ def convert_video(input_file, output_folder): """ Converts a video file to MP4 format with H.264 codec. """ + # Ensure we use absolute path for output folder + if not os.path.isabs(output_folder): + output_folder = os.path.abspath(output_folder) + print(f"Converted output folder to absolute path: {output_folder}") + if not os.path.exists(output_folder): - os.makedirs(output_folder) + os.makedirs(output_folder, exist_ok=True) + print(f"Created output folder: {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") + print(f"Converting video: {input_file} -> {output_file}") # FFmpeg command to convert the video command = [ @@ -75,7 +97,14 @@ def convert_video_and_update_playlist(app, file_path, original_filename, target_ 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']) + + # Ensure we use absolute path for upload folder + upload_folder = app.config['UPLOAD_FOLDER'] + if not os.path.isabs(upload_folder): + upload_folder = os.path.abspath(upload_folder) + print(f"Converted upload folder to absolute path: {upload_folder}") + + converted_file = convert_video(file_path, upload_folder) if converted_file: converted_filename = os.path.basename(converted_file) print(f"Video converted successfully: {converted_filename}") @@ -105,39 +134,79 @@ def convert_video_and_update_playlist(app, file_path, original_filename, target_ 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): +def convert_pdf_to_images(pdf_file, output_folder, delete_pdf=True, dpi=300): """ - Convert a PDF file to images in sequential order at high resolution (4K). + Convert a PDF file to high-quality JPG images in sequential order. + Uses standard 300 DPI for reliable conversion. """ - print(f"Converting PDF to images: {pdf_file} at {dpi} DPI") + print(f"Converting PDF to JPG images: {pdf_file} at {dpi} DPI") + print(f"Original output folder: {output_folder}") + + # Force absolute path resolution to ensure we use the app directory + if not os.path.isabs(output_folder): + # If relative path, resolve from the current working directory + output_folder = os.path.abspath(output_folder) + print(f"Converted relative path to absolute: {output_folder}") + else: + print(f"Using provided absolute path: {output_folder}") + + # Ensure we're using the app static folder, not workspace root + if output_folder.endswith('static/uploads'): + # Check if we're accidentally using workspace root instead of app folder + expected_app_path = '/opt/digiserver/app/static/uploads' + if output_folder != expected_app_path: + print(f"WARNING: Correcting path from {output_folder} to {expected_app_path}") + output_folder = expected_app_path + + print(f"Final output folder: {output_folder}") + try: - # Convert PDF to images + # 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 an image with zero-padded page numbers for proper sorting + # Save each page as JPG image for i, image in enumerate(images): - # Use consistent naming with zero-padded page numbers (e.g., page_001.jpg) + # 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) - image.save(image_path, 'JPEG') + + # Save as JPG + image.save(image_path, 'JPEG', quality=85, optimize=True) image_filenames.append(image_filename) - print(f"Saved page {i + 1} as image: {image_path}") + print(f"Saved page {i + 1} to: {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): + 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 images: {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): @@ -194,21 +263,35 @@ def process_pdf(input_file, output_folder, duration, target_type, target_id): Returns: bool: True if successful, False otherwise """ + print(f"Processing PDF file: {input_file}") + print(f"Output folder: {output_folder}") + + # Ensure we have absolute path for output folder + if not os.path.isabs(output_folder): + output_folder = os.path.abspath(output_folder) + print(f"Converted output folder to absolute path: {output_folder}") + # Ensure output folder exists if not os.path.exists(output_folder): - os.makedirs(output_folder) + os.makedirs(output_folder, exist_ok=True) + print(f"Created output folder: {output_folder}") - # Convert PDF to images - image_filenames = convert_pdf_to_images(input_file, 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 playlist with generated images if image_filenames: - return update_playlist_with_files(image_filenames, duration, target_type, target_id) - return False + 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") + return success + else: + print("Failed to convert PDF to images") + 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. + 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 @@ -220,51 +303,55 @@ def process_pptx(input_file, output_folder, duration, target_type, target_id): Returns: bool: True if successful, False otherwise """ + print(f"Processing PPTX file using PDF workflow: {input_file}") + print(f"Output folder: {output_folder}") + + # Ensure we have absolute path for output folder + if not os.path.isabs(output_folder): + output_folder = os.path.abspath(output_folder) + print(f"Converted output folder to absolute path: {output_folder}") + # Ensure output folder exists if not os.path.exists(output_folder): - os.makedirs(output_folder) + os.makedirs(output_folder, exist_ok=True) + print(f"Created output folder: {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 1: Convert PPTX to PDF using LibreOffice for vector quality + from utils.pptx_converter import pptx_to_pdf_libreoffice + pdf_file = pptx_to_pdf_libreoffice(input_file, output_folder) - # 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") + if not pdf_file: + print("Error: Failed to convert PPTX to PDF") return False - print(f"Generated {len(image_filenames)} images for PPTX") + print(f"PPTX successfully converted to PDF: {pdf_file}") - # Step 3: Delete the original PPTX file + # Step 2: Use the same PDF to images workflow as direct PDF uploads + # 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") + 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}") # Step 4: Update playlist with generated images in sequential order - return update_playlist_with_files(image_filenames, duration, target_type, target_id) + 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") + return success - 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}") + import traceback + traceback.print_exc() return False def process_uploaded_files(app, files, media_type, duration, target_type, target_id): @@ -289,8 +376,20 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target try: # Generate a secure filename and save the file filename = secure_filename(file.filename) - file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + + # Ensure we use absolute path for upload folder + upload_folder = app.config['UPLOAD_FOLDER'] + if not os.path.isabs(upload_folder): + upload_folder = os.path.abspath(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': ''} @@ -316,7 +415,7 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target player.playlist_version += 1 db.session.commit() - # Start background conversion + # Start background conversion using absolute path import threading threading.Thread(target=convert_video_and_update_playlist, args=(app, file_path, filename, target_type, target_id, duration)).start() @@ -324,8 +423,8 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target 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'], + # For PDFs, convert to images and update playlist using absolute path + success = process_pdf(file_path, upload_folder, duration, target_type, target_id) if success: result['message'] = f"PDF {filename} processed successfully" @@ -335,8 +434,8 @@ def process_uploaded_files(app, files, media_type, duration, target_type, target 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'], + # 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) if success: result['message'] = f"PowerPoint {filename} processed successfully" diff --git a/cleanup-docker.sh b/cleanup-docker.sh new file mode 100755 index 0000000..dc6dc3e --- /dev/null +++ b/cleanup-docker.sh @@ -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" diff --git a/deploy-docker.sh b/deploy-docker.sh new file mode 100755 index 0000000..d178ab8 --- /dev/null +++ b/deploy-docker.sh @@ -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! ๐ŸŽ‰" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..13508fb --- /dev/null +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index f1ef723..b89bb5b 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,35 @@ -#version: '"1.1.0"' +# DigiServer - Digital Signage Management Platform +# Version: 1.1.0 +# Build Date: 2025-06-29 + services: - web: + digiserver: build: . image: digiserver:latest + container_name: digiserver ports: - "8880:5000" environment: - FLASK_APP=app.py - FLASK_RUN_HOST=0.0.0.0 - - ADMIN_USER=admin - - ADMIN_PASSWORD=Initial01! + - DEFAULT_USER=admin + - DEFAULT_PASSWORD=Initial01! - SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana volumes: - - /opt/digi-s/instance:/app/instance - - /opt/digi-s/uploads:/app/static/uploads + # 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 diff --git a/enviroment.txt b/enviroment.txt deleted file mode 100644 index 4cedbb5..0000000 --- a/enviroment.txt +++ /dev/null @@ -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 \ No newline at end of file diff --git a/models/__pycache__/__init__.cpython-311.pyc b/models/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 1135501..0000000 Binary files a/models/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/models/__pycache__/content.cpython-311.pyc b/models/__pycache__/content.cpython-311.pyc deleted file mode 100644 index d1742f7..0000000 Binary files a/models/__pycache__/content.cpython-311.pyc and /dev/null differ diff --git a/models/__pycache__/create_default_user.cpython-311.pyc b/models/__pycache__/create_default_user.cpython-311.pyc deleted file mode 100644 index 7d01eaa..0000000 Binary files a/models/__pycache__/create_default_user.cpython-311.pyc and /dev/null differ diff --git a/models/__pycache__/group.cpython-311.pyc b/models/__pycache__/group.cpython-311.pyc deleted file mode 100644 index 353c366..0000000 Binary files a/models/__pycache__/group.cpython-311.pyc and /dev/null differ diff --git a/models/__pycache__/player.cpython-311.pyc b/models/__pycache__/player.cpython-311.pyc deleted file mode 100644 index bff7c05..0000000 Binary files a/models/__pycache__/player.cpython-311.pyc and /dev/null differ diff --git a/models/__pycache__/server_log.cpython-311.pyc b/models/__pycache__/server_log.cpython-311.pyc deleted file mode 100644 index daa62a6..0000000 Binary files a/models/__pycache__/server_log.cpython-311.pyc and /dev/null differ diff --git a/models/__pycache__/user.cpython-311.pyc b/models/__pycache__/user.cpython-311.pyc deleted file mode 100644 index 09c1534..0000000 Binary files a/models/__pycache__/user.cpython-311.pyc and /dev/null differ diff --git a/templates/upload_content.html b/templates/upload_content.html deleted file mode 100644 index 9508c25..0000000 --- a/templates/upload_content.html +++ /dev/null @@ -1,251 +0,0 @@ - - - - - - Upload Content - - - - -
      -
      - {% if logo_exists %} - - {% endif %} -

      Upload Content

      -
      -
      - -
      -
      -
      - - -
      -
      -
      -
      - - -
      -
      -
      -
      -
      -
      - - -
      -
      -
      -
      - - -
      -
      -
      -
      -
      -
      - - -
      -
      -
      -
      - - Back - Back to Dashboard -
      -
      - - - -
      - - - - - diff --git a/utils/__pycache__/__init__.cpython-311.pyc b/utils/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index f3fb9d2..0000000 Binary files a/utils/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/utils/__pycache__/__init__.cpython-312.pyc b/utils/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index fd08d1a..0000000 Binary files a/utils/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/utils/__pycache__/group_player_management.cpython-311.pyc b/utils/__pycache__/group_player_management.cpython-311.pyc deleted file mode 100644 index e4c0fbd..0000000 Binary files a/utils/__pycache__/group_player_management.cpython-311.pyc and /dev/null differ diff --git a/utils/__pycache__/group_player_management.cpython-312.pyc b/utils/__pycache__/group_player_management.cpython-312.pyc deleted file mode 100644 index 079f229..0000000 Binary files a/utils/__pycache__/group_player_management.cpython-312.pyc and /dev/null differ diff --git a/utils/__pycache__/logger.cpython-311.pyc b/utils/__pycache__/logger.cpython-311.pyc deleted file mode 100644 index 675508c..0000000 Binary files a/utils/__pycache__/logger.cpython-311.pyc and /dev/null differ diff --git a/utils/__pycache__/logger.cpython-312.pyc b/utils/__pycache__/logger.cpython-312.pyc deleted file mode 100644 index a965d93..0000000 Binary files a/utils/__pycache__/logger.cpython-312.pyc and /dev/null differ diff --git a/utils/__pycache__/uploads.cpython-311.pyc b/utils/__pycache__/uploads.cpython-311.pyc deleted file mode 100644 index 5ebc474..0000000 Binary files a/utils/__pycache__/uploads.cpython-311.pyc and /dev/null differ diff --git a/utils/__pycache__/uploads.cpython-312.pyc b/utils/__pycache__/uploads.cpython-312.pyc deleted file mode 100644 index 423572d..0000000 Binary files a/utils/__pycache__/uploads.cpython-312.pyc and /dev/null differ