Compare commits
14 Commits
f04e91ee08
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eb0aa3658 | |||
| 4e5aff1c02 | |||
| 318f783de3 | |||
| 70d76f45e7 | |||
| 1326543418 | |||
| c8bbbebb48 | |||
| 756f9052b5 | |||
| da57e066ae | |||
| 68cc47882c | |||
| 73c41303a9 | |||
| f20a606183 | |||
| 1800c9c310 | |||
| 67c9083a6e | |||
| d154853c7d |
45
.dockerignore
Normal file
45
.dockerignore
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Data folders (these will be mounted as volumes)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.example
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
41
.env.example
Normal file
41
.env.example
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# DigiServer Environment Configuration
|
||||||
|
# Copy this file to .env and modify the values as needed
|
||||||
|
|
||||||
|
# Flask Configuration
|
||||||
|
FLASK_APP=app.py
|
||||||
|
FLASK_RUN_HOST=0.0.0.0
|
||||||
|
FLASK_ENV=production
|
||||||
|
|
||||||
|
# Security
|
||||||
|
SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
|
||||||
|
# Change this to a secure random string in production!
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Application Settings
|
||||||
|
MAX_CONTENT_LENGTH=2147483648 # 2GB in bytes
|
||||||
|
UPLOAD_FOLDER=static/uploads
|
||||||
|
UPLOAD_FOLDERLOGO=static/resurse
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Optional: External Database (for advanced users)
|
||||||
|
# DATABASE_URL=postgresql://user:password@localhost/digiserver
|
||||||
|
# DATABASE_URL=mysql://user:password@localhost/digiserver
|
||||||
38
.gitignore
vendored
Normal file → Executable file
38
.gitignore
vendored
Normal file → Executable file
@@ -1,3 +1,41 @@
|
|||||||
|
# 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/
|
digiscreen/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Backups
|
||||||
|
backups/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|||||||
92
DEPLOYMENT.md
Normal file
92
DEPLOYMENT.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# DigiServer v1.1.0 - Production Deployment Guide
|
||||||
|
|
||||||
|
## 🎯 Ready for Deployment
|
||||||
|
|
||||||
|
Your DigiServer application has been cleaned and prepared for Docker deployment.
|
||||||
|
|
||||||
|
### ✅ What's Been Prepared
|
||||||
|
|
||||||
|
1. **Application Cleaned**
|
||||||
|
- Python cache files removed (`__pycache__/`, `*.pyc`)
|
||||||
|
- Development artifacts cleaned
|
||||||
|
- Production-ready structure
|
||||||
|
|
||||||
|
2. **Docker Configuration**
|
||||||
|
- Dockerfile optimized with LibreOffice and poppler-utils
|
||||||
|
- docker-compose.yml configured for production
|
||||||
|
- .dockerignore updated to exclude development files
|
||||||
|
- Data persistence configured via volumes
|
||||||
|
|
||||||
|
3. **Deployment Scripts**
|
||||||
|
- `deploy-docker.sh` - Automated deployment script
|
||||||
|
- `cleanup-docker.sh` - Complete cleanup script
|
||||||
|
- Both scripts use modern `docker compose` syntax
|
||||||
|
|
||||||
|
4. **Data Structure**
|
||||||
|
- `./data/instance/` - Database files
|
||||||
|
- `./data/uploads/` - Media uploads
|
||||||
|
- `./data/resurse/` - System resources
|
||||||
|
- All directories auto-created and volume-mounted
|
||||||
|
|
||||||
|
### 🚀 Quick Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy DigiServer
|
||||||
|
./deploy-docker.sh
|
||||||
|
|
||||||
|
# Access at: http://localhost:8880
|
||||||
|
# Username: admin
|
||||||
|
# Password: Initial01!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📋 Features Ready
|
||||||
|
|
||||||
|
- ✅ **Document Processing**: LibreOffice + poppler-utils integrated
|
||||||
|
- ✅ **File Uploads**: PPTX → PDF → 4K JPG workflow
|
||||||
|
- ✅ **Path Resolution**: Absolute path handling for containerized deployment
|
||||||
|
- ✅ **File Management**: Bulk delete functions with physical file cleanup
|
||||||
|
- ✅ **User Management**: Admin user auto-creation
|
||||||
|
- ✅ **Data Persistence**: Volume-mounted data directories
|
||||||
|
- ✅ **Health Checks**: Container health monitoring
|
||||||
|
- ✅ **Production Logging**: Structured output and error handling
|
||||||
|
|
||||||
|
### 🔧 System Requirements
|
||||||
|
|
||||||
|
- Docker Engine 20.10+
|
||||||
|
- Docker Compose v2 (plugin)
|
||||||
|
- 2GB RAM minimum
|
||||||
|
- 10GB disk space
|
||||||
|
|
||||||
|
### 📁 Deployment Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
digiserver/
|
||||||
|
├── 📁 app/ # Application code
|
||||||
|
├── 📁 data/ # Persistent data (auto-created)
|
||||||
|
│ ├── 📁 instance/ # Database
|
||||||
|
│ ├── 📁 uploads/ # Media files
|
||||||
|
│ └── 📁 resurse/ # Resources
|
||||||
|
├── 🐳 Dockerfile # Production image
|
||||||
|
├── 🔧 docker-compose.yml # Container orchestration
|
||||||
|
├── 🚀 deploy-docker.sh # Deployment script
|
||||||
|
├── 🧹 cleanup-docker.sh # Cleanup script
|
||||||
|
└── 📖 README.md # Documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔐 Security Notes
|
||||||
|
|
||||||
|
- Change default password after first login
|
||||||
|
- SECRET_KEY configured for session security
|
||||||
|
- File upload restrictions in place
|
||||||
|
- Container runs with proper permissions
|
||||||
|
|
||||||
|
### 📊 Monitoring
|
||||||
|
|
||||||
|
- Health checks configured (30s intervals)
|
||||||
|
- Container auto-restart on failure
|
||||||
|
- Logs available via `docker compose logs -f`
|
||||||
|
- Status monitoring with `docker compose ps`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next Step**: Run `./deploy-docker.sh` to deploy your DigiServer! 🚀
|
||||||
55
Dockerfile
55
Dockerfile
@@ -1,30 +1,59 @@
|
|||||||
|
# Use Python 3.11 slim image
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
WORKDIR /app
|
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 \
|
RUN apt-get update && apt-get install -y \
|
||||||
libreoffice poppler-utils ffmpeg \
|
poppler-utils \
|
||||||
libffi-dev libssl-dev g++ curl libjpeg-dev zlib1g-dev \
|
libreoffice \
|
||||||
libxml2-dev libxslt-dev build-essential cargo \
|
ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
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
|
# Debug: Verify Rust installation
|
||||||
RUN rustc --version && cargo --version
|
RUN rustc --version && cargo --version
|
||||||
|
|
||||||
# Copy application files
|
# Verify LibreOffice and poppler-utils installation
|
||||||
COPY . /app
|
RUN libreoffice --version && pdftoppm -v
|
||||||
|
|
||||||
# Copy entrypoint script and make it executable
|
# Copy requirements first for better layer caching
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY app/requirements.txt .
|
||||||
RUN chmod +x /entrypoint.sh
|
|
||||||
|
|
||||||
# Upgrade pip and install Python dependencies (using piwheels for ARM)
|
# Upgrade pip and install Python dependencies
|
||||||
RUN python -m pip install --upgrade pip && \
|
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 the application port
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV FLASK_APP=app.py
|
||||||
|
ENV FLASK_RUN_HOST=0.0.0.0
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
# Use entrypoint script
|
# Use entrypoint script
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["./entrypoint.sh"]
|
||||||
258
README.md
Normal file
258
README.md
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
# DigiServer - Digital Signage Management Platform
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
DigiServer is a comprehensive digital signage management platform built with Flask. It allows you to manage digital displays, create playlists, organize content into groups, and control multiple players from a centralized dashboard.
|
||||||
|
|
||||||
|
## 🚀 Features
|
||||||
|
|
||||||
|
- **Multi-Player Management**: Control multiple digital signage players from a single dashboard
|
||||||
|
- **Group Management**: Organize players into groups for synchronized content
|
||||||
|
- **Content Management**: Upload and manage various media types (images, videos, PDFs, PowerPoint presentations)
|
||||||
|
- **Real-time Updates**: Players automatically sync with the latest content
|
||||||
|
- **User Management**: Admin and user role-based access control
|
||||||
|
- **Orientation Support**: Configure display orientation (Landscape/Portrait) per player and group
|
||||||
|
- **API Integration**: RESTful API for player authentication and playlist retrieval
|
||||||
|
- **Docker Support**: Easy deployment with Docker containers
|
||||||
|
|
||||||
|
## 📋 Requirements
|
||||||
|
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- Python 3.11+ (if running without Docker)
|
||||||
|
- FFmpeg (for video processing)
|
||||||
|
- LibreOffice (for document conversion)
|
||||||
|
|
||||||
|
## 📁 Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
digiserver/
|
||||||
|
├── app/ # Application code
|
||||||
|
│ ├── models/ # Database models
|
||||||
|
│ ├── templates/ # HTML templates
|
||||||
|
│ ├── utils/ # Utility functions
|
||||||
|
│ ├── app.py # Main Flask application
|
||||||
|
│ ├── extensions.py # Flask extensions
|
||||||
|
│ ├── requirements.txt # Python dependencies
|
||||||
|
│ └── entrypoint.sh # Container entry point
|
||||||
|
├── data/ # Persistent data (created on first run)
|
||||||
|
│ ├── instance/ # Database files
|
||||||
|
│ ├── uploads/ # Media uploads
|
||||||
|
│ └── resurse/ # System resources (logos, etc.)
|
||||||
|
├── docker-compose.yml # Docker Compose configuration
|
||||||
|
├── Dockerfile # Docker image definition
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Quick Start with Docker
|
||||||
|
|
||||||
|
### Automated Deployment (Recommended)
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd digiserver
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Deploy with automated script**
|
||||||
|
```bash
|
||||||
|
./deploy-docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This script will:
|
||||||
|
- Check Docker requirements
|
||||||
|
- Build the DigiServer image
|
||||||
|
- Create necessary data directories
|
||||||
|
- Start the containers
|
||||||
|
- Display access information
|
||||||
|
|
||||||
|
3. **Access the application**
|
||||||
|
- Open your browser and navigate to `http://localhost:8880`
|
||||||
|
- Default admin credentials:
|
||||||
|
- Username: `admin`
|
||||||
|
- Password: `Initial01!`
|
||||||
|
|
||||||
|
### Manual Docker Commands
|
||||||
|
|
||||||
|
Alternatively, you can use Docker commands directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and start
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clean Up
|
||||||
|
|
||||||
|
To completely remove DigiServer containers and images:
|
||||||
|
```bash
|
||||||
|
./cleanup-docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
You can customize the application by modifying the environment variables in `docker-compose.yml`:
|
||||||
|
|
||||||
|
- `ADMIN_USER`: Default admin username (default: admin)
|
||||||
|
- `ADMIN_PASSWORD`: Default admin password (default: Initial01!)
|
||||||
|
- `SECRET_KEY`: Flask secret key for session security
|
||||||
|
- `FLASK_APP`: Flask application entry point
|
||||||
|
- `FLASK_RUN_HOST`: Host to bind the Flask application
|
||||||
|
|
||||||
|
### Data Persistence
|
||||||
|
|
||||||
|
All persistent data is stored in the `data/` folder:
|
||||||
|
- `data/instance/`: SQLite database files
|
||||||
|
- `data/uploads/`: Uploaded media files
|
||||||
|
- `data/resurse/`: System resources (logo, login images)
|
||||||
|
|
||||||
|
This folder will be created automatically on first run and persists between container restarts.
|
||||||
|
|
||||||
|
## 💻 Manual Installation (Development)
|
||||||
|
|
||||||
|
If you prefer to run without Docker:
|
||||||
|
|
||||||
|
1. **Install system dependencies**
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install python3.11 python3-pip libreoffice ffmpeg
|
||||||
|
|
||||||
|
# CentOS/RHEL
|
||||||
|
sudo yum install python3.11 python3-pip libreoffice ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install Python dependencies**
|
||||||
|
```bash
|
||||||
|
cd app/
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Run the application**
|
||||||
|
```bash
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎮 Usage
|
||||||
|
|
||||||
|
### Managing Players
|
||||||
|
|
||||||
|
1. **Add a Player**: Navigate to the dashboard and click "Add Player"
|
||||||
|
2. **Configure Player**: Set username, hostname, passwords, and orientation
|
||||||
|
3. **Upload Content**: Upload media files to the player's playlist
|
||||||
|
4. **Player Authentication**: Players can authenticate using hostname and password/quickconnect code
|
||||||
|
|
||||||
|
### Managing Groups
|
||||||
|
|
||||||
|
1. **Create Group**: Group multiple players for synchronized content
|
||||||
|
2. **Assign Players**: Add/remove players from groups
|
||||||
|
3. **Upload Group Content**: Upload content that will be shared across all players in the group
|
||||||
|
4. **Group Display**: View group content in fullscreen mode
|
||||||
|
|
||||||
|
### Content Types Supported
|
||||||
|
|
||||||
|
- **Images**: JPG, PNG, GIF
|
||||||
|
- **Videos**: MP4, AVI, MOV (automatically converted to MP4)
|
||||||
|
- **Documents**: PDF (converted to images)
|
||||||
|
- **Presentations**: PPTX (converted to images)
|
||||||
|
|
||||||
|
## 🔌 API Endpoints
|
||||||
|
|
||||||
|
### Player API
|
||||||
|
|
||||||
|
- `GET /api/playlists?hostname={hostname}&quickconnect_code={code}`: Get player playlist
|
||||||
|
- `GET /api/playlist_version?hostname={hostname}&quickconnect_code={code}`: Get playlist version
|
||||||
|
- `GET /media/{filename}`: Serve media files
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
Players authenticate using:
|
||||||
|
- **Hostname**: Unique identifier for the player
|
||||||
|
- **Password**: Primary authentication method
|
||||||
|
- **Quickconnect Code**: Alternative authentication method
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
|
### Building the Docker Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t digiserver:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install test dependencies
|
||||||
|
pip install pytest pytest-flask
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Management
|
||||||
|
|
||||||
|
The application uses SQLite with Flask-Migrate for database management:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initialize database
|
||||||
|
flask db init
|
||||||
|
|
||||||
|
# Create migration
|
||||||
|
flask db migrate -m "Description of changes"
|
||||||
|
|
||||||
|
# Apply migration
|
||||||
|
flask db upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Security
|
||||||
|
|
||||||
|
- **User Authentication**: Role-based access control (admin/user)
|
||||||
|
- **Player Authentication**: Secure hostname and password-based authentication
|
||||||
|
- **File Upload Security**: Secure filename handling and file type validation
|
||||||
|
- **Session Management**: Secure session handling with configurable secret key
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
- **Server Logs**: View recent server activities from the dashboard
|
||||||
|
- **Health Check**: Docker health check endpoint for monitoring
|
||||||
|
- **Content Management**: Track content usage and cleanup unused files
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||||
|
|
||||||
|
## 🆘 Support
|
||||||
|
|
||||||
|
For support and questions:
|
||||||
|
- Create an issue in the repository
|
||||||
|
- Check the documentation in the `docs/` folder
|
||||||
|
- Review the application logs for troubleshooting
|
||||||
|
|
||||||
|
## 🔄 Version History
|
||||||
|
|
||||||
|
- **1.1.0** (2025-06-29): Added orientation support, improved group management
|
||||||
|
- **1.0.0**: Initial release with basic digital signage functionality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Note**: Make sure to change the default admin password after first login for security purposes.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
656
app.py → app/app.py
Normal file → Executable file
656
app.py → app/app.py
Normal file → Executable file
@@ -1,26 +1,72 @@
|
|||||||
import os
|
import os
|
||||||
from flask import Flask, render_template, request, redirect, url_for, session, flash, jsonify, send_from_directory
|
import click
|
||||||
|
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
|
from flask_migrate import Migrate
|
||||||
from pdf2image import convert_from_path
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from extensions import db, bcrypt, login_manager
|
from extensions import db, bcrypt, login_manager
|
||||||
from models import User, Player, Content, Group # Add Group to the imports
|
from sqlalchemy import text
|
||||||
from flask_login import login_user, logout_user, login_required, current_user
|
from dotenv import load_dotenv
|
||||||
from pptx_to_images import convert_pptx_to_images # Assuming you have a module for PPTX conversion
|
|
||||||
import os
|
|
||||||
# Define global variables for server version and build date
|
|
||||||
SERVER_VERSION = "1.0.0"
|
|
||||||
BUILD_DATE = "2025-06-25"
|
|
||||||
|
|
||||||
app = Flask(__name__, instance_relative_config=True)
|
# Load environment variables from .env file
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# First import models
|
||||||
|
from models import User, Player, Group, Content, ServerLog, group_player
|
||||||
|
|
||||||
|
# Then import utilities that use the models
|
||||||
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
|
from utils.logger import get_recent_logs, log_action, log_upload, log_process, log_user_deleted, log_user_created
|
||||||
|
from utils.group_player_management import (
|
||||||
|
create_group as create_group_util,
|
||||||
|
edit_group as edit_group_util,
|
||||||
|
delete_group as delete_group_util,
|
||||||
|
add_player as add_player_util,
|
||||||
|
edit_player as edit_player_util,
|
||||||
|
delete_player as delete_player_util,
|
||||||
|
get_group_content,
|
||||||
|
get_player_content,
|
||||||
|
update_player_content_order,
|
||||||
|
update_group_content_order,
|
||||||
|
edit_group_media,
|
||||||
|
delete_group_media
|
||||||
|
)
|
||||||
|
|
||||||
|
# Finally, import modules that depend on both models and logger
|
||||||
|
from utils.uploads import (
|
||||||
|
add_image_to_playlist,
|
||||||
|
convert_video_and_update_playlist,
|
||||||
|
process_pdf,
|
||||||
|
process_pptx,
|
||||||
|
process_uploaded_files
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define global variables for server version and build date
|
||||||
|
SERVER_VERSION = "1.1.0"
|
||||||
|
BUILD_DATE = "2025-06-29"
|
||||||
|
|
||||||
|
# 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
|
# 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')
|
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'Ana_Are_Multe_Mere-Si_Nu_Are_Pere')
|
||||||
|
|
||||||
# Configure the database location to be in the instance folder
|
instance_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'instance'))
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(app.instance_path, 'dashboard.db')
|
os.makedirs(instance_dir, exist_ok=True)
|
||||||
|
db_path = os.path.join(instance_dir, 'dashboard.db')
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
|
||||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
|
||||||
# Set maximum content length to 1GB
|
# Set maximum content length to 1GB
|
||||||
@@ -28,6 +74,7 @@ app.config['MAX_CONTENT_LENGTH'] = 2048 * 2048 * 2048 # 2GB, adjust as needed
|
|||||||
|
|
||||||
# Ensure the instance folder exists
|
# Ensure the instance folder exists
|
||||||
os.makedirs(app.instance_path, exist_ok=True)
|
os.makedirs(app.instance_path, exist_ok=True)
|
||||||
|
os.makedirs(instance_dir, exist_ok=True)
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
bcrypt.init_app(app)
|
bcrypt.init_app(app)
|
||||||
@@ -40,8 +87,9 @@ app.config['UPLOAD_FOLDERLOGO'] = UPLOAD_FOLDERLOGO
|
|||||||
|
|
||||||
# Ensure the upload folder exists
|
# Ensure the upload folder exists
|
||||||
if not os.path.exists(UPLOAD_FOLDER):
|
if not os.path.exists(UPLOAD_FOLDER):
|
||||||
os.makedirs(UPLOAD_FOLDER)
|
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||||
os.makedirs(UPLOAD_FOLDERLOGO)
|
if not os.path.exists(UPLOAD_FOLDERLOGO):
|
||||||
|
os.makedirs(UPLOAD_FOLDERLOGO, exist_ok=True)
|
||||||
|
|
||||||
login_manager.login_view = 'login'
|
login_manager.login_view = 'login'
|
||||||
|
|
||||||
@@ -59,153 +107,60 @@ def admin_required(f):
|
|||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
def add_image_to_playlist(file, filename, duration, target_type, target_id):
|
def get_system_info():
|
||||||
"""
|
"""Get system monitoring information"""
|
||||||
Save the image file and add it to the playlist database.
|
|
||||||
Increment the playlist version for the player or group.
|
|
||||||
"""
|
|
||||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
|
||||||
# Only save if file does not already exist (prevents double-saving)
|
|
||||||
if not os.path.exists(file_path):
|
|
||||||
file.save(file_path)
|
|
||||||
|
|
||||||
if target_type == 'group':
|
|
||||||
group = Group.query.get_or_404(target_id)
|
|
||||||
for player in group.players:
|
|
||||||
new_content = Content(file_name=filename, duration=duration, player_id=player.id)
|
|
||||||
db.session.add(new_content)
|
|
||||||
# Increment playlist version for each player in the group
|
|
||||||
player.playlist_version += 1
|
|
||||||
# Increment playlist version for the group
|
|
||||||
group.playlist_version += 1
|
|
||||||
elif target_type == 'player':
|
|
||||||
player = Player.query.get_or_404(target_id)
|
|
||||||
new_content = Content(file_name=filename, duration=duration, player_id=target_id)
|
|
||||||
db.session.add(new_content)
|
|
||||||
# Increment playlist version for the player
|
|
||||||
player.playlist_version += 1
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def convert_pdf_to_images(input_file, output_folder):
|
|
||||||
"""
|
|
||||||
Converts a PDF file to images using pdf2image.
|
|
||||||
Each page is saved as a separate image in the output folder.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(output_folder):
|
|
||||||
os.makedirs(output_folder)
|
|
||||||
|
|
||||||
# Convert the PDF file to images
|
|
||||||
images = convert_from_path(input_file, dpi=300)
|
|
||||||
base_name = os.path.splitext(os.path.basename(input_file))[0]
|
|
||||||
for i, image in enumerate(images):
|
|
||||||
image_filename = f"{base_name}_{i + 1}.jpg"
|
|
||||||
image_path = os.path.join(output_folder, image_filename)
|
|
||||||
image.save(image_path, 'JPEG')
|
|
||||||
|
|
||||||
# Delete the original PDF file after conversion
|
|
||||||
if os.path.exists(input_file):
|
|
||||||
os.remove(input_file)
|
|
||||||
print(f"Original PDF file deleted: {input_file}")
|
|
||||||
|
|
||||||
def convert_video(input_file, output_folder):
|
|
||||||
""" Converts a video file to MP4 format with H.264 codec, 720p resolution, and 30 FPS.
|
|
||||||
Args:
|
|
||||||
input_file (str): Path to the input video file.
|
|
||||||
output_folder (str): Path to the folder where the converted video will be saved.
|
|
||||||
Returns:
|
|
||||||
str: Path to the converted video file, or None if conversion fails.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(output_folder):
|
|
||||||
os.makedirs(output_folder)
|
|
||||||
|
|
||||||
# Generate the output file path
|
|
||||||
base_name = os.path.splitext(os.path.basename(input_file))[0]
|
|
||||||
output_file = os.path.join(output_folder, f"{base_name}.mp4")
|
|
||||||
|
|
||||||
# FFmpeg command to convert the video
|
|
||||||
command = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-i", input_file, # Input file
|
|
||||||
"-c:v", "libx264", # Video codec: H.264
|
|
||||||
"-preset", "fast", # Encoding speed/quality tradeoff
|
|
||||||
"-crf", "23", # Constant Rate Factor (quality, lower is better)
|
|
||||||
"-vf", "scale=-1:1080", # Scale video to 720p (preserve aspect ratio)
|
|
||||||
"-r", "30", # Frame rate: 30 FPS
|
|
||||||
"-c:a", "aac", # Audio codec: AAC
|
|
||||||
"-b:a", "128k", # Audio bitrate
|
|
||||||
output_file # Output file
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run the FFmpeg command
|
# CPU information
|
||||||
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
cpu_percent = psutil.cpu_percent(interval=1)
|
||||||
print(f"Video converted successfully: {output_file}")
|
cpu_count = psutil.cpu_count()
|
||||||
return output_file
|
|
||||||
except subprocess.CalledProcessError as e:
|
# Memory information
|
||||||
print(f"Error converting video: {e.stderr.decode()}")
|
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
|
return None
|
||||||
|
|
||||||
def convert_video_and_update_playlist(file_path, original_filename, target_type, target_id, duration):
|
|
||||||
print(f"Starting video conversion for: {file_path}")
|
|
||||||
converted_file = convert_video(file_path, app.config['UPLOAD_FOLDER'])
|
|
||||||
if converted_file:
|
|
||||||
converted_filename = os.path.basename(converted_file)
|
|
||||||
print(f"Video converted successfully: {converted_filename}")
|
|
||||||
|
|
||||||
# Use the application context to interact with the database
|
|
||||||
with app.app_context():
|
|
||||||
# Update the database with the converted filename
|
|
||||||
if target_type == 'group':
|
|
||||||
group = Group.query.get_or_404(target_id)
|
|
||||||
for player in group.players:
|
|
||||||
content = Content.query.filter_by(player_id=player.id, file_name=original_filename).first()
|
|
||||||
if content:
|
|
||||||
content.file_name = converted_filename
|
|
||||||
elif target_type == 'player':
|
|
||||||
content = Content.query.filter_by(player_id=target_id, file_name=original_filename).first()
|
|
||||||
if content:
|
|
||||||
content.file_name = converted_filename
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
print(f"Database updated with converted video: {converted_filename}")
|
|
||||||
|
|
||||||
# Delete the original file only if it exists
|
|
||||||
if os.path.exists(file_path):
|
|
||||||
os.remove(file_path)
|
|
||||||
print(f"Original file deleted: {file_path}")
|
|
||||||
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(f"Video conversion failed for: {file_path}")
|
|
||||||
|
|
||||||
def convert_pptx_to_images(input_file, output_folder):
|
|
||||||
"""
|
|
||||||
Calls the external pptx_to_images.py script to convert PPTX to images.
|
|
||||||
"""
|
|
||||||
command = [
|
|
||||||
"python", "pptx_to_images.py", input_file, output_folder
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
||||||
return True
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"Error converting PPTX: {e.stderr.decode()}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Convert EMU to pixels
|
|
||||||
def emu_to_pixels(emu):
|
|
||||||
return int(emu / 914400 * 96)
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
players = Player.query.all()
|
players = Player.query.all()
|
||||||
groups = Group.query.all()
|
groups = Group.query.all()
|
||||||
logo_exists = os.path.exists(os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'logo.png'))
|
logo_exists = os.path.exists(os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'logo.png'))
|
||||||
return render_template('dashboard.html', players=players, groups=groups, logo_exists=logo_exists)
|
server_logs = get_recent_logs(20) # Get the 20 most recent logs
|
||||||
|
return render_template('dashboard.html', players=players, groups=groups, logo_exists=logo_exists, server_logs=server_logs)
|
||||||
|
|
||||||
@app.route('/register', methods=['GET', 'POST'])
|
@app.route('/register', methods=['GET', 'POST'])
|
||||||
def register():
|
def register():
|
||||||
@@ -252,46 +207,14 @@ def upload_content():
|
|||||||
return_url = request.form.get('return_url')
|
return_url = request.form.get('return_url')
|
||||||
media_type = request.form['media_type']
|
media_type = request.form['media_type']
|
||||||
|
|
||||||
|
print(f"Target Type: {target_type}, Target ID: {target_id}, Media Type: {media_type}")
|
||||||
|
|
||||||
if not target_type or not target_id:
|
if not target_type or not target_id:
|
||||||
flash('Please select a target type and target ID.', 'danger')
|
flash('Please select a target type and target ID.', 'danger')
|
||||||
return redirect(url_for('upload_content'))
|
return redirect(url_for('upload_content'))
|
||||||
|
|
||||||
for file in files:
|
# Process uploaded files and get results
|
||||||
try:
|
results = process_uploaded_files(app, files, media_type, duration, target_type, target_id)
|
||||||
# Generate a secure filename and save the file
|
|
||||||
filename = secure_filename(file.filename)
|
|
||||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
|
||||||
file.save(file_path)
|
|
||||||
file_ext = os.path.splitext(filename)[1].lower()
|
|
||||||
|
|
||||||
if media_type == 'ppt':
|
|
||||||
print(f"Processing PPT file: {file_path}")
|
|
||||||
success = convert_pptx_to_images(file_path, app.config['UPLOAD_FOLDER'])
|
|
||||||
|
|
||||||
if success:
|
|
||||||
base_name = os.path.splitext(filename)[0]
|
|
||||||
# Find all PNGs generated for this PPTX
|
|
||||||
slide_images = sorted([
|
|
||||||
f for f in os.listdir(app.config['UPLOAD_FOLDER'])
|
|
||||||
if (f.startswith(base_name) and f.endswith('.png'))
|
|
||||||
])
|
|
||||||
print("Slide images found:", slide_images)
|
|
||||||
if target_type == 'group':
|
|
||||||
group = Group.query.get_or_404(target_id)
|
|
||||||
for player in group.players:
|
|
||||||
for slide_image in slide_images:
|
|
||||||
new_content = Content(file_name=slide_image, duration=duration, player_id=player.id)
|
|
||||||
db.session.add(new_content)
|
|
||||||
elif target_type == 'player':
|
|
||||||
for slide_image in slide_images:
|
|
||||||
new_content = Content(file_name=slide_image, duration=duration, player_id=target_id)
|
|
||||||
db.session.add(new_content)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error processing file {file.filename}: {e}")
|
|
||||||
flash(f"Error processing file {file.filename}: {e}", 'danger')
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return redirect(return_url)
|
return redirect(return_url)
|
||||||
|
|
||||||
@@ -302,11 +225,12 @@ def upload_content():
|
|||||||
|
|
||||||
players = [{'id': player.id, 'username': player.username} for player in Player.query.all()]
|
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()]
|
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)
|
return render_template('upload_content.html', target_type=target_type, target_id=target_id,
|
||||||
|
players=players, groups=groups, return_url=return_url, system_info=system_info)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin')
|
@app.route('/admin')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -315,13 +239,18 @@ def admin():
|
|||||||
logo_exists = os.path.exists(os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'logo.png'))
|
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'))
|
login_picture_exists = os.path.exists(os.path.join(app.config['UPLOAD_FOLDERLOGO'], 'login_picture.png'))
|
||||||
users = User.query.all()
|
users = User.query.all()
|
||||||
|
|
||||||
|
# Get system information for monitoring
|
||||||
|
system_info = get_system_info()
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'admin.html',
|
'admin.html',
|
||||||
users=users,
|
users=users,
|
||||||
logo_exists=logo_exists,
|
logo_exists=logo_exists,
|
||||||
login_picture_exists=login_picture_exists,
|
login_picture_exists=login_picture_exists,
|
||||||
server_version=SERVER_VERSION,
|
server_version=SERVER_VERSION,
|
||||||
build_date=BUILD_DATE
|
build_date=BUILD_DATE,
|
||||||
|
system_info=system_info
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.route('/admin/change_role/<int:user_id>', methods=['POST'])
|
@app.route('/admin/change_role/<int:user_id>', methods=['POST'])
|
||||||
@@ -339,8 +268,11 @@ def change_role(user_id):
|
|||||||
@admin_required
|
@admin_required
|
||||||
def delete_user(user_id):
|
def delete_user(user_id):
|
||||||
user = User.query.get_or_404(user_id)
|
user = User.query.get_or_404(user_id)
|
||||||
|
username = user.username # Store username before deletion for logging
|
||||||
db.session.delete(user)
|
db.session.delete(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
# Add log entry for user deletion
|
||||||
|
log_user_deleted(username)
|
||||||
return redirect(url_for('admin'))
|
return redirect(url_for('admin'))
|
||||||
|
|
||||||
@app.route('/admin/create_user', methods=['POST'])
|
@app.route('/admin/create_user', methods=['POST'])
|
||||||
@@ -354,13 +286,15 @@ def create_user():
|
|||||||
new_user = User(username=username, password=hashed_password, role=role)
|
new_user = User(username=username, password=hashed_password, role=role)
|
||||||
db.session.add(new_user)
|
db.session.add(new_user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
# Add log entry for user creation
|
||||||
|
log_user_created(username, role)
|
||||||
return redirect(url_for('admin'))
|
return redirect(url_for('admin'))
|
||||||
|
|
||||||
@app.route('/player/<int:player_id>')
|
@app.route('/player/<int:player_id>')
|
||||||
@login_required
|
@login_required
|
||||||
def player_page(player_id):
|
def player_page(player_id):
|
||||||
player = db.session.get(Player, player_id)
|
player = db.session.get(Player, player_id)
|
||||||
content = Content.query.filter_by(player_id=player_id).all()
|
content = get_player_content(player_id)
|
||||||
return render_template('player_page.html', player=player, content=content)
|
return render_template('player_page.html', player=player, content=content)
|
||||||
|
|
||||||
@app.route('/player/<int:player_id>/upload', methods=['POST'])
|
@app.route('/player/<int:player_id>/upload', methods=['POST'])
|
||||||
@@ -398,6 +332,61 @@ def delete_content(content_id):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return redirect(url_for('player_page', player_id=player_id))
|
return redirect(url_for('player_page', player_id=player_id))
|
||||||
|
|
||||||
|
@app.route('/player/<int:player_id>/bulk_delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def bulk_delete_player_content(player_id):
|
||||||
|
"""Bulk delete selected media files from player"""
|
||||||
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
|
# Check if player is in a group (should be managed at group level)
|
||||||
|
if player.groups:
|
||||||
|
flash('Cannot delete media from players that are in groups. Manage media at the group level.', 'warning')
|
||||||
|
return redirect(url_for('player_page', player_id=player_id))
|
||||||
|
|
||||||
|
selected_content_ids = request.form.getlist('selected_content')
|
||||||
|
|
||||||
|
if not selected_content_ids:
|
||||||
|
flash('No media files selected for deletion.', 'warning')
|
||||||
|
return redirect(url_for('player_page', player_id=player_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted_files = []
|
||||||
|
deleted_count = 0
|
||||||
|
|
||||||
|
for content_id in selected_content_ids:
|
||||||
|
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
|
||||||
|
if content:
|
||||||
|
# Delete file from filesystem using absolute path
|
||||||
|
upload_folder = app.config['UPLOAD_FOLDER']
|
||||||
|
if not os.path.isabs(upload_folder):
|
||||||
|
upload_folder = os.path.abspath(upload_folder)
|
||||||
|
file_path = os.path.join(upload_folder, content.file_name)
|
||||||
|
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
deleted_files.append(content.file_name)
|
||||||
|
print(f"Deleted file: {file_path}")
|
||||||
|
except OSError as e:
|
||||||
|
print(f"Error deleting file {file_path}: {e}")
|
||||||
|
|
||||||
|
# Delete from database
|
||||||
|
db.session.delete(content)
|
||||||
|
deleted_count += 1
|
||||||
|
|
||||||
|
# Update playlist version for the player
|
||||||
|
player.playlist_version += 1
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f'Successfully deleted {deleted_count} media file(s). Playlist updated to version {player.playlist_version}.', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"Error in bulk delete: {e}")
|
||||||
|
flash('An error occurred while deleting media files.', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('player_page', player_id=player_id))
|
||||||
|
|
||||||
@app.route('/player/<int:player_id>/fullscreen', methods=['GET', 'POST'])
|
@app.route('/player/<int:player_id>/fullscreen', methods=['GET', 'POST'])
|
||||||
def player_fullscreen(player_id):
|
def player_fullscreen(player_id):
|
||||||
player = Player.query.get_or_404(player_id)
|
player = Player.query.get_or_404(player_id)
|
||||||
@@ -430,19 +419,10 @@ def player_fullscreen(player_id):
|
|||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def delete_player(player_id):
|
def delete_player(player_id):
|
||||||
player = Player.query.get_or_404(player_id)
|
delete_player_util(player_id)
|
||||||
|
|
||||||
# Delete all media related to the player
|
|
||||||
media_items = Content.query.filter_by(player_id=player_id).all()
|
|
||||||
for media in media_items:
|
|
||||||
db.session.delete(media)
|
|
||||||
|
|
||||||
# Delete the player
|
|
||||||
db.session.delete(player)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
# Update the add_player function
|
||||||
@app.route('/player/add', methods=['GET', 'POST'])
|
@app.route('/player/add', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
@@ -452,9 +432,9 @@ def add_player():
|
|||||||
hostname = request.form['hostname']
|
hostname = request.form['hostname']
|
||||||
password = bcrypt.generate_password_hash(request.form['password']).decode('utf-8')
|
password = bcrypt.generate_password_hash(request.form['password']).decode('utf-8')
|
||||||
quickconnect_password = bcrypt.generate_password_hash(request.form['quickconnect_password']).decode('utf-8')
|
quickconnect_password = bcrypt.generate_password_hash(request.form['quickconnect_password']).decode('utf-8')
|
||||||
new_player = Player(username=username, hostname=hostname, password=password, quickconnect_password=quickconnect_password)
|
orientation = request.form.get('orientation', 'Landscape') # <-- Get orientation
|
||||||
db.session.add(new_player)
|
add_player_util(username, hostname, password, quickconnect_password, orientation) # <-- Pass orientation
|
||||||
db.session.commit()
|
flash(f'Player "{username}" added successfully.', 'success')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
return render_template('add_player.html')
|
return render_template('add_player.html')
|
||||||
|
|
||||||
@@ -464,13 +444,13 @@ def add_player():
|
|||||||
def edit_player(player_id):
|
def edit_player(player_id):
|
||||||
player = Player.query.get_or_404(player_id)
|
player = Player.query.get_or_404(player_id)
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
player.username = request.form['username']
|
username = request.form['username']
|
||||||
player.hostname = request.form['hostname']
|
hostname = request.form['hostname']
|
||||||
if request.form['password']:
|
password = request.form['password'] if request.form['password'] else None
|
||||||
player.password = bcrypt.generate_password_hash(request.form['password']).decode('utf-8')
|
quickconnect_password = request.form['quickconnect_password'] if request.form['quickconnect_password'] else None
|
||||||
if request.form['quickconnect_password']:
|
orientation = request.form.get('orientation', player.orientation) # <-- Get orientation
|
||||||
player.quickconnect_password = bcrypt.generate_password_hash(request.form['quickconnect_password']).decode('utf-8')
|
edit_player_util(player_id, username, hostname, password, quickconnect_password, orientation) # <-- Pass orientation
|
||||||
db.session.commit()
|
flash(f'Player "{username}" updated successfully.', 'success')
|
||||||
return redirect(url_for('player_page', player_id=player.id))
|
return redirect(url_for('player_page', player_id=player.id))
|
||||||
|
|
||||||
return_url = request.args.get('return_url', url_for('player_page', player_id=player.id))
|
return_url = request.args.get('return_url', url_for('player_page', player_id=player.id))
|
||||||
@@ -545,11 +525,15 @@ def clean_unused_files():
|
|||||||
print("Used files:", used_files)
|
print("Used files:", used_files)
|
||||||
print("Unused files:", unused_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:
|
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):
|
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)
|
os.remove(file_path)
|
||||||
|
|
||||||
flash('Unused files have been cleaned.', 'success')
|
flash('Unused files have been cleaned.', 'success')
|
||||||
@@ -570,8 +554,30 @@ def get_playlists():
|
|||||||
if not player or not bcrypt.check_password_hash(player.quickconnect_password, quickconnect_code):
|
if not player or not bcrypt.check_password_hash(player.quickconnect_password, quickconnect_code):
|
||||||
return jsonify({'error': 'Invalid hostname or quick connect code'}), 404
|
return jsonify({'error': 'Invalid hostname or quick connect code'}), 404
|
||||||
|
|
||||||
# Query the Content table for media files associated with the player
|
# Check if player is locked to a group
|
||||||
content = Content.query.filter_by(player_id=player.id).all()
|
if player.locked_to_group_id:
|
||||||
|
# Get content for all players in the group to ensure shared content
|
||||||
|
group_players = player.locked_to_group.players
|
||||||
|
player_ids = [p.id for p in group_players]
|
||||||
|
|
||||||
|
# Use the first occurrence of each file for the playlist
|
||||||
|
content_query = (
|
||||||
|
db.session.query(
|
||||||
|
Content.file_name,
|
||||||
|
db.func.min(Content.id).label('id'),
|
||||||
|
db.func.min(Content.duration).label('duration')
|
||||||
|
)
|
||||||
|
.filter(Content.player_id.in_(player_ids))
|
||||||
|
.group_by(Content.file_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
content = db.session.query(Content).filter(
|
||||||
|
Content.id.in_([c.id for c in content_query])
|
||||||
|
).all()
|
||||||
|
else:
|
||||||
|
# Get player's individual content
|
||||||
|
content = Content.query.filter_by(player_id=player.id).all()
|
||||||
|
|
||||||
playlist = [
|
playlist = [
|
||||||
{
|
{
|
||||||
'file_name': media.file_name,
|
'file_name': media.file_name,
|
||||||
@@ -607,13 +613,9 @@ def create_group():
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
group_name = request.form['name']
|
group_name = request.form['name']
|
||||||
player_ids = request.form.getlist('players')
|
player_ids = request.form.getlist('players')
|
||||||
new_group = Group(name=group_name)
|
orientation = request.form.get('orientation', 'Landscape')
|
||||||
for player_id in player_ids:
|
create_group_util(group_name, player_ids, orientation)
|
||||||
player = Player.query.get(player_id)
|
flash(f'Group "{group_name}" created successfully.', 'success')
|
||||||
if player:
|
|
||||||
new_group.players.append(player)
|
|
||||||
db.session.add(new_group)
|
|
||||||
db.session.commit()
|
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
players = Player.query.all()
|
players = Player.query.all()
|
||||||
return render_template('create_group.html', players=players)
|
return render_template('create_group.html', players=players)
|
||||||
@@ -623,15 +625,11 @@ def create_group():
|
|||||||
@admin_required
|
@admin_required
|
||||||
def manage_group(group_id):
|
def manage_group(group_id):
|
||||||
group = Group.query.get_or_404(group_id)
|
group = Group.query.get_or_404(group_id)
|
||||||
|
content = get_group_content(group_id)
|
||||||
# Get unique media files for the group
|
# Debug content ordering
|
||||||
content = (
|
print("Group content positions before sorting:", [(c.id, c.file_name, c.position) for c in content])
|
||||||
db.session.query(Content.id, Content.file_name, db.func.min(Content.duration).label('duration'))
|
content = sorted(content, key=lambda c: c.position)
|
||||||
.filter(Content.player_id.in_([player.id for player in group.players]))
|
print("Group content positions after sorting:", [(c.id, c.file_name, c.position) for c in content])
|
||||||
.group_by(Content.file_name)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
return render_template('manage_group.html', group=group, content=content)
|
return render_template('manage_group.html', group=group, content=content)
|
||||||
|
|
||||||
@app.route('/group/<int:group_id>/edit', methods=['GET', 'POST'])
|
@app.route('/group/<int:group_id>/edit', methods=['GET', 'POST'])
|
||||||
@@ -640,10 +638,11 @@ def manage_group(group_id):
|
|||||||
def edit_group(group_id):
|
def edit_group(group_id):
|
||||||
group = Group.query.get_or_404(group_id)
|
group = Group.query.get_or_404(group_id)
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
group.name = request.form['name']
|
name = request.form['name']
|
||||||
player_ids = request.form.getlist('players')
|
player_ids = request.form.getlist('players')
|
||||||
group.players = [Player.query.get(player_id) for player_id in player_ids if Player.query.get(player_id)]
|
orientation = request.form.get('orientation', group.orientation)
|
||||||
db.session.commit()
|
edit_group_util(group_id, name, player_ids, orientation)
|
||||||
|
flash(f'Group "{name}" updated successfully.', 'success')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
players = Player.query.all()
|
players = Player.query.all()
|
||||||
return render_template('edit_group.html', group=group, players=players)
|
return render_template('edit_group.html', group=group, players=players)
|
||||||
@@ -653,47 +652,100 @@ def edit_group(group_id):
|
|||||||
@admin_required
|
@admin_required
|
||||||
def delete_group(group_id):
|
def delete_group(group_id):
|
||||||
group = Group.query.get_or_404(group_id)
|
group = Group.query.get_or_404(group_id)
|
||||||
db.session.delete(group)
|
group_name = group.name
|
||||||
db.session.commit()
|
delete_group_util(group_id)
|
||||||
|
flash(f'Group "{group_name}" deleted successfully.', 'success')
|
||||||
return redirect(url_for('dashboard'))
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
@app.route('/group/<int:group_id>/fullscreen', methods=['GET'])
|
@app.route('/group/<int:group_id>/fullscreen', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def group_fullscreen(group_id):
|
def group_fullscreen(group_id):
|
||||||
group = Group.query.get_or_404(group_id)
|
group = Group.query.get_or_404(group_id)
|
||||||
content = Content.query.filter(Content.player_id.in_([player.id for player in group.players])).all()
|
content = Content.query.filter(Content.player_id.in_([player.id for player in group.players])).order_by(Content.position).all()
|
||||||
return render_template('group_fullscreen.html', group=group, content=content)
|
return render_template('group_fullscreen.html', group=group, content=content)
|
||||||
|
|
||||||
@app.route('/group/<int:group_id>/media/<int:content_id>/edit', methods=['POST'])
|
@app.route('/group/<int:group_id>/media/<int:content_id>/edit', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def edit_group_media(group_id, content_id):
|
def edit_group_media_route(group_id, content_id):
|
||||||
group = Group.query.get_or_404(group_id)
|
|
||||||
new_duration = int(request.form['duration'])
|
new_duration = int(request.form['duration'])
|
||||||
|
success = edit_group_media(group_id, content_id, new_duration)
|
||||||
|
|
||||||
# Update the duration for all players in the group
|
if success:
|
||||||
for player in group.players:
|
flash('Media duration updated successfully.', 'success')
|
||||||
content = Content.query.filter_by(player_id=player.id, file_name=Content.query.get(content_id).file_name).first()
|
else:
|
||||||
if content:
|
flash('Error updating media duration.', 'danger')
|
||||||
content.duration = new_duration
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
return redirect(url_for('manage_group', group_id=group_id))
|
return redirect(url_for('manage_group', group_id=group_id))
|
||||||
|
|
||||||
@app.route('/group/<int:group_id>/media/<int:content_id>/delete', methods=['POST'])
|
@app.route('/group/<int:group_id>/media/<int:content_id>/delete', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
def delete_group_media(group_id, content_id):
|
def delete_group_media_route(group_id, content_id):
|
||||||
|
success = delete_group_media(group_id, content_id)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
flash('Media deleted successfully.', 'success')
|
||||||
|
else:
|
||||||
|
flash('Error deleting media.', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('manage_group', group_id=group_id))
|
||||||
|
|
||||||
|
@app.route('/group/<int:group_id>/bulk_delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def bulk_delete_group_content(group_id):
|
||||||
|
"""Bulk delete selected media files from group"""
|
||||||
group = Group.query.get_or_404(group_id)
|
group = Group.query.get_or_404(group_id)
|
||||||
file_name = Content.query.get(content_id).file_name
|
selected_content_ids = request.form.getlist('selected_content')
|
||||||
|
|
||||||
# Delete the media for all players in the group
|
if not selected_content_ids:
|
||||||
for player in group.players:
|
flash('No media files selected for deletion.', 'warning')
|
||||||
content = Content.query.filter_by(player_id=player.id, file_name=file_name).first()
|
return redirect(url_for('manage_group', group_id=group_id))
|
||||||
if content:
|
|
||||||
db.session.delete(content)
|
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')
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
return redirect(url_for('manage_group', group_id=group_id))
|
return redirect(url_for('manage_group', group_id=group_id))
|
||||||
|
|
||||||
@app.route('/api/playlist_version', methods=['GET'])
|
@app.route('/api/playlist_version', methods=['GET'])
|
||||||
@@ -716,6 +768,92 @@ def get_playlist_version():
|
|||||||
'hashed_quickconnect': player.quickconnect_password
|
'hashed_quickconnect': player.quickconnect_password
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@app.route('/api/system_info', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def api_system_info():
|
||||||
|
"""API endpoint to get real-time system information"""
|
||||||
|
system_info = get_system_info()
|
||||||
|
if system_info:
|
||||||
|
return jsonify(system_info)
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'Could not retrieve system information'}), 500
|
||||||
|
|
||||||
|
@app.route('/player/<int:player_id>/update_order', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def update_content_order(player_id):
|
||||||
|
if not request.is_json:
|
||||||
|
return jsonify({'success': False, 'error': 'Invalid request format'}), 400
|
||||||
|
|
||||||
|
player = Player.query.get_or_404(player_id)
|
||||||
|
if player.groups and current_user.role != 'admin':
|
||||||
|
return jsonify({'success': False, 'error': 'Cannot reorder playlist for players in groups'}), 403
|
||||||
|
|
||||||
|
items = request.json.get('items', [])
|
||||||
|
|
||||||
|
success, error, new_version = update_player_content_order(player_id, items)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({'success': True, 'new_version': new_version})
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'error': error}), 500
|
||||||
|
|
||||||
|
@app.route('/group/<int:group_id>/update_order', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def update_group_content_order_route(group_id):
|
||||||
|
if not request.is_json:
|
||||||
|
return jsonify({'success': False, 'error': 'Invalid request format'}), 400
|
||||||
|
|
||||||
|
items = request.json.get('items', [])
|
||||||
|
success, error = update_group_content_order(group_id, items)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({'success': True})
|
||||||
|
else:
|
||||||
|
return jsonify({'success': False, 'error': error}), 500
|
||||||
|
|
||||||
|
@app.route('/debug/content_positions/<int:group_id>')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def debug_content_positions(group_id):
|
||||||
|
group = Group.query.get_or_404(group_id)
|
||||||
|
player_ids = [p.id for p in group.players]
|
||||||
|
|
||||||
|
# Query directly with SQL to see positions
|
||||||
|
sql = text("SELECT id, file_name, position, player_id FROM content WHERE player_id IN :player_ids ORDER BY position")
|
||||||
|
result = db.session.execute(sql, {"player_ids": tuple(player_ids)})
|
||||||
|
|
||||||
|
content_data = [{"id": row.id, "file_name": row.file_name, "position": row.position, "player_id": row.player_id} for row in result]
|
||||||
|
|
||||||
|
return jsonify(content_data)
|
||||||
|
|
||||||
|
@app.cli.command("create-admin")
|
||||||
|
@click.option("--username", default="admin", help="Admin username")
|
||||||
|
@click.option("--password", help="Admin password")
|
||||||
|
def create_admin(username, password):
|
||||||
|
"""Create an admin user."""
|
||||||
|
from models import User
|
||||||
|
from extensions import bcrypt
|
||||||
|
|
||||||
|
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||||
|
user = User(username=username, password=hashed_password, role='admin')
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Admin user '{username}' created successfully.")
|
||||||
|
|
||||||
|
from models.create_default_user import create_default_user
|
||||||
|
|
||||||
|
if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
db.session.execute(db.select(User).limit(1))
|
||||||
|
except Exception as e:
|
||||||
|
print("Database not initialized or missing tables. Re-initializing...")
|
||||||
|
db.create_all()
|
||||||
|
create_default_user(db, User, bcrypt)
|
||||||
|
|
||||||
|
# Add this at the end of app.py
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||||
|
|
||||||
29
app/entrypoint.sh
Executable file
29
app/entrypoint.sh
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
mkdir -p static/uploads static/resurse
|
||||||
|
mkdir -p instance
|
||||||
|
|
||||||
|
# Check if database exists
|
||||||
|
if [ ! -f instance/dashboard.db ]; then
|
||||||
|
echo "No database found, creating fresh database..."
|
||||||
|
|
||||||
|
# Create admin user if environment variables are set
|
||||||
|
if [ -n "$ADMIN_USER" ] && [ -n "$ADMIN_PASSWORD" ]; then
|
||||||
|
echo "Creating admin user: $ADMIN_USER"
|
||||||
|
flask create-admin --username "$ADMIN_USER" --password "$ADMIN_PASSWORD"
|
||||||
|
else
|
||||||
|
echo "Warning: ADMIN_USER or ADMIN_PASSWORD not set, skipping admin creation"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Existing database found, skipping initialization..."
|
||||||
|
echo "Creating admin user if needed..."
|
||||||
|
if [ -n "$ADMIN_USER" ] && [ -n "$ADMIN_PASSWORD" ]; then
|
||||||
|
flask create-admin --username "$ADMIN_USER" --password "$ADMIN_PASSWORD" 2>/dev/null || echo "Default user '$ADMIN_USER' already exists."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting DigiServer..."
|
||||||
|
# Start the application
|
||||||
|
exec flask run --host=0.0.0.0
|
||||||
5
app/models/__init__.py
Normal file
5
app/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from .user import User
|
||||||
|
from .player import Player
|
||||||
|
from .group import Group, group_player
|
||||||
|
from .content import Content
|
||||||
|
from .server_log import ServerLog
|
||||||
21
app/models/clear_db.py
Normal file
21
app/models/clear_db.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import os
|
||||||
|
from flask import Flask
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
# Ensure the instance directory exists (relative to project root)
|
||||||
|
instance_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'instance'))
|
||||||
|
os.makedirs(instance_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Set the correct database URI
|
||||||
|
db_path = os.path.join(instance_dir, 'dashboard.db')
|
||||||
|
print(f"Using database at: {db_path}")
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
db = SQLAlchemy(app)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.reflect() # This loads all tables from the database
|
||||||
|
db.drop_all()
|
||||||
|
print("Dropped all tables successfully.")
|
||||||
8
app/models/content.py
Normal file
8
app/models/content.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from extensions import db
|
||||||
|
|
||||||
|
class Content(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
file_name = db.Column(db.String(255), nullable=False)
|
||||||
|
duration = db.Column(db.Integer, nullable=False)
|
||||||
|
player_id = db.Column(db.Integer, db.ForeignKey('player.id'), nullable=False)
|
||||||
|
position = db.Column(db.Integer, default=0)
|
||||||
18
app/models/create_default_user.py
Normal file
18
app/models/create_default_user.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#from app import app, db, User, bcrypt
|
||||||
|
import os
|
||||||
|
|
||||||
|
def create_default_user(db, User, bcrypt):
|
||||||
|
username = os.getenv('DEFAULT_USER', 'admin')
|
||||||
|
password = os.getenv('DEFAULT_PASSWORD', '1234')
|
||||||
|
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||||
|
existing_user = User.query.filter_by(username=username).first()
|
||||||
|
if not existing_user:
|
||||||
|
default_user = User(username=username, password=hashed_password, role='admin')
|
||||||
|
db.session.add(default_user)
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Default user '{username}' created with password '{password}'")
|
||||||
|
else:
|
||||||
|
print(f"Default user '{username}' already exists.")
|
||||||
|
|
||||||
|
#with app.app_context():
|
||||||
|
# create_default_user(db, User, bcrypt)
|
||||||
13
app/models/group.py
Normal file
13
app/models/group.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from extensions import db
|
||||||
|
|
||||||
|
class Group(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||||
|
orientation = db.Column(db.String(16), nullable=False, default='Landscape') # <-- Add this line
|
||||||
|
players = db.relationship('Player', secondary='group_player', backref='groups')
|
||||||
|
playlist_version = db.Column(db.Integer, default=0)
|
||||||
|
|
||||||
|
group_player = db.Table('group_player',
|
||||||
|
db.Column('group_id', db.Integer, db.ForeignKey('group.id'), primary_key=True),
|
||||||
|
db.Column('player_id', db.Integer, db.ForeignKey('player.id'), primary_key=True)
|
||||||
|
)
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
from app import app, db, User, bcrypt
|
from app import app
|
||||||
|
from extensions import db, bcrypt
|
||||||
|
from models import User, ServerLog # Import from models.py instead of app.py
|
||||||
|
|
||||||
def create_admin_user():
|
def create_admin_user():
|
||||||
admin_username = os.getenv('ADMIN_USER', 'admin')
|
admin_username = os.getenv('ADMIN_USER', 'admin')
|
||||||
@@ -18,4 +20,5 @@ if __name__ == '__main__':
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
create_admin_user()
|
create_admin_user()
|
||||||
|
print("Database initialized with all models including ServerLog")
|
||||||
|
|
||||||
18
app/models/player.py
Normal file
18
app/models/player.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from extensions import db
|
||||||
|
from flask_bcrypt import Bcrypt
|
||||||
|
|
||||||
|
bcrypt = Bcrypt()
|
||||||
|
|
||||||
|
class Player(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(255), nullable=False)
|
||||||
|
hostname = db.Column(db.String(255), nullable=False)
|
||||||
|
password = db.Column(db.String(255), nullable=False)
|
||||||
|
quickconnect_password = db.Column(db.String(255), nullable=True)
|
||||||
|
playlist_version = db.Column(db.Integer, default=1)
|
||||||
|
locked_to_group_id = db.Column(db.Integer, db.ForeignKey('group.id'), nullable=True)
|
||||||
|
locked_to_group = db.relationship('Group', foreign_keys=[locked_to_group_id], backref='locked_players')
|
||||||
|
orientation = db.Column(db.String(16), nullable=False, default='Landscape') # <-- Add this line
|
||||||
|
|
||||||
|
def verify_quickconnect_code(self, code):
|
||||||
|
return bcrypt.check_password_hash(self.quickconnect_password, code)
|
||||||
10
app/models/server_log.py
Normal file
10
app/models/server_log.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from extensions import db
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
class ServerLog(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
action = db.Column(db.String(255), nullable=False)
|
||||||
|
timestamp = db.Column(db.DateTime, default=datetime.datetime.utcnow)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<ServerLog {self.action}>"
|
||||||
33
app/models/user.py
Normal file
33
app/models/user.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from extensions import db
|
||||||
|
from flask_bcrypt import Bcrypt
|
||||||
|
from flask_login import UserMixin
|
||||||
|
|
||||||
|
bcrypt = Bcrypt()
|
||||||
|
|
||||||
|
class User(db.Model, UserMixin):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||||
|
password = db.Column(db.String(120), nullable=False)
|
||||||
|
role = db.Column(db.String(80), nullable=False)
|
||||||
|
theme = db.Column(db.String(80), default='light')
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
return bcrypt.check_password_hash(self.password, password)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_authenticated(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_anonymous(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_id(self):
|
||||||
|
return str(self.id)
|
||||||
46
app/requirements.txt
Executable file
46
app/requirements.txt
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
# Core Flask
|
||||||
|
Flask==3.1.0
|
||||||
|
Werkzeug==3.1.3
|
||||||
|
Jinja2==3.1.5
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
click==8.1.8
|
||||||
|
blinker==1.9.0
|
||||||
|
|
||||||
|
# Flask Extensions
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
Flask-Migrate==4.1.0
|
||||||
|
Flask-Bcrypt==1.0.1
|
||||||
|
Flask-Login==0.6.3
|
||||||
|
|
||||||
|
# Database
|
||||||
|
SQLAlchemy==2.0.37
|
||||||
|
alembic==1.14.1
|
||||||
|
Mako==1.3.8
|
||||||
|
greenlet==3.1.1
|
||||||
|
|
||||||
|
# File Processing
|
||||||
|
pdf2image==1.17.0
|
||||||
|
PyPDF2==3.0.1
|
||||||
|
Pillow==10.0.1
|
||||||
|
cairosvg==2.7.0
|
||||||
|
ffmpeg-python==0.2.0
|
||||||
|
python-magic==0.4.27
|
||||||
|
|
||||||
|
# Security
|
||||||
|
bcrypt==4.2.1
|
||||||
|
Flask-Talisman==1.1.0
|
||||||
|
Flask-Cors==4.0.0
|
||||||
|
|
||||||
|
# Production Server
|
||||||
|
gunicorn==20.1.0
|
||||||
|
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
|
||||||
|
MarkupSafe==3.0.2
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
@@ -60,6 +60,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="orientation" class="form-label">Orientation</label>
|
||||||
|
<select class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="orientation" name="orientation" required>
|
||||||
|
<option value="Landscape" selected>Landscape</option>
|
||||||
|
<option value="Portret">Portret</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<button type="submit" class="btn btn-primary">Add Player</button>
|
<button type="submit" class="btn btn-primary">Add Player</button>
|
||||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
|
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
|
||||||
@@ -207,6 +207,94 @@
|
|||||||
<p><strong>Date of Build:</strong> {{ build_date }}</p>
|
<p><strong>Date of Build:</strong> {{ build_date }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- System Monitoring Card -->
|
||||||
|
{% if system_info %}
|
||||||
|
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>📊 System Monitoring</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<!-- CPU Information -->
|
||||||
|
<div class="col-md-3 col-6 text-center mb-3">
|
||||||
|
<div class="h6">CPU Usage</div>
|
||||||
|
<div class="progress mb-2" style="height: 25px;">
|
||||||
|
<div class="progress-bar
|
||||||
|
{% if system_info.cpu_percent < 50 %}bg-success
|
||||||
|
{% elif system_info.cpu_percent < 80 %}bg-warning
|
||||||
|
{% else %}bg-danger{% endif %}"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {{ system_info.cpu_percent }}%;">
|
||||||
|
{{ system_info.cpu_percent }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ system_info.cpu_count }} cores available</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Memory Information -->
|
||||||
|
<div class="col-md-3 col-6 text-center mb-3">
|
||||||
|
<div class="h6">Memory Usage</div>
|
||||||
|
<div class="progress mb-2" style="height: 25px;">
|
||||||
|
<div class="progress-bar
|
||||||
|
{% if system_info.memory_percent < 60 %}bg-success
|
||||||
|
{% elif system_info.memory_percent < 85 %}bg-warning
|
||||||
|
{% else %}bg-danger{% endif %}"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {{ system_info.memory_percent }}%;">
|
||||||
|
{{ system_info.memory_percent }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ system_info.memory_used }}GB / {{ system_info.memory_total }}GB</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Disk Information -->
|
||||||
|
<div class="col-md-3 col-6 text-center mb-3">
|
||||||
|
<div class="h6">Disk Usage</div>
|
||||||
|
<div class="progress mb-2" style="height: 25px;">
|
||||||
|
<div class="progress-bar
|
||||||
|
{% if system_info.disk_percent < 70 %}bg-success
|
||||||
|
{% elif system_info.disk_percent < 90 %}bg-warning
|
||||||
|
{% else %}bg-danger{% endif %}"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {{ system_info.disk_percent }}%;">
|
||||||
|
{{ system_info.disk_percent }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ system_info.disk_used }}GB / {{ system_info.disk_total }}GB</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Folder Size -->
|
||||||
|
<div class="col-md-3 col-6 text-center mb-3">
|
||||||
|
<div class="h6">Media Storage</div>
|
||||||
|
<div class="text-primary display-6">{{ system_info.upload_folder_size }}GB</div>
|
||||||
|
<small class="text-muted">Total media files</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Details -->
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<hr>
|
||||||
|
<div class="row text-center">
|
||||||
|
<div class="col-md-4 col-12 mb-2">
|
||||||
|
<strong>Available Disk Space:</strong><br>
|
||||||
|
<span class="text-success">{{ system_info.disk_free }}GB free</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-12 mb-2">
|
||||||
|
<strong>Total Disk Space:</strong><br>
|
||||||
|
<span class="text-info">{{ system_info.disk_total }}GB total</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-12 mb-2">
|
||||||
|
<strong>Last Updated:</strong><br>
|
||||||
|
<span class="text-muted" id="last-update-admin">Just now</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -227,6 +315,77 @@
|
|||||||
popup.style.display = 'none';
|
popup.style.display = 'none';
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-refresh system monitoring every 15 seconds
|
||||||
|
{% if system_info %}
|
||||||
|
function updateAdminSystemInfo() {
|
||||||
|
fetch('/api/system_info')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
console.warn('Could not fetch system info:', data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress bars and their colors
|
||||||
|
const progressBars = document.querySelectorAll('.progress-bar');
|
||||||
|
|
||||||
|
if (progressBars.length >= 3) {
|
||||||
|
// CPU Bar
|
||||||
|
progressBars[0].style.width = data.cpu_percent + '%';
|
||||||
|
progressBars[0].textContent = data.cpu_percent + '%';
|
||||||
|
progressBars[0].className = 'progress-bar ' +
|
||||||
|
(data.cpu_percent < 50 ? 'bg-success' :
|
||||||
|
data.cpu_percent < 80 ? 'bg-warning' : 'bg-danger');
|
||||||
|
|
||||||
|
// Memory Bar
|
||||||
|
progressBars[1].style.width = data.memory_percent + '%';
|
||||||
|
progressBars[1].textContent = data.memory_percent + '%';
|
||||||
|
progressBars[1].className = 'progress-bar ' +
|
||||||
|
(data.memory_percent < 60 ? 'bg-success' :
|
||||||
|
data.memory_percent < 85 ? 'bg-warning' : 'bg-danger');
|
||||||
|
|
||||||
|
// Disk Bar
|
||||||
|
progressBars[2].style.width = data.disk_percent + '%';
|
||||||
|
progressBars[2].textContent = data.disk_percent + '%';
|
||||||
|
progressBars[2].className = 'progress-bar ' +
|
||||||
|
(data.disk_percent < 70 ? 'bg-success' :
|
||||||
|
data.disk_percent < 90 ? 'bg-warning' : 'bg-danger');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update text values
|
||||||
|
const smallTexts = document.querySelectorAll('.text-muted');
|
||||||
|
smallTexts.forEach((text, index) => {
|
||||||
|
if (index === 1) text.textContent = data.memory_used + 'GB / ' + data.memory_total + 'GB';
|
||||||
|
if (index === 2) text.textContent = data.disk_used + 'GB / ' + data.disk_total + 'GB';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update storage size
|
||||||
|
const storageDisplay = document.querySelector('.display-6');
|
||||||
|
if (storageDisplay) {
|
||||||
|
storageDisplay.textContent = data.upload_folder_size + 'GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update disk space info
|
||||||
|
const diskFree = document.querySelector('.text-success');
|
||||||
|
const diskTotal = document.querySelector('.text-info');
|
||||||
|
if (diskFree) diskFree.textContent = data.disk_free + 'GB free';
|
||||||
|
if (diskTotal) diskTotal.textContent = data.disk_total + 'GB total';
|
||||||
|
|
||||||
|
// Update timestamp
|
||||||
|
const lastUpdate = document.getElementById('last-update-admin');
|
||||||
|
if (lastUpdate) {
|
||||||
|
lastUpdate.textContent = new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.warn('Admin system monitoring update failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update every 15 seconds
|
||||||
|
setInterval(updateAdminSystemInfo, 15000);
|
||||||
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -49,6 +49,22 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="orientation" class="form-label">Group Orientation</label>
|
||||||
|
<select class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="orientation" name="orientation" required>
|
||||||
|
<option value="Landscape" selected>Landscape</option>
|
||||||
|
<option value="Portret">Portret</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<strong>Warning:</strong> Adding players to a group will delete their individual playlists.
|
||||||
|
All players in a group will share the same content.
|
||||||
|
</div>
|
||||||
|
<div id="orientation-warning" class="alert alert-danger d-none" role="alert">
|
||||||
|
No players with the selected orientation are available.
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<button type="submit" class="btn btn-primary">Create Group</button>
|
<button type="submit" class="btn btn-primary">Create Group</button>
|
||||||
@@ -57,5 +73,38 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// Get all players and their orientations from the backend
|
||||||
|
const players = [
|
||||||
|
{% for player in players %}
|
||||||
|
{id: {{ player.id }}, username: "{{ player.username }}", orientation: "{{ player.orientation }}"},
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
const orientationSelect = document.getElementById('orientation');
|
||||||
|
const playersSelect = document.getElementById('players');
|
||||||
|
const orientationWarning = document.getElementById('orientation-warning');
|
||||||
|
|
||||||
|
function filterPlayers() {
|
||||||
|
const selectedOrientation = orientationSelect.value;
|
||||||
|
playersSelect.innerHTML = '';
|
||||||
|
let compatibleCount = 0;
|
||||||
|
players.forEach(player => {
|
||||||
|
if (player.orientation === selectedOrientation) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = player.id;
|
||||||
|
option.textContent = player.username;
|
||||||
|
playersSelect.appendChild(option);
|
||||||
|
compatibleCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('orientation-warning').classList.toggle('d-none', compatibleCount > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
orientationSelect.addEventListener('change', filterPlayers);
|
||||||
|
|
||||||
|
// Initial filter on page load
|
||||||
|
filterPlayers();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -137,6 +137,35 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Activity Log Section -->
|
||||||
|
{% if current_user.role == 'admin' %}
|
||||||
|
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
|
<div class="card-header bg-secondary text-white">
|
||||||
|
<h2>Server Activity Log</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped {{ 'table-dark' if theme == 'dark' else '' }}">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in server_logs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||||
|
<td>{{ log.action }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
118
app/templates/edit_group.html
Normal file
118
app/templates/edit_group.html
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Edit Group</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body.dark-mode {
|
||||||
|
background-color: #121212;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.card.dark-mode {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.dark-mode label, .dark-mode th, .dark-mode td {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
|
<div class="container py-5">
|
||||||
|
<h1 class="text-center mb-4">Edit Group</h1>
|
||||||
|
<form method="POST">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="name" class="form-label">Group Name</label>
|
||||||
|
<input type="text" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="name" name="name" value="{{ group.name }}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="players" class="form-label">Select Players</label>
|
||||||
|
<select multiple class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="players" name="players">
|
||||||
|
{% for player in players %}
|
||||||
|
<option value="{{ player.id }}" {% if player in group.players %}selected{% endif %}>{{ player.username }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="orientation" class="form-label">Group Orientation</label>
|
||||||
|
<select class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="orientation" name="orientation" required>
|
||||||
|
<option value="Landscape" {% if group.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
|
||||||
|
<option value="Portret" {% if group.orientation == 'Portret' %}selected{% endif %}>Portret</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Add this above the player selection -->
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<strong>Warning:</strong> Adding new players to this group will delete their individual playlists.
|
||||||
|
Removing players from the group will allow them to have their own playlists again.
|
||||||
|
</div>
|
||||||
|
<div id="orientation-warning" class="alert alert-danger d-none" role="alert">
|
||||||
|
No players with the selected orientation are available.
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// Get all players and their orientations from the backend
|
||||||
|
const players = [
|
||||||
|
{% for player in players %}
|
||||||
|
{id: {{ player.id }}, username: "{{ player.username }}", orientation: "{{ player.orientation }}", inGroup: {% if player in group.players %}true{% else %}false{% endif %}},
|
||||||
|
{% endfor %}
|
||||||
|
];
|
||||||
|
|
||||||
|
const orientationSelect = document.getElementById('orientation');
|
||||||
|
const playersSelect = document.getElementById('players');
|
||||||
|
const orientationWarning = document.getElementById('orientation-warning');
|
||||||
|
|
||||||
|
function filterPlayers() {
|
||||||
|
const selectedOrientation = orientationSelect.value;
|
||||||
|
const currentSelection = Array.from(playersSelect.selectedOptions).map(option => option.value);
|
||||||
|
playersSelect.innerHTML = '';
|
||||||
|
let compatibleCount = 0;
|
||||||
|
|
||||||
|
players.forEach(player => {
|
||||||
|
if (player.orientation === selectedOrientation) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = player.id;
|
||||||
|
option.textContent = player.username;
|
||||||
|
// Re-select if it was previously selected
|
||||||
|
if (currentSelection.includes(player.id.toString()) || player.inGroup) {
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
playersSelect.appendChild(option);
|
||||||
|
compatibleCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
orientationWarning.classList.toggle('d-none', compatibleCount > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
orientationSelect.addEventListener('change', filterPlayers);
|
||||||
|
|
||||||
|
// Initial filter on page load
|
||||||
|
filterPlayers();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -60,6 +60,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="orientation" class="form-label">Orientation</label>
|
||||||
|
<select class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="orientation" name="orientation" required>
|
||||||
|
<option value="Landscape" {% if player.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
|
||||||
|
<option value="Portret" {% if player.orientation == 'Portret' %}selected{% endif %}>Portret</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<button type="submit" class="btn btn-primary">Update Player</button>
|
<button type="submit" class="btn btn-primary">Update Player</button>
|
||||||
<a href="{{ return_url }}" class="btn btn-secondary mt-3">Back to Player Page</a>
|
<a href="{{ return_url }}" class="btn btn-secondary mt-3">Back to Player Page</a>
|
||||||
318
app/templates/manage_group.html
Normal file
318
app/templates/manage_group.html
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Manage Group</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body.dark-mode {
|
||||||
|
background-color: #121212;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.card.dark-mode {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.dark-mode label, .dark-mode th, .dark-mode td {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-list li {
|
||||||
|
cursor: move;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-list li.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-over {
|
||||||
|
border-top: 2px solid #0d6efd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
|
<div class="container py-5">
|
||||||
|
<h1 class="text-center mb-4">Manage Group: {{ group.name }}</h1>
|
||||||
|
|
||||||
|
<!-- Group Information Card -->
|
||||||
|
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
|
<div class="card-header bg-info text-white">
|
||||||
|
<h2>Group Info</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p><strong>Group Name:</strong> {{ group.name }}</p>
|
||||||
|
<p><strong>Number of Players:</strong> {{ group.players|length }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List of Players in the Group -->
|
||||||
|
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
|
<div class="card-header bg-secondary text-white">
|
||||||
|
<h2>Players in Group</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for player in group.players %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>{{ player.username }}</strong> ({{ player.hostname }})
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manage Media Section -->
|
||||||
|
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
|
<div class="card-header bg-info text-white">
|
||||||
|
<h2>Manage Media</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if content %}
|
||||||
|
<!-- Bulk Actions Controls -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="selectAll">
|
||||||
|
<label class="form-check-label" for="selectAll">
|
||||||
|
Select All
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-end">
|
||||||
|
<button type="button" class="btn btn-danger" id="bulkDeleteBtn" style="display:none;" onclick="confirmBulkDelete()">
|
||||||
|
<i class="bi bi-trash"></i> Delete Selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="list-group sortable-list" id="groupMediaList">
|
||||||
|
{% for media in content %}
|
||||||
|
<li class="list-group-item d-flex align-items-center {{ 'dark-mode' if theme == 'dark' else '' }}"
|
||||||
|
draggable="true"
|
||||||
|
data-id="{{ media.id }}"
|
||||||
|
data-position="{{ loop.index0 }}">
|
||||||
|
<!-- Checkbox for bulk selection -->
|
||||||
|
<div class="me-2">
|
||||||
|
<input class="form-check-input media-checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
name="selected_content"
|
||||||
|
value="{{ media.id }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drag handle -->
|
||||||
|
<div class="drag-handle me-2" title="Drag to reorder">
|
||||||
|
<i class="bi bi-grip-vertical"></i>
|
||||||
|
☰
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
|
||||||
|
</div>
|
||||||
|
<form action="{{ url_for('edit_group_media', group_id=group.id, content_id=media.id) }}" method="post" class="d-flex align-items-center">
|
||||||
|
<div class="input-group me-2">
|
||||||
|
<span class="input-group-text">seconds</span>
|
||||||
|
<input type="number" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" name="duration" value="{{ media.duration }}" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-warning me-2">Edit</button>
|
||||||
|
</form>
|
||||||
|
<form action="{{ url_for('delete_group_media', group_id=group.id, content_id=media.id) }}" method="post" style="display:inline;">
|
||||||
|
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<!-- Add a save button for the reordering -->
|
||||||
|
<button id="saveGroupOrder" class="btn btn-success mt-3">Save Playlist Order</button>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-center">No media uploaded for this group.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Media Button -->
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<a href="{{ url_for('upload_content', target_type='group', target_id=group.id, return_url=url_for('manage_group', group_id=group.id)) }}" class="btn btn-primary btn-lg">Go to Upload Media</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Back to Dashboard Button -->
|
||||||
|
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const groupMediaList = document.getElementById('groupMediaList');
|
||||||
|
let draggedItem = null;
|
||||||
|
|
||||||
|
// Initialize drag events for all items
|
||||||
|
const items = groupMediaList.querySelectorAll('li');
|
||||||
|
items.forEach(item => {
|
||||||
|
// Drag start
|
||||||
|
item.addEventListener('dragstart', function(e) {
|
||||||
|
draggedItem = item;
|
||||||
|
setTimeout(() => {
|
||||||
|
item.classList.add('dragging');
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag end
|
||||||
|
item.addEventListener('dragend', function() {
|
||||||
|
item.classList.remove('dragging');
|
||||||
|
draggedItem = null;
|
||||||
|
updatePositions();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag over
|
||||||
|
item.addEventListener('dragover', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (item !== draggedItem) {
|
||||||
|
const rect = item.getBoundingClientRect();
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const height = rect.height;
|
||||||
|
|
||||||
|
if (y < height / 2) {
|
||||||
|
groupMediaList.insertBefore(draggedItem, item);
|
||||||
|
} else {
|
||||||
|
groupMediaList.insertBefore(draggedItem, item.nextSibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save button click handler
|
||||||
|
document.getElementById('saveGroupOrder').addEventListener('click', function() {
|
||||||
|
// Collect new order
|
||||||
|
const newOrder = [];
|
||||||
|
groupMediaList.querySelectorAll('li').forEach((item, index) => {
|
||||||
|
newOrder.push({
|
||||||
|
id: item.dataset.id,
|
||||||
|
position: index
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to server
|
||||||
|
fetch('{{ url_for("update_group_content_order_route", group_id=group.id) }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({items: newOrder})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert('Playlist order updated successfully!');
|
||||||
|
console.log('Group playlist update successful:', data);
|
||||||
|
} else {
|
||||||
|
alert('Error updating playlist order: ' + (data.error || 'Unknown error'));
|
||||||
|
console.error('Failed to update group playlist:', data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('An error occurred while updating the playlist order.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update positions in the UI
|
||||||
|
function updatePositions() {
|
||||||
|
groupMediaList.querySelectorAll('li').forEach((item, index) => {
|
||||||
|
item.dataset.position = index;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk selection functionality
|
||||||
|
const selectAllCheckbox = document.getElementById('selectAll');
|
||||||
|
const mediaCheckboxes = document.querySelectorAll('.media-checkbox');
|
||||||
|
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
|
||||||
|
|
||||||
|
// Select all functionality
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
selectAllCheckbox.addEventListener('change', function() {
|
||||||
|
mediaCheckboxes.forEach(checkbox => {
|
||||||
|
checkbox.checked = this.checked;
|
||||||
|
});
|
||||||
|
updateBulkDeleteButton();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual checkbox change
|
||||||
|
mediaCheckboxes.forEach(checkbox => {
|
||||||
|
checkbox.addEventListener('change', function() {
|
||||||
|
updateSelectAllState();
|
||||||
|
updateBulkDeleteButton();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateSelectAllState() {
|
||||||
|
const checkedBoxes = Array.from(mediaCheckboxes).filter(cb => cb.checked);
|
||||||
|
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
selectAllCheckbox.checked = checkedBoxes.length === mediaCheckboxes.length && mediaCheckboxes.length > 0;
|
||||||
|
selectAllCheckbox.indeterminate = checkedBoxes.length > 0 && checkedBoxes.length < mediaCheckboxes.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkDeleteButton() {
|
||||||
|
const checkedBoxes = Array.from(mediaCheckboxes).filter(cb => cb.checked);
|
||||||
|
if (bulkDeleteBtn) {
|
||||||
|
bulkDeleteBtn.style.display = checkedBoxes.length > 0 ? 'inline-block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function confirmBulkDelete() {
|
||||||
|
const checkedBoxes = Array.from(document.querySelectorAll('.media-checkbox:checked'));
|
||||||
|
if (checkedBoxes.length === 0) {
|
||||||
|
alert('No media files selected.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = checkedBoxes.length;
|
||||||
|
const message = `Are you sure you want to delete ${count} selected media file${count > 1 ? 's' : ''}? This action cannot be undone.`;
|
||||||
|
|
||||||
|
if (confirm(message)) {
|
||||||
|
// Create a form with selected IDs
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '{{ url_for("bulk_delete_group_content", group_id=group.id) }}';
|
||||||
|
|
||||||
|
checkedBoxes.forEach(checkbox => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'selected_content';
|
||||||
|
input.value = checkbox.value;
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
343
app/templates/player_page.html
Normal file
343
app/templates/player_page.html
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Player Schedule</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body.dark-mode {
|
||||||
|
background-color: #121212;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.card.dark-mode {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.dark-mode label, .dark-mode th, .dark-mode td {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sortable-list li {
|
||||||
|
cursor: move;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sortable-list li.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-over {
|
||||||
|
border-top: 2px solid #0d6efd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="{% if theme == 'dark' %}dark-mode{% endif %}">
|
||||||
|
<div class="container py-5">
|
||||||
|
<h1 class="text-center mb-4">Player Schedule for {{ player.username }}</h1>
|
||||||
|
|
||||||
|
<!-- Player Info Section -->
|
||||||
|
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
|
||||||
|
<div class="card-header bg-info text-white">
|
||||||
|
<h2>Player Info</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p><strong>Player Name:</strong> {{ player.username }}</p>
|
||||||
|
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
|
||||||
|
{% if current_user.role == 'admin' %}
|
||||||
|
<a href="{{ url_for('edit_player', player_id=player.id, return_url=url_for('player_page', player_id=player.id)) }}" class="btn btn-warning">Update</a>
|
||||||
|
<form action="{{ url_for('delete_player', player_id=player.id) }}" method="post" style="display:inline;">
|
||||||
|
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this player?');">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group Membership Section -->
|
||||||
|
<div class="mb-4">
|
||||||
|
{% if player.groups %}
|
||||||
|
<h4 class="text-center">Member of Group(s):</h4>
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for group in player.groups %}
|
||||||
|
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}">{{ group.name }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-center">This player is not a member of any groups.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Management Section -->
|
||||||
|
{% if current_user.role == 'admin' %}
|
||||||
|
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
|
||||||
|
<div class="card-header bg-info text-white">
|
||||||
|
<h2>Manage Media</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if content %}
|
||||||
|
<!-- Bulk Actions Controls -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="selectAll" {% if player.groups %}disabled{% endif %}>
|
||||||
|
<label class="form-check-label" for="selectAll">
|
||||||
|
Select All
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-end">
|
||||||
|
<button type="button" class="btn btn-danger" id="bulkDeleteBtn" {% if player.groups %}disabled{% endif %} style="display:none;" onclick="confirmBulkDelete()">
|
||||||
|
<i class="bi bi-trash"></i> Delete Selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Delete Form -->
|
||||||
|
<form id="bulkDeleteForm" action="{{ url_for('bulk_delete_player_content', player_id=player.id) }}" method="post" style="display:none;">
|
||||||
|
<input type="hidden" name="selected_content_ids" id="selectedContentIds">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul class="list-group sortable-list" id="mediaList">
|
||||||
|
{% for media in content %}
|
||||||
|
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}"
|
||||||
|
draggable="true"
|
||||||
|
data-id="{{ media.id }}"
|
||||||
|
data-position="{{ loop.index0 }}">
|
||||||
|
<div class="d-flex flex-column flex-md-row align-items-md-center">
|
||||||
|
<!-- Checkbox for bulk selection -->
|
||||||
|
<div class="me-2">
|
||||||
|
<input class="form-check-input media-checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
name="selected_content"
|
||||||
|
value="{{ media.id }}"
|
||||||
|
{% if player.groups %}disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drag handle -->
|
||||||
|
<div class="drag-handle me-2" title="Drag to reorder">
|
||||||
|
<i class="bi bi-grip-vertical"></i>
|
||||||
|
☰
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Thumbnail and Name -->
|
||||||
|
<div class="flex-grow-1 mb-2 mb-md-0 d-flex align-items-center">
|
||||||
|
<img src="{{ url_for('static', filename='uploads/' ~ media.file_name) }}"
|
||||||
|
alt="thumbnail"
|
||||||
|
style="width: 48px; height: 48px; object-fit: cover; margin-right: 10px; border-radius: 4px;"
|
||||||
|
onerror="this.style.display='none';">
|
||||||
|
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="d-flex flex-wrap justify-content-start">
|
||||||
|
<form action="{{ url_for('edit_content', content_id=media.id) }}" method="post" class="d-flex align-items-center me-2 mb-2">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">seconds</span>
|
||||||
|
<input type="number" class="form-control {% if theme == 'dark' %}dark-mode{% endif %}" name="duration" value="{{ media.duration }}" {% if player.groups %}disabled{% endif %} required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-warning ms-2" {% if player.groups %}disabled{% endif %}>Edit</button>
|
||||||
|
</form>
|
||||||
|
<form action="{{ url_for('delete_content', content_id=media.id) }}" method="post" class="mb-2">
|
||||||
|
<button type="submit" class="btn btn-danger" {% if player.groups %}disabled{% endif %} onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<!-- Add a save button for the reordering -->
|
||||||
|
<button id="saveOrder" class="btn btn-success mt-3" {% if player.groups %}disabled{% endif %}>Save Playlist Order</button>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-center">No media uploaded for this player.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
|
||||||
|
<a href="{{ url_for('player_fullscreen', player_id=player.id) }}" class="btn btn-primary">Full Screen</a>
|
||||||
|
<a href="{{ url_for('upload_content', target_type='player', target_id=player.id, return_url=url_for('player_page', player_id=player.id)) }}"
|
||||||
|
class="btn btn-success"
|
||||||
|
{% if player.groups %}disabled onclick="return false;"{% endif %}>
|
||||||
|
{% if player.groups %}Manage Media by Group{% else %}Upload Media{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Only enable if the player is not in a group (if the buttons are not disabled)
|
||||||
|
if (!document.querySelector('#saveOrder').hasAttribute('disabled')) {
|
||||||
|
const mediaList = document.getElementById('mediaList');
|
||||||
|
let draggedItem = null;
|
||||||
|
|
||||||
|
// Initialize drag events for all items
|
||||||
|
const items = mediaList.querySelectorAll('li');
|
||||||
|
items.forEach(item => {
|
||||||
|
// Drag start
|
||||||
|
item.addEventListener('dragstart', function(e) {
|
||||||
|
draggedItem = item;
|
||||||
|
setTimeout(() => {
|
||||||
|
item.classList.add('dragging');
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag end
|
||||||
|
item.addEventListener('dragend', function() {
|
||||||
|
item.classList.remove('dragging');
|
||||||
|
draggedItem = null;
|
||||||
|
updatePositions();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag over
|
||||||
|
item.addEventListener('dragover', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (item !== draggedItem) {
|
||||||
|
const rect = item.getBoundingClientRect();
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const height = rect.height;
|
||||||
|
|
||||||
|
if (y < height / 2) {
|
||||||
|
mediaList.insertBefore(draggedItem, item);
|
||||||
|
} else {
|
||||||
|
mediaList.insertBefore(draggedItem, item.nextSibling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save button click handler
|
||||||
|
document.getElementById('saveOrder').addEventListener('click', function() {
|
||||||
|
// Collect new order
|
||||||
|
const newOrder = [];
|
||||||
|
mediaList.querySelectorAll('li').forEach((item, index) => {
|
||||||
|
newOrder.push({
|
||||||
|
id: item.dataset.id,
|
||||||
|
position: index
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to server
|
||||||
|
fetch('{{ url_for("update_content_order", player_id=player.id) }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({items: newOrder})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert('Playlist order updated successfully!');
|
||||||
|
console.log('Playlist version updated to:', data.new_version);
|
||||||
|
} else {
|
||||||
|
alert('Error updating playlist order: ' + (data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('An error occurred while updating the playlist order.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update positions in the UI
|
||||||
|
function updatePositions() {
|
||||||
|
mediaList.querySelectorAll('li').forEach((item, index) => {
|
||||||
|
item.dataset.position = index;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk selection functionality
|
||||||
|
const selectAllCheckbox = document.getElementById('selectAll');
|
||||||
|
const mediaCheckboxes = document.querySelectorAll('.media-checkbox');
|
||||||
|
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
|
||||||
|
|
||||||
|
// Select all functionality
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
selectAllCheckbox.addEventListener('change', function() {
|
||||||
|
mediaCheckboxes.forEach(checkbox => {
|
||||||
|
if (!checkbox.disabled) {
|
||||||
|
checkbox.checked = this.checked;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateBulkDeleteButton();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual checkbox change
|
||||||
|
mediaCheckboxes.forEach(checkbox => {
|
||||||
|
checkbox.addEventListener('change', function() {
|
||||||
|
updateSelectAllState();
|
||||||
|
updateBulkDeleteButton();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateSelectAllState() {
|
||||||
|
const enabledCheckboxes = Array.from(mediaCheckboxes).filter(cb => !cb.disabled);
|
||||||
|
const checkedBoxes = enabledCheckboxes.filter(cb => cb.checked);
|
||||||
|
|
||||||
|
if (selectAllCheckbox) {
|
||||||
|
selectAllCheckbox.checked = checkedBoxes.length === enabledCheckboxes.length && enabledCheckboxes.length > 0;
|
||||||
|
selectAllCheckbox.indeterminate = checkedBoxes.length > 0 && checkedBoxes.length < enabledCheckboxes.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkDeleteButton() {
|
||||||
|
const checkedBoxes = Array.from(mediaCheckboxes).filter(cb => cb.checked);
|
||||||
|
if (bulkDeleteBtn) {
|
||||||
|
bulkDeleteBtn.style.display = checkedBoxes.length > 0 ? 'inline-block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function confirmBulkDelete() {
|
||||||
|
const checkedBoxes = Array.from(document.querySelectorAll('.media-checkbox:checked'));
|
||||||
|
if (checkedBoxes.length === 0) {
|
||||||
|
alert('No media files selected.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = checkedBoxes.length;
|
||||||
|
const message = `Are you sure you want to delete ${count} selected media file${count > 1 ? 's' : ''}? This action cannot be undone.`;
|
||||||
|
|
||||||
|
if (confirm(message)) {
|
||||||
|
// Create a form with selected IDs
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = '{{ url_for("bulk_delete_player_content", player_id=player.id) }}';
|
||||||
|
|
||||||
|
checkedBoxes.forEach(checkbox => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'selected_content';
|
||||||
|
input.value = checkbox.value;
|
||||||
|
form.appendChild(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
463
app/templates/upload_content.html
Normal file
463
app/templates/upload_content.html
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Upload Content</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body.dark-mode {
|
||||||
|
background-color: #121212;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.card.dark-mode {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.dark-mode label, .dark-mode th, .dark-mode td {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
max-height: 100px;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
/* Modal styling for dark mode */
|
||||||
|
.modal-content.dark-mode {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.modal-header.dark-mode {
|
||||||
|
border-bottom: 1px solid #444;
|
||||||
|
}
|
||||||
|
.modal-footer.dark-mode {
|
||||||
|
border-top: 1px solid #444;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
background-color: #007bff;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="d-flex justify-content-start align-items-center mb-4">
|
||||||
|
{% if logo_exists %}
|
||||||
|
<img src="{{ url_for('static', filename='uploads/logo.png') }}" alt="Logo" class="logo">
|
||||||
|
{% endif %}
|
||||||
|
<h1 class="mb-0">Upload Content</h1>
|
||||||
|
</div>
|
||||||
|
<form id="upload-form" action="{{ url_for('upload_content') }}" method="post" enctype="multipart/form-data" onsubmit="showStatusModal()">
|
||||||
|
<input type="hidden" name="return_url" value="{{ return_url }}">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="target_type" class="form-label">Target Type:</label>
|
||||||
|
<select name="target_type" id="target_type" class="form-select" required onchange="updateTargetIdOptions()">
|
||||||
|
<option value="" disabled selected>Select Target Type</option>
|
||||||
|
<option value="player" {% if target_type == 'player' %}selected{% endif %}>Player</option>
|
||||||
|
<option value="group" {% if target_type == 'group' %}selected{% endif %}>Group</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="target_id" class="form-label">Target ID:</label>
|
||||||
|
<select name="target_id" id="target_id" class="form-select" required>
|
||||||
|
{% if target_type == 'player' %}
|
||||||
|
<optgroup label="Players">
|
||||||
|
{% for player in players %}
|
||||||
|
<option value="{{ player.id }}" {% if target_id == player.id %}selected{% endif %}>{{ player.username }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% elif target_type == 'group' %}
|
||||||
|
<optgroup label="Groups">
|
||||||
|
{% for group in groups %}
|
||||||
|
<option value="{{ group.id }}" {% if target_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</optgroup>
|
||||||
|
{% else %}
|
||||||
|
<option value="" disabled selected>Select a Target ID</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="media_type" class="form-label">Media Type:</label>
|
||||||
|
<select name="media_type" id="media_type" class="form-select" required>
|
||||||
|
<option value="image">Image</option>
|
||||||
|
<option value="video">Video</option>
|
||||||
|
<option value="pdf">PDF</option>
|
||||||
|
<option value="ppt">PPT/PPTX</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="files" class="form-label">Files:</label>
|
||||||
|
<input type="file" name="files" id="files" class="form-control" multiple required onchange="handleFileChange()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 col-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="duration" class="form-label">Duration (seconds):</label>
|
||||||
|
<input type="number" name="duration" id="duration" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<button type="submit" id="submit-button" class="btn btn-primary">Upload</button>
|
||||||
|
<a href="{{ return_url }}" class="btn btn-secondary mt-3">Back</a>
|
||||||
|
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Modal for Status Updates -->
|
||||||
|
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||||
|
<div class="modal-content {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
|
<div class="modal-header {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
|
<h5 class="modal-title" id="statusModalLabel">Processing Files</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="status-message">Uploading and processing your files. Please wait...</p>
|
||||||
|
|
||||||
|
<!-- File Processing Progress -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-bold">File Processing Progress</label>
|
||||||
|
<div class="progress" style="height: 25px;">
|
||||||
|
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Monitoring in Modal -->
|
||||||
|
{% if system_info %}
|
||||||
|
<div class="mt-4">
|
||||||
|
<h6 class="mb-3">📊 Server Performance During Upload</h6>
|
||||||
|
<div class="row">
|
||||||
|
<!-- CPU Usage -->
|
||||||
|
<div class="col-md-4 col-12 mb-3">
|
||||||
|
<label class="form-label">CPU Usage</label>
|
||||||
|
<div class="progress" style="height: 20px;">
|
||||||
|
<div id="cpu-progress" class="progress-bar
|
||||||
|
{% if system_info.cpu_percent < 50 %}bg-success
|
||||||
|
{% elif system_info.cpu_percent < 80 %}bg-warning
|
||||||
|
{% else %}bg-danger{% endif %}"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {{ system_info.cpu_percent }}%;">
|
||||||
|
{{ system_info.cpu_percent }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ system_info.cpu_count }} cores available</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Memory Usage -->
|
||||||
|
<div class="col-md-4 col-12 mb-3">
|
||||||
|
<label class="form-label">Memory Usage</label>
|
||||||
|
<div class="progress" style="height: 20px;">
|
||||||
|
<div id="memory-progress" class="progress-bar
|
||||||
|
{% if system_info.memory_percent < 60 %}bg-success
|
||||||
|
{% elif system_info.memory_percent < 85 %}bg-warning
|
||||||
|
{% else %}bg-danger{% endif %}"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {{ system_info.memory_percent }}%;">
|
||||||
|
{{ system_info.memory_percent }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted" id="memory-text">{{ system_info.memory_used }}GB / {{ system_info.memory_total }}GB</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Disk Usage -->
|
||||||
|
<div class="col-md-4 col-12 mb-3">
|
||||||
|
<label class="form-label">Disk Space</label>
|
||||||
|
<div class="progress" style="height: 20px;">
|
||||||
|
<div id="disk-progress" class="progress-bar
|
||||||
|
{% if system_info.disk_percent < 70 %}bg-success
|
||||||
|
{% elif system_info.disk_percent < 90 %}bg-warning
|
||||||
|
{% else %}bg-danger{% endif %}"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {{ system_info.disk_percent }}%;">
|
||||||
|
{{ system_info.disk_percent }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted" id="disk-text">{{ system_info.disk_free }}GB free</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Storage Summary -->
|
||||||
|
<div class="row mt-2">
|
||||||
|
<div class="col-md-6 col-12 text-center">
|
||||||
|
<strong>Current Media Storage:</strong>
|
||||||
|
<span class="text-primary" id="storage-size">{{ system_info.upload_folder_size }}GB</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-12 text-center">
|
||||||
|
<strong>Last Updated:</strong>
|
||||||
|
<span class="text-muted" id="modal-last-update">Just now</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer {{ 'dark-mode' if theme == 'dark' else '' }}">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" disabled>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
function showStatusModal() {
|
||||||
|
console.log("Processing popup triggered");
|
||||||
|
const statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
|
||||||
|
statusModal.show();
|
||||||
|
|
||||||
|
// Update status message based on media type
|
||||||
|
const mediaType = document.getElementById('media_type').value;
|
||||||
|
const statusMessage = document.getElementById('status-message');
|
||||||
|
|
||||||
|
switch(mediaType) {
|
||||||
|
case 'image':
|
||||||
|
statusMessage.textContent = 'Uploading images...';
|
||||||
|
break;
|
||||||
|
case 'video':
|
||||||
|
statusMessage.textContent = 'Uploading and processing video. This may take a while...';
|
||||||
|
break;
|
||||||
|
case 'pdf':
|
||||||
|
statusMessage.textContent = 'Converting PDF to 4K images. This may take a while...';
|
||||||
|
break;
|
||||||
|
case 'ppt':
|
||||||
|
statusMessage.textContent = 'Converting PowerPoint to 4K images. This may take a while...';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start system monitoring updates in modal
|
||||||
|
{% if system_info %}
|
||||||
|
startModalSystemMonitoring();
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
// Simulate progress updates
|
||||||
|
const progressBar = document.getElementById('progress-bar');
|
||||||
|
let progress = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
// For slow processes, increment more slowly
|
||||||
|
const increment = (mediaType === 'image') ? 20 : 5;
|
||||||
|
progress += increment;
|
||||||
|
|
||||||
|
if (progress >= 100) {
|
||||||
|
clearInterval(interval);
|
||||||
|
statusMessage.textContent = 'Files uploaded and processed successfully!';
|
||||||
|
|
||||||
|
// Stop system monitoring updates
|
||||||
|
{% if system_info %}
|
||||||
|
stopModalSystemMonitoring();
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
// Enable the close button
|
||||||
|
document.querySelector('[data-bs-dismiss="modal"]').disabled = false;
|
||||||
|
} else {
|
||||||
|
progressBar.style.width = `${progress}%`;
|
||||||
|
progressBar.setAttribute('aria-valuenow', progress);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
{% if system_info %}
|
||||||
|
let modalSystemInterval;
|
||||||
|
|
||||||
|
function updateModalSystemInfo() {
|
||||||
|
fetch('/api/system_info')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
console.warn('Could not fetch system info:', data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update CPU
|
||||||
|
const cpuProgress = document.getElementById('cpu-progress');
|
||||||
|
if (cpuProgress) {
|
||||||
|
cpuProgress.style.width = data.cpu_percent + '%';
|
||||||
|
cpuProgress.textContent = data.cpu_percent + '%';
|
||||||
|
cpuProgress.className = 'progress-bar ' +
|
||||||
|
(data.cpu_percent < 50 ? 'bg-success' :
|
||||||
|
data.cpu_percent < 80 ? 'bg-warning' : 'bg-danger');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Memory
|
||||||
|
const memoryProgress = document.getElementById('memory-progress');
|
||||||
|
const memoryText = document.getElementById('memory-text');
|
||||||
|
if (memoryProgress) {
|
||||||
|
memoryProgress.style.width = data.memory_percent + '%';
|
||||||
|
memoryProgress.textContent = data.memory_percent + '%';
|
||||||
|
memoryProgress.className = 'progress-bar ' +
|
||||||
|
(data.memory_percent < 60 ? 'bg-success' :
|
||||||
|
data.memory_percent < 85 ? 'bg-warning' : 'bg-danger');
|
||||||
|
}
|
||||||
|
if (memoryText) {
|
||||||
|
memoryText.textContent = data.memory_used + 'GB / ' + data.memory_total + 'GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Disk
|
||||||
|
const diskProgress = document.getElementById('disk-progress');
|
||||||
|
const diskText = document.getElementById('disk-text');
|
||||||
|
if (diskProgress) {
|
||||||
|
diskProgress.style.width = data.disk_percent + '%';
|
||||||
|
diskProgress.textContent = data.disk_percent + '%';
|
||||||
|
diskProgress.className = 'progress-bar ' +
|
||||||
|
(data.disk_percent < 70 ? 'bg-success' :
|
||||||
|
data.disk_percent < 90 ? 'bg-warning' : 'bg-danger');
|
||||||
|
}
|
||||||
|
if (diskText) {
|
||||||
|
diskText.textContent = data.disk_free + 'GB free';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update storage size
|
||||||
|
const storageSize = document.getElementById('storage-size');
|
||||||
|
if (storageSize) {
|
||||||
|
storageSize.textContent = data.upload_folder_size + 'GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update timestamp
|
||||||
|
const lastUpdate = document.getElementById('modal-last-update');
|
||||||
|
if (lastUpdate) {
|
||||||
|
lastUpdate.textContent = new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.warn('Modal system monitoring update failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startModalSystemMonitoring() {
|
||||||
|
// Update immediately
|
||||||
|
updateModalSystemInfo();
|
||||||
|
// Then update every 3 seconds for real-time monitoring during upload
|
||||||
|
modalSystemInterval = setInterval(updateModalSystemInfo, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopModalSystemMonitoring() {
|
||||||
|
if (modalSystemInterval) {
|
||||||
|
clearInterval(modalSystemInterval);
|
||||||
|
modalSystemInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
function updateTargetIdOptions() {
|
||||||
|
const targetType = document.getElementById('target_type').value;
|
||||||
|
const targetIdSelect = document.getElementById('target_id');
|
||||||
|
targetIdSelect.innerHTML = ''; // Clear existing options
|
||||||
|
|
||||||
|
if (targetType === 'player') {
|
||||||
|
const players = {{ players|tojson }};
|
||||||
|
const optgroup = document.createElement('optgroup');
|
||||||
|
optgroup.label = 'Players';
|
||||||
|
players.forEach(player => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = player.id;
|
||||||
|
option.textContent = player.username;
|
||||||
|
optgroup.appendChild(option);
|
||||||
|
});
|
||||||
|
targetIdSelect.appendChild(optgroup);
|
||||||
|
} else if (targetType === 'group') {
|
||||||
|
const groups = {{ groups|tojson }};
|
||||||
|
const optgroup = document.createElement('optgroup');
|
||||||
|
optgroup.label = 'Groups';
|
||||||
|
groups.forEach(group => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = group.id;
|
||||||
|
option.textContent = group.name;
|
||||||
|
optgroup.appendChild(option);
|
||||||
|
});
|
||||||
|
targetIdSelect.appendChild(optgroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileChange() {
|
||||||
|
const mediaType = document.getElementById('media_type').value;
|
||||||
|
const filesInput = document.getElementById('files');
|
||||||
|
const durationInput = document.getElementById('duration');
|
||||||
|
|
||||||
|
if (mediaType === 'video' && filesInput.files.length > 0) {
|
||||||
|
const file = filesInput.files[0];
|
||||||
|
const video = document.createElement('video');
|
||||||
|
|
||||||
|
video.preload = 'metadata';
|
||||||
|
video.onloadedmetadata = function () {
|
||||||
|
window.URL.revokeObjectURL(video.src);
|
||||||
|
const duration = Math.round(video.duration);
|
||||||
|
durationInput.value = duration; // Set the duration in the input field
|
||||||
|
};
|
||||||
|
|
||||||
|
video.src = URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatusModal() {
|
||||||
|
console.log("Processing popup triggered");
|
||||||
|
const statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
|
||||||
|
statusModal.show();
|
||||||
|
|
||||||
|
// Update status message based on media type
|
||||||
|
const mediaType = document.getElementById('media_type').value;
|
||||||
|
const statusMessage = document.getElementById('status-message');
|
||||||
|
|
||||||
|
switch(mediaType) {
|
||||||
|
case 'image':
|
||||||
|
statusMessage.textContent = 'Uploading images...';
|
||||||
|
break;
|
||||||
|
case 'video':
|
||||||
|
statusMessage.textContent = 'Uploading and processing video. This may take a while...';
|
||||||
|
break;
|
||||||
|
case 'pdf':
|
||||||
|
statusMessage.textContent = 'Converting PDF to images. This may take a while...';
|
||||||
|
break;
|
||||||
|
case 'ppt':
|
||||||
|
statusMessage.textContent = 'Converting PowerPoint to images. This may take a while...';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate progress updates
|
||||||
|
const progressBar = document.getElementById('progress-bar');
|
||||||
|
let progress = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
// For slow processes, increment more slowly
|
||||||
|
const increment = (mediaType === 'image') ? 20 : 5;
|
||||||
|
progress += increment;
|
||||||
|
|
||||||
|
if (progress >= 100) {
|
||||||
|
clearInterval(interval);
|
||||||
|
statusMessage.textContent = 'Files uploaded and processed successfully!';
|
||||||
|
|
||||||
|
// Enable the close button
|
||||||
|
document.querySelector('[data-bs-dismiss="modal"]').disabled = false;
|
||||||
|
} else {
|
||||||
|
progressBar.style.width = `${progress}%`;
|
||||||
|
progressBar.setAttribute('aria-valuenow', progress);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
385
app/utils/group_player_management.py
Normal file
385
app/utils/group_player_management.py
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
from models import Player, Group, Content
|
||||||
|
from extensions import db
|
||||||
|
from utils.logger import (
|
||||||
|
log_group_created, log_group_edited, log_group_deleted,
|
||||||
|
log_player_created, log_player_edited, log_player_deleted,
|
||||||
|
log_player_added_to_group, log_player_removed_from_group,
|
||||||
|
log_player_unlocked, log_content_reordered,
|
||||||
|
log_content_duration_changed, log_content_added
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_group(name, player_ids, orientation='Landscape'):
|
||||||
|
"""
|
||||||
|
Create a new group with the given name, orientation, and add selected players.
|
||||||
|
Only players with the same orientation can be added.
|
||||||
|
"""
|
||||||
|
# Check all players have the same orientation
|
||||||
|
for player_id in player_ids:
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
if player and player.orientation != orientation:
|
||||||
|
raise ValueError(f"Player '{player.username}' has orientation '{player.orientation}', which does not match group orientation '{orientation}'.")
|
||||||
|
|
||||||
|
new_group = Group(name=name, orientation=orientation)
|
||||||
|
db.session.add(new_group)
|
||||||
|
db.session.flush() # Get the group ID
|
||||||
|
|
||||||
|
# Add players to the group and lock them
|
||||||
|
for player_id in player_ids:
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
if player:
|
||||||
|
new_group.players.append(player)
|
||||||
|
Content.query.filter_by(player_id=player.id).delete()
|
||||||
|
player.locked_to_group_id = new_group.id
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
log_group_created(name)
|
||||||
|
return new_group
|
||||||
|
|
||||||
|
def edit_group(group_id, name, player_ids, orientation=None):
|
||||||
|
"""
|
||||||
|
Edit an existing group, updating its name, orientation, and players.
|
||||||
|
Handles locking/unlocking players appropriately.
|
||||||
|
"""
|
||||||
|
group = Group.query.get_or_404(group_id)
|
||||||
|
old_name = group.name # Store old name in case it changes
|
||||||
|
group.name = name
|
||||||
|
|
||||||
|
# Update orientation if provided
|
||||||
|
if orientation:
|
||||||
|
group.orientation = orientation
|
||||||
|
# Validate that all selected players have the matching orientation
|
||||||
|
for player_id in player_ids:
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
if player and player.orientation != orientation:
|
||||||
|
raise ValueError(f"Player '{player.username}' has orientation '{player.orientation}', which does not match group orientation '{orientation}'.")
|
||||||
|
|
||||||
|
# Get current players in the group
|
||||||
|
current_player_ids = [player.id for player in group.players]
|
||||||
|
|
||||||
|
# Determine players to add and remove
|
||||||
|
players_to_add = [pid for pid in player_ids if pid not in current_player_ids]
|
||||||
|
players_to_remove = [pid for pid in current_player_ids if pid not in player_ids]
|
||||||
|
|
||||||
|
# Handle players to add
|
||||||
|
for player_id in players_to_add:
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
if player:
|
||||||
|
# Add to group
|
||||||
|
group.players.append(player)
|
||||||
|
|
||||||
|
# Delete individual playlist
|
||||||
|
Content.query.filter_by(player_id=player.id).delete()
|
||||||
|
|
||||||
|
# Lock to group
|
||||||
|
player.locked_to_group_id = group.id
|
||||||
|
|
||||||
|
# Log this action
|
||||||
|
log_player_added_to_group(player.username, name)
|
||||||
|
|
||||||
|
# Handle players to remove
|
||||||
|
for player_id in players_to_remove:
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
if player:
|
||||||
|
# Remove from group
|
||||||
|
group.players.remove(player)
|
||||||
|
|
||||||
|
# Unlock from group
|
||||||
|
player.locked_to_group_id = None
|
||||||
|
|
||||||
|
# Log this action
|
||||||
|
log_player_removed_from_group(player.username, name)
|
||||||
|
log_player_unlocked(player.username)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log the group edit
|
||||||
|
if old_name != name:
|
||||||
|
log_group_edited(f"{old_name} → {name}")
|
||||||
|
else:
|
||||||
|
log_group_edited(name)
|
||||||
|
|
||||||
|
return group
|
||||||
|
|
||||||
|
def delete_group(group_id):
|
||||||
|
"""
|
||||||
|
Delete a group and unlock all associated players.
|
||||||
|
"""
|
||||||
|
group = Group.query.get_or_404(group_id)
|
||||||
|
group_name = group.name
|
||||||
|
|
||||||
|
# Unlock all players in the group
|
||||||
|
for player in group.players:
|
||||||
|
player.locked_to_group_id = None
|
||||||
|
log_player_unlocked(player.username)
|
||||||
|
|
||||||
|
db.session.delete(group)
|
||||||
|
db.session.commit()
|
||||||
|
log_group_deleted(group_name)
|
||||||
|
|
||||||
|
def add_player(username, hostname, password, quickconnect_password, orientation='Landscape'):
|
||||||
|
"""
|
||||||
|
Add a new player with the given details.
|
||||||
|
"""
|
||||||
|
from flask_bcrypt import Bcrypt
|
||||||
|
bcrypt = Bcrypt()
|
||||||
|
|
||||||
|
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||||
|
hashed_quickconnect = bcrypt.generate_password_hash(quickconnect_password).decode('utf-8')
|
||||||
|
|
||||||
|
new_player = Player(
|
||||||
|
username=username,
|
||||||
|
hostname=hostname,
|
||||||
|
password=hashed_password,
|
||||||
|
quickconnect_password=hashed_quickconnect,
|
||||||
|
orientation=orientation
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(new_player)
|
||||||
|
db.session.commit()
|
||||||
|
log_player_created(username, hostname)
|
||||||
|
return new_player
|
||||||
|
|
||||||
|
def edit_player(player_id, username, hostname, password=None, quickconnect_password=None, orientation=None):
|
||||||
|
"""
|
||||||
|
Edit an existing player's details.
|
||||||
|
"""
|
||||||
|
from flask_bcrypt import Bcrypt
|
||||||
|
bcrypt = Bcrypt()
|
||||||
|
|
||||||
|
player = Player.query.get_or_404(player_id)
|
||||||
|
player.username = username
|
||||||
|
player.hostname = hostname
|
||||||
|
|
||||||
|
if password:
|
||||||
|
player.password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||||
|
|
||||||
|
if quickconnect_password:
|
||||||
|
player.quickconnect_password = bcrypt.generate_password_hash(quickconnect_password).decode('utf-8')
|
||||||
|
|
||||||
|
if orientation:
|
||||||
|
player.orientation = orientation
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
log_player_edited(username)
|
||||||
|
return player
|
||||||
|
|
||||||
|
def delete_player(player_id):
|
||||||
|
"""
|
||||||
|
Delete a player and all its content.
|
||||||
|
"""
|
||||||
|
player = Player.query.get_or_404(player_id)
|
||||||
|
username = player.username
|
||||||
|
|
||||||
|
# Delete all media related to the player
|
||||||
|
Content.query.filter_by(player_id=player_id).delete()
|
||||||
|
|
||||||
|
# Delete the player
|
||||||
|
db.session.delete(player)
|
||||||
|
db.session.commit()
|
||||||
|
log_player_deleted(username)
|
||||||
|
|
||||||
|
def get_group_content(group_id):
|
||||||
|
"""
|
||||||
|
Get content for all players in a group, ordered by position.
|
||||||
|
"""
|
||||||
|
from models import Group, Content
|
||||||
|
|
||||||
|
group = Group.query.get_or_404(group_id)
|
||||||
|
|
||||||
|
# Get all player IDs in the group
|
||||||
|
player_ids = [player.id for player in group.players]
|
||||||
|
|
||||||
|
# Get unique content based on file_name, preserving position
|
||||||
|
unique_content = {}
|
||||||
|
|
||||||
|
# For each player, get their content
|
||||||
|
for player_id in player_ids:
|
||||||
|
# Get content for this player, ordered by position
|
||||||
|
player_content = Content.query.filter_by(player_id=player_id).order_by(Content.position).all()
|
||||||
|
|
||||||
|
for content in player_content:
|
||||||
|
if content.file_name not in unique_content:
|
||||||
|
unique_content[content.file_name] = content
|
||||||
|
|
||||||
|
# Sort the unique content by position
|
||||||
|
return sorted(unique_content.values(), key=lambda c: c.position)
|
||||||
|
|
||||||
|
def get_player_content(player_id):
|
||||||
|
"""
|
||||||
|
Get content for a specific player, ordered by position.
|
||||||
|
"""
|
||||||
|
from models import Content
|
||||||
|
return Content.query.filter_by(player_id=player_id).order_by(Content.position).all()
|
||||||
|
|
||||||
|
def update_player_content_order(player_id, items):
|
||||||
|
"""
|
||||||
|
Update the order of content items for a player.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_id (int): ID of the player
|
||||||
|
items (list): List of items with id and position
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, error_message, new_version)
|
||||||
|
"""
|
||||||
|
from models import Player, Content
|
||||||
|
from extensions import db
|
||||||
|
|
||||||
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update the position field for each content item
|
||||||
|
for item in items:
|
||||||
|
content_id = int(item['id'])
|
||||||
|
position = int(item['position'])
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
if content.player_id != player_id:
|
||||||
|
continue # Skip if not for this player
|
||||||
|
content.position = position
|
||||||
|
|
||||||
|
# Force increment the playlist version to trigger client refresh
|
||||||
|
player.playlist_version = (player.playlist_version or 0) + 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log the reordering action
|
||||||
|
log_content_reordered("player", player.username)
|
||||||
|
|
||||||
|
return True, None, player.playlist_version
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, str(e), None
|
||||||
|
|
||||||
|
def update_group_content_order(group_id, items):
|
||||||
|
"""
|
||||||
|
Update the order of content items for all players in a group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id (int): ID of the group
|
||||||
|
items (list): List of items with id and position
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (success, error_message)
|
||||||
|
"""
|
||||||
|
from models import Group, Content
|
||||||
|
from extensions import db
|
||||||
|
|
||||||
|
group = Group.query.get_or_404(group_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get file names corresponding to the content IDs
|
||||||
|
content_files = {}
|
||||||
|
for item in items:
|
||||||
|
content_id = int(item['id'])
|
||||||
|
position = int(item['position'])
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
content_files[content.file_name] = position
|
||||||
|
|
||||||
|
# Update all content items for all players in this group
|
||||||
|
for player in group.players:
|
||||||
|
for content in Content.query.filter_by(player_id=player.id).all():
|
||||||
|
if content.file_name in content_files:
|
||||||
|
content.position = content_files[content.file_name]
|
||||||
|
|
||||||
|
# Force increment the playlist version to trigger client refresh
|
||||||
|
player.playlist_version = (player.playlist_version or 0) + 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log the reordering action
|
||||||
|
log_content_reordered("group", group.name)
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def edit_group_media(group_id, content_id, new_duration):
|
||||||
|
"""
|
||||||
|
Update the duration for all instances of a media item across all players in a group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id (int): ID of the group
|
||||||
|
content_id (int): ID of the content item
|
||||||
|
new_duration (int): New duration in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: Success or failure
|
||||||
|
"""
|
||||||
|
from models import Group, Content
|
||||||
|
from extensions import db
|
||||||
|
|
||||||
|
group = Group.query.get_or_404(group_id)
|
||||||
|
content = Content.query.get(content_id)
|
||||||
|
file_name = content.file_name
|
||||||
|
old_duration = content.duration
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update the duration for all players in the group
|
||||||
|
for player in group.players:
|
||||||
|
content = Content.query.filter_by(player_id=player.id, file_name=file_name).first()
|
||||||
|
if content:
|
||||||
|
content.duration = new_duration
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Log the duration change
|
||||||
|
log_content_duration_changed(file_name, old_duration, new_duration, "group", group.name)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_group_media(group_id, content_id):
|
||||||
|
"""
|
||||||
|
Delete a media item from all players in a group and remove the physical file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_id (int): ID of the group
|
||||||
|
content_id (int): ID of the content item
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: Success or failure
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
file_name = content.file_name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Delete the media for all players in the group
|
||||||
|
count = 0
|
||||||
|
for player in group.players:
|
||||||
|
content = Content.query.filter_by(player_id=player.id, file_name=file_name).first()
|
||||||
|
if content:
|
||||||
|
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
|
||||||
|
log_content_deleted(file_name, "group", group.name)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"Error in delete_group_media: {e}")
|
||||||
|
return False
|
||||||
84
app/utils/logger.py
Normal file
84
app/utils/logger.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import datetime
|
||||||
|
from extensions import db
|
||||||
|
from models import ServerLog
|
||||||
|
|
||||||
|
def log_action(action):
|
||||||
|
"""
|
||||||
|
Log an action to the server log database
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
new_log = ServerLog(action=action)
|
||||||
|
db.session.add(new_log)
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Logged action: {action}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error logging action: {e}")
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
def get_recent_logs(limit=20):
|
||||||
|
"""
|
||||||
|
Get the most recent log entries
|
||||||
|
"""
|
||||||
|
return ServerLog.query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
# Helper functions for common log actions
|
||||||
|
def log_upload(file_type, file_name, target_type, target_name):
|
||||||
|
log_action(f"{file_type.upper()} file '{file_name}' uploaded for {target_type} '{target_name}'")
|
||||||
|
|
||||||
|
def log_process(file_type, file_name, target_type, target_name):
|
||||||
|
log_action(f"{file_type.upper()} file '{file_name}' processed for {target_type} '{target_name}'")
|
||||||
|
|
||||||
|
def log_player_created(username, hostname):
|
||||||
|
log_action(f"Player '{username}' with hostname '{hostname}' was created")
|
||||||
|
|
||||||
|
def log_player_edited(username):
|
||||||
|
log_action(f"Player '{username}' was edited")
|
||||||
|
|
||||||
|
def log_player_deleted(username):
|
||||||
|
log_action(f"Player '{username}' was deleted")
|
||||||
|
|
||||||
|
def log_group_created(name):
|
||||||
|
log_action(f"Group '{name}' was created")
|
||||||
|
|
||||||
|
def log_group_edited(name):
|
||||||
|
log_action(f"Group '{name}' was edited")
|
||||||
|
|
||||||
|
def log_group_deleted(name):
|
||||||
|
log_action(f"Group '{name}' was deleted")
|
||||||
|
|
||||||
|
def log_user_created(username, role):
|
||||||
|
log_action(f"User '{username}' with role '{role}' was created")
|
||||||
|
|
||||||
|
def log_user_role_changed(username, new_role):
|
||||||
|
log_action(f"User '{username}' role changed to '{new_role}'")
|
||||||
|
|
||||||
|
def log_user_deleted(username):
|
||||||
|
log_action(f"User '{username}' was deleted")
|
||||||
|
|
||||||
|
def log_content_deleted(content_name, target_type, target_name):
|
||||||
|
log_action(f"Content '{content_name}' removed from {target_type} '{target_name}'")
|
||||||
|
|
||||||
|
def log_settings_changed(setting_name):
|
||||||
|
log_action(f"Setting '{setting_name}' was changed")
|
||||||
|
|
||||||
|
def log_files_cleaned(count):
|
||||||
|
log_action(f"{count} unused files were cleaned from storage")
|
||||||
|
|
||||||
|
# New logging functions for more detailed activities
|
||||||
|
def log_player_added_to_group(player_name, group_name):
|
||||||
|
log_action(f"Player '{player_name}' was added to group '{group_name}'")
|
||||||
|
|
||||||
|
def log_player_removed_from_group(player_name, group_name):
|
||||||
|
log_action(f"Player '{player_name}' was removed from group '{group_name}'")
|
||||||
|
|
||||||
|
def log_player_unlocked(player_name):
|
||||||
|
log_action(f"Player '{player_name}' was unlocked from its group")
|
||||||
|
|
||||||
|
def log_content_reordered(target_type, target_name):
|
||||||
|
log_action(f"Content for {target_type} '{target_name}' was reordered")
|
||||||
|
|
||||||
|
def log_content_duration_changed(content_name, old_duration, new_duration, target_type, target_name):
|
||||||
|
log_action(f"Duration for '{content_name}' changed from {old_duration}s to {new_duration}s in {target_type} '{target_name}'")
|
||||||
|
|
||||||
|
def log_content_added(content_name, target_type, target_name):
|
||||||
|
log_action(f"Content '{content_name}' added to {target_type} '{target_name}'")
|
||||||
86
app/utils/pptx_converter.py
Normal file
86
app/utils/pptx_converter.py
Normal file
@@ -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 <pptx_file>")
|
||||||
457
app/utils/uploads.py
Normal file
457
app/utils/uploads.py
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from flask import Flask
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from pdf2image import convert_from_path
|
||||||
|
from extensions import db
|
||||||
|
from models import Content, Player, Group
|
||||||
|
from utils.logger import log_content_added, log_upload, log_process
|
||||||
|
|
||||||
|
# Function to add image to playlist
|
||||||
|
def add_image_to_playlist(app, file, filename, duration, target_type, target_id):
|
||||||
|
"""
|
||||||
|
Save the image file and add it to the playlist database.
|
||||||
|
"""
|
||||||
|
# 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}")
|
||||||
|
|
||||||
|
if target_type == 'group':
|
||||||
|
group = Group.query.get_or_404(target_id)
|
||||||
|
for player in group.players:
|
||||||
|
new_content = Content(file_name=filename, duration=duration, player_id=player.id)
|
||||||
|
db.session.add(new_content)
|
||||||
|
log_content_added(filename, target_type, group.name)
|
||||||
|
elif target_type == 'player':
|
||||||
|
player = Player.query.get_or_404(target_id)
|
||||||
|
new_content = Content(file_name=filename, duration=duration, player_id=target_id)
|
||||||
|
db.session.add(new_content)
|
||||||
|
log_content_added(filename, target_type, player.username)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
log_upload('image', filename, target_type, target_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Video conversion functions
|
||||||
|
def convert_video(input_file, output_folder):
|
||||||
|
"""
|
||||||
|
Converts a video file to MP4 format with H.264 codec.
|
||||||
|
"""
|
||||||
|
# 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, 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 = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-i", input_file, # Input file
|
||||||
|
"-c:v", "libx264", # Video codec: H.264
|
||||||
|
"-preset", "fast", # Encoding speed/quality tradeoff
|
||||||
|
"-crf", "23", # Constant Rate Factor (quality, lower is better)
|
||||||
|
"-vf", "scale=-1:1080", # Scale video to 1080p (preserve aspect ratio)
|
||||||
|
"-r", "30", # Frame rate: 30 FPS
|
||||||
|
"-c:a", "aac", # Audio codec: AAC
|
||||||
|
"-b:a", "128k", # Audio bitrate
|
||||||
|
output_file # Output file
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run the FFmpeg command
|
||||||
|
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
print(f"Video converted successfully: {output_file}")
|
||||||
|
return output_file
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error converting video: {e.stderr.decode()}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def convert_video_and_update_playlist(app, file_path, original_filename, target_type, target_id, duration):
|
||||||
|
"""
|
||||||
|
Converts a video and updates the playlist database.
|
||||||
|
"""
|
||||||
|
print(f"Starting video conversion for: {file_path}")
|
||||||
|
|
||||||
|
# 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}")
|
||||||
|
|
||||||
|
# Use the application context to interact with the database
|
||||||
|
with app.app_context():
|
||||||
|
# Update the database with the converted filename
|
||||||
|
if target_type == 'group':
|
||||||
|
group = Group.query.get_or_404(target_id)
|
||||||
|
for player in group.players:
|
||||||
|
content = Content.query.filter_by(player_id=player.id, file_name=original_filename).first()
|
||||||
|
if content:
|
||||||
|
content.file_name = converted_filename
|
||||||
|
elif target_type == 'player':
|
||||||
|
content = Content.query.filter_by(player_id=target_id, file_name=original_filename).first()
|
||||||
|
if content:
|
||||||
|
content.file_name = converted_filename
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Database updated with converted video: {converted_filename}")
|
||||||
|
|
||||||
|
# Delete the original file only if it exists
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
print(f"Original file deleted: {file_path}")
|
||||||
|
else:
|
||||||
|
print(f"Video conversion failed for: {file_path}")
|
||||||
|
|
||||||
|
# PDF conversion functions
|
||||||
|
def convert_pdf_to_images(pdf_file, output_folder, delete_pdf=True, dpi=300):
|
||||||
|
"""
|
||||||
|
Convert a PDF file to high-quality JPG images in sequential order.
|
||||||
|
Uses standard 300 DPI for reliable conversion.
|
||||||
|
"""
|
||||||
|
print(f"Converting PDF to JPG images: {pdf_file} at {dpi} DPI")
|
||||||
|
print(f"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:
|
||||||
|
# Ensure output folder exists
|
||||||
|
if not os.path.exists(output_folder):
|
||||||
|
os.makedirs(output_folder, exist_ok=True)
|
||||||
|
print(f"Created output folder: {output_folder}")
|
||||||
|
|
||||||
|
# Convert PDF to images using pdf2image
|
||||||
|
print("Starting PDF conversion...")
|
||||||
|
images = convert_from_path(pdf_file, dpi=dpi)
|
||||||
|
print(f"PDF converted to {len(images)} page(s)")
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
print("ERROR: No images generated from PDF")
|
||||||
|
return []
|
||||||
|
|
||||||
|
base_name = os.path.splitext(os.path.basename(pdf_file))[0]
|
||||||
|
image_filenames = []
|
||||||
|
|
||||||
|
# Save each page as JPG image
|
||||||
|
for i, image in enumerate(images):
|
||||||
|
# Convert to RGB if necessary
|
||||||
|
if image.mode != 'RGB':
|
||||||
|
image = image.convert('RGB')
|
||||||
|
|
||||||
|
# Simple naming with page numbers
|
||||||
|
page_num = str(i + 1).zfill(3) # e.g., 001, 002, etc.
|
||||||
|
image_filename = f"{base_name}_page_{page_num}.jpg"
|
||||||
|
image_path = os.path.join(output_folder, image_filename)
|
||||||
|
|
||||||
|
# Save as JPG
|
||||||
|
image.save(image_path, 'JPEG', quality=85, optimize=True)
|
||||||
|
image_filenames.append(image_filename)
|
||||||
|
print(f"Saved page {i + 1} to: {image_path}")
|
||||||
|
|
||||||
|
print(f"PDF conversion complete. {len(image_filenames)} JPG images saved to {output_folder}")
|
||||||
|
|
||||||
|
# Delete the PDF file if requested and conversion was successful
|
||||||
|
if delete_pdf and os.path.exists(pdf_file) and image_filenames:
|
||||||
|
os.remove(pdf_file)
|
||||||
|
print(f"PDF file deleted: {pdf_file}")
|
||||||
|
|
||||||
|
return image_filenames
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error converting PDF to JPG images: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return []
|
||||||
|
|
||||||
|
def update_playlist_with_files(image_filenames, duration, target_type, target_id):
|
||||||
|
"""
|
||||||
|
Add files to a player or group playlist and update version numbers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_filenames (list): List of filenames to add to playlist
|
||||||
|
duration (int): Duration in seconds for each file
|
||||||
|
target_type (str): 'player' or 'group'
|
||||||
|
target_id (int): ID of the player or group
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if target_type == 'group':
|
||||||
|
group = Group.query.get_or_404(target_id)
|
||||||
|
for player in group.players:
|
||||||
|
for image_filename in image_filenames:
|
||||||
|
new_content = Content(file_name=image_filename, duration=duration, player_id=player.id)
|
||||||
|
db.session.add(new_content)
|
||||||
|
player.playlist_version += 1
|
||||||
|
group.playlist_version += 1
|
||||||
|
elif target_type == 'player':
|
||||||
|
player = Player.query.get_or_404(target_id)
|
||||||
|
for image_filename in image_filenames:
|
||||||
|
new_content = Content(file_name=image_filename, duration=duration, player_id=target_id)
|
||||||
|
db.session.add(new_content)
|
||||||
|
player.playlist_version += 1
|
||||||
|
else:
|
||||||
|
print(f"Invalid target type: {target_type}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
print(f"Added {len(image_filenames)} files to playlist")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
print(f"Error updating playlist: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def process_pdf(input_file, output_folder, duration, target_type, target_id):
|
||||||
|
"""
|
||||||
|
Process a PDF file: convert to images and update playlist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_file (str): Path to the PDF file
|
||||||
|
output_folder (str): Path to save the images
|
||||||
|
duration (int): Duration in seconds for each image
|
||||||
|
target_type (str): 'player' or 'group'
|
||||||
|
target_id (int): ID of the player or group
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
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, exist_ok=True)
|
||||||
|
print(f"Created output folder: {output_folder}")
|
||||||
|
|
||||||
|
# Convert PDF to images using standard quality (delete PDF after successful conversion)
|
||||||
|
image_filenames = convert_pdf_to_images(input_file, output_folder, delete_pdf=True, dpi=300)
|
||||||
|
|
||||||
|
# Update playlist with generated images
|
||||||
|
if image_filenames:
|
||||||
|
success = update_playlist_with_files(image_filenames, duration, target_type, target_id)
|
||||||
|
if success:
|
||||||
|
print(f"Successfully processed PDF: {len(image_filenames)} images added to playlist")
|
||||||
|
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 first, then to JPG images (same workflow as PDF).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_file (str): Path to the PPTX file
|
||||||
|
output_folder (str): Path to save the images
|
||||||
|
duration (int): Duration in seconds for each image
|
||||||
|
target_type (str): 'player' or 'group'
|
||||||
|
target_id (int): ID of the player or group
|
||||||
|
|
||||||
|
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, exist_ok=True)
|
||||||
|
print(f"Created output folder: {output_folder}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
if not pdf_file:
|
||||||
|
print("Error: Failed to convert PPTX to PDF")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"PPTX successfully converted to PDF: {pdf_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
|
||||||
|
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 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):
|
||||||
|
"""
|
||||||
|
Process uploaded files based on media type and add them to playlists.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: List of result dictionaries with success status and messages
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Get target name for logging
|
||||||
|
target_name = ""
|
||||||
|
if target_type == 'group':
|
||||||
|
group = Group.query.get_or_404(target_id)
|
||||||
|
target_name = group.name
|
||||||
|
elif target_type == 'player':
|
||||||
|
player = Player.query.get_or_404(target_id)
|
||||||
|
target_name = player.username
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
try:
|
||||||
|
# Generate a secure filename and save the file
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
|
||||||
|
# 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': ''}
|
||||||
|
|
||||||
|
if media_type == 'image':
|
||||||
|
add_image_to_playlist(app, file, filename, duration, target_type, target_id)
|
||||||
|
result['message'] = f"Image {filename} added to playlist"
|
||||||
|
log_upload('image', filename, target_type, target_id)
|
||||||
|
|
||||||
|
elif media_type == 'video':
|
||||||
|
# For videos, add to playlist then start conversion in background
|
||||||
|
if target_type == 'group':
|
||||||
|
group = Group.query.get_or_404(target_id)
|
||||||
|
for player in group.players:
|
||||||
|
new_content = Content(file_name=filename, duration=duration, player_id=player.id)
|
||||||
|
db.session.add(new_content)
|
||||||
|
player.playlist_version += 1
|
||||||
|
group.playlist_version += 1
|
||||||
|
elif target_type == 'player':
|
||||||
|
player = Player.query.get_or_404(target_id)
|
||||||
|
new_content = Content(file_name=filename, duration=duration, player_id=target_id)
|
||||||
|
db.session.add(new_content)
|
||||||
|
player.playlist_version += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
# Start background conversion using absolute path
|
||||||
|
import threading
|
||||||
|
threading.Thread(target=convert_video_and_update_playlist,
|
||||||
|
args=(app, file_path, filename, target_type, target_id, duration)).start()
|
||||||
|
result['message'] = f"Video {filename} added to playlist and being processed"
|
||||||
|
log_upload('video', filename, target_type, target_id)
|
||||||
|
|
||||||
|
elif media_type == 'pdf':
|
||||||
|
# For PDFs, convert to images and update playlist using absolute path
|
||||||
|
success = process_pdf(file_path, upload_folder,
|
||||||
|
duration, target_type, target_id)
|
||||||
|
if success:
|
||||||
|
result['message'] = f"PDF {filename} processed successfully"
|
||||||
|
log_process('pdf', filename, target_type, target_id)
|
||||||
|
else:
|
||||||
|
result['success'] = False
|
||||||
|
result['message'] = f"Error processing PDF file: {filename}"
|
||||||
|
|
||||||
|
elif media_type == 'ppt':
|
||||||
|
# For PPT/PPTX, convert to PDF, then to images, and update playlist using absolute path
|
||||||
|
success = process_pptx(file_path, upload_folder,
|
||||||
|
duration, target_type, target_id)
|
||||||
|
if success:
|
||||||
|
result['message'] = f"PowerPoint {filename} processed successfully"
|
||||||
|
log_process('ppt', filename, target_type, target_id)
|
||||||
|
else:
|
||||||
|
result['success'] = False
|
||||||
|
result['message'] = f"Error processing PowerPoint file: {filename}"
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing file {file.filename}: {e}")
|
||||||
|
results.append({
|
||||||
|
'filename': file.filename,
|
||||||
|
'success': False,
|
||||||
|
'message': f"Error processing file {file.filename}: {str(e)}"
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
64
cleanup-docker.sh
Executable file
64
cleanup-docker.sh
Executable file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# DigiServer Docker Cleanup Script
|
||||||
|
# Version: 1.1.0
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🧹 DigiServer Docker Cleanup"
|
||||||
|
echo "============================"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to print colored output
|
||||||
|
print_status() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Confirm cleanup
|
||||||
|
print_warning "This will stop and remove all DigiServer containers and images."
|
||||||
|
print_warning "Your data in the ./data directory will be preserved."
|
||||||
|
echo ""
|
||||||
|
read -p "Are you sure you want to continue? (y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
print_status "Cleanup cancelled."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop and remove containers
|
||||||
|
print_status "Stopping DigiServer containers..."
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Remove DigiServer images
|
||||||
|
print_status "Removing DigiServer images..."
|
||||||
|
docker rmi digiserver:latest 2>/dev/null || print_warning "DigiServer image not found"
|
||||||
|
|
||||||
|
# Clean up unused Docker resources
|
||||||
|
print_status "Cleaning up unused Docker resources..."
|
||||||
|
docker system prune -f
|
||||||
|
|
||||||
|
# Clean up development cache files
|
||||||
|
print_status "Cleaning up development cache files..."
|
||||||
|
find ./app -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find ./app -name "*.pyc" -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
print_success "Cleanup completed!"
|
||||||
|
print_status "Data directory preserved at: ./data"
|
||||||
|
print_status "To redeploy, run: ./deploy-docker.sh"
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
|
|
||||||
# drop_user_table.py
|
|
||||||
from app import app, db
|
|
||||||
|
|
||||||
with app.app_context():
|
|
||||||
db.drop_all()
|
|
||||||
print("Dropped all tables.")
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
from app import app, db, User, bcrypt
|
|
||||||
|
|
||||||
# Create the default user
|
|
||||||
username = 'admin'
|
|
||||||
password = '1234'
|
|
||||||
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
|
||||||
|
|
||||||
with app.app_context():
|
|
||||||
# Delete the existing user if it exists
|
|
||||||
existing_user = User.query.filter_by(username=username).first()
|
|
||||||
if existing_user:
|
|
||||||
db.session.delete(existing_user)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Add the new user to the database
|
|
||||||
default_user = User(username=username, password=hashed_password, role='admin')
|
|
||||||
db.session.add(default_user)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
print(f"Default user '{username}' created with password '{password}'")
|
|
||||||
109
deploy-docker.sh
Executable file
109
deploy-docker.sh
Executable file
@@ -0,0 +1,109 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# DigiServer Docker Deployment Script
|
||||||
|
# Version: 1.1.0
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 DigiServer Docker Deployment"
|
||||||
|
echo "================================"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to print colored output
|
||||||
|
print_status() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if Docker is running
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
print_error "Docker is not running. Please start Docker and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_status "Docker is running ✓"
|
||||||
|
|
||||||
|
# Check if docker compose is available
|
||||||
|
if ! docker compose version >/dev/null 2>&1; then
|
||||||
|
print_error "docker compose is not available. Please install Docker Compose and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_status "docker compose is available ✓"
|
||||||
|
|
||||||
|
# Stop existing containers if running
|
||||||
|
print_status "Stopping existing containers..."
|
||||||
|
docker compose down 2>/dev/null || true
|
||||||
|
|
||||||
|
# Remove old images (optional)
|
||||||
|
read -p "Do you want to remove old DigiServer images? (y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
print_status "Removing old images..."
|
||||||
|
docker image prune -f --filter label=app=digiserver 2>/dev/null || true
|
||||||
|
docker rmi digiserver:latest 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create data directories if they don't exist
|
||||||
|
print_status "Creating data directories..."
|
||||||
|
mkdir -p data/instance data/uploads data/resurse
|
||||||
|
|
||||||
|
# Build the Docker image
|
||||||
|
print_status "Building DigiServer Docker image..."
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
# Check if build was successful
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
print_success "Docker image built successfully!"
|
||||||
|
else
|
||||||
|
print_error "Docker build failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start the containers
|
||||||
|
print_status "Starting DigiServer containers..."
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Wait a moment for containers to start
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Check if containers are running
|
||||||
|
if docker compose ps | grep -q "Up"; then
|
||||||
|
print_success "DigiServer is now running!"
|
||||||
|
echo ""
|
||||||
|
echo "🌐 Access your DigiServer at: http://localhost:8880"
|
||||||
|
echo "📊 Admin Panel: http://localhost:8880/admin"
|
||||||
|
echo ""
|
||||||
|
echo "Default credentials:"
|
||||||
|
echo "Username: admin"
|
||||||
|
echo "Password: Initial01!"
|
||||||
|
echo ""
|
||||||
|
print_warning "Please change the default password after first login!"
|
||||||
|
echo ""
|
||||||
|
echo "📝 To view logs: docker compose logs -f"
|
||||||
|
echo "🛑 To stop: docker compose down"
|
||||||
|
echo "📊 To check status: docker compose ps"
|
||||||
|
else
|
||||||
|
print_error "Failed to start DigiServer containers!"
|
||||||
|
echo ""
|
||||||
|
echo "Check logs with: docker compose logs"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_success "Deployment completed successfully! 🎉"
|
||||||
32
docker-compose.dev.yml
Normal file
32
docker-compose.dev.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Development Docker Compose Configuration
|
||||||
|
# Use this for development with hot reloading
|
||||||
|
|
||||||
|
services:
|
||||||
|
digiserver-dev:
|
||||||
|
build: .
|
||||||
|
image: digiserver:dev
|
||||||
|
container_name: digiserver-dev
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
- FLASK_APP=app.py
|
||||||
|
- FLASK_RUN_HOST=0.0.0.0
|
||||||
|
- FLASK_ENV=development
|
||||||
|
- FLASK_DEBUG=1
|
||||||
|
- ADMIN_USER=admin
|
||||||
|
- ADMIN_PASSWORD=Initial01!
|
||||||
|
- SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
|
||||||
|
volumes:
|
||||||
|
# Mount app code for hot reloading
|
||||||
|
- ./app:/app
|
||||||
|
# Persistent data volumes
|
||||||
|
- ./data/instance:/app/instance
|
||||||
|
- ./data/uploads:/app/static/uploads
|
||||||
|
- ./data/resurse:/app/static/resurse
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- digiserver-dev-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
digiserver-dev-network:
|
||||||
|
driver: bridge
|
||||||
30
docker-compose.yml
Normal file → Executable file
30
docker-compose.yml
Normal file → Executable file
@@ -1,15 +1,35 @@
|
|||||||
|
# DigiServer - Digital Signage Management Platform
|
||||||
|
# Version: 1.1.0
|
||||||
|
# Build Date: 2025-06-29
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
digiserver:
|
||||||
|
build: .
|
||||||
image: digiserver:latest
|
image: digiserver:latest
|
||||||
|
container_name: digiserver
|
||||||
ports:
|
ports:
|
||||||
- "8880:5000"
|
- "8880:5000"
|
||||||
environment:
|
environment:
|
||||||
- FLASK_APP=app.py
|
- FLASK_APP=app.py
|
||||||
- FLASK_RUN_HOST=0.0.0.0
|
- FLASK_RUN_HOST=0.0.0.0
|
||||||
- ADMIN_USER=admin
|
- DEFAULT_USER=admin
|
||||||
- ADMIN_PASSWORD=Initial01!
|
- DEFAULT_PASSWORD=Initial01!
|
||||||
- SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
|
- SECRET_KEY=Ma_Duc_Dupa_Merele_Lui_Ana
|
||||||
volumes:
|
volumes:
|
||||||
- /home/pi/Desktop/digi-server/db:/app/instance
|
# Persistent data volumes
|
||||||
- /home/pi/Desktop/digi-server/static:/app/static/uploads
|
- ./data/instance:/app/instance
|
||||||
|
- ./data/uploads:/app/static/uploads
|
||||||
|
- ./data/resurse:/app/static/resurse
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- digiserver-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
digiserver-network:
|
||||||
|
driver: bridge
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# filepath: /home/ske087/digiserver/entrypoint.sh
|
|
||||||
|
|
||||||
# Initialize the database if it doesn't exist
|
|
||||||
if [ ! -f "/app/instance/dashboard.db" ]; then
|
|
||||||
echo "Initializing database..."
|
|
||||||
python init_db.py
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start Gunicorn
|
|
||||||
exec gunicorn -w 4 -b 0.0.0.0:5000 app:app
|
|
||||||
@@ -1,15 +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
|
|
||||||
Binary file not shown.
64
models.py
64
models.py
@@ -1,64 +0,0 @@
|
|||||||
from extensions import db
|
|
||||||
from flask_bcrypt import Bcrypt
|
|
||||||
from flask_login import UserMixin
|
|
||||||
|
|
||||||
bcrypt = Bcrypt()
|
|
||||||
|
|
||||||
class Content(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
file_name = db.Column(db.String(120), nullable=False)
|
|
||||||
duration = db.Column(db.Integer, nullable=False)
|
|
||||||
player_id = db.Column(db.Integer, db.ForeignKey('player.id'), nullable=True)
|
|
||||||
|
|
||||||
class Player(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
username = db.Column(db.String(100), nullable=False, unique=True)
|
|
||||||
hostname = db.Column(db.String(100), nullable=False)
|
|
||||||
password = db.Column(db.String(200), nullable=False)
|
|
||||||
quickconnect_password = db.Column(db.String(200), nullable=True) # Add this field
|
|
||||||
playlist_version = db.Column(db.Integer, default=0) # Playlist version counter
|
|
||||||
|
|
||||||
def verify_quickconnect_code(self, code):
|
|
||||||
return bcrypt.check_password_hash(self.quickconnect_password, code)
|
|
||||||
|
|
||||||
class User(db.Model, UserMixin):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
|
||||||
password = db.Column(db.String(120), nullable=False)
|
|
||||||
role = db.Column(db.String(80), nullable=False)
|
|
||||||
theme = db.Column(db.String(80), default='light')
|
|
||||||
|
|
||||||
def set_password(self, password):
|
|
||||||
self.password = bcrypt.generate_password_hash(password).decode('utf-8')
|
|
||||||
|
|
||||||
def check_password(self, password):
|
|
||||||
return bcrypt.check_password_hash(self.password, password)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_active(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_authenticated(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_anonymous(self):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_id(self):
|
|
||||||
return str(self.id)
|
|
||||||
|
|
||||||
class Group(db.Model):
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
name = db.Column(db.String(100), nullable=False, unique=True)
|
|
||||||
players = db.relationship('Player', secondary='group_player', backref='groups')
|
|
||||||
playlist_version = db.Column(db.Integer, default=0) # Playlist version counter
|
|
||||||
|
|
||||||
# Association table for many-to-many relationship between Group and Player
|
|
||||||
group_player = db.Table('group_player',
|
|
||||||
db.Column('group_id', db.Integer, db.ForeignKey('group.id'), primary_key=True),
|
|
||||||
db.Column('player_id', db.Integer, db.ForeignKey('player.id'), primary_key=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
# other models...
|
|
||||||
30
ppt
30
ppt
@@ -1,30 +0,0 @@
|
|||||||
def convert_ppt_to_images(input_file, output_folder):
|
|
||||||
"""
|
|
||||||
Converts a PowerPoint file (.ppt or .pptx) to images using LibreOffice.
|
|
||||||
Each slide is saved as a separate image in the output folder.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(output_folder):
|
|
||||||
os.makedirs(output_folder)
|
|
||||||
|
|
||||||
# Convert the PowerPoint file to images using LibreOffice
|
|
||||||
command = [
|
|
||||||
'libreoffice',
|
|
||||||
'--headless',
|
|
||||||
'--convert-to', 'png',
|
|
||||||
'--outdir', output_folder,
|
|
||||||
input_file
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
||||||
print(f"PPT file converted to images: {input_file}")
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"Error converting PPT to images: {e.stderr.decode()}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Rename the generated images to follow the naming convention
|
|
||||||
base_name = os.path.splitext(os.path.basename(input_file))[0]
|
|
||||||
for i, file_name in enumerate(sorted(os.listdir(output_folder))):
|
|
||||||
if file_name.endswith('.png'):
|
|
||||||
new_name = f"{base_name}_{i + 1}.png"
|
|
||||||
os.rename(os.path.join(output_folder, file_name), os.path.join(output_folder, new_name))
|
|
||||||
return True
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
def convert_pptx_to_images(input_file, output_folder):
|
|
||||||
"""
|
|
||||||
Converts a PowerPoint file (.ppt or .pptx) to images using LibreOffice.
|
|
||||||
Each slide is saved as a separate image in the output folder.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(output_folder):
|
|
||||||
os.makedirs(output_folder)
|
|
||||||
|
|
||||||
# Convert the PowerPoint file to images using LibreOffice
|
|
||||||
command = [
|
|
||||||
'libreoffice',
|
|
||||||
'--headless',
|
|
||||||
'--convert-to', 'png',
|
|
||||||
'--outdir', output_folder,
|
|
||||||
input_file
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
||||||
print(f"PPTX file converted to images: {input_file}")
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"Error converting PPTX to images: {e.stderr.decode()}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Rename the generated images to follow the naming convention
|
|
||||||
base_name = os.path.splitext(os.path.basename(input_file))[0]
|
|
||||||
png_files = sorted([f for f in os.listdir(output_folder) if f.endswith('.png') and base_name in f])
|
|
||||||
for i, file_name in enumerate(png_files):
|
|
||||||
new_name = f"{base_name}_{i + 1}.png"
|
|
||||||
os.rename(os.path.join(output_folder, file_name), os.path.join(output_folder, new_name))
|
|
||||||
print("Renamed slide images:", [f"{base_name}_{i + 1}.png" for i in range(len(png_files))])
|
|
||||||
return True
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
alembic==1.14.1
|
|
||||||
bcrypt==4.2.1
|
|
||||||
blinker==1.9.0
|
|
||||||
click==8.1.8
|
|
||||||
Flask==3.1.0
|
|
||||||
Flask-Bcrypt==1.0.1
|
|
||||||
Flask-Login==0.6.3
|
|
||||||
Flask-Migrate==4.1.0
|
|
||||||
Flask-SQLAlchemy==3.1.1
|
|
||||||
greenlet==3.1.1
|
|
||||||
itsdangerous==2.2.0
|
|
||||||
Jinja2==3.1.5
|
|
||||||
Mako==1.3.8
|
|
||||||
MarkupSafe==3.0.2
|
|
||||||
SQLAlchemy==2.0.37
|
|
||||||
typing_extensions==4.12.2
|
|
||||||
Werkzeug==3.1.3
|
|
||||||
gunicorn==20.1.0
|
|
||||||
pdf2image==1.17.0
|
|
||||||
python-pptx==0.6.21
|
|
||||||
cairosvg==2.7.0
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Edit Group</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body.dark-mode {
|
|
||||||
background-color: #121212;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.card.dark-mode {
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.dark-mode label, .dark-mode th, .dark-mode td {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
||||||
<div class="container py-5">
|
|
||||||
<h1 class="text-center mb-4">Edit Group</h1>
|
|
||||||
<form method="POST">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 col-12">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="name" class="form-label">Group Name</label>
|
|
||||||
<input type="text" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="name" name="name" value="{{ group.name }}" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-12">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="players" class="form-label">Select Players</label>
|
|
||||||
<select multiple class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" id="players" name="players">
|
|
||||||
{% for player in players %}
|
|
||||||
<option value="{{ player.id }}" {% if player in group.players %}selected{% endif %}>{{ player.username }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
|
||||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Manage Group</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body.dark-mode {
|
|
||||||
background-color: #121212;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.card.dark-mode {
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.dark-mode label, .dark-mode th, .dark-mode td {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
||||||
<div class="container py-5">
|
|
||||||
<h1 class="text-center mb-4">Manage Group: {{ group.name }}</h1>
|
|
||||||
|
|
||||||
<!-- Group Information Card -->
|
|
||||||
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
||||||
<div class="card-header bg-info text-white">
|
|
||||||
<h2>Group Info</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p><strong>Group Name:</strong> {{ group.name }}</p>
|
|
||||||
<p><strong>Number of Players:</strong> {{ group.players|length }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- List of Players in the Group -->
|
|
||||||
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
||||||
<div class="card-header bg-secondary text-white">
|
|
||||||
<h2>Players in Group</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<ul class="list-group">
|
|
||||||
{% for player in group.players %}
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<div>
|
|
||||||
<strong>{{ player.username }}</strong> ({{ player.hostname }})
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manage Media Section -->
|
|
||||||
<div class="card mb-4 {{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
||||||
<div class="card-header bg-info text-white">
|
|
||||||
<h2>Manage Media</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if content %}
|
|
||||||
<ul class="list-group">
|
|
||||||
{% for media in content %}
|
|
||||||
<li class="list-group-item d-flex align-items-center {{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
|
|
||||||
</div>
|
|
||||||
<form action="{{ url_for('edit_group_media', group_id=group.id, content_id=media.id) }}" method="post" class="d-flex align-items-center">
|
|
||||||
<div class="input-group me-2">
|
|
||||||
<span class="input-group-text">seconds</span>
|
|
||||||
<input type="number" class="form-control {{ 'dark-mode' if theme == 'dark' else '' }}" name="duration" value="{{ media.duration }}" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-warning me-2">Edit</button>
|
|
||||||
</form>
|
|
||||||
<form action="{{ url_for('delete_group_media', group_id=group.id, content_id=media.id) }}" method="post" style="display:inline;">
|
|
||||||
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-center">No media uploaded for this group.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upload Media Button -->
|
|
||||||
<div class="text-center mb-4">
|
|
||||||
<a href="{{ url_for('upload_content', target_type='group', target_id=group.id, return_url=url_for('manage_group', group_id=group.id)) }}" class="btn btn-primary btn-lg">Go to Upload Media</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Back to Dashboard Button -->
|
|
||||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Player Schedule</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body.dark-mode {
|
|
||||||
background-color: #121212;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.card.dark-mode {
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.dark-mode label, .dark-mode th, .dark-mode td {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="{% if theme == 'dark' %}dark-mode{% endif %}">
|
|
||||||
<div class="container py-5">
|
|
||||||
<h1 class="text-center mb-4">Player Schedule for {{ player.username }}</h1>
|
|
||||||
|
|
||||||
<!-- Player Info Section -->
|
|
||||||
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
|
|
||||||
<div class="card-header bg-info text-white">
|
|
||||||
<h2>Player Info</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p><strong>Player Name:</strong> {{ player.username }}</p>
|
|
||||||
<p><strong>Hostname:</strong> {{ player.hostname }}</p>
|
|
||||||
{% if current_user.role == 'admin' %}
|
|
||||||
<a href="{{ url_for('edit_player', player_id=player.id, return_url=url_for('player_page', player_id=player.id)) }}" class="btn btn-warning">Update</a>
|
|
||||||
<form action="{{ url_for('delete_player', player_id=player.id) }}" method="post" style="display:inline;">
|
|
||||||
<button type="submit" class="btn btn-danger" onclick="return confirm('Are you sure you want to delete this player?');">Delete</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Group Membership Section -->
|
|
||||||
<div class="mb-4">
|
|
||||||
{% if player.groups %}
|
|
||||||
<h4 class="text-center">Member of Group(s):</h4>
|
|
||||||
<ul class="list-group">
|
|
||||||
{% for group in player.groups %}
|
|
||||||
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}">{{ group.name }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-center">This player is not a member of any groups.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Media Management Section -->
|
|
||||||
{% if current_user.role == 'admin' %}
|
|
||||||
<div class="card mb-4 {% if theme == 'dark' %}dark-mode{% endif %}">
|
|
||||||
<div class="card-header bg-info text-white">
|
|
||||||
<h2>Manage Media</h2>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if content %}
|
|
||||||
<ul class="list-group">
|
|
||||||
{% for media in content %}
|
|
||||||
<li class="list-group-item {% if theme == 'dark' %}dark-mode{% endif %}">
|
|
||||||
<div class="d-flex flex-column flex-md-row align-items-md-center">
|
|
||||||
<!-- Media Name -->
|
|
||||||
<div class="flex-grow-1 mb-2 mb-md-0">
|
|
||||||
<p class="mb-0"><strong>Media Name:</strong> {{ media.file_name }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="d-flex flex-wrap justify-content-start">
|
|
||||||
<form action="{{ url_for('edit_content', content_id=media.id) }}" method="post" class="d-flex align-items-center me-2 mb-2">
|
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-text">seconds</span>
|
|
||||||
<input type="number" class="form-control {% if theme == 'dark' %}dark-mode{% endif %}" name="duration" value="{{ media.duration }}" {% if player.groups %}disabled{% endif %} required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-warning ms-2" {% if player.groups %}disabled{% endif %}>Edit</button>
|
|
||||||
</form>
|
|
||||||
<form action="{{ url_for('delete_content', content_id=media.id) }}" method="post" class="mb-2">
|
|
||||||
<button type="submit" class="btn btn-danger" {% if player.groups %}disabled{% endif %} onclick="return confirm('Are you sure you want to delete this media?');">Delete</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-center">No media uploaded for this player.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">Back to Dashboard</a>
|
|
||||||
<a href="{{ url_for('player_fullscreen', player_id=player.id) }}" class="btn btn-primary">Full Screen</a>
|
|
||||||
<a href="{{ url_for('upload_content', target_type='player', target_id=player.id, return_url=url_for('player_page', player_id=player.id)) }}"
|
|
||||||
class="btn btn-success"
|
|
||||||
{% if player.groups %}disabled onclick="return false;"{% endif %}>
|
|
||||||
{% if player.groups %}Manage Media by Group{% else %}Upload Media{% endif %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Upload Content</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body.dark-mode {
|
|
||||||
background-color: #121212;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.card.dark-mode {
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.dark-mode label, .dark-mode th, .dark-mode td {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.logo {
|
|
||||||
max-height: 100px;
|
|
||||||
margin-right: 20px;
|
|
||||||
}
|
|
||||||
/* Modal styling for dark mode */
|
|
||||||
.modal-content.dark-mode {
|
|
||||||
background-color: #1e1e1e;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
.modal-header.dark-mode {
|
|
||||||
border-bottom: 1px solid #444;
|
|
||||||
}
|
|
||||||
.modal-footer.dark-mode {
|
|
||||||
border-top: 1px solid #444;
|
|
||||||
}
|
|
||||||
.progress-bar {
|
|
||||||
background-color: #007bff;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="{{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
||||||
<div class="container py-5">
|
|
||||||
<div class="d-flex justify-content-start align-items-center mb-4">
|
|
||||||
{% if logo_exists %}
|
|
||||||
<img src="{{ url_for('static', filename='uploads/logo.png') }}" alt="Logo" class="logo">
|
|
||||||
{% endif %}
|
|
||||||
<h1 class="mb-0">Upload Content</h1>
|
|
||||||
</div>
|
|
||||||
<form id="upload-form" action="{{ url_for('upload_content') }}" method="post" enctype="multipart/form-data" onsubmit="showStatusModal()">
|
|
||||||
<input type="hidden" name="return_url" value="{{ return_url }}">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 col-12">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="target_type" class="form-label">Target Type:</label>
|
|
||||||
<select name="target_type" id="target_type" class="form-select" required onchange="updateTargetIdOptions()">
|
|
||||||
<option value="" disabled selected>Select Target Type</option>
|
|
||||||
<option value="player" {% if target_type == 'player' %}selected{% endif %}>Player</option>
|
|
||||||
<option value="group" {% if target_type == 'group' %}selected{% endif %}>Group</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-12">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="target_id" class="form-label">Target ID:</label>
|
|
||||||
<select name="target_id" id="target_id" class="form-select" required>
|
|
||||||
{% if target_type == 'player' %}
|
|
||||||
<optgroup label="Players">
|
|
||||||
{% for player in players %}
|
|
||||||
<option value="{{ player.id }}" {% if target_id == player.id %}selected{% endif %}>{{ player.username }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</optgroup>
|
|
||||||
{% elif target_type == 'group' %}
|
|
||||||
<optgroup label="Groups">
|
|
||||||
{% for group in groups %}
|
|
||||||
<option value="{{ group.id }}" {% if target_id == group.id %}selected{% endif %}>{{ group.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</optgroup>
|
|
||||||
{% else %}
|
|
||||||
<option value="" disabled selected>Select a Target ID</option>
|
|
||||||
{% endif %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 col-12">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="media_type" class="form-label">Media Type:</label>
|
|
||||||
<select name="media_type" id="media_type" class="form-select" required>
|
|
||||||
<option value="image">Image</option>
|
|
||||||
<option value="video">Video</option>
|
|
||||||
<option value="pdf">PDF</option>
|
|
||||||
<option value="ppt">PPT/PPTX</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-12">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="files" class="form-label">Files:</label>
|
|
||||||
<input type="file" name="files" id="files" class="form-control" multiple required onchange="handleFileChange()">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6 col-12">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="duration" class="form-label">Duration (seconds):</label>
|
|
||||||
<input type="number" name="duration" id="duration" class="form-control" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center">
|
|
||||||
<button type="submit" id="submit-button" class="btn btn-primary">Upload</button>
|
|
||||||
<a href="{{ return_url }}" class="btn btn-secondary mt-3">Back</a>
|
|
||||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary mt-3">Back to Dashboard</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Modal for Status Updates -->
|
|
||||||
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content {{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
||||||
<div class="modal-header {{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
||||||
<h5 class="modal-title" id="statusModalLabel">Processing Files</h5>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p id="status-message">Uploading and processing your files. Please wait...</p>
|
|
||||||
<div class="progress">
|
|
||||||
<div id="progress-bar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer {{ 'dark-mode' if theme == 'dark' else '' }}">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" disabled>Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script>
|
|
||||||
function updateTargetIdOptions() {
|
|
||||||
const targetType = document.getElementById('target_type').value;
|
|
||||||
const targetIdSelect = document.getElementById('target_id');
|
|
||||||
targetIdSelect.innerHTML = ''; // Clear existing options
|
|
||||||
|
|
||||||
if (targetType === 'player') {
|
|
||||||
const players = {{ players|tojson }};
|
|
||||||
const optgroup = document.createElement('optgroup');
|
|
||||||
optgroup.label = 'Players';
|
|
||||||
players.forEach(player => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = player.id;
|
|
||||||
option.textContent = player.username;
|
|
||||||
optgroup.appendChild(option);
|
|
||||||
});
|
|
||||||
targetIdSelect.appendChild(optgroup);
|
|
||||||
} else if (targetType === 'group') {
|
|
||||||
const groups = {{ groups|tojson }};
|
|
||||||
const optgroup = document.createElement('optgroup');
|
|
||||||
optgroup.label = 'Groups';
|
|
||||||
groups.forEach(group => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = group.id;
|
|
||||||
option.textContent = group.name;
|
|
||||||
optgroup.appendChild(option);
|
|
||||||
});
|
|
||||||
targetIdSelect.appendChild(optgroup);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileChange() {
|
|
||||||
const mediaType = document.getElementById('media_type').value;
|
|
||||||
const filesInput = document.getElementById('files');
|
|
||||||
const durationInput = document.getElementById('duration');
|
|
||||||
|
|
||||||
if (mediaType === 'video' && filesInput.files.length > 0) {
|
|
||||||
const file = filesInput.files[0];
|
|
||||||
const video = document.createElement('video');
|
|
||||||
|
|
||||||
video.preload = 'metadata';
|
|
||||||
video.onloadedmetadata = function () {
|
|
||||||
window.URL.revokeObjectURL(video.src);
|
|
||||||
const duration = Math.round(video.duration);
|
|
||||||
durationInput.value = duration; // Set the duration in the input field
|
|
||||||
};
|
|
||||||
|
|
||||||
video.src = URL.createObjectURL(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showStatusModal() {
|
|
||||||
const statusModal = new bootstrap.Modal(document.getElementById('statusModal'));
|
|
||||||
statusModal.show();
|
|
||||||
|
|
||||||
// Simulate progress updates
|
|
||||||
const progressBar = document.getElementById('progress-bar');
|
|
||||||
let progress = 0;
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
progress += 10;
|
|
||||||
progressBar.style.width = `${progress}%`;
|
|
||||||
progressBar.setAttribute('aria-valuenow', progress);
|
|
||||||
if (progress >= 100) {
|
|
||||||
clearInterval(interval);
|
|
||||||
document.getElementById('status-message').textContent = 'Files uploaded and processed successfully!';
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
60
test_api.py
60
test_api.py
@@ -1,60 +0,0 @@
|
|||||||
import requests
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Replace with the actual server IP address or domain name, hostname, and quick connect code
|
|
||||||
server_ip = 'http://localhost:5000'
|
|
||||||
hostname = 'rpi-tv11'
|
|
||||||
quickconnect_code = '8887779'
|
|
||||||
|
|
||||||
# Construct the URL for the playlist API
|
|
||||||
url = f'{server_ip}/api/playlists'
|
|
||||||
params = {
|
|
||||||
'hostname': hostname,
|
|
||||||
'quickconnect_code': quickconnect_code
|
|
||||||
}
|
|
||||||
|
|
||||||
# Make the GET request to the API
|
|
||||||
response = requests.get(url, params=params)
|
|
||||||
|
|
||||||
# Print the raw response content and status code for debugging
|
|
||||||
print(f'Status Code: {response.status_code}')
|
|
||||||
print(f'Response Content: {response.text}')
|
|
||||||
|
|
||||||
# Check if the request was successful
|
|
||||||
if response.status_code == 200:
|
|
||||||
try:
|
|
||||||
# Parse the JSON response
|
|
||||||
response_data = response.json()
|
|
||||||
playlist = response_data.get('playlist', [])
|
|
||||||
playlist_version = response_data.get('playlist_version', None)
|
|
||||||
|
|
||||||
print(f'Playlist Version: {playlist_version}')
|
|
||||||
print(f'Playlist: {playlist}')
|
|
||||||
|
|
||||||
# Define the local folder for saving files
|
|
||||||
local_folder = './static/resurse'
|
|
||||||
if not os.path.exists(local_folder):
|
|
||||||
os.makedirs(local_folder)
|
|
||||||
|
|
||||||
# Download each file in the playlist
|
|
||||||
for media in playlist:
|
|
||||||
file_name = media.get('file_name', '')
|
|
||||||
file_url = media.get('url', '')
|
|
||||||
duration = media.get('duration', 10) # Default duration if not provided
|
|
||||||
local_file_path = os.path.join(local_folder, file_name)
|
|
||||||
|
|
||||||
print(f'Downloading {file_name} from {file_url}...')
|
|
||||||
try:
|
|
||||||
file_response = requests.get(file_url, timeout=10)
|
|
||||||
if file_response.status_code == 200:
|
|
||||||
with open(local_file_path, 'wb') as file:
|
|
||||||
file.write(file_response.content)
|
|
||||||
print(f'Successfully downloaded {file_name} to {local_file_path}')
|
|
||||||
else:
|
|
||||||
print(f'Failed to download {file_name}. Status Code: {file_response.status_code}')
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
print(f'Error downloading {file_name}: {e}')
|
|
||||||
except requests.exceptions.JSONDecodeError as e:
|
|
||||||
print(f'Failed to parse JSON response: {e}')
|
|
||||||
else:
|
|
||||||
print(f'Failed to retrieve playlist. Status Code: {response.status_code}')
|
|
||||||
Reference in New Issue
Block a user