From 1eb0aa36587288294980e85e0e7eeea3e1f4b1aa Mon Sep 17 00:00:00 2001 From: ske087 Date: Tue, 5 Aug 2025 18:04:02 -0400 Subject: [PATCH] feat: v1.1.0 - Production-Ready Docker Deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿš€ Major Release: DigiServer v1.1.0 Production Deployment ## ๐Ÿ“ Project Restructure - Moved all application code to app/ directory for Docker containerization - Centralized persistent data in data/ directory with volume mounting - Removed development artifacts and cleaned up project structure ## ๐Ÿณ Docker Integration - Added production-ready Dockerfile with LibreOffice and poppler-utils - Updated docker-compose.yml for production deployment - Added .dockerignore for optimized build context - Created automated deployment script (deploy-docker.sh) - Added cleanup script (cleanup-docker.sh) ## ๐Ÿ“„ Document Processing Enhancements - Integrated LibreOffice for professional PPTX to PDF conversion - Implemented PPTX โ†’ PDF โ†’ 4K JPG workflow for optimal quality - Added poppler-utils for enhanced PDF processing - Simplified PDF conversion to 300 DPI for reliability ## ๐Ÿ”ง File Management Improvements - Fixed absolute path resolution for containerized deployment - Updated all file deletion functions with proper path handling - Enhanced bulk delete functions for players and groups - Improved file upload workflow with consistent path management ## ๐Ÿ› ๏ธ Code Quality & Stability - Cleaned up pptx_converter.py from 442 to 86 lines - Removed all Python cache files (__pycache__/, *.pyc) - Updated file operations for production reliability - Enhanced error handling and logging ## ๐Ÿ“š Documentation Updates - Updated README.md with Docker deployment instructions - Added comprehensive DEPLOYMENT.md guide - Included production deployment best practices - Added automated deployment workflow documentation ## ๐Ÿ” Security & Production Features - Environment-based configuration - Health checks and container monitoring - Automated admin user creation - Volume-mounted persistent data - Production logging and error handling ## โœ… Ready for Production - Clean project structure optimized for Docker - Automated deployment with ./deploy-docker.sh - Professional document processing pipeline - Reliable file management system - Complete documentation and deployment guides Access: http://localhost:8880 | Admin: admin/Initial01! --- .dockerignore | 45 ++ .env.example | 44 +- .gitignore | 41 +- DEPLOYMENT.md | 92 ++++ Dockerfile | 56 ++- README.md | 258 ++++++++++ __pycache__/app.cpython-311.pyc | Bin 43623 -> 0 bytes __pycache__/app.cpython-312.pyc | Bin 36644 -> 0 bytes __pycache__/extensions.cpython-311.pyc | Bin 456 -> 0 bytes __pycache__/extensions.cpython-312.pyc | Bin 371 -> 0 bytes __pycache__/models.cpython-311.pyc | Bin 6419 -> 0 bytes __pycache__/models.cpython-312.pyc | Bin 6257 -> 0 bytes __pycache__/pptx_to_images.cpython-311.pyc | Bin 3659 -> 0 bytes __pycache__/pptx_to_images.cpython-312.pyc | Bin 2375 -> 0 bytes __pycache__/server_logger.cpython-311.pyc | Bin 5019 -> 0 bytes __pycache__/upload_utils.cpython-311.pyc | Bin 17675 -> 0 bytes app.py => app/app.py | 207 +++++++- entrypoint.sh => app/entrypoint.sh | 0 extensions.py => app/extensions.py | 0 {models => app/models}/__init__.py | 0 {models => app/models}/clear_db.py | 0 {models => app/models}/content.py | 0 {models => app/models}/create_default_user.py | 0 {models => app/models}/group.py | 0 {models => app/models}/init_db.py | 0 {models => app/models}/player.py | 0 {models => app/models}/server_log.py | 0 {models => app/models}/user.py | 0 requirements.txt => app/requirements.txt | 2 +- .../static}/resurse/login_picture.png | Bin {static => app/static}/resurse/logo.png | Bin {templates => app/templates}/add_player.html | 0 {templates => app/templates}/admin.html | 159 ++++++ .../templates}/create_group.html | 0 {templates => app/templates}/dashboard.html | 0 {templates => app/templates}/edit_group.html | 0 {templates => app/templates}/edit_player.html | 0 .../templates}/group_fullscreen.html | 0 {templates => app/templates}/login.html | 0 .../templates}/manage_group.html | 93 ++++ {templates => app/templates}/player_auth.html | 0 .../templates}/player_fullscreen.html | 0 {templates => app/templates}/player_page.html | 102 ++++ {templates => app/templates}/register.html | 0 app/templates/upload_content.html | 463 ++++++++++++++++++ {utils => app/utils}/__init__.py | 0 .../utils}/group_player_management.py | 18 +- {utils => app/utils}/logger.py | 0 app/utils/pptx_converter.py | 86 ++++ {utils => app/utils}/uploads.py | 219 ++++++--- cleanup-docker.sh | 64 +++ deploy-docker.sh | 109 +++++ docker-compose.dev.yml | 32 ++ docker-compose.yml | 30 +- enviroment.txt | 25 - models/__pycache__/__init__.cpython-311.pyc | Bin 450 -> 0 bytes models/__pycache__/content.cpython-311.pyc | Bin 1020 -> 0 bytes .../create_default_user.cpython-311.pyc | Bin 1359 -> 0 bytes models/__pycache__/group.cpython-311.pyc | Bin 1448 -> 0 bytes models/__pycache__/player.cpython-311.pyc | Bin 1976 -> 0 bytes models/__pycache__/server_log.cpython-311.pyc | Bin 1058 -> 0 bytes models/__pycache__/user.cpython-311.pyc | Bin 2471 -> 0 bytes templates/upload_content.html | 251 ---------- utils/__pycache__/__init__.cpython-311.pyc | Bin 154 -> 0 bytes utils/__pycache__/__init__.cpython-312.pyc | Bin 138 -> 0 bytes .../group_player_management.cpython-311.pyc | Bin 16093 -> 0 bytes .../group_player_management.cpython-312.pyc | Bin 14260 -> 0 bytes utils/__pycache__/logger.cpython-311.pyc | Bin 6714 -> 0 bytes utils/__pycache__/logger.cpython-312.pyc | Bin 5711 -> 0 bytes utils/__pycache__/uploads.cpython-311.pyc | Bin 17996 -> 0 bytes utils/__pycache__/uploads.cpython-312.pyc | Bin 16791 -> 0 bytes 71 files changed, 2017 insertions(+), 379 deletions(-) create mode 100644 .dockerignore create mode 100644 DEPLOYMENT.md create mode 100644 README.md delete mode 100644 __pycache__/app.cpython-311.pyc delete mode 100644 __pycache__/app.cpython-312.pyc delete mode 100644 __pycache__/extensions.cpython-311.pyc delete mode 100644 __pycache__/extensions.cpython-312.pyc delete mode 100644 __pycache__/models.cpython-311.pyc delete mode 100644 __pycache__/models.cpython-312.pyc delete mode 100644 __pycache__/pptx_to_images.cpython-311.pyc delete mode 100644 __pycache__/pptx_to_images.cpython-312.pyc delete mode 100644 __pycache__/server_logger.cpython-311.pyc delete mode 100644 __pycache__/upload_utils.cpython-311.pyc rename app.py => app/app.py (76%) rename entrypoint.sh => app/entrypoint.sh (100%) rename extensions.py => app/extensions.py (100%) rename {models => app/models}/__init__.py (100%) rename {models => app/models}/clear_db.py (100%) rename {models => app/models}/content.py (100%) rename {models => app/models}/create_default_user.py (100%) rename {models => app/models}/group.py (100%) rename {models => app/models}/init_db.py (100%) rename {models => app/models}/player.py (100%) rename {models => app/models}/server_log.py (100%) rename {models => app/models}/user.py (100%) rename requirements.txt => app/requirements.txt (97%) rename {static => app/static}/resurse/login_picture.png (100%) rename {static => app/static}/resurse/logo.png (100%) rename {templates => app/templates}/add_player.html (100%) rename {templates => app/templates}/admin.html (54%) rename {templates => app/templates}/create_group.html (100%) rename {templates => app/templates}/dashboard.html (100%) rename {templates => app/templates}/edit_group.html (100%) rename {templates => app/templates}/edit_player.html (100%) rename {templates => app/templates}/group_fullscreen.html (100%) rename {templates => app/templates}/login.html (100%) rename {templates => app/templates}/manage_group.html (70%) rename {templates => app/templates}/player_auth.html (100%) rename {templates => app/templates}/player_fullscreen.html (100%) rename {templates => app/templates}/player_page.html (70%) rename {templates => app/templates}/register.html (100%) create mode 100644 app/templates/upload_content.html rename {utils => app/utils}/__init__.py (100%) rename {utils => app/utils}/group_player_management.py (94%) rename {utils => app/utils}/logger.py (100%) create mode 100644 app/utils/pptx_converter.py rename {utils => app/utils}/uploads.py (63%) create mode 100755 cleanup-docker.sh create mode 100755 deploy-docker.sh create mode 100644 docker-compose.dev.yml delete mode 100644 enviroment.txt delete mode 100644 models/__pycache__/__init__.cpython-311.pyc delete mode 100644 models/__pycache__/content.cpython-311.pyc delete mode 100644 models/__pycache__/create_default_user.cpython-311.pyc delete mode 100644 models/__pycache__/group.cpython-311.pyc delete mode 100644 models/__pycache__/player.cpython-311.pyc delete mode 100644 models/__pycache__/server_log.cpython-311.pyc delete mode 100644 models/__pycache__/user.cpython-311.pyc delete mode 100644 templates/upload_content.html delete mode 100644 utils/__pycache__/__init__.cpython-311.pyc delete mode 100644 utils/__pycache__/__init__.cpython-312.pyc delete mode 100644 utils/__pycache__/group_player_management.cpython-311.pyc delete mode 100644 utils/__pycache__/group_player_management.cpython-312.pyc delete mode 100644 utils/__pycache__/logger.cpython-311.pyc delete mode 100644 utils/__pycache__/logger.cpython-312.pyc delete mode 100644 utils/__pycache__/uploads.cpython-311.pyc delete mode 100644 utils/__pycache__/uploads.cpython-312.pyc 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 b0108ecf0e68eec5c8714cea0ac93d374da369a6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43623 zcmeIb3v?S-mKaz73P2HG{{JFDilhXIUr~R_`tl=EBxU_933eN%SP(@CAmsvNNgUG6 zc59|-k7msD$T2e`XE2HFrl-{zCUGasq-R%|-DI^not!G3+9eOp@~n2cl}u+(5<2eA z^g8G4?!E6-6{-pVCAE8Uvgd5Ecu}AC?)%+)-+lMp_v(9IuZw}>(beC+>KV&0IKTw9{PkPn`*QGYByluw@7qXn_TP$9W?M2lj@q2gFcs3cYzDkaaH z(Xv>1sGMAL(TZ4QsM5k%nMmcQkj?L_d1lD_yl7RdI#eCo6xtN43Dv}EL$$F$C=ja) z)sgqEXnkyRXmhL~)DUY7HO87kP2{;dx+S(Xv^5qC1!LPn+hW^8+hfh4=2%Opg}nDf zTVrjZ_E<+~N31inGqx+VJGLjZH?}XdKXxE=Aa*cxF!oaDrP!g+q1fTj;nErVs7JO=j(!f7GLjCZDxpq>$ z=L`8FV~9xqwSn~8gK!Dq2h}IZA@xfCYY1^l|3V}1Lh;Ep(e_)Cin`&vr6);%J8fj zpPBO3f9<@Q&uqCgh0az8(zLHQW_4a`GcHzBmEH*g@s;? zlwNyH4a3*pWsH&_^bLOVT_*H;q(2!}L+O9{24l`5jeMgShY8~wIdL`dTllS`)`vkO zb@JQH7(>H3acs|lqd5b|<(xQLGRo9yj%_N_NKRaB+4B==jJ(dbZ;aKH2A?yH4s(71 z_l_6B9m$Ei6I#S#ZV{6EoiCKam7FQ;%9+CM7fN9?X9|0Arm**gQn;Ekg?;80;P-Eg zIW(3N*MaQ01TD1{q2Q#h3~g_mC_g*S7iaGKweDP8$9X6zv$Cx){|$-|$^h#Ln@ zCZYD({P~RcH*?~CWdlmQV8&zQA>&=BEoT}g&4zH&r5D2eR!-cZ4f6l$3#HMKGmY1B z(#SVnD22|PDZHMIMnKOBZ;U^5D<{S`a^`t>W1OZ|y`3|K%Q;gRd7%{UXrVNj~Y{fk%Q&hj2eQE@#Fl&!yBL4VB`w_^e}~alNTPwOund8$f;qHzo}m7e<82M*M3WV5~((pHu)X(jsBN^%NWXd zXJN*hH#3ph21aNIy$kr?<8PTmkW_zL#c9mBg}DTFq0qapzB|s*-&>Yj%x&8x=9UHG zeLGSdsg9HwYaHwz7W^~TGUUX)ndxBS>@6#ekGL)H2bPiB*NII;sD%2wr{+Ze%jffV z_{oRAk(tt2NbM{Wocb9C9}4EB>^;%p`1O=uh>Y_QA)JWBZbXL@k(5)2ym>PcPoy{@ z!jB1&kwnUQQ;3GIOb97wJQ9zOO^m1PS0M7$l=E7AVtnk%om5dAu!OG&6R|LjGa=jw zT2juzu~7k#!lgeR8M!G$!dJ$kk@4YJ5f9o@R{nCzad||zb0d-RMkhwc z#>27U@!`>k01??l;;S$e4gv<*l#3>C6Qcvp(TSUhFuC#37g&5KHlOl9exY*c zTS(DA8cBqqKJXC)jT21a;gJMZ2x1N2yn%TnSi?7ji4mx*R6cnDiG}${G?DYzQ&ABs2kDm+=p6crB z={tV(T;Hjovr$zjb4;pm@aV0%osCe`PlNim=W7h%0A4;#>P{58qEe-0c$743KP+YfbA$? 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 diff --git a/__pycache__/extensions.cpython-311.pyc b/__pycache__/extensions.cpython-311.pyc deleted file mode 100644 index 05e2225841836c8406cc894a701380e78686bda9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 456 zcmZ3^%ge<81P(Rd(+>dY#~=<2FhLog(}0ZW3@HpLj5!Rsj8Tk?AU0DDQ!aB9Gnmbs z!;;Gy#mdOQ#E{Ck45)fF%nXJoHlP?YnixA!j1^6cBZVcHL6h|*NT(*_Ew11|AIF^J zjMUspAdk%{xu~+B1jyp?$xqMB^G(c4OiwNH)8xD*kd~8JoE=|Wn3D)mUc?ME;T8`> zI0>w;hy^6X4H3!#8BoLu5@AY7y2S=ja*G!%9-j*_c_qVVpaO6yi;MP;c)`l%Hqsd>ej`FX{91(m-zY;yBcN^?@}iui##8G*Ps0Z4pc zW@Kc1z@UBs72RNvzkrHvFsNKWMGsi{8=@LxT4EZQZgBETzNHnnB5RjR`Gm&o! NUjr`)7V!ct0RRA`d1L?p diff --git a/__pycache__/extensions.cpython-312.pyc b/__pycache__/extensions.cpython-312.pyc deleted file mode 100644 index b6757c209679f2f6045fd60af574ac01f605090b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 371 zcmX@j%ge<81R2&^83%y$V-N=hn4pZ$K|scIh7^Vr#vF!R#wbQc5SuB7DVI5l8O&zR zVaa8UVr67tVn}6N4b>LK2IMirdF((QE1bts$)d^n5~Na-@fKHbppRotaz<)yC6LGF zlw4FOH%=FCS z)S|M~BK_2glGME7%>2A!y@JYL95%W6DWy57c18R^U5r3ntPCVRFf%eTK4nn9%OHQ3 fLFFMUe{)O&(@jqPZixoAn*uVE`5JkPc!BBwV54B6 diff --git a/__pycache__/models.cpython-311.pyc b/__pycache__/models.cpython-311.pyc deleted file mode 100644 index 30219bd24a8d3030ec0f6613d5c1399586049602..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6419 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 P-EZ4qfaE;*quTagVD3)Y diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc deleted file mode 100644 index eb7ec95f74b39bfc2dd58e462c17d2b9bcd0a74a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6257 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 diff --git a/__pycache__/server_logger.cpython-311.pyc b/__pycache__/server_logger.cpython-311.pyc deleted file mode 100644 index a9993f99a4c75a6ff407762f0c0b308c56e81f81..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5019 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 Ezm=b}?*IS* 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 113550175539cb6aeac6d5164e7f153bbed3483a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 450 zcmZusu}Z{15Zz6(n_Tn|8w;_q5n^%s17f3SBO)9&+XOL7F5GT5Y;wYVgCB68ApT9t zu&tGyaNSkTCRzk;c+9-_n0d>7Os6q|y5#SR4a_em{>%F#+dCi|l%Whuq%g*yD&{e8I!8(PjUg<-Hh8Xufk~g_%Nm$x$-U95${yx! zq}5nbMh%HA~Z?ESfZ(RqI`J1?EbMYjX_e n?pO5^+K-Ph?$Aj)Mu$$@F*DkKvM9Kp`0UM2*$5> zUutg!gnn})9I=sd{uGp5gb@}jWD8|MKq7jLu=p9_2oW~K5O5Gd=m=IY$`Kr0MVZ(s zH!}hgab<1f@_+G>asB{^cM(Bl0i&`gA`;n%hJm1mX#oAPcmQNamxSf`c(iXNLM)CP zz{y9jf`OyQu`76K96!c(JzBm!+V}Xa3qCyu2nc}`*(TA5^tqU$t> z(|7~@oTyXNHfT%VB(2kbz&a0`lGC&-qhgUya@{gogl0`ldB4Dyz+~NMT8)*=6q9t_ zF>IpiEUD|Zi<=hLCw2XO)35>$$R|^BR&|VxW(;d3i+Dmos9g?1s?<@I%l5a1BxS8(5eS5$1 z%|2P`&&~~I=Y9gZ)K^ObwKR?{^%SkIXahxSFYrn4hUu1!mW8wyccvew#@4b zYP!QC{qSkfE~Mwdb-bYW!Q_wETLy6!5d>j~(!KHTEE*GXXXv`#4tx#_>d|C;ZA?y|p6 zPfr3)f7w5kUdRBv6h=tg4(Qn6;#YtGlFHyFK=NI1Q$kV}q?CUJV56V}xB-iBA?)Ml zl8ACR4{3L(BMHd|JpW->1MDmRgH(`u3aPqsdl(ny7SdG@c!k4ZmG)>~Lm<+|QTv)J z?W*B*TMM;O=_-%)@u;>wdLQZsTsaI4u&=v1{ZT01$MQ-vF9$Rn8|Lp4x@P-*WbC_^F4=={^K9 z72ROb%#|-zH^2HOx0$_ijcIvQv8(Kz(E0UhHhcZ*wG2}iK3!be(;CjsC!eJx1{)NU zYQBO~3K4@dSkF^>yG9UGNv(|cW-@pu-zYnl09uK?*@RUfIPYL&-L{>Q6~e8=iImto zld$q-evO(IzGG8|o=;6Nxki~@#15|BVrCInG2!94Ffdoj(-PAWF4Pca+V>4inrvdn zE<2dywwp}bu?clpgknnVT9qkzgqU8aRVuc_v^ue?P6{#^Z8LR~x5)JE0%_KrU>YLc z3yX;g7!513>7X~eTFi-|SQHIL3@j?PV7;M}3+Ym=g41<7ox$`wr&doRyJ+*ETbQIP zyx}rU57v(~8WbeMkPtNh=;v1*0IBS7tu`~CY56(S`d$9#Sw$NG&9G%&ZA8mQD zB|o;*TI-wd_sqqvx%gzwGcWn(rJkATnwh89J#*bR*IQTn#@S!K>>0_fk$jTu7)j4q z_KoG9vD!6OJ>#-(TyCxRr_LS$U0dnL=k7c08-K?BE_UKuUVO`sZ~YMM!^wM-KhOL) zbKmvgybtF)a6a($;A|JpdT`E%a~(MM3{H1u)*syAjGw2;f#lqS8$R6Vgy$cDyC`rM z2gifUK3wjE=a|n9QZk8h5p3iSKt2@u2b`V26~5O-8GlYb;->hC&;(~kiX=(T!9*)~ Pj?^h>{WsUpGAWc0){6*2BwXT>b z$77K_i9+;HscSZg{HiGpgh`^xfMqkVBEDr&MA1U)h7M^W60Iofn;1RBjP4wVlk|2s zmn1P+)|DEPWfGU=ngQz?(-X4%xUOh{2eZvfBDV~!Ueif*S+`M@CgoK~B1IdkdX)$) zl1K(tk!~xtYUm_^k>+dGhH8>%P@6^4Owm-!mVZasqJgi$5rqfuzsYYHHIz5i{35b8 zZNtn%wW?YO)3`h>3~5&0Z*0bFV77~V!Ai6KqM~_n48=?<(>~vs>t26#%@Jo^an=)O zn+ro}sx{aCu5-V$*1g}eUOebNc!mC5bfkhS6+EfXTpVV;>|E^Tddt0qt>u2@&y}r} z!H@sla5AfIX4T8AHt!5m7h09J(c2iz>|b$GMK@LSQpM)dFgxAB-9|sN{l(y(lbv_7 z^Imq|5vGC@Pmk)wXQ)PE!Ms%XN1)B9u!Px1PsD&HnYxZu>bsj0NgO?~kUU7X&xpTbu4yK|rs93<`(V^R;49Q*KNRa9Dh@*& z#|^=GXZ$=A!rZrq;A7ERX#ezuSH&uWZ)_ zKf9N1dY5kQmmFcu71lgqZ4|ZHo7=kHf7-{}PY1ubmkZwI!oKYYC08hULg{Tdl;+<7 LDw+v)_`Q4p>px?B diff --git a/models/__pycache__/player.cpython-311.pyc b/models/__pycache__/player.cpython-311.pyc deleted file mode 100644 index bff7c0539016fbc188d4f3077c69ea4f75a4bf4b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1976 zcmbtU%}*Og6rWwM*PpOpim?MqP!uY;tuRU}fzq^6DNR5GNJ^z5Usmg#vAuY`>&~v* zI!YuTa-b>)4oD#q2UkLr3LiM;m_K11S*kTsrAj?MK%5FGRjCbC=nfH4i z^WGc(JTMRjbc~xn7E~Vqf3Qtw_%E3A4az(R5I{}`B`(8pjOTT}@qhxDTN38Gr)fHam8}A@y)fr#CVp^sj@Ic*?dZj_@!`sW`9VYS~sVK<*-5Y|h{1 zSs(~xI0!O4bG#{ZfRyh#f?R_?q}XFRy^#@n(e5tLkwV`gN6~b80}2g)pMLO)`k^c8 zhyRy8_n-J9z4|@b5*vZO+eR-(yOM!!rhQRmv#E;}AW;5lYlb|*eH)M;b|eobsK9&o&+oO8VpT>1=F%Uu9GP| z%JhfW)pX6WWg54onFa~p^NPKUsw8&N3brY`T_RKCQH*rOrbw%xm5J1cAQ2O5$go`m zPEr@Y0DO%uIKc?}b8`t}tj}omYu#Tv6x)hzCwFe|-Z}cn3C%P^Gp*1}ZN4ofw(xd! zFS!@nPu4$taee>#$&@3_HKnjLkO3W?N&kj_^)9dDD&Q_Q9Q_r$@N)^kt@*UTmcoo%OGs zHXT^hSU4~9+I}=KP0vj aHStV{axC<>)2%PJH~w^Ge_eubqxuI{`s2m` diff --git a/models/__pycache__/server_log.cpython-311.pyc b/models/__pycache__/server_log.cpython-311.pyc deleted file mode 100644 index daa62a6df2d762e218b56f20bdda7f516055c4c0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1058 zcmZuvO=}ZD7@pbeCcA0Vgo4~ODS6V0osEtg6m<~W~S+;`w?e1+7u}{ z^iZ&e2!R%?;Hg&HgFnI_kU|b&ubz5~)Kf3|PBt59>+H@m&-=0O*X-9+Dv9{gKCC^) z2>t8{eUSR+`81F{L=eFivayCS=Yl0@f`H}_iESeyQe2Mp{m~(?5Q9m zI1tFnVli^%=OE7y5cUv78YYOxrm?aZIRcOTppFyaP=Fk;!==S9d-tS7WF@gf=@*;~qaF{3MI~m*^m>)5wo9nx zsiCA>ZfU$y3uIkqw8C_Kb99hmJks$Ac{c&-sFTj`FCV;arStt)&Y$x-7jq*#hGW=N z*Mp?4Lzb$=`LM3PsTx*jVZ6;C{?xUqwi6^49G{jb3j~t{@g<*`PKgb34|&G$>9T3l zKqkO^&f-0yV=Hr=A)yB zEv0a(6xvFm{-l$-wew>4RioBm&Dxh6AGexY-=$V+;xskUPEFKjJDF@lXe#^n4)RAE zZFRb>&b2c0r1A6|r=`i>^ah6)z|!1xdQ$Q^$jw@_Hj+StKLh1ZVY4GLD|;jw}aC4-qRK17^=Ar(1HAm>wo{p*vtDDSsVDt diff --git a/models/__pycache__/user.cpython-311.pyc b/models/__pycache__/user.cpython-311.pyc deleted file mode 100644 index 09c15348d2f66e59654ac38638a61138db4ab7e3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2471 zcmb7F&2JM|5P!S&ZtU2}=F91ZLI@SDWF!bmh#IP@NG46ofq)uN8%t>Acwg*I)@$>2 z7vd;FKBSUSeK1udK;n>78bl5pIrdMmktL#)Dk1g6Ehs&4>dbDuwH<`g=iS+P^Jd=6 z{AS+sA3Z(Y1jermzhxf;2>BZ)%^@B#2TQ;_CMr=mo#ePA$05(_d@hg-xO_kl=7glc z5uVV%HfXKC~O|@E=jT zH%Kz7Mu7IvUNyQwI;tpvwC9Ml52U?Er2VP{`wXakP|toRh=sca6^s;^mP@gsvOVo?6uDyH!|;b1VWMN6@uQ7OUT-(xsHGTWP;)u#EWQd$0`T zF|mL)$Ch8OrUbw1Tze(9T6gmm)efz7qWG0o3FO4jtqHcZbbdM>bi_rsL7m|YH7HA2 zR4JrP^M0PGN;YL?9Z{v}ybAd>#m{*sXi|NN4T3`x0~j0oaBe)C&(ZOMHa2f(UD-l1f|JJ-XKG`uFD; z?*U#DCGu7pEL+F&!$!e?oklVSq}5X&?rd9GYFJtt8cAJ?`3_sfm=5~_$#D{cW}W@G zzbN`M@jg(YU|P)K zHT4J*Vg@=~Q`aJH{@!E^Y)tGREc8J2(&pC>*DLF_(C2PgbMZkZq$oxzM-|2CRunih zwvK#MQSRC)-CJQ8ts`E|>vqm?x~>@(%}~bhusGtp#WW-1glsrasP1;96J&XvIs%jh zB%gsILtNlEvwVRvtH{ov>@1S6kRTKu-fo?+kr{&M5@gH?0H);s_QC_@fyzjAV)L7w zOSSODdiY`^e6f_+ljJh16rYSd8F)6bHSzS+vr{|PD_WCg>e5U@nkoI*t}*#^^x5dk zFKg0tU7Bu4)36>HDo<1{R~M>3Z7w`vkM3;V*|}YdOx7cljmTtaW-l5mCo0#fT6J+# z+u|SPHgh{a*P>JP=u{&*Rr-EE$O$*OebPcjPPpnyd@2&Lcc15kA=DdsAAsq`FP`8S zOEI*HwOGaW!Np0Gb`rPDE{JpSu+vRfEozur-Y~tIMwj%IxuSTNF$)6DPQ(-G`HW^T z{QLJVAmKV*r(R9mbLiLUL+D$tZN6idK^^ - - - - - 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 f3fb9d27294947c4a4e26c4355fe392f99990690..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 154 zcmZ3^%ge<81YUFEGC=fW5CH>>P{wCAAY(d13PUi1CZpd2X#0(Sz0Io11!2kdN diff --git a/utils/__pycache__/__init__.cpython-312.pyc b/utils/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index fd08d1aa809e7b3e6e3f4383f4326043648a9308..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 138 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 VetJ9Ixcmm)Lf1Y%fjK!P_#ZY~>FfXi diff --git a/utils/__pycache__/logger.cpython-311.pyc b/utils/__pycache__/logger.cpython-311.pyc deleted file mode 100644 index 675508cb57c55d8fc758219091991307fe75fa02..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6714 zcmc&&-ES1v6`$F?YmdFQ*O*UZKBlo#vluWYEu=sR7&{0s8v}(D(rUBp4EDl$cXMZq zVQVT><)Nw~HR=nNB1?X03Q8XG$Ro*LFoQ+{jl@%*^5%+GimX2LckZ1T&)TM{6r;|b z`Q5K`@1Aq-x#!$Fe`{}VRq(u@yEFOkw4(f-3gMFoGOxZy=AmLJhFVsxQ<^BNQ;F*d zmGa4Qa*ADN$g@wC>nWqhX!=yW-efeR)NE)#&1eC(7_GooBMnR&ZNN689oTMk06UBf zFk^HAJBhcHnCRU*T~kqJl1kAP-+)Z9nX4Gt|eyCN)gSpoW6}?cj zOVx^QS9N>R)Ge_rdKqm-!7kh=Sf*gck~K-rdIlXQOe5%cRIhIt<-E$N&qS$$F~xvK z^;}ltG?Sbb!*8}s%fcV|$%0|{O~vZeRLS;JHD0RNe#_|HqFEz3e2rJjCXcaw)wF0NNWL^MI9Z)C2WzFl5!1SDS8IbtX`&=$ADp&JWowY6G5LYoJ--#% zycCLeVgRx+>j~h2vZytucD?NCSvc%;?RC5MdR=?}*!)s!{jBxR9e?Whe8$ywd)jVC z+b!yzYu(SZZddE^v>r$6d8xHK9eWm<03cV(dRo>A>4%r;O^Zq*wQCs!|K5d9oQ}O9 zR~zuO0Y@7MhVFT$^|;zjPuuCNML*zKOkq5v);0+Lhf}*x^(x=?whr$}e!KtRaBuRv z-W2lRZ$e|ko}x7nd+Ix69x664d~QWMX(8G>VH>3lywRRo_#gI1<4#zhIbZe6Dl52X zy;nVrwhdRJ*0aJS&zQC_*{P~!>)b4w68I@ir`ZzqVS!pK(hj>fm{PGV<^S-XqJ?JE8U{R2Ub2qQvkwRV zGU#^odR@IvrU-g;R;EvH%hb8@agfOrU1oU9a6;nSG0gvUGWiZjQ19&5YrI;7;|pez z??g4q!#&DVGG1Cc2&P#l! zQ#27qb_kh9BZKiEroni~gp%{AHO+?QmbCG5;hxE1Hyir(nUX!JPhvyTg2QUsQB|B&l?KzeIkR+EK z^4K9Kr1cejsrd}APS^B)J^&Gd3HO=sDzvhJOA#|ZL7b7%978MZqF1pQ-#53($?TKl zvi%<0?}W6zV)J)N+93k65&jE6e4abWLZrNbft&ybsTenq0TOpWlFRmaY@ZX-n-KTs zB;~^Z&vK?d-V&nOY8XA0v zm|gNSkui)wE?SMa2N|BzoXjxjku6i8%T9ajv=h>s(2Ea4j{3%asZVjTP@y9=4&nVW zJkSD!s%dlrUZvh1?Ex3K>I% zbKp3N*P^)zLcx`}<4)!Z=t)|p6w|2;a@kdnU3EfQ-}S2FI&Rx^d6OHBLbfzX;R$g0 zYqKPSP&DqtHj#2cqZL`A%dYYQE{9@|seYZtog>D#0D)H-A+1Ivxop&9qfSU~ zV$tetp#!D6qi~sYqoiv9H^u!sI5H!=^+8JMFykvHB3tM$m;2`1SZI@}#3#Rrmz?p&VzJWD%J0XEGXD>JR~t_JQy^-P zkE2`zn)C2kuW-%JW5jy8Uypp^iXhF$zz}{olpk}1dk^hP^y4O&E)!!c8egP%brJN< zd6_;vCR3oxE_sZ;+ei}n;j_^O43X{eHGDcX=>wDBYTm_Jg1&oN@-t}DR23%xOMV#b z1iSc>BtC$M+rGHva=IsSx^Z#3zi_%0@RI~&vEqow>A=M45Wy+diufd4Hz&s?`~as7 z!)g76dXMG#Tui}|b{uj{m(3IW7TU?HSWke9NmW%}D47T1_d@A*8tH|y!)c^NmR9kN zGlK0*y{s-OtFS{=P1MJ*NmXx(ng~&o6EzW{<`8uX0L6Ex7yya~s2Bi>`>7bf>edl4 zc!XdW7Bvx~W{5@z0L2dlLxiY#mly(AJ%XmHW1>rh;29S+5u)aOQ4=9*&WoA|X=Lo6 N7@~$Ic5M{)_aE}z!L|SZ diff --git a/utils/__pycache__/logger.cpython-312.pyc b/utils/__pycache__/logger.cpython-312.pyc deleted file mode 100644 index a965d93b8ade36722a2f25145f86ef22bb14cdbf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5711 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 T^L?Ev_lp_T6_u)(4EetSTYFg8