Compare commits
21 Commits
nginx
..
b4c3f65636
| Author | SHA1 | Date | |
|---|---|---|---|
| b4c3f65636 | |||
| 84b5cd3d48 | |||
| 57b7810069 | |||
| 46602f1933 | |||
| 1c5186463a | |||
| 046f5e5efd | |||
| 052751b599 | |||
| b7b21547f7 | |||
| 05194a19b6 | |||
| 2af04e1db3 | |||
| ae3b82862d | |||
| 8a89df3486 | |||
| 9c0a45afab | |||
| 49393d9a73 | |||
| d235c8e057 | |||
| 52e910346b | |||
| f2470e27ec | |||
| 0e242eb0b3 | |||
| c4e43ce69b | |||
| cf44843418 | |||
| bb293b6a81 |
@@ -44,6 +44,14 @@ flask_session/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Runtime data volumes (mounted at runtime, NOT part of the image)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Archived snapshot of the pre-sanitization codebase (not part of the image).
|
||||||
|
# Matched at any depth so it stays excluded regardless of where it is moved.
|
||||||
|
legacy code/
|
||||||
|
**/legacy code/
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
BLUEPRINT_GUIDE.md
|
BLUEPRINT_GUIDE.md
|
||||||
ICON_INTEGRATION.md
|
ICON_INTEGRATION.md
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# DigiServer v2 Production Environment Configuration
|
||||||
|
# Copy to .env and update with your production values
|
||||||
|
# IMPORTANT: Never commit this file to git
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deploy-time TLS bootstrap. Copy this file to `.env` and set these two before
|
||||||
|
# `docker compose up`. Both must be present for HTTPS to be configured at
|
||||||
|
# startup; if either is missing the app stays on the plain-HTTP fallback and you
|
||||||
|
# can enable HTTPS later from Admin → HTTPS Configuration (no restart needed).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Hostname shown in the UI and used in the Caddy site block.
|
||||||
|
HOSTNAME_INTERNAL=digiserver
|
||||||
|
|
||||||
|
# The host's LAN IP as reachable by the players/browsers.
|
||||||
|
# Replace 192.168.1.100 with THIS server's actual LAN IP. It is used for the
|
||||||
|
# Caddy site blocks and the certificate, so a wrong value breaks HTTPS.
|
||||||
|
# Find it with: ip -4 route get 1.1.1.1 | grep -oP 'src \K[\d.]+'
|
||||||
|
HOST_IP=192.168.1.100
|
||||||
|
|
||||||
|
# Public domain for Let's Encrypt. LEAVE EMPTY for an intranet/internal name
|
||||||
|
# (e.g. "digiserver" or "signage.corp.local") — a non-public name cannot pass an
|
||||||
|
# ACME challenge, so an empty DOMAIN selects Caddy's internal CA instead.
|
||||||
|
DOMAIN=
|
||||||
|
|
||||||
|
# Email for ACME/Let's Encrypt notifications (unused by the internal CA).
|
||||||
|
SSL_EMAIL=admin@example.com
|
||||||
|
|
||||||
|
# Published ports. Caddy listens on 80/443 inside the container; these control
|
||||||
|
# which host ports they are mapped to. Port 80 is always answered — the site
|
||||||
|
# responds whether clients use the IP or the hostname.
|
||||||
|
HTTP_PORT=80
|
||||||
|
HTTPS_PORT=443
|
||||||
|
|
||||||
|
# "true" → also serve plain HTTP alongside HTTPS. Required for players whose
|
||||||
|
# trust store lacks the internal CA (i.e. verify_ssl is not disabled).
|
||||||
|
# "false" → serve TLS only and redirect HTTP to https://<host>:<HTTPS_PORT>.
|
||||||
|
HTTPS_HTTP_FALLBACK=true
|
||||||
|
|
||||||
|
# After configuring HTTPS, probe it and automatically fall back to plain HTTP if
|
||||||
|
# it does not come up — so a bad certificate can never make the site unreachable.
|
||||||
|
# Set "false" to trust the configuration without probing.
|
||||||
|
HTTPS_VERIFY=true
|
||||||
|
|
||||||
|
# Flask Configuration
|
||||||
|
FLASK_ENV=production
|
||||||
|
FLASK_APP=app.app:create_app
|
||||||
|
|
||||||
|
# Security - MUST BE SET IN PRODUCTION
|
||||||
|
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
SECRET_KEY=change-me-to-a-strong-random-secret-key-at-least-32-characters
|
||||||
|
|
||||||
|
# Admin User Configuration
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=change-me-to-a-strong-password
|
||||||
|
ADMIN_EMAIL=admin@your-domain.com
|
||||||
|
|
||||||
|
# Database Configuration (optional - defaults to SQLite)
|
||||||
|
# For PostgreSQL: postgresql://user:pass@host:5432/database
|
||||||
|
# For SQLite: sqlite:////data/instance/dashboard.db
|
||||||
|
# DATABASE_URL=
|
||||||
|
|
||||||
|
PREFERRED_URL_SCHEME=https
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Features (optional)
|
||||||
|
ENABLE_LIBREOFFICE=true
|
||||||
|
MAX_UPLOAD_SIZE=500000000 # 500MB
|
||||||
|
|
||||||
|
# Cache Configuration (optional)
|
||||||
|
CACHE_TYPE=simple
|
||||||
|
CACHE_DEFAULT_TIMEOUT=300
|
||||||
|
|
||||||
|
# Session Configuration
|
||||||
|
SESSION_COOKIE_SECURE=true
|
||||||
|
SESSION_COOKIE_HTTPONLY=true
|
||||||
|
SESSION_COOKIE_SAMESITE=Lax
|
||||||
|
|
||||||
|
# Proxy Configuration (configured in app.py)
|
||||||
|
# IMPORTANT: Set this to your actual network range or specific proxy IP
|
||||||
|
# Examples:
|
||||||
|
# - 192.168.0.0/24 (local network with /24 subnet)
|
||||||
|
# - 10.0.0.0/8 (AWS or similar cloud)
|
||||||
|
# - 172.16.0.0/12 (Docker networks)
|
||||||
|
# For multiple IPs: 192.168.0.121,10.0.1.50
|
||||||
|
TRUSTED_PROXIES=192.168.0.0/24
|
||||||
+24
@@ -26,6 +26,23 @@ data/
|
|||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Ad-hoc backups of .env (they contain real secrets — must never be committed)
|
||||||
|
.env.bak
|
||||||
|
.env.bak.*
|
||||||
|
.env.*.bak
|
||||||
|
*.env.bak
|
||||||
|
|
||||||
|
# Deployment artefacts that contain secrets
|
||||||
|
.deployment-credentials
|
||||||
|
caddy-root.crt
|
||||||
|
|
||||||
|
# Certificates / keys (generated by Caddy or the host)
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
!data/caddy-data/**
|
||||||
|
!Caddyfile.example
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
@@ -59,3 +76,10 @@ build/
|
|||||||
#data
|
#data
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# Local archive / restore-point snapshots.
|
||||||
|
# Kept on disk for reference (see docs/SANITIZATION-REVIEW.md) but deliberately
|
||||||
|
# NOT tracked: docs/legacy code/ is a full pre-sanitization repo snapshot and
|
||||||
|
# docs/old_code_documentation/ is a byte-identical copy of the tree inside it.
|
||||||
|
docs/legacy code/
|
||||||
|
docs/old_code_documentation/
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
# Caddy admin API — used by DigiServer to reload config after HTTPS is enabled
|
||||||
|
admin 0.0.0.0:2019
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default: serve the app on HTTP port 80
|
||||||
|
# Once HTTPS is configured via Admin → HTTPS Config, Caddy will reload
|
||||||
|
# this file and start provisioning a Let's Encrypt certificate automatically.
|
||||||
|
:80 {
|
||||||
|
reverse_proxy digiserver-app:5000 {
|
||||||
|
header_up Host {host}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
transport http {
|
||||||
|
read_timeout 300s
|
||||||
|
write_timeout 300s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request_body {
|
||||||
|
max_size 2GB
|
||||||
|
}
|
||||||
|
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/access.log
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -15,6 +15,10 @@ RUN apt-get update && \
|
|||||||
libreoffice-core \
|
libreoffice-core \
|
||||||
libreoffice-impress \
|
libreoffice-impress \
|
||||||
libreoffice-writer \
|
libreoffice-writer \
|
||||||
|
sshpass \
|
||||||
|
openssh-client \
|
||||||
|
rsync \
|
||||||
|
git \
|
||||||
&& apt-get clean && \
|
&& apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
@@ -24,7 +28,9 @@ COPY requirements.txt .
|
|||||||
# Install Python dependencies
|
# Install Python dependencies
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# Copy entire application code into container
|
||||||
|
# This includes: app/, migrations/, configs, and all scripts
|
||||||
|
# Code is immutable in the image - only data folders are mounted as volumes
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Copy and set permissions for entrypoint script
|
# Copy and set permissions for entrypoint script
|
||||||
|
|||||||
+197
-384
@@ -5,111 +5,156 @@
|
|||||||
DigiServer is deployed using Docker Compose with the following architecture:
|
DigiServer is deployed using Docker Compose with the following architecture:
|
||||||
|
|
||||||
```
|
```
|
||||||
Internet (User)
|
Internet (User) — Port 8080 (HTTP) / 8443 (HTTPS)
|
||||||
↓
|
↓
|
||||||
Nginx Reverse Proxy (Port 80/443)
|
Caddy Reverse Proxy (auto HTTPS, gzip, security headers)
|
||||||
↓
|
↓
|
||||||
Internal Docker Network
|
Internal Docker Network (digiserver-network)
|
||||||
↓
|
↓
|
||||||
Flask App (Gunicorn on Port 5000)
|
Flask App (Gunicorn on Port 5000)
|
||||||
↓
|
↓
|
||||||
SQLite Database
|
SQLite Database
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Caddy is used instead of Nginx because it:
|
||||||
|
- **Auto-provisions Let's Encrypt certificates** — no manual certbot
|
||||||
|
- **Simpler config** — a `Caddyfile` replaces the verbose `nginx.conf`
|
||||||
|
- **Built-in HTTP/2, gzip, security headers**
|
||||||
|
- **Admin API** (port 2019) — Flask reloads Caddy config dynamically
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Quick Start (One Command)
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### **1️⃣ Clone & Setup**
|
||||||
```bash
|
```bash
|
||||||
cd /srv/digiserver-v2
|
git clone <repository>
|
||||||
bash deploy.sh
|
cd digiserver-v2
|
||||||
```
|
```
|
||||||
|
|
||||||
This will:
|
### **2️⃣ Start Containers**
|
||||||
1. ✅ Start Docker containers
|
```bash
|
||||||
2. ✅ Initialize database
|
docker compose up -d
|
||||||
3. ✅ Run migrations
|
```
|
||||||
4. ✅ Configure HTTPS
|
|
||||||
5. ✅ Display access information
|
This automatically:
|
||||||
|
1. ✅ Builds the Flask app image from `Dockerfile`
|
||||||
|
2. ✅ Creates persistent data directories (`data/instance`, `data/uploads`, etc.)
|
||||||
|
3. ✅ Starts Caddy reverse proxy on ports **8080** (HTTP) and **8443** (HTTPS)
|
||||||
|
4. ✅ Initializes the database on first run (auto-creates admin user)
|
||||||
|
5. ✅ Runs all required migrations
|
||||||
|
|
||||||
|
**Access the app at:** `http://localhost:8080`
|
||||||
|
|
||||||
|
### **3️⃣ First-Time Login**
|
||||||
|
```
|
||||||
|
URL: http://localhost:8080
|
||||||
|
Username: admin
|
||||||
|
Password: admin123
|
||||||
|
```
|
||||||
|
⚠️ **CHANGE PASSWORD IMMEDIATELY IN PRODUCTION!**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 Container Architecture
|
## 📦 Container Architecture
|
||||||
|
|
||||||
### **Container 1: digiserver-app (Flask)**
|
### **Container 1: digiserver-app (Flask)**
|
||||||
- **Image**: Built from Dockerfile (Python 3.13)
|
| Property | Value |
|
||||||
- **Port**: 5000 (internal only)
|
|----------|-------|
|
||||||
- **Volumes**:
|
| **Image** | Built from `Dockerfile` (Python 3.13-slim) |
|
||||||
- `./data:/app` - Persistent application data
|
| **Container name** | `digiserver-v2` |
|
||||||
- `./data/instance:/app/instance` - Database & configuration
|
| **Exposed port** | `5000` (internal — proxied by Caddy) |
|
||||||
- `./data/uploads:/app/app/static/uploads` - User uploads
|
| **Mapped port** | `5000:5000` (for direct dev access) |
|
||||||
- **Startup**: Automatically initializes database on first run
|
| **Entrypoint** | `docker-entrypoint.sh` (init DB → gunicorn) |
|
||||||
- **Health Check**: Every 30 seconds
|
| **Workers** | 4 gunicorn workers, 120s timeout |
|
||||||
|
|
||||||
### **Container 2: nginx (Reverse Proxy)**
|
**Volumes (persistent data):**
|
||||||
- **Image**: nginx:alpine
|
| Host path | Container path | Purpose |
|
||||||
- **Ports**: 80 & 443 (exposed to internet)
|
|-----------|---------------|---------|
|
||||||
- **Volumes**:
|
| `./data/instance` | `/app/instance` | SQLite database |
|
||||||
- `nginx.conf` - Main configuration
|
| `./data/uploads` | `/app/app/static/uploads` | Media uploads |
|
||||||
- `./data/nginx-ssl/` - SSL certificates
|
|
||||||
- `./data/nginx-logs/` - Access/error logs
|
> **Code is baked into the Docker image** — see [Rebuild](#-rebuild-after-code-changes) below.
|
||||||
- `./data/certbot/` - Let's Encrypt challenges
|
|
||||||
- **Startup**: Waits for Flask app to start
|
### **Container 2: caddy (Reverse Proxy)**
|
||||||
- **Health Check**: Every 30 seconds
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| **Image** | `caddy:2-alpine` |
|
||||||
|
| **Container name** | `digiserver-caddy` |
|
||||||
|
| **HTTP** | `8080 → 80` (inside container) |
|
||||||
|
| **HTTPS** | `8443 → 443` (inside container) |
|
||||||
|
| **Admin API** | `2019` (internal, for dynamic reloads) |
|
||||||
|
|
||||||
|
**Volumes:**
|
||||||
|
| Host path | Container path | Purpose |
|
||||||
|
|-----------|---------------|---------|
|
||||||
|
| `./data/Caddyfile` | `/etc/caddy/Caddyfile:rw` | Caddy config (rewritable by Flask) |
|
||||||
|
| `./data/caddy-data` | `/data` | Let's Encrypt certs & keys |
|
||||||
|
| `./data/caddy-config` | `/config` | Caddy JSON config |
|
||||||
|
| `./data/caddy-logs` | `/var/log/caddy` | Access logs |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 Deployment Steps Explained
|
## 🚀 Full Automated Deployment
|
||||||
|
|
||||||
### **Step 1: Start Containers**
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
./deploy.sh
|
||||||
```
|
```
|
||||||
- Builds Flask app image (if needed)
|
|
||||||
- Starts both containers
|
This runs the complete workflow:
|
||||||
- Waits for containers to be healthy
|
1. Creates `data/` directories (`instance`, `uploads`, `caddy-*`)
|
||||||
|
2. Starts containers (`docker compose up -d`)
|
||||||
|
3. Initialises database and admin user
|
||||||
|
4. Runs all database migrations
|
||||||
|
5. Enables HTTPS via Flask Admin
|
||||||
|
6. Validates Caddy configuration
|
||||||
|
7. Prints access URLs and credentials
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Deployment Steps (Manual)
|
||||||
|
|
||||||
|
### **Step 1: Build & Start**
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
- Builds Flask app image (layer cached)
|
||||||
|
- Creates `digiserver-network` bridge
|
||||||
|
- Starts `digiserver-app`, then `caddy`
|
||||||
|
|
||||||
### **Step 2: Database Initialization** (docker-entrypoint.sh)
|
### **Step 2: Database Initialization** (docker-entrypoint.sh)
|
||||||
When Flask container starts:
|
On container start the entrypoint automatically:
|
||||||
1. Create required directories
|
1. Creates required directories
|
||||||
2. Check if database exists
|
2. Checks for existing `dashboard.db`
|
||||||
3. If NOT exists:
|
3. If absent — creates tables and seeds admin user
|
||||||
- Initialize SQLite database (dashboard.db)
|
4. Starts Gunicorn (4 workers, 120s timeout)
|
||||||
- Create admin user from environment variables
|
|
||||||
4. Start Gunicorn server (4 workers, 120s timeout)
|
|
||||||
|
|
||||||
### **Step 3: Run Migrations**
|
### **Step 3: Run Migrations**
|
||||||
```bash
|
```bash
|
||||||
docker-compose exec -T digiserver-app python /app/migrations/[migration_name].py
|
docker compose exec -T digiserver-app python /app/migrations/<name>.py
|
||||||
```
|
```
|
||||||
Applied migrations:
|
Key migrations:
|
||||||
- `add_https_config_table.py` - HTTPS settings
|
- `add_https_config_table.py` — HTTPS settings table
|
||||||
- `add_player_user_table.py` - Player user management
|
- `add_player_user_table.py` — Player user management
|
||||||
- `add_email_to_https_config.py` - Email configuration
|
- `add_email_to_https_config.py` — Email field
|
||||||
- `migrate_player_user_global.py` - Global settings
|
- `migrate_player_user_global.py` — Global settings
|
||||||
|
- `add_url_to_content.py` — Web link support
|
||||||
|
- `add_deployment_fields_to_player.py` — Deployment tracking
|
||||||
|
|
||||||
### **Step 4: Configure HTTPS**
|
### **Step 4: Configure HTTPS**
|
||||||
- SSL certificates stored in `./data/nginx-ssl/`
|
Via Admin UI (recommended):
|
||||||
- Pre-generated self-signed certs for development
|
1. Go to **Admin → HTTPS Config**
|
||||||
- Ready for Let's Encrypt integration
|
2. Enter hostname, domain, email, IP, port
|
||||||
|
3. Caddy auto-provisions a Let's Encrypt certificate
|
||||||
|
|
||||||
### **Step 5: Reverse Proxy Routing** (nginx.conf)
|
Via CLI:
|
||||||
```
|
```bash
|
||||||
HTTP (80):
|
docker compose exec -T digiserver-app python /app/https_manager.py enable \
|
||||||
• Redirect all traffic to HTTPS
|
<hostname> <domain> <email> <ip_address> <port>
|
||||||
• Allow ACME challenges for Let's Encrypt
|
|
||||||
|
|
||||||
HTTPS (443):
|
|
||||||
• TLS 1.2+, HTTP/2 enabled
|
|
||||||
• Proxy all requests to Flask app
|
|
||||||
• Security headers added
|
|
||||||
• Gzip compression enabled
|
|
||||||
• Max upload size: 2GB
|
|
||||||
• Proxy timeout: 300s
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Step 6: ProxyFix Middleware** (app/app.py)
|
### **Step 5: ProxyFix Middleware** (app/app.py)
|
||||||
Extracts real client information from Nginx headers:
|
Flask extracts real client info from Caddy headers:
|
||||||
- `X-Forwarded-For` → Real client IP
|
- `X-Forwarded-For` → Real client IP
|
||||||
- `X-Forwarded-Proto` → Protocol (http/https)
|
- `X-Forwarded-Proto` → Protocol (http/https)
|
||||||
- `X-Forwarded-Host` → Original hostname
|
- `X-Forwarded-Host` → Original hostname
|
||||||
@@ -117,173 +162,118 @@ Extracts real client information from Nginx headers:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📂 Directory Structure & Persistence
|
## 🔄 Rebuild After Code Changes
|
||||||
|
|
||||||
```
|
Because the application code is baked into the Docker image, update it with:
|
||||||
/srv/digiserver-v2/
|
|
||||||
├── app/ (Flask application code)
|
|
||||||
├── data/ (PERSISTENT - mounted as Docker volume)
|
|
||||||
│ ├── app/ (Copy of app/ for container)
|
|
||||||
│ ├── instance/ (dashboard.db - SQLite database)
|
|
||||||
│ ├── uploads/ (User uploaded files)
|
|
||||||
│ ├── nginx-ssl/ (SSL certificates)
|
|
||||||
│ ├── nginx-logs/ (Nginx logs)
|
|
||||||
│ └── certbot/ (Let's Encrypt challenges)
|
|
||||||
├── migrations/ (Database schema updates)
|
|
||||||
├── docker-compose.yml (Container orchestration)
|
|
||||||
├── Dockerfile (Flask app image definition)
|
|
||||||
├── nginx.conf (Reverse proxy configuration)
|
|
||||||
└── docker-entrypoint.sh (Container startup script)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 Default Credentials
|
|
||||||
|
|
||||||
```
|
|
||||||
Username: admin
|
|
||||||
Password: admin123
|
|
||||||
```
|
|
||||||
|
|
||||||
⚠️ **CHANGE IMMEDIATELY IN PRODUCTION!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌐 Access Points
|
|
||||||
|
|
||||||
After deployment, access the app at:
|
|
||||||
- `https://localhost` (if deployed locally)
|
|
||||||
- `https://192.168.0.121` (if deployed on server)
|
|
||||||
- `https://<DOMAIN>` (if DNS configured)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ Environment Variables (Optional)
|
|
||||||
|
|
||||||
Create `.env` file in project root:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Network Configuration
|
# 1. Rebuild the image
|
||||||
HOSTNAME=digiserver
|
docker build --no-cache -t digiserver-v2-digiserver-app .
|
||||||
DOMAIN=digiserver.example.com
|
|
||||||
IP_ADDRESS=192.168.0.121
|
|
||||||
|
|
||||||
# SSL/HTTPS
|
# 2. Replace the running container
|
||||||
EMAIL=admin@example.com
|
docker stop digiserver-v2 && docker rm digiserver-v2
|
||||||
|
docker compose up -d digiserver-app
|
||||||
# Flask Configuration
|
|
||||||
SECRET_KEY=your-secret-key-here
|
|
||||||
FLASK_ENV=production
|
|
||||||
|
|
||||||
# Admin User
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_PASSWORD=admin123
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then start with:
|
**Quick test (no rebuild)** — copy files into the running container:
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d
|
docker cp <local_file> digiserver-v2:/app/<path>
|
||||||
|
docker exec digiserver-v2 pkill -HUP -f gunicorn
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Network Migration
|
||||||
|
|
||||||
|
When the server moves to a different network / IP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./migrate_network.sh 10.55.150.160
|
||||||
|
# Optional: ./migrate_network.sh 10.55.150.160 digiserver-secured
|
||||||
|
```
|
||||||
|
|
||||||
|
This regenerates SSL certificates, updates the database HTTPS config, and restarts both containers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
digiserver-v2/
|
||||||
|
├── app/ (Flask application code)
|
||||||
|
│ ├── app.py (App factory)
|
||||||
|
│ ├── blueprints/ (Route definitions)
|
||||||
|
│ ├── models/ (SQLAlchemy models)
|
||||||
|
│ ├── templates/ (Jinja2 templates)
|
||||||
|
│ ├── static/uploads/ (Media files on disk)
|
||||||
|
│ └── utils/ (Helpers)
|
||||||
|
├── data/ (PERSISTENT — survives restarts)
|
||||||
|
│ ├── instance/dashboard.db (SQLite database)
|
||||||
|
│ ├── uploads/ (Media files)
|
||||||
|
│ ├── Caddyfile (Caddy config)
|
||||||
|
│ ├── caddy-data/ (Let's Encrypt certificates)
|
||||||
|
│ ├── caddy-config/ (Caddy JSON config)
|
||||||
|
│ └── caddy-logs/ (Access logs)
|
||||||
|
├── migrations/ (One-shot DB scripts)
|
||||||
|
├── old_code_documentation/ (Legacy Nginx files, etc.)
|
||||||
|
├── docker-compose.yml (Service definitions)
|
||||||
|
├── Dockerfile (Flask image build)
|
||||||
|
├── deploy.sh (Full automated deploy)
|
||||||
|
├── migrate_network.sh (Re-config after IP change)
|
||||||
|
└── docker-entrypoint.sh (Container startup)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ Common Commands
|
## 🛠️ Common Commands
|
||||||
|
|
||||||
### **Start/Stop Containers**
|
### **Containers**
|
||||||
```bash
|
```bash
|
||||||
# Start containers
|
docker compose up -d # Start all services
|
||||||
docker-compose up -d
|
docker compose down # Stop all services
|
||||||
|
docker compose restart # Restart all
|
||||||
# Stop containers
|
docker compose restart caddy # Restart only Caddy
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
# Restart containers
|
|
||||||
docker-compose restart
|
|
||||||
|
|
||||||
# Restart specific container
|
|
||||||
docker-compose restart digiserver-app
|
|
||||||
docker-compose restart nginx
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### **View Logs**
|
### **Logs**
|
||||||
```bash
|
```bash
|
||||||
# All containers
|
docker compose logs -f # Follow all
|
||||||
docker-compose logs
|
docker compose logs -f digiserver-app # Flask only
|
||||||
|
docker compose logs -f caddy # Caddy only
|
||||||
# Follow logs (real-time)
|
docker compose logs --tail=50 digiserver-app
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# Specific container
|
|
||||||
docker-compose logs -f digiserver-app
|
|
||||||
docker-compose logs -f nginx
|
|
||||||
|
|
||||||
# Show last 50 lines
|
|
||||||
docker-compose logs --tail=50 digiserver-app
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Container Status**
|
### **Status**
|
||||||
```bash
|
```bash
|
||||||
# Show running containers
|
docker compose ps
|
||||||
docker-compose ps
|
|
||||||
|
|
||||||
# Show container details
|
|
||||||
docker-compose ps -a
|
|
||||||
|
|
||||||
# Check container health
|
|
||||||
docker ps --format="table {{.Names}}\t{{.Status}}"
|
docker ps --format="table {{.Names}}\t{{.Status}}"
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Database Operations**
|
### **Database**
|
||||||
```bash
|
```bash
|
||||||
# Access database shell
|
docker compose exec digiserver-app sqlite3 /app/instance/dashboard.db
|
||||||
docker-compose exec digiserver-app sqlite3 /app/instance/dashboard.db
|
docker compose exec digiserver-app cp /app/instance/dashboard.db \
|
||||||
|
/app/instance/dashboard.db.backup
|
||||||
# Backup database
|
|
||||||
docker-compose exec digiserver-app cp /app/instance/dashboard.db /app/instance/dashboard.db.backup
|
|
||||||
|
|
||||||
# Restore database
|
|
||||||
docker-compose exec digiserver-app cp /app/instance/dashboard.db.backup /app/instance/dashboard.db
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Nginx Operations**
|
### **Caddy**
|
||||||
```bash
|
```bash
|
||||||
# Validate Nginx configuration
|
docker exec digiserver-caddy caddy validate --config /etc/caddy/Caddyfile
|
||||||
docker exec digiserver-nginx nginx -t
|
docker exec digiserver-caddy caddy reload --config /etc/caddy/Caddyfile
|
||||||
|
|
||||||
# Reload Nginx (without restart)
|
|
||||||
docker exec digiserver-nginx nginx -s reload
|
|
||||||
|
|
||||||
# View Nginx logs
|
|
||||||
docker-compose logs -f nginx
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔒 SSL Certificate Management
|
## 🔒 HTTPS / SSL
|
||||||
|
|
||||||
|
Caddy automatically provisions Let's Encrypt certificates when a public domain is configured. For development with self-signed certificates:
|
||||||
|
|
||||||
### **Generate New Self-Signed Certificate**
|
|
||||||
```bash
|
```bash
|
||||||
bash generate_nginx_certs.sh 192.168.0.121 365
|
bash generate_nginx_certs.sh 192.168.0.121 365
|
||||||
docker-compose restart nginx
|
docker compose restart caddy
|
||||||
```
|
```
|
||||||
|
|
||||||
Parameters:
|
> **Note:** `generate_nginx_certs.sh` has been moved to `old_code_documentation/` but still works.
|
||||||
- `192.168.0.121` - Domain/IP for certificate
|
|
||||||
- `365` - Certificate validity in days
|
|
||||||
|
|
||||||
### **Set Up Let's Encrypt (Production)**
|
|
||||||
1. Update `DOMAIN` and `EMAIL` in environment
|
|
||||||
2. Modify `nginx.conf` to enable certbot challenges
|
|
||||||
3. Run certbot:
|
|
||||||
```bash
|
|
||||||
docker run --rm -v $(pwd)/data/certbot:/etc/letsencrypt \
|
|
||||||
-v $(pwd)/data/nginx-logs:/var/log/letsencrypt \
|
|
||||||
certbot/certbot certonly --webroot \
|
|
||||||
-w /var/www/certbot \
|
|
||||||
-d yourdomain.com \
|
|
||||||
-m your-email@example.com \
|
|
||||||
--agree-tos
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -291,197 +281,20 @@ Parameters:
|
|||||||
|
|
||||||
### **Containers not starting?**
|
### **Containers not starting?**
|
||||||
```bash
|
```bash
|
||||||
# Check docker-compose logs
|
docker compose logs
|
||||||
docker-compose logs
|
|
||||||
|
|
||||||
# Check system resources
|
|
||||||
docker stats
|
docker stats
|
||||||
|
|
||||||
# Restart Docker daemon
|
|
||||||
sudo systemctl restart docker
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Application not responding?**
|
### **App not responding?**
|
||||||
```bash
|
```bash
|
||||||
# Check app container health
|
docker compose ps
|
||||||
docker-compose ps
|
curl http://localhost:5000/ # Bypass Caddy, hit Flask directly
|
||||||
|
docker compose logs -f digiserver-app
|
||||||
# View app logs
|
|
||||||
docker-compose logs -f digiserver-app
|
|
||||||
|
|
||||||
# Test Flask directly
|
|
||||||
docker-compose exec digiserver-app curl http://localhost:5000/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### **HTTPS not working?**
|
### **Caddy / HTTPS issues?**
|
||||||
```bash
|
```bash
|
||||||
# Verify Nginx config
|
docker exec digiserver-caddy caddy validate --config /etc/caddy/Caddyfile
|
||||||
docker exec digiserver-nginx nginx -t
|
ls -la ./data/caddy-data/certificates/
|
||||||
|
docker compose restart caddy
|
||||||
# Check SSL certificates exist
|
|
||||||
ls -la ./data/nginx-ssl/
|
|
||||||
|
|
||||||
# View Nginx error logs
|
|
||||||
docker-compose logs nginx
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Database issues?**
|
|
||||||
```bash
|
|
||||||
# Check database file exists
|
|
||||||
ls -la ./data/instance/dashboard.db
|
|
||||||
|
|
||||||
# Verify database permissions
|
|
||||||
docker-compose exec digiserver-app ls -la /app/instance/
|
|
||||||
|
|
||||||
# Check database tables
|
|
||||||
docker-compose exec digiserver-app sqlite3 /app/instance/dashboard.db ".tables"
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Port already in use?**
|
|
||||||
```bash
|
|
||||||
# Find process using port 80
|
|
||||||
sudo lsof -i :80
|
|
||||||
|
|
||||||
# Find process using port 443
|
|
||||||
sudo lsof -i :443
|
|
||||||
|
|
||||||
# Kill process (if needed)
|
|
||||||
sudo kill -9 <PID>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Health Checks
|
|
||||||
|
|
||||||
Both containers have health checks:
|
|
||||||
|
|
||||||
**Flask App**: Pings `http://localhost:5000/` every 30 seconds
|
|
||||||
**Nginx**: Pings `http://localhost:80/` every 30 seconds
|
|
||||||
|
|
||||||
Check health status:
|
|
||||||
```bash
|
|
||||||
docker-compose ps
|
|
||||||
# Look for "Up (healthy)" status
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Database Backup & Restore
|
|
||||||
|
|
||||||
### **Backup**
|
|
||||||
```bash
|
|
||||||
# Create backup
|
|
||||||
docker-compose exec digiserver-app cp /app/instance/dashboard.db /app/instance/dashboard.backup.db
|
|
||||||
|
|
||||||
# Download to local machine
|
|
||||||
cp ./data/instance/dashboard.backup.db ./backup/
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Restore**
|
|
||||||
```bash
|
|
||||||
# Stop containers
|
|
||||||
docker-compose stop
|
|
||||||
|
|
||||||
# Restore database
|
|
||||||
cp ./backup/dashboard.backup.db ./data/instance/dashboard.db
|
|
||||||
|
|
||||||
# Start containers
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 Performance Tuning
|
|
||||||
|
|
||||||
### **Gunicorn Workers** (docker-entrypoint.sh)
|
|
||||||
```bash
|
|
||||||
# Default: 4 workers
|
|
||||||
# Formula: (2 × CPU_count) + 1
|
|
||||||
# For 4-core CPU: 9 workers
|
|
||||||
|
|
||||||
# Modify docker-entrypoint.sh:
|
|
||||||
gunicorn --workers 9 ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Nginx Worker Processes** (nginx.conf)
|
|
||||||
```nginx
|
|
||||||
# Default: auto (CPU count)
|
|
||||||
worker_processes auto;
|
|
||||||
|
|
||||||
# Or specify manually:
|
|
||||||
worker_processes 4;
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Upload Timeout** (nginx.conf)
|
|
||||||
```nginx
|
|
||||||
# Default: 300s
|
|
||||||
proxy_connect_timeout 300s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 Security Checklist
|
|
||||||
|
|
||||||
- [ ] Change admin password immediately
|
|
||||||
- [ ] Set strong `SECRET_KEY` environment variable
|
|
||||||
- [ ] Enable firewall rules (allow only ports 80, 443)
|
|
||||||
- [ ] Set up HTTPS with Let's Encrypt
|
|
||||||
- [ ] Configure regular database backups
|
|
||||||
- [ ] Review Nginx security headers
|
|
||||||
- [ ] Update Flask dependencies regularly
|
|
||||||
- [ ] Monitor container logs for errors
|
|
||||||
- [ ] Restrict admin panel access (IP whitelist optional)
|
|
||||||
- [ ] Enable Flask debug mode only in development
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Support & Documentation
|
|
||||||
|
|
||||||
- **Nginx Setup**: See `NGINX_SETUP_QUICK.md`
|
|
||||||
- **ProxyFix Configuration**: See `PROXY_FIX_SETUP.md`
|
|
||||||
- **Deployment Commands**: See `DEPLOYMENT_COMMANDS.md`
|
|
||||||
- **Issue Troubleshooting**: Check `old_code_documentation/`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Deployment Checklist
|
|
||||||
|
|
||||||
- [ ] Docker & Docker Compose installed
|
|
||||||
- [ ] Running from `/srv/digiserver-v2` directory
|
|
||||||
- [ ] Environment variables configured (optional)
|
|
||||||
- [ ] Port 80/443 available
|
|
||||||
- [ ] Sufficient disk space (min 5GB)
|
|
||||||
- [ ] Sufficient RAM (min 2GB free)
|
|
||||||
- [ ] Network connectivity verified
|
|
||||||
- [ ] SSL certificates generated or obtained
|
|
||||||
- [ ] Admin credentials changed (production)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Next Steps After Deployment
|
|
||||||
|
|
||||||
1. **Access Web Interface**
|
|
||||||
- Login with admin credentials
|
|
||||||
- Change password immediately
|
|
||||||
|
|
||||||
2. **Configure Application**
|
|
||||||
- Set up players
|
|
||||||
- Upload content
|
|
||||||
- Configure groups & permissions
|
|
||||||
|
|
||||||
3. **Production Hardening**
|
|
||||||
- Enable Let's Encrypt HTTPS
|
|
||||||
- Configure firewall rules
|
|
||||||
- Set up database backups
|
|
||||||
- Monitor logs
|
|
||||||
|
|
||||||
4. **Optional Enhancements**
|
|
||||||
- Set up custom domain
|
|
||||||
- Configure email notifications
|
|
||||||
- Install optional dependencies (LibreOffice, etc.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: January 15, 2026
|
|
||||||
|
|||||||
+45
-4
@@ -42,6 +42,11 @@ def create_app(config_name=None):
|
|||||||
# This ensures proper handling of X-Forwarded-* headers
|
# This ensures proper handling of X-Forwarded-* headers
|
||||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
||||||
|
|
||||||
|
# ScriptNameFix: reads X-Script-Name header set by the umbrella nginx
|
||||||
|
# (e.g. /digiserver) so that url_for() generates correct full paths.
|
||||||
|
from app.utils.script_name_fix import ScriptNameFix
|
||||||
|
app.wsgi_app = ScriptNameFix(app.wsgi_app)
|
||||||
|
|
||||||
# Initialize extensions
|
# Initialize extensions
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
bcrypt.init_app(app)
|
bcrypt.init_app(app)
|
||||||
@@ -52,6 +57,18 @@ def create_app(config_name=None):
|
|||||||
# Configure Flask-Login
|
# Configure Flask-Login
|
||||||
configure_login_manager(app)
|
configure_login_manager(app)
|
||||||
|
|
||||||
|
# Initialize CORS for player API access
|
||||||
|
from app.extensions import cors
|
||||||
|
cors.init_app(app, resources={
|
||||||
|
r"/api/*": {
|
||||||
|
"origins": ["*"],
|
||||||
|
"methods": ["GET", "POST", "OPTIONS", "PUT", "DELETE"],
|
||||||
|
"allow_headers": ["Content-Type", "Authorization"],
|
||||||
|
"supports_credentials": True,
|
||||||
|
"max_age": 3600
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
# Register components
|
# Register components
|
||||||
register_blueprints(app)
|
register_blueprints(app)
|
||||||
register_error_handlers(app)
|
register_error_handlers(app)
|
||||||
@@ -59,6 +76,14 @@ def create_app(config_name=None):
|
|||||||
register_context_processors(app)
|
register_context_processors(app)
|
||||||
register_template_filters(app)
|
register_template_filters(app)
|
||||||
|
|
||||||
|
# Portal SSO: auto-login users arriving via the umbrella nginx gateway
|
||||||
|
from app.utils.portal_sso import init_portal_sso
|
||||||
|
init_portal_sso(app)
|
||||||
|
|
||||||
|
# Ensure DB schema exists (idempotent; safe to call even with migrate)
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -68,9 +93,7 @@ def register_blueprints(app):
|
|||||||
from app.blueprints.auth import auth_bp
|
from app.blueprints.auth import auth_bp
|
||||||
from app.blueprints.admin import admin_bp
|
from app.blueprints.admin import admin_bp
|
||||||
from app.blueprints.players import players_bp
|
from app.blueprints.players import players_bp
|
||||||
from app.blueprints.groups import groups_bp
|
|
||||||
from app.blueprints.content import content_bp
|
from app.blueprints.content import content_bp
|
||||||
from app.blueprints.playlist import playlist_bp
|
|
||||||
from app.blueprints.api import api_bp
|
from app.blueprints.api import api_bp
|
||||||
|
|
||||||
# Register blueprints (using URL prefixes from blueprint definitions)
|
# Register blueprints (using URL prefixes from blueprint definitions)
|
||||||
@@ -78,9 +101,7 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
app.register_blueprint(players_bp)
|
app.register_blueprint(players_bp)
|
||||||
app.register_blueprint(groups_bp)
|
|
||||||
app.register_blueprint(content_bp)
|
app.register_blueprint(content_bp)
|
||||||
app.register_blueprint(playlist_bp)
|
|
||||||
app.register_blueprint(api_bp)
|
app.register_blueprint(api_bp)
|
||||||
|
|
||||||
|
|
||||||
@@ -170,9 +191,29 @@ def register_context_processors(app):
|
|||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_config():
|
def inject_config():
|
||||||
"""Inject configuration variables into all templates"""
|
"""Inject configuration variables into all templates"""
|
||||||
|
# Cache-busting token for the uploadable logos.
|
||||||
|
#
|
||||||
|
# Must be appended OUTSIDE url_for(): passing 'logo.png?v=1' as the
|
||||||
|
# filename makes Flask percent-encode the '?' into '%3F', which asks
|
||||||
|
# for a file literally named "logo.png%3Fv=1" and 404s.
|
||||||
|
#
|
||||||
|
# Derived from the newest logo's mtime so that uploading a new logo in
|
||||||
|
# Admin → Customize Logos is picked up immediately, rather than reusing
|
||||||
|
# a hardcoded value the browser has already cached.
|
||||||
|
logo_version = 1
|
||||||
|
for _logo_name in ('header_logo.png', 'login_logo.png'):
|
||||||
|
try:
|
||||||
|
_mtime = int(os.path.getmtime(
|
||||||
|
os.path.join(app.config['UPLOAD_FOLDER'], _logo_name)))
|
||||||
|
logo_version = max(logo_version, _mtime)
|
||||||
|
except OSError:
|
||||||
|
# Logo not uploaded yet — keep the current token.
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'server_version': app.config['SERVER_VERSION'],
|
'server_version': app.config['SERVER_VERSION'],
|
||||||
'build_date': app.config['BUILD_DATE'],
|
'build_date': app.config['BUILD_DATE'],
|
||||||
|
'logo_version': logo_version,
|
||||||
'logo_exists': os.path.exists(
|
'logo_exists': os.path.exists(
|
||||||
os.path.join(app.root_path, app.config['UPLOAD_FOLDERLOGO'], 'logo.png')
|
os.path.join(app.root_path, app.config['UPLOAD_FOLDERLOGO'], 'logo.png')
|
||||||
)
|
)
|
||||||
|
|||||||
+170
-7
@@ -8,10 +8,9 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from app.extensions import db, bcrypt
|
from app.extensions import db, bcrypt
|
||||||
from app.models import User, Player, Group, Content, ServerLog, Playlist, HTTPSConfig
|
from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig
|
||||||
from app.utils.logger import log_action
|
from app.utils.logger import log_action
|
||||||
from app.utils.caddy_manager import CaddyConfigGenerator
|
from app.utils.caddy_manager import CaddyConfigGenerator
|
||||||
from app.utils.nginx_config_reader import get_nginx_status
|
|
||||||
|
|
||||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||||
|
|
||||||
@@ -871,14 +870,10 @@ def https_config():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
log_action('info', f'HTTPS status auto-corrected to enabled (detected from request)')
|
log_action('info', f'HTTPS status auto-corrected to enabled (detected from request)')
|
||||||
|
|
||||||
# Get Nginx configuration status
|
|
||||||
nginx_status = get_nginx_status()
|
|
||||||
|
|
||||||
return render_template('admin/https_config.html',
|
return render_template('admin/https_config.html',
|
||||||
config=config,
|
config=config,
|
||||||
is_https_active=is_https_active,
|
is_https_active=is_https_active,
|
||||||
current_host=current_host,
|
current_host=current_host)
|
||||||
nginx_status=nginx_status)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_action('error', f'Error loading HTTPS config page: {str(e)}')
|
log_action('error', f'Error loading HTTPS config page: {str(e)}')
|
||||||
flash('Error loading HTTPS configuration page.', 'danger')
|
flash('Error loading HTTPS configuration page.', 'danger')
|
||||||
@@ -1015,3 +1010,171 @@ def https_config_status():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_action('error', f'Error getting HTTPS status: {str(e)}')
|
log_action('error', f'Error getting HTTPS status: {str(e)}')
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Player Build & Deployment Routes
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _player_build_meta_path() -> str:
|
||||||
|
"""Path to the persisted player-build settings (in the instance folder)."""
|
||||||
|
from app.utils.player_build import BUILD_META_FILENAME
|
||||||
|
return os.path.join(current_app.instance_path, BUILD_META_FILENAME)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/build-player', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def build_player():
|
||||||
|
"""Display the 'Build player files for deployment' admin page."""
|
||||||
|
from app.utils.ssh_deploy import get_local_player_code_status
|
||||||
|
from app.utils.player_build import load_build_settings
|
||||||
|
|
||||||
|
player_code_dir = current_app.config['PLAYER_CODE_DIR']
|
||||||
|
settings = load_build_settings(_player_build_meta_path()) or {}
|
||||||
|
|
||||||
|
# Prefill server address from saved settings, else from HTTPS config.
|
||||||
|
if not settings.get('server_ip'):
|
||||||
|
https_cfg = HTTPSConfig.get_config()
|
||||||
|
if https_cfg and (https_cfg.domain or https_cfg.ip_address):
|
||||||
|
settings.setdefault('server_ip', https_cfg.domain or https_cfg.ip_address)
|
||||||
|
settings.setdefault('port', str(https_cfg.port or 443))
|
||||||
|
settings.setdefault('use_https', bool(https_cfg.https_enabled))
|
||||||
|
|
||||||
|
# Sensible defaults.
|
||||||
|
settings.setdefault('repo_url', current_app.config.get('PLAYER_REPO_URL', ''))
|
||||||
|
settings.setdefault('branch', 'main')
|
||||||
|
default_host = request.host.split(':')[0]
|
||||||
|
if default_host in ('localhost', '127.0.0.1', '') or default_host.startswith('127.'):
|
||||||
|
from app.utils.ssh_deploy import detect_server_ip
|
||||||
|
default_host = detect_server_ip() or default_host
|
||||||
|
settings.setdefault('server_ip', default_host)
|
||||||
|
settings.setdefault('port', '443')
|
||||||
|
settings.setdefault('use_https', True)
|
||||||
|
settings.setdefault('verify_ssl', False)
|
||||||
|
settings.setdefault('orientation', 'Landscape')
|
||||||
|
settings.setdefault('max_resolution', '1920x1080')
|
||||||
|
|
||||||
|
code_status = get_local_player_code_status(player_code_dir)
|
||||||
|
if code_status.get('updated'):
|
||||||
|
code_status['updated_str'] = datetime.fromtimestamp(
|
||||||
|
code_status['updated']).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'admin/build_player.html',
|
||||||
|
settings=settings,
|
||||||
|
code_status=code_status,
|
||||||
|
player_code_dir=player_code_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/build-player', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def build_player_action():
|
||||||
|
"""Start building/refreshing the staged player code.
|
||||||
|
|
||||||
|
The build runs in a background thread because a full clone of the player
|
||||||
|
repository takes far longer than gunicorn's worker timeout; running it
|
||||||
|
in-request would get the worker killed mid-clone and leave a broken
|
||||||
|
checkout. The page then polls ``admin.build_player_status`` for progress.
|
||||||
|
"""
|
||||||
|
from app.utils.player_build import start_background_build, is_build_running
|
||||||
|
|
||||||
|
player_code_dir = current_app.config['PLAYER_CODE_DIR']
|
||||||
|
action = request.form.get('action', 'build_and_config')
|
||||||
|
|
||||||
|
repo_url = request.form.get('repo_url', '').strip()
|
||||||
|
branch = request.form.get('branch', 'main').strip() or 'main'
|
||||||
|
server_ip = request.form.get('server_ip', '').strip()
|
||||||
|
port = request.form.get('port', '443').strip()
|
||||||
|
use_https = request.form.get('use_https') == 'on'
|
||||||
|
verify_ssl = request.form.get('verify_ssl') == 'on'
|
||||||
|
orientation = request.form.get('orientation', 'Landscape').strip() or 'Landscape'
|
||||||
|
max_resolution = request.form.get('max_resolution', '1920x1080').strip() or '1920x1080'
|
||||||
|
|
||||||
|
# Validation (unchanged — fail fast before starting any work)
|
||||||
|
errors = []
|
||||||
|
if action in ('build_files', 'build_and_config') and not repo_url:
|
||||||
|
errors.append('Repository URL is required to build player files.')
|
||||||
|
if action in ('save_config', 'build_and_config') and not server_ip:
|
||||||
|
errors.append('Server IP / domain is required for the player configuration.')
|
||||||
|
try:
|
||||||
|
int(port)
|
||||||
|
except ValueError:
|
||||||
|
errors.append('Port must be a valid number.')
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for err in errors:
|
||||||
|
flash(err, 'warning')
|
||||||
|
return redirect(url_for('admin.build_player'))
|
||||||
|
|
||||||
|
# 'save_config' only writes the config file — it touches no network and is
|
||||||
|
# fast, so it stays synchronous.
|
||||||
|
if action == 'save_config':
|
||||||
|
from app.utils.player_build import (
|
||||||
|
write_base_config, get_short_head, save_build_settings, make_build_record,
|
||||||
|
)
|
||||||
|
cfg_result = write_base_config(
|
||||||
|
player_code_dir=player_code_dir,
|
||||||
|
server_ip=server_ip, port=port, use_https=use_https,
|
||||||
|
verify_ssl=verify_ssl, orientation=orientation,
|
||||||
|
max_resolution=max_resolution,
|
||||||
|
)
|
||||||
|
version = get_short_head(player_code_dir)
|
||||||
|
save_build_settings(
|
||||||
|
_player_build_meta_path(),
|
||||||
|
make_build_record(
|
||||||
|
repo_url=repo_url, branch=branch, server_ip=server_ip, port=port,
|
||||||
|
use_https=use_https, verify_ssl=verify_ssl, orientation=orientation,
|
||||||
|
max_resolution=max_resolution, version=version,
|
||||||
|
built_by=current_user.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if cfg_result['success']:
|
||||||
|
log_action('info', f'Player config saved by {current_user.username}')
|
||||||
|
flash(f"✅ {cfg_result['message']}", 'success')
|
||||||
|
else:
|
||||||
|
log_action('error', f'Player config write failed: {cfg_result["message"]}')
|
||||||
|
flash(f"⚠️ {cfg_result['message']}", 'danger')
|
||||||
|
return redirect(url_for('admin.build_player'))
|
||||||
|
|
||||||
|
# build_files / build_and_config → background thread.
|
||||||
|
if is_build_running():
|
||||||
|
flash('⚠️ A build is already running — wait for it to finish.', 'warning')
|
||||||
|
return redirect(url_for('admin.build_player'))
|
||||||
|
|
||||||
|
config_payload = None
|
||||||
|
if action == 'build_and_config':
|
||||||
|
config_payload = {
|
||||||
|
'server_ip': server_ip, 'port': port, 'use_https': use_https,
|
||||||
|
'verify_ssl': verify_ssl, 'orientation': orientation,
|
||||||
|
'max_resolution': max_resolution,
|
||||||
|
}
|
||||||
|
|
||||||
|
started = start_background_build(
|
||||||
|
player_code_dir=player_code_dir,
|
||||||
|
repo_url=repo_url,
|
||||||
|
branch=branch,
|
||||||
|
config_payload=config_payload,
|
||||||
|
meta_path=_player_build_meta_path(),
|
||||||
|
built_by=current_user.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not started:
|
||||||
|
flash('⚠️ A build is already running — wait for it to finish.', 'warning')
|
||||||
|
else:
|
||||||
|
log_action('info', f'Player build started by {current_user.username} '
|
||||||
|
f'({branch} @ {repo_url})')
|
||||||
|
flash('⏳ Build started — this page will update automatically.', 'info')
|
||||||
|
|
||||||
|
return redirect(url_for('admin.build_player'))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/build-player/status', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def build_player_status():
|
||||||
|
"""JSON progress for the running/last player build (polled by the page)."""
|
||||||
|
from app.utils.player_build import get_build_state
|
||||||
|
return jsonify(get_build_state())
|
||||||
|
|||||||
+247
-65
@@ -3,11 +3,14 @@ from flask import Blueprint, request, jsonify, current_app
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import secrets
|
import secrets
|
||||||
|
import hashlib
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional, Dict, List
|
||||||
|
|
||||||
from app.extensions import db, cache
|
from app.extensions import db, cache
|
||||||
from app.models import Player, Group, Content, PlayerFeedback, ServerLog
|
from app.models import (
|
||||||
|
Player, Playlist, Content, PlayerFeedback, ServerLog,
|
||||||
|
)
|
||||||
from app.utils.logger import log_action
|
from app.utils.logger import log_action
|
||||||
|
|
||||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||||
@@ -85,6 +88,25 @@ def verify_player_auth(f):
|
|||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def get_assigned_playlist(player: Player) -> Optional[Playlist]:
|
||||||
|
"""Return the playlist assigned to *player*, or ``None`` if unassigned.
|
||||||
|
|
||||||
|
Centralises playlist lookup so every endpoint reports the same sync
|
||||||
|
version. Playlist edits bump ``Playlist.version``; players poll that
|
||||||
|
value to decide whether their cached content is stale. A player with no
|
||||||
|
assigned playlist has nothing to sync and resolves to version 0.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: The player whose assigned playlist should be resolved.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The assigned ``Playlist`` instance, or ``None`` when unassigned.
|
||||||
|
"""
|
||||||
|
if not player.playlist_id:
|
||||||
|
return None
|
||||||
|
return db.session.get(Playlist, player.playlist_id)
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/health', methods=['GET'])
|
@api_bp.route('/health', methods=['GET'])
|
||||||
def health_check():
|
def health_check():
|
||||||
"""API health check endpoint."""
|
"""API health check endpoint."""
|
||||||
@@ -95,6 +117,12 @@ def health_check():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/certificate', methods=['GET'])
|
||||||
|
def get_server_certificate():
|
||||||
|
"""Get server SSL certificate."""
|
||||||
|
return jsonify({'test': 'certificate_endpoint_works'}), 200
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/auth/player', methods=['POST'])
|
@api_bp.route('/auth/player', methods=['POST'])
|
||||||
@rate_limit(max_requests=120, window=60)
|
@rate_limit(max_requests=120, window=60)
|
||||||
def authenticate_player():
|
def authenticate_player():
|
||||||
@@ -106,7 +134,7 @@ def authenticate_player():
|
|||||||
quickconnect_code: Quick connect code (optional if using password)
|
quickconnect_code: Quick connect code (optional if using password)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON with auth_code, player_id, group_id, and configuration
|
JSON with auth_code, player_id, playlist_id, and configuration
|
||||||
"""
|
"""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
@@ -258,12 +286,8 @@ def get_playlist_by_quickconnect():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Get playlist version from the assigned playlist
|
# Get playlist version from the assigned playlist
|
||||||
playlist_version = 1
|
assigned_playlist = get_assigned_playlist(player)
|
||||||
if player.playlist_id:
|
playlist_version = assigned_playlist.version if assigned_playlist else 0
|
||||||
from app.models import Playlist
|
|
||||||
assigned_playlist = Playlist.query.get(player.playlist_id)
|
|
||||||
if assigned_playlist:
|
|
||||||
playlist_version = assigned_playlist.version
|
|
||||||
|
|
||||||
# Hash the quickconnect code for validation on client side
|
# Hash the quickconnect code for validation on client side
|
||||||
hashed_quickconnect = bcrypt.hashpw(
|
hashed_quickconnect = bcrypt.hashpw(
|
||||||
@@ -315,12 +339,8 @@ def get_player_playlist(player_id: int):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
# Get playlist version from the assigned playlist
|
# Get playlist version from the assigned playlist
|
||||||
playlist_version = 1
|
assigned_playlist = get_assigned_playlist(player)
|
||||||
if player.playlist_id:
|
playlist_version = assigned_playlist.version if assigned_playlist else 0
|
||||||
from app.models import Playlist
|
|
||||||
assigned_playlist = Playlist.query.get(player.playlist_id)
|
|
||||||
if assigned_playlist:
|
|
||||||
playlist_version = assigned_playlist.version
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'player_id': player_id,
|
'player_id': player_id,
|
||||||
@@ -356,10 +376,16 @@ def get_playlist_version(player_id: int):
|
|||||||
player.last_seen = datetime.utcnow()
|
player.last_seen = datetime.utcnow()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# Player syncs against the version of its assigned playlist; the
|
||||||
|
# content count comes from that same playlist (Content has no
|
||||||
|
# player_id column - it reaches players through the playlist).
|
||||||
|
assigned_playlist = get_assigned_playlist(player)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'player_id': player_id,
|
'player_id': player_id,
|
||||||
'playlist_version': player.playlist_version,
|
'playlist_id': player.playlist_id,
|
||||||
'content_count': Content.query.filter_by(player_id=player_id).count()
|
'playlist_version': assigned_playlist.version if assigned_playlist else 0,
|
||||||
|
'content_count': assigned_playlist.contents.count() if assigned_playlist else 0
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -371,7 +397,6 @@ def get_playlist_version(player_id: int):
|
|||||||
def get_cached_playlist(player_id: int) -> List[Dict]:
|
def get_cached_playlist(player_id: int) -> List[Dict]:
|
||||||
"""Get cached playlist for a player based on assigned playlist."""
|
"""Get cached playlist for a player based on assigned playlist."""
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
from app.models import Playlist
|
|
||||||
|
|
||||||
player = Player.query.get(player_id)
|
player = Player.query.get(player_id)
|
||||||
if not player or not player.playlist_id:
|
if not player or not player.playlist_id:
|
||||||
@@ -390,9 +415,13 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
|
|||||||
for idx, content in enumerate(content_list, start=1):
|
for idx, content in enumerate(content_list, start=1):
|
||||||
# Generate full URL for content
|
# Generate full URL for content
|
||||||
from flask import request as current_request
|
from flask import request as current_request
|
||||||
# Get server base URL
|
|
||||||
server_base = current_request.host_url.rstrip('/')
|
server_base = current_request.host_url.rstrip('/')
|
||||||
content_url = f"{server_base}/static/uploads/{content.filename}"
|
script_root = current_request.script_root.rstrip('/')
|
||||||
|
content_url = f"{server_base}{script_root}/static/uploads/{content.filename}"
|
||||||
|
|
||||||
|
# Web links carry the page URL directly instead of a file download URL.
|
||||||
|
is_weblink = content.content_type == 'weblink'
|
||||||
|
item_url = content.url if is_weblink else content_url
|
||||||
|
|
||||||
playlist_data.append({
|
playlist_data.append({
|
||||||
'id': content.id,
|
'id': content.id,
|
||||||
@@ -400,9 +429,14 @@ def get_cached_playlist(player_id: int) -> List[Dict]:
|
|||||||
'type': content.content_type,
|
'type': content.content_type,
|
||||||
'duration': content._playlist_duration or content.duration or 10,
|
'duration': content._playlist_duration or content.duration or 10,
|
||||||
'position': content._playlist_position or idx,
|
'position': content._playlist_position or idx,
|
||||||
'url': content_url, # Full URL for downloads
|
'url': item_url, # Web page URL for weblinks, file download URL otherwise
|
||||||
'description': content.description,
|
'description': content.description,
|
||||||
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False)
|
'edit_on_player': getattr(content, '_playlist_edit_on_player_enabled', False),
|
||||||
|
'muted': getattr(content, '_playlist_muted', True),
|
||||||
|
# Player expects 'audio' = "on"/"off" to set playback sound.
|
||||||
|
# The playlist_content.muted column stores True when audio is muted,
|
||||||
|
# so audio is "on" exactly when muted is False.
|
||||||
|
'audio': 'off' if getattr(content, '_playlist_muted', True) else 'on'
|
||||||
})
|
})
|
||||||
|
|
||||||
return playlist_data
|
return playlist_data
|
||||||
@@ -491,6 +525,18 @@ def receive_player_feedback():
|
|||||||
player.last_seen = datetime.utcnow()
|
player.last_seen = datetime.utcnow()
|
||||||
player.status = status
|
player.status = status
|
||||||
|
|
||||||
|
# A player that is sending feedback is running, so its deployment
|
||||||
|
# finished successfully. Auto-mark it as deployed so the players list
|
||||||
|
# doesn't stay stuck on "pending"/"deploying" after deployment is done.
|
||||||
|
if player.deployment_status in ('pending', 'deploying'):
|
||||||
|
player.deployment_status = 'deployed'
|
||||||
|
player.last_deployment_status = 'success'
|
||||||
|
player.last_deployment_message = (
|
||||||
|
'Player is running and sending feedback'
|
||||||
|
)
|
||||||
|
if not player.last_deployment_at:
|
||||||
|
player.last_deployment_at = datetime.utcnow()
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
log_action('info', f'Feedback received from {player.name} ({player.hostname}): {status} - {message}')
|
log_action('info', f'Feedback received from {player.name} ({player.hostname}): {status} - {message}')
|
||||||
@@ -528,7 +574,6 @@ def get_player_status(player_id: int):
|
|||||||
'player_id': player_id,
|
'player_id': player_id,
|
||||||
'name': player.name,
|
'name': player.name,
|
||||||
'location': player.location,
|
'location': player.location,
|
||||||
'group_id': player.group_id,
|
|
||||||
'status': player.status,
|
'status': player.status,
|
||||||
'is_online': is_online,
|
'is_online': is_online,
|
||||||
'last_seen': player.last_seen.isoformat() if player.last_seen else None,
|
'last_seen': player.last_seen.isoformat() if player.last_seen else None,
|
||||||
@@ -565,7 +610,6 @@ def system_info():
|
|||||||
try:
|
try:
|
||||||
# Get counts
|
# Get counts
|
||||||
total_players = Player.query.count()
|
total_players = Player.query.count()
|
||||||
total_groups = Group.query.count()
|
|
||||||
total_content = Content.query.count()
|
total_content = Content.query.count()
|
||||||
|
|
||||||
# Count online players (seen in last 5 minutes)
|
# Count online players (seen in last 5 minutes)
|
||||||
@@ -582,7 +626,6 @@ def system_info():
|
|||||||
'total': total_players,
|
'total': total_players,
|
||||||
'online': online_players
|
'online': online_players
|
||||||
},
|
},
|
||||||
'groups': total_groups,
|
|
||||||
'content': total_content,
|
'content': total_content,
|
||||||
'logs_24h': recent_logs,
|
'logs_24h': recent_logs,
|
||||||
'timestamp': datetime.utcnow().isoformat()
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
@@ -593,33 +636,6 @@ def system_info():
|
|||||||
return jsonify({'error': 'Internal server error'}), 500
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/groups', methods=['GET'])
|
|
||||||
@rate_limit(max_requests=60, window=60)
|
|
||||||
def list_groups():
|
|
||||||
"""List all groups with basic information."""
|
|
||||||
try:
|
|
||||||
groups = Group.query.order_by(Group.name).all()
|
|
||||||
|
|
||||||
groups_data = []
|
|
||||||
for group in groups:
|
|
||||||
groups_data.append({
|
|
||||||
'id': group.id,
|
|
||||||
'name': group.name,
|
|
||||||
'description': group.description,
|
|
||||||
'player_count': group.players.count(),
|
|
||||||
'content_count': group.contents.count()
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'groups': groups_data,
|
|
||||||
'count': len(groups_data)
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log_action('error', f'Error listing groups: {str(e)}')
|
|
||||||
return jsonify({'error': 'Internal server error'}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/content', methods=['GET'])
|
@api_bp.route('/content', methods=['GET'])
|
||||||
@rate_limit(max_requests=60, window=60)
|
@rate_limit(max_requests=60, window=60)
|
||||||
def list_content():
|
def list_content():
|
||||||
@@ -635,8 +651,7 @@ def list_content():
|
|||||||
'type': content.content_type,
|
'type': content.content_type,
|
||||||
'duration': content.duration,
|
'duration': content.duration,
|
||||||
'size': content.file_size,
|
'size': content.file_size,
|
||||||
'uploaded_at': content.uploaded_at.isoformat(),
|
'uploaded_at': content.uploaded_at.isoformat()
|
||||||
'group_count': content.groups.count()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -747,42 +762,90 @@ def receive_edited_media():
|
|||||||
if field not in metadata:
|
if field not in metadata:
|
||||||
return jsonify({'error': f'Missing required field: {field}'}), 400
|
return jsonify({'error': f'Missing required field: {field}'}), 400
|
||||||
|
|
||||||
# Import required modules
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
from datetime import datetime
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
from app.models.player_edit import PlayerEdit
|
from app.models.player_edit import PlayerEdit
|
||||||
|
|
||||||
# Find the original content by filename
|
|
||||||
original_name = metadata['original_name']
|
original_name = metadata['original_name']
|
||||||
|
version = metadata['version']
|
||||||
|
new_filename = metadata['new_name']
|
||||||
|
|
||||||
|
# ── Content lookup ───────────────────────────────────────────────
|
||||||
|
# If the player sends the current filename (pointing to edited_media/...),
|
||||||
|
# we find the content by its ID embedded in the path. Otherwise do a
|
||||||
|
# direct filename match.
|
||||||
content = Content.query.filter_by(filename=original_name).first()
|
content = Content.query.filter_by(filename=original_name).first()
|
||||||
|
if not content:
|
||||||
|
# Try to extract content_id from a path like "edited_media/<id>/file"
|
||||||
|
import re
|
||||||
|
m = re.match(r'edited_media/(\d+)/', original_name)
|
||||||
|
if m:
|
||||||
|
content = db.session.get(Content, int(m.group(1)))
|
||||||
|
if not content:
|
||||||
|
# Last resort – look up the most recent PlayerEdit for this
|
||||||
|
# content and use its content_id.
|
||||||
|
fallback_edit = PlayerEdit.query.filter_by(new_name=original_name)\
|
||||||
|
.order_by(PlayerEdit.created_at.desc()).first()
|
||||||
|
if fallback_edit:
|
||||||
|
content = db.session.get(Content, fallback_edit.content_id)
|
||||||
|
|
||||||
if not content:
|
if not content:
|
||||||
log_action('warning', f'Player {player.name} tried to edit non-existent content: {original_name}')
|
log_action('warning', f'Player {player.name} tried to edit non-existent content: {original_name}')
|
||||||
return jsonify({'error': f'Original content not found: {original_name}'}), 404
|
return jsonify({'error': f'Original content not found: {original_name}'}), 404
|
||||||
|
|
||||||
# Create versioned folder structure: edited_media/<content_id>/
|
# ── Versionized folder ───────────────────────────────────────────
|
||||||
base_upload_dir = os.path.join(current_app.root_path, 'static', 'uploads')
|
base_upload_dir = os.path.join(current_app.root_path, 'static', 'uploads')
|
||||||
edited_media_dir = os.path.join(base_upload_dir, 'edited_media', str(content.id))
|
edited_media_dir = os.path.join(base_upload_dir, 'edited_media', str(content.id))
|
||||||
os.makedirs(edited_media_dir, exist_ok=True)
|
os.makedirs(edited_media_dir, exist_ok=True)
|
||||||
|
|
||||||
# Save the edited file with version suffix
|
# On the very first edit (v1) move the original file into the
|
||||||
version = metadata['version']
|
# versionized folder so it is never orphaned.
|
||||||
new_filename = metadata['new_name']
|
is_first_edit = PlayerEdit.query.filter_by(content_id=content.id).count() == 0
|
||||||
|
if is_first_edit:
|
||||||
|
orig_upload = os.path.join(base_upload_dir, content.filename)
|
||||||
|
# content.filename might already be an edited_media/ path if
|
||||||
|
# this is a re-process; only move if it's a plain filename.
|
||||||
|
if os.path.isfile(orig_upload) and not content.filename.startswith('edited_media/'):
|
||||||
|
orig_stored = f"original_{content.filename}"
|
||||||
|
shutil.move(orig_upload, os.path.join(edited_media_dir, orig_stored))
|
||||||
|
log_action('info', f'Moved original file "{content.filename}" to versionized folder as "{orig_stored}"')
|
||||||
|
|
||||||
|
# ── Save the edited file ─────────────────────────────────────────
|
||||||
edited_file_path = os.path.join(edited_media_dir, new_filename)
|
edited_file_path = os.path.join(edited_media_dir, new_filename)
|
||||||
file.save(edited_file_path)
|
file.save(edited_file_path)
|
||||||
|
|
||||||
# Save metadata JSON file
|
# Side-car metadata JSON
|
||||||
metadata_filename = f"{os.path.splitext(new_filename)[0]}_metadata.json"
|
metadata_filename = f"{os.path.splitext(new_filename)[0]}_metadata.json"
|
||||||
metadata_path = os.path.join(edited_media_dir, metadata_filename)
|
metadata_path = os.path.join(edited_media_dir, metadata_filename)
|
||||||
with open(metadata_path, 'w') as f:
|
with open(metadata_path, 'w') as f:
|
||||||
json.dump(metadata, f, indent=2)
|
json.dump(metadata, f, indent=2)
|
||||||
|
|
||||||
# Update the content record to reference the edited version path
|
# ── Preserve the original filename ──────────────────────────────
|
||||||
# Keep original filename unchanged, point to edited_media folder
|
# `content.filename` is (re)pointed at the latest edit below so the
|
||||||
|
# player downloads the newest version. The pristine original must be
|
||||||
|
# remembered separately, otherwise the original is lost forever and
|
||||||
|
# the UI shows the edited image as the "original".
|
||||||
|
if not content.original_filename:
|
||||||
|
if not content.filename.startswith('edited_media/'):
|
||||||
|
# Normal case: current filename is still the pristine upload.
|
||||||
|
content.original_filename = content.filename
|
||||||
|
else:
|
||||||
|
# Re-process: content.filename already points at an edited
|
||||||
|
# file. Recover the pristine original from the first (v1) edit.
|
||||||
|
first_edit = PlayerEdit.query.filter_by(content_id=content.id)\
|
||||||
|
.order_by(PlayerEdit.version.asc(), PlayerEdit.created_at.asc())\
|
||||||
|
.first()
|
||||||
|
if first_edit and first_edit.original_name:
|
||||||
|
content.original_filename = first_edit.original_name
|
||||||
|
|
||||||
|
# ── Point Content.filename to the latest edit ────────────────────
|
||||||
|
# This tells the player to download the latest edited version.
|
||||||
old_filename = content.filename
|
old_filename = content.filename
|
||||||
content.filename = f"edited_media/{content.id}/{new_filename}"
|
content.filename = f"edited_media/{content.id}/{new_filename}"
|
||||||
|
|
||||||
# Create edit record
|
# ── Create edit record ───────────────────────────────────────────
|
||||||
time_of_mod = None
|
time_of_mod = None
|
||||||
if metadata.get('time_of_modification'):
|
if metadata.get('time_of_modification'):
|
||||||
try:
|
try:
|
||||||
@@ -818,7 +881,8 @@ def receive_edited_media():
|
|||||||
)
|
)
|
||||||
db.session.add(edit_record)
|
db.session.add(edit_record)
|
||||||
|
|
||||||
# Update playlist version to force player refresh
|
# ── Update playlist version to force player refresh ──────────────
|
||||||
|
playlist = None
|
||||||
if player.playlist_id:
|
if player.playlist_id:
|
||||||
from app.models.playlist import Playlist
|
from app.models.playlist import Playlist
|
||||||
playlist = db.session.get(Playlist, player.playlist_id)
|
playlist = db.session.get(Playlist, player.playlist_id)
|
||||||
@@ -839,7 +903,7 @@ def receive_edited_media():
|
|||||||
'version': version,
|
'version': version,
|
||||||
'old_filename': old_filename,
|
'old_filename': old_filename,
|
||||||
'new_filename': new_filename,
|
'new_filename': new_filename,
|
||||||
'new_playlist_version': playlist.version if player.playlist_id and playlist else None
|
'new_playlist_version': playlist.version if playlist else None
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -848,6 +912,124 @@ def receive_edited_media():
|
|||||||
return jsonify({'error': 'Internal server error'}), 500
|
return jsonify({'error': 'Internal server error'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# SSH/Deployment Endpoints - For player provisioning and code deployment
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@api_bp.route('/deploy/test-ssh', methods=['POST'])
|
||||||
|
@rate_limit(max_requests=30, window=60)
|
||||||
|
def test_ssh_connection():
|
||||||
|
"""Test SSH connection to a remote host.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
hostname: Target hostname or IP (required)
|
||||||
|
username: SSH username (required)
|
||||||
|
password: SSH password (required)
|
||||||
|
port: SSH port (default: 22)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with connection test result
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.utils.ssh_deploy import test_ssh_connection as test_ssh
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'No data provided'}), 400
|
||||||
|
|
||||||
|
hostname = data.get('hostname', '').strip()
|
||||||
|
username = data.get('username', '').strip()
|
||||||
|
password = data.get('password', '').strip()
|
||||||
|
port = data.get('port', 22)
|
||||||
|
|
||||||
|
if not hostname or not username or not password:
|
||||||
|
return jsonify({'error': 'hostname, username, and password are required'}), 400
|
||||||
|
|
||||||
|
result = test_ssh(hostname, username, password, port)
|
||||||
|
|
||||||
|
log_action('info', f'SSH test for {username}@{hostname}: {result["message"]}')
|
||||||
|
|
||||||
|
return jsonify(result), 200 if result['success'] else 400
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Error testing SSH connection: {str(e)}')
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
'message': f'SSH test error: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/deploy/player', methods=['POST'])
|
||||||
|
@rate_limit(max_requests=20, window=60)
|
||||||
|
def deploy_player():
|
||||||
|
"""Deploy player code to a remote host via SSH.
|
||||||
|
|
||||||
|
Request JSON:
|
||||||
|
hostname: Target hostname or IP (required)
|
||||||
|
username: SSH username (required)
|
||||||
|
password: SSH password (required)
|
||||||
|
player_name: Name for the player instance (required)
|
||||||
|
port: SSH port (default: 22)
|
||||||
|
deploy_path: Deployment path on remote host
|
||||||
|
repo_url: Git repository URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with deployment status and step details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.utils.ssh_deploy import deploy_player_to_host
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'No data provided'}), 400
|
||||||
|
|
||||||
|
hostname = data.get('hostname', '').strip()
|
||||||
|
username = data.get('username', '').strip()
|
||||||
|
password = data.get('password', '').strip()
|
||||||
|
player_name = data.get('player_name', '').strip()
|
||||||
|
port = data.get('port', 22)
|
||||||
|
deploy_path = data.get('deploy_path', None)
|
||||||
|
repo_url = data.get('repo_url', 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git').strip()
|
||||||
|
|
||||||
|
if not hostname or not username or not password:
|
||||||
|
return jsonify({'error': 'hostname, username, and password are required'}), 400
|
||||||
|
|
||||||
|
if not player_name:
|
||||||
|
return jsonify({'error': 'player_name is required'}), 400
|
||||||
|
|
||||||
|
scheme = request.headers.get('X-Forwarded-Proto', request.scheme)
|
||||||
|
host = request.headers.get('X-Forwarded-Host', request.host)
|
||||||
|
server_url = f"{scheme}://{host}"
|
||||||
|
|
||||||
|
api_key = hashlib.sha256(f'{player_name}:{hostname}'.encode()).hexdigest()[:32]
|
||||||
|
|
||||||
|
result = deploy_player_to_host(
|
||||||
|
hostname=hostname,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
player_name=player_name,
|
||||||
|
repo_url=repo_url,
|
||||||
|
deploy_path=deploy_path,
|
||||||
|
port=port,
|
||||||
|
server_url=server_url,
|
||||||
|
server_api_key=api_key
|
||||||
|
)
|
||||||
|
|
||||||
|
log_action('info', f'Player deployment for {player_name} on {hostname}: success={result["success"]}')
|
||||||
|
|
||||||
|
return jsonify(result), 200 if result['success'] else 400
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Error deploying player: {str(e)}')
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
'message': f'Deployment error: {str(e)}',
|
||||||
|
'steps': []
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@api_bp.errorhandler(404)
|
@api_bp.errorhandler(404)
|
||||||
def api_not_found(error):
|
def api_not_found(error):
|
||||||
"""Handle 404 errors in API."""
|
"""Handle 404 errors in API."""
|
||||||
|
|||||||
+215
-2
@@ -6,6 +6,9 @@ from werkzeug.utils import secure_filename
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from app.extensions import db, cache
|
from app.extensions import db, cache
|
||||||
from app.models import Content, Playlist, Player
|
from app.models import Content, Playlist, Player
|
||||||
@@ -201,8 +204,10 @@ def manage_playlist_content(playlist_id: int):
|
|||||||
# Get content in playlist (ordered)
|
# Get content in playlist (ordered)
|
||||||
playlist_content = playlist.get_content_ordered()
|
playlist_content = playlist.get_content_ordered()
|
||||||
|
|
||||||
# Get all available content not in this playlist
|
# Get all available content not in this playlist.
|
||||||
all_content = Content.query.all()
|
# Web links are created on demand per playlist, so they are not offered
|
||||||
|
# as reusable library items here.
|
||||||
|
all_content = Content.query.filter(Content.content_type != 'weblink').all()
|
||||||
playlist_content_ids = {c.id for c in playlist_content}
|
playlist_content_ids = {c.id for c in playlist_content}
|
||||||
available_content = [c for c in all_content if c.id not in playlist_content_ids]
|
available_content = [c for c in all_content if c.id not in playlist_content_ids]
|
||||||
|
|
||||||
@@ -262,6 +267,152 @@ def add_content_to_playlist(playlist_id: int):
|
|||||||
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||||
|
|
||||||
|
|
||||||
|
@content_bp.route('/add-weblink', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def add_weblink():
|
||||||
|
"""Create a web link content item from the Upload Media page.
|
||||||
|
|
||||||
|
Optionally adds it directly to a playlist if playlist_id is supplied.
|
||||||
|
Returns JSON when the request carries Accept: application/json, otherwise
|
||||||
|
redirects back to the upload page.
|
||||||
|
"""
|
||||||
|
use_json = 'application/json' in request.accept_mimetypes.best or \
|
||||||
|
request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||||
|
|
||||||
|
try:
|
||||||
|
web_url = (request.form.get('url') or '').strip()
|
||||||
|
duration = request.form.get('duration', type=int, default=30)
|
||||||
|
description = (request.form.get('description') or '').strip() or None
|
||||||
|
playlist_id = request.form.get('playlist_id', type=int)
|
||||||
|
|
||||||
|
parsed = urlparse(web_url)
|
||||||
|
if parsed.scheme.lower() not in ('http', 'https') or not parsed.netloc:
|
||||||
|
if use_json:
|
||||||
|
return jsonify({'success': False, 'error': 'Please enter a valid http:// or https:// web address.'}), 400
|
||||||
|
flash('Please enter a valid http:// or https:// web address.', 'warning')
|
||||||
|
return redirect(url_for('content.upload_media_page'))
|
||||||
|
|
||||||
|
if not duration or duration < 1:
|
||||||
|
duration = 30
|
||||||
|
|
||||||
|
content = Content(
|
||||||
|
filename=f'weblink-{uuid.uuid4().hex[:12]}',
|
||||||
|
content_type='weblink',
|
||||||
|
url=web_url,
|
||||||
|
duration=duration,
|
||||||
|
description=description or web_url,
|
||||||
|
uploaded_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.session.add(content)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
if playlist_id:
|
||||||
|
playlist = Playlist.query.get(playlist_id)
|
||||||
|
if playlist:
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
max_pos = db.session.execute(
|
||||||
|
select(func.max(playlist_content.c.position)).where(
|
||||||
|
playlist_content.c.playlist_id == playlist_id
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
db.session.execute(
|
||||||
|
playlist_content.insert().values(
|
||||||
|
playlist_id=playlist_id,
|
||||||
|
content_id=content.id,
|
||||||
|
position=max_pos + 1,
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
playlist.increment_version()
|
||||||
|
log_action('info', f'Web link "{web_url}" added to playlist "{playlist.name}"')
|
||||||
|
else:
|
||||||
|
log_action('warning', f'Web link "{web_url}" created; playlist {playlist_id} not found')
|
||||||
|
else:
|
||||||
|
log_action('info', f'Web link "{web_url}" added to media library')
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
if use_json:
|
||||||
|
return jsonify({'success': True, 'content_id': content.id, 'message': 'Web link added successfully.'})
|
||||||
|
flash('Web link added successfully.', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
log_action('error', f'Error adding web link: {str(e)}')
|
||||||
|
if use_json:
|
||||||
|
return jsonify({'success': False, 'error': 'Failed to add web link.'}), 500
|
||||||
|
flash('Error adding web link.', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('content.upload_media_page'))
|
||||||
|
|
||||||
|
|
||||||
|
@content_bp.route('/playlist/<int:playlist_id>/add-weblink', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def add_weblink_to_playlist(playlist_id: int):
|
||||||
|
"""Create a web link content item and add it to the playlist."""
|
||||||
|
playlist = Playlist.query.get_or_404(playlist_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
web_url = (request.form.get('url') or '').strip()
|
||||||
|
duration = request.form.get('duration', type=int, default=30)
|
||||||
|
description = (request.form.get('description') or '').strip() or None
|
||||||
|
|
||||||
|
# Validate the URL: only http/https schemes are allowed (avoid file://, etc.)
|
||||||
|
parsed = urlparse(web_url)
|
||||||
|
if parsed.scheme.lower() not in ('http', 'https') or not parsed.netloc:
|
||||||
|
flash('Please enter a valid http:// or https:// web address.', 'warning')
|
||||||
|
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||||
|
|
||||||
|
if duration is None or duration < 1:
|
||||||
|
duration = 30
|
||||||
|
|
||||||
|
# Create a weblink Content row. filename is a synthetic unique label
|
||||||
|
# (no file on disk); the real target lives in the url column.
|
||||||
|
content = Content(
|
||||||
|
filename=f'weblink-{uuid.uuid4().hex[:12]}',
|
||||||
|
content_type='weblink',
|
||||||
|
url=web_url,
|
||||||
|
duration=duration,
|
||||||
|
description=description or web_url,
|
||||||
|
uploaded_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.session.add(content)
|
||||||
|
db.session.flush() # assign content.id
|
||||||
|
|
||||||
|
# Append to the end of the playlist
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
|
max_pos = db.session.execute(
|
||||||
|
select(func.max(playlist_content.c.position)).where(
|
||||||
|
playlist_content.c.playlist_id == playlist_id
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
db.session.execute(
|
||||||
|
playlist_content.insert().values(
|
||||||
|
playlist_id=playlist_id,
|
||||||
|
content_id=content.id,
|
||||||
|
position=max_pos + 1,
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
playlist.increment_version()
|
||||||
|
db.session.commit()
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
log_action('info', f'Added web link "{web_url}" to playlist "{playlist.name}"')
|
||||||
|
flash('Web link added to playlist.', 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
log_action('error', f'Error adding web link to playlist: {str(e)}')
|
||||||
|
flash('Error adding web link to playlist.', 'danger')
|
||||||
|
|
||||||
|
return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id))
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/playlist/<int:playlist_id>/remove-content/<int:content_id>', methods=['POST'])
|
@content_bp.route('/playlist/<int:playlist_id>/remove-content/<int:content_id>', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def remove_content_from_playlist(playlist_id: int, content_id: int):
|
def remove_content_from_playlist(playlist_id: int, content_id: int):
|
||||||
@@ -278,6 +429,12 @@ def remove_content_from_playlist(playlist_id: int, content_id: int):
|
|||||||
)
|
)
|
||||||
db.session.execute(stmt)
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
# Web link items are playlist-specific and have no media-library
|
||||||
|
# presence, so delete the orphan Content row when it is removed.
|
||||||
|
content = db.session.get(Content, content_id)
|
||||||
|
if content is not None and content.content_type == 'weblink':
|
||||||
|
db.session.delete(content)
|
||||||
|
|
||||||
playlist.increment_version()
|
playlist.increment_version()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
cache.clear()
|
cache.clear()
|
||||||
@@ -458,6 +615,56 @@ def update_playlist_content_edit_enabled(playlist_id: int, content_id: int):
|
|||||||
return jsonify({'success': False, 'message': str(e)}), 500
|
return jsonify({'success': False, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@content_bp.route('/playlist/<int:playlist_id>/update-duration/<int:content_id>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def update_playlist_content_duration(playlist_id: int, content_id: int):
|
||||||
|
"""Update content duration in playlist."""
|
||||||
|
playlist = Playlist.query.get_or_404(playlist_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = Content.query.get_or_404(content_id)
|
||||||
|
|
||||||
|
# Get duration from request
|
||||||
|
try:
|
||||||
|
duration = int(request.form.get('duration', 10))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({'success': False, 'message': 'Invalid duration value'}), 400
|
||||||
|
|
||||||
|
# Validate duration (minimum 1 second)
|
||||||
|
if duration < 1:
|
||||||
|
return jsonify({'success': False, 'message': 'Duration must be at least 1 second'}), 400
|
||||||
|
|
||||||
|
from app.models.playlist import playlist_content
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
# Update duration in association table
|
||||||
|
stmt = update(playlist_content).where(
|
||||||
|
(playlist_content.c.playlist_id == playlist_id) &
|
||||||
|
(playlist_content.c.content_id == content_id)
|
||||||
|
).values(duration=duration)
|
||||||
|
db.session.execute(stmt)
|
||||||
|
|
||||||
|
# Increment playlist version
|
||||||
|
playlist.increment_version()
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
cache.clear()
|
||||||
|
|
||||||
|
log_action('info', f'Updated duration={duration}s for "{content.filename}" in playlist "{playlist.name}"')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Duration updated',
|
||||||
|
'duration': duration,
|
||||||
|
'version': playlist.version
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
log_action('error', f'Error updating duration: {str(e)}')
|
||||||
|
return jsonify({'success': False, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/upload-media-page')
|
@content_bp.route('/upload-media-page')
|
||||||
@login_required
|
@login_required
|
||||||
def upload_media_page():
|
def upload_media_page():
|
||||||
@@ -622,6 +829,7 @@ def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
|
|||||||
page_filename = os.path.basename(page_file)
|
page_filename = os.path.basename(page_file)
|
||||||
page_content = Content(
|
page_content = Content(
|
||||||
filename=page_filename,
|
filename=page_filename,
|
||||||
|
original_filename=page_filename,
|
||||||
content_type='image',
|
content_type='image',
|
||||||
duration=duration,
|
duration=duration,
|
||||||
file_size=os.path.getsize(page_file)
|
file_size=os.path.getsize(page_file)
|
||||||
@@ -679,6 +887,7 @@ def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
|
|||||||
slide_filename = os.path.basename(slide_file)
|
slide_filename = os.path.basename(slide_file)
|
||||||
slide_content = Content(
|
slide_content = Content(
|
||||||
filename=slide_filename,
|
filename=slide_filename,
|
||||||
|
original_filename=slide_filename,
|
||||||
content_type='image',
|
content_type='image',
|
||||||
duration=duration,
|
duration=duration,
|
||||||
file_size=os.path.getsize(slide_file)
|
file_size=os.path.getsize(slide_file)
|
||||||
@@ -723,6 +932,7 @@ def process_file_in_background(app, filepath: str, filename: str, file_ext: str,
|
|||||||
if processing_success and os.path.exists(filepath):
|
if processing_success and os.path.exists(filepath):
|
||||||
content = Content(
|
content = Content(
|
||||||
filename=filename,
|
filename=filename,
|
||||||
|
original_filename=filename,
|
||||||
content_type=detected_type,
|
content_type=detected_type,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
file_size=os.path.getsize(filepath)
|
file_size=os.path.getsize(filepath)
|
||||||
@@ -1106,6 +1316,7 @@ def upload_media():
|
|||||||
# Create content record for page
|
# Create content record for page
|
||||||
page_content = Content(
|
page_content = Content(
|
||||||
filename=page_filename,
|
filename=page_filename,
|
||||||
|
original_filename=page_filename,
|
||||||
content_type='image',
|
content_type='image',
|
||||||
duration=duration,
|
duration=duration,
|
||||||
file_size=os.path.getsize(page_file)
|
file_size=os.path.getsize(page_file)
|
||||||
@@ -1166,6 +1377,7 @@ def upload_media():
|
|||||||
# Create content record for slide
|
# Create content record for slide
|
||||||
slide_content = Content(
|
slide_content = Content(
|
||||||
filename=slide_filename,
|
filename=slide_filename,
|
||||||
|
original_filename=slide_filename,
|
||||||
content_type='image',
|
content_type='image',
|
||||||
duration=duration,
|
duration=duration,
|
||||||
file_size=os.path.getsize(slide_file)
|
file_size=os.path.getsize(slide_file)
|
||||||
@@ -1212,6 +1424,7 @@ def upload_media():
|
|||||||
if os.path.exists(filepath):
|
if os.path.exists(filepath):
|
||||||
content = Content(
|
content = Content(
|
||||||
filename=filename,
|
filename=filename,
|
||||||
|
original_filename=filename,
|
||||||
content_type=detected_type,
|
content_type=detected_type,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
file_size=os.path.getsize(filepath)
|
file_size=os.path.getsize(filepath)
|
||||||
|
|||||||
@@ -1,500 +0,0 @@
|
|||||||
"""Content blueprint for media upload and management."""
|
|
||||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
|
||||||
flash, jsonify, current_app, send_from_directory)
|
|
||||||
from flask_login import login_required
|
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
import os
|
|
||||||
from typing import Optional, Dict
|
|
||||||
import json
|
|
||||||
|
|
||||||
from app.extensions import db, cache
|
|
||||||
from app.models import Content, Group
|
|
||||||
from app.utils.logger import log_action
|
|
||||||
from app.utils.uploads import (
|
|
||||||
save_uploaded_file,
|
|
||||||
process_video_file,
|
|
||||||
process_pdf_file,
|
|
||||||
get_upload_progress,
|
|
||||||
set_upload_progress
|
|
||||||
)
|
|
||||||
|
|
||||||
content_bp = Blueprint('content', __name__, url_prefix='/content')
|
|
||||||
|
|
||||||
|
|
||||||
# In-memory storage for upload progress (for simple demo; use Redis in production)
|
|
||||||
upload_progress = {}
|
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/')
|
|
||||||
@login_required
|
|
||||||
def content_list():
|
|
||||||
"""Display list of all content."""
|
|
||||||
try:
|
|
||||||
# Get all unique content files (by filename)
|
|
||||||
from sqlalchemy import func
|
|
||||||
|
|
||||||
# Get content with player information
|
|
||||||
contents = Content.query.order_by(Content.filename, Content.uploaded_at.desc()).all()
|
|
||||||
|
|
||||||
# Group content by filename to show which players have each file
|
|
||||||
content_map = {}
|
|
||||||
for content in contents:
|
|
||||||
if content.filename not in content_map:
|
|
||||||
content_map[content.filename] = {
|
|
||||||
'content': content,
|
|
||||||
'players': [],
|
|
||||||
'groups': []
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add player info if assigned to a player
|
|
||||||
if content.player_id:
|
|
||||||
from app.models import Player
|
|
||||||
player = Player.query.get(content.player_id)
|
|
||||||
if player:
|
|
||||||
content_map[content.filename]['players'].append({
|
|
||||||
'id': player.id,
|
|
||||||
'name': player.name,
|
|
||||||
'group': player.group.name if player.group else None
|
|
||||||
})
|
|
||||||
|
|
||||||
# Convert to list for template
|
|
||||||
content_list = []
|
|
||||||
for filename, data in content_map.items():
|
|
||||||
content_list.append({
|
|
||||||
'filename': filename,
|
|
||||||
'content_type': data['content'].content_type,
|
|
||||||
'duration': data['content'].duration,
|
|
||||||
'file_size': data['content'].file_size_mb,
|
|
||||||
'uploaded_at': data['content'].uploaded_at,
|
|
||||||
'players': data['players'],
|
|
||||||
'player_count': len(data['players'])
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by upload date
|
|
||||||
content_list.sort(key=lambda x: x['uploaded_at'], reverse=True)
|
|
||||||
|
|
||||||
return render_template('content/content_list.html',
|
|
||||||
content_list=content_list)
|
|
||||||
except Exception as e:
|
|
||||||
log_action('error', f'Error loading content list: {str(e)}')
|
|
||||||
flash('Error loading content list.', 'danger')
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/upload', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def upload_content():
|
|
||||||
"""Upload new content."""
|
|
||||||
if request.method == 'GET':
|
|
||||||
# Get parameters for return URL and pre-selection
|
|
||||||
player_id = request.args.get('player_id', type=int)
|
|
||||||
return_url = request.args.get('return_url', url_for('content.content_list'))
|
|
||||||
|
|
||||||
# Get all players for selection
|
|
||||||
from app.models import Player
|
|
||||||
players = Player.query.order_by(Player.name).all()
|
|
||||||
|
|
||||||
return render_template('content/upload_content.html',
|
|
||||||
players=players,
|
|
||||||
selected_player_id=player_id,
|
|
||||||
return_url=return_url)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get form data
|
|
||||||
player_id = request.form.get('player_id', type=int)
|
|
||||||
media_type = request.form.get('media_type', 'image')
|
|
||||||
duration = request.form.get('duration', type=int, default=10)
|
|
||||||
session_id = request.form.get('session_id', os.urandom(8).hex())
|
|
||||||
return_url = request.form.get('return_url', url_for('content.content_list'))
|
|
||||||
|
|
||||||
# Get files
|
|
||||||
files = request.files.getlist('files')
|
|
||||||
|
|
||||||
if not files or files[0].filename == '':
|
|
||||||
flash('No files provided.', 'warning')
|
|
||||||
return redirect(url_for('content.upload_content'))
|
|
||||||
|
|
||||||
if not player_id:
|
|
||||||
flash('Please select a player.', 'warning')
|
|
||||||
return redirect(url_for('content.upload_content'))
|
|
||||||
|
|
||||||
# Initialize progress tracking using shared utility
|
|
||||||
set_upload_progress(session_id, 0, 'Starting upload...', 'uploading')
|
|
||||||
|
|
||||||
# Process each file
|
|
||||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
|
||||||
os.makedirs(upload_folder, exist_ok=True)
|
|
||||||
|
|
||||||
processed_count = 0
|
|
||||||
total_files = len(files)
|
|
||||||
|
|
||||||
for idx, file in enumerate(files):
|
|
||||||
if file.filename == '':
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Update progress
|
|
||||||
progress_pct = int((idx / total_files) * 80) # 0-80% for file processing
|
|
||||||
set_upload_progress(session_id, progress_pct,
|
|
||||||
f'Processing file {idx + 1} of {total_files}...', 'processing')
|
|
||||||
|
|
||||||
filename = secure_filename(file.filename)
|
|
||||||
filepath = os.path.join(upload_folder, filename)
|
|
||||||
|
|
||||||
# Save file
|
|
||||||
file.save(filepath)
|
|
||||||
|
|
||||||
# Determine content type
|
|
||||||
file_ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
|
||||||
|
|
||||||
if file_ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
|
|
||||||
content_type = 'image'
|
|
||||||
elif file_ext in ['mp4', 'avi', 'mov', 'mkv', 'webm']:
|
|
||||||
content_type = 'video'
|
|
||||||
# Process video (convert to Raspberry Pi optimized format)
|
|
||||||
set_upload_progress(session_id, progress_pct + 5,
|
|
||||||
f'Optimizing video {idx + 1} for Raspberry Pi (30fps, H.264)...', 'processing')
|
|
||||||
success, message = process_video_file(filepath, session_id)
|
|
||||||
if not success:
|
|
||||||
log_action('error', f'Video optimization failed: {message}')
|
|
||||||
continue # Skip this file and move to next
|
|
||||||
elif file_ext == 'pdf':
|
|
||||||
content_type = 'pdf'
|
|
||||||
# Process PDF (convert to images)
|
|
||||||
set_upload_progress(session_id, progress_pct + 5,
|
|
||||||
f'Converting PDF {idx + 1}...', 'processing')
|
|
||||||
# process_pdf_file(filepath, session_id)
|
|
||||||
elif file_ext in ['ppt', 'pptx']:
|
|
||||||
content_type = 'presentation'
|
|
||||||
# Process presentation (convert to PDF then images)
|
|
||||||
set_upload_progress(session_id, progress_pct + 5,
|
|
||||||
f'Converting PowerPoint {idx + 1}...', 'processing')
|
|
||||||
# This would call pptx_converter utility
|
|
||||||
else:
|
|
||||||
content_type = 'other'
|
|
||||||
|
|
||||||
# Create content record linked to player
|
|
||||||
from app.models import Player
|
|
||||||
player = Player.query.get(player_id)
|
|
||||||
if player:
|
|
||||||
new_content = Content(
|
|
||||||
filename=filename,
|
|
||||||
content_type=content_type,
|
|
||||||
duration=duration,
|
|
||||||
file_size=os.path.getsize(filepath),
|
|
||||||
player_id=player_id
|
|
||||||
)
|
|
||||||
db.session.add(new_content)
|
|
||||||
|
|
||||||
# Increment playlist version
|
|
||||||
player.playlist_version += 1
|
|
||||||
log_action('info', f'Content "{filename}" added to player "{player.name}" (version {player.playlist_version})')
|
|
||||||
|
|
||||||
processed_count += 1
|
|
||||||
|
|
||||||
# Commit all changes
|
|
||||||
set_upload_progress(session_id, 90, 'Saving to database...', 'processing')
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Complete
|
|
||||||
set_upload_progress(session_id, 100,
|
|
||||||
f'Successfully uploaded {processed_count} file(s)!', 'complete')
|
|
||||||
|
|
||||||
# Clear all playlist caches
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
log_action('info', f'{processed_count} files uploaded successfully (Type: {media_type})')
|
|
||||||
flash(f'{processed_count} file(s) uploaded successfully.', 'success')
|
|
||||||
|
|
||||||
return redirect(return_url)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
|
|
||||||
# Update progress to error state
|
|
||||||
if 'session_id' in locals():
|
|
||||||
set_upload_progress(session_id, 0, f'Upload failed: {str(e)}', 'error')
|
|
||||||
|
|
||||||
log_action('error', f'Error uploading content: {str(e)}')
|
|
||||||
flash('Error uploading content. Please try again.', 'danger')
|
|
||||||
return redirect(url_for('content.upload_content'))
|
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/<int:content_id>/edit', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def edit_content(content_id: int):
|
|
||||||
"""Edit content metadata."""
|
|
||||||
content = Content.query.get_or_404(content_id)
|
|
||||||
|
|
||||||
if request.method == 'GET':
|
|
||||||
return render_template('content/edit_content.html', content=content)
|
|
||||||
|
|
||||||
try:
|
|
||||||
duration = request.form.get('duration', type=int)
|
|
||||||
description = request.form.get('description', '').strip()
|
|
||||||
|
|
||||||
# Update content
|
|
||||||
if duration is not None:
|
|
||||||
content.duration = duration
|
|
||||||
content.description = description or None
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Clear caches
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
log_action('info', f'Content "{content.filename}" (ID: {content_id}) updated')
|
|
||||||
flash(f'Content "{content.filename}" updated successfully.', 'success')
|
|
||||||
|
|
||||||
return redirect(url_for('content.content_list'))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error updating content: {str(e)}')
|
|
||||||
flash('Error updating content. Please try again.', 'danger')
|
|
||||||
return redirect(url_for('content.edit_content', content_id=content_id))
|
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/<int:content_id>/delete', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def delete_content(content_id: int):
|
|
||||||
"""Delete content and associated file."""
|
|
||||||
try:
|
|
||||||
content = Content.query.get_or_404(content_id)
|
|
||||||
filename = content.filename
|
|
||||||
|
|
||||||
# Delete file from disk
|
|
||||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
|
||||||
if os.path.exists(filepath):
|
|
||||||
os.remove(filepath)
|
|
||||||
|
|
||||||
# Delete from database
|
|
||||||
db.session.delete(content)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Clear caches
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
log_action('info', f'Content "{filename}" (ID: {content_id}) deleted')
|
|
||||||
flash(f'Content "{filename}" deleted successfully.', 'success')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error deleting content: {str(e)}')
|
|
||||||
flash('Error deleting content. Please try again.', 'danger')
|
|
||||||
|
|
||||||
return redirect(url_for('content.content_list'))
|
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/delete-by-filename', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def delete_by_filename():
|
|
||||||
"""Delete all content entries with a specific filename."""
|
|
||||||
try:
|
|
||||||
data = request.get_json()
|
|
||||||
filename = data.get('filename')
|
|
||||||
|
|
||||||
if not filename:
|
|
||||||
return jsonify({'success': False, 'message': 'No filename provided'}), 400
|
|
||||||
|
|
||||||
# Find all content entries with this filename
|
|
||||||
contents = Content.query.filter_by(filename=filename).all()
|
|
||||||
|
|
||||||
if not contents:
|
|
||||||
return jsonify({'success': False, 'message': 'Content not found'}), 404
|
|
||||||
|
|
||||||
deleted_count = len(contents)
|
|
||||||
|
|
||||||
# Delete file from disk (only once)
|
|
||||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], filename)
|
|
||||||
if os.path.exists(filepath):
|
|
||||||
os.remove(filepath)
|
|
||||||
log_action('info', f'Deleted file from disk: {filename}')
|
|
||||||
|
|
||||||
# Delete all database entries
|
|
||||||
for content in contents:
|
|
||||||
db.session.delete(content)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Clear caches
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
log_action('info', f'Content "{filename}" deleted from {deleted_count} playlist(s)')
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'message': f'Content deleted from {deleted_count} playlist(s)',
|
|
||||||
'deleted_count': deleted_count
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error deleting content by filename: {str(e)}')
|
|
||||||
return jsonify({'success': False, 'message': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/bulk/delete', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def bulk_delete_content():
|
|
||||||
"""Delete multiple content items at once."""
|
|
||||||
try:
|
|
||||||
content_ids = request.json.get('content_ids', [])
|
|
||||||
|
|
||||||
if not content_ids:
|
|
||||||
return jsonify({'success': False, 'error': 'No content selected'}), 400
|
|
||||||
|
|
||||||
# Delete content
|
|
||||||
deleted_count = 0
|
|
||||||
for content_id in content_ids:
|
|
||||||
content = Content.query.get(content_id)
|
|
||||||
if content:
|
|
||||||
# Delete file
|
|
||||||
filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], content.filename)
|
|
||||||
if os.path.exists(filepath):
|
|
||||||
os.remove(filepath)
|
|
||||||
|
|
||||||
db.session.delete(content)
|
|
||||||
deleted_count += 1
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Clear caches
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
log_action('info', f'Bulk deleted {deleted_count} content items')
|
|
||||||
return jsonify({'success': True, 'deleted': deleted_count})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error bulk deleting content: {str(e)}')
|
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/upload-progress/<upload_id>')
|
|
||||||
@login_required
|
|
||||||
def upload_progress_status(upload_id: str):
|
|
||||||
"""Get upload progress for a specific upload."""
|
|
||||||
progress = get_upload_progress(upload_id)
|
|
||||||
return jsonify(progress)
|
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/preview/<int:content_id>')
|
|
||||||
@login_required
|
|
||||||
def preview_content(content_id: int):
|
|
||||||
"""Preview content in browser."""
|
|
||||||
try:
|
|
||||||
content = Content.query.get_or_404(content_id)
|
|
||||||
|
|
||||||
# Serve file from uploads folder
|
|
||||||
return send_from_directory(
|
|
||||||
current_app.config['UPLOAD_FOLDER'],
|
|
||||||
content.filename,
|
|
||||||
as_attachment=False
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log_action('error', f'Error previewing content: {str(e)}')
|
|
||||||
return "Error loading content", 500
|
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/<int:content_id>/download')
|
|
||||||
@login_required
|
|
||||||
def download_content(content_id: int):
|
|
||||||
"""Download content file."""
|
|
||||||
try:
|
|
||||||
content = Content.query.get_or_404(content_id)
|
|
||||||
|
|
||||||
log_action('info', f'Content "{content.filename}" downloaded')
|
|
||||||
|
|
||||||
return send_from_directory(
|
|
||||||
current_app.config['UPLOAD_FOLDER'],
|
|
||||||
content.filename,
|
|
||||||
as_attachment=True
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log_action('error', f'Error downloading content: {str(e)}')
|
|
||||||
return "Error downloading content", 500
|
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/statistics')
|
|
||||||
@login_required
|
|
||||||
def content_statistics():
|
|
||||||
"""Get content statistics."""
|
|
||||||
try:
|
|
||||||
total_content = Content.query.count()
|
|
||||||
|
|
||||||
# Count by type
|
|
||||||
type_counts = {}
|
|
||||||
for content_type in ['image', 'video', 'pdf', 'presentation', 'other']:
|
|
||||||
count = Content.query.filter_by(content_type=content_type).count()
|
|
||||||
type_counts[content_type] = count
|
|
||||||
|
|
||||||
# Calculate total storage
|
|
||||||
upload_folder = current_app.config['UPLOAD_FOLDER']
|
|
||||||
total_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):
|
|
||||||
total_size += os.path.getsize(filepath)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'total': total_content,
|
|
||||||
'by_type': type_counts,
|
|
||||||
'total_size_mb': round(total_size / (1024 * 1024), 2)
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log_action('error', f'Error getting content statistics: {str(e)}')
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/check-duplicates')
|
|
||||||
@login_required
|
|
||||||
def check_duplicates():
|
|
||||||
"""Check for duplicate filenames."""
|
|
||||||
try:
|
|
||||||
# Get all filenames
|
|
||||||
all_content = Content.query.all()
|
|
||||||
filename_counts = {}
|
|
||||||
|
|
||||||
for content in all_content:
|
|
||||||
filename_counts[content.filename] = filename_counts.get(content.filename, 0) + 1
|
|
||||||
|
|
||||||
# Find duplicates
|
|
||||||
duplicates = {fname: count for fname, count in filename_counts.items() if count > 1}
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'has_duplicates': len(duplicates) > 0,
|
|
||||||
'duplicates': duplicates
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log_action('error', f'Error checking duplicates: {str(e)}')
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@content_bp.route('/<int:content_id>/groups')
|
|
||||||
@login_required
|
|
||||||
def content_groups_info(content_id: int):
|
|
||||||
"""Get groups that contain this content."""
|
|
||||||
try:
|
|
||||||
content = Content.query.get_or_404(content_id)
|
|
||||||
|
|
||||||
groups_data = []
|
|
||||||
for group in content.groups:
|
|
||||||
groups_data.append({
|
|
||||||
'id': group.id,
|
|
||||||
'name': group.name,
|
|
||||||
'description': group.description,
|
|
||||||
'player_count': group.players.count()
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'content_id': content_id,
|
|
||||||
'filename': content.filename,
|
|
||||||
'groups': groups_data
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
log_action('error', f'Error getting content groups: {str(e)}')
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
"""Groups blueprint for group management and player assignments."""
|
|
||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
|
||||||
from flask_login import login_required
|
|
||||||
from typing import List, Dict
|
|
||||||
|
|
||||||
from app.extensions import db, cache
|
|
||||||
from app.models import Group, Player, Content
|
|
||||||
from app.utils.logger import log_action
|
|
||||||
from app.utils.group_player_management import get_player_status_info, get_group_statistics
|
|
||||||
|
|
||||||
groups_bp = Blueprint('groups', __name__, url_prefix='/groups')
|
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.route('/')
|
|
||||||
@login_required
|
|
||||||
def groups_list():
|
|
||||||
"""Display list of all groups."""
|
|
||||||
try:
|
|
||||||
groups = Group.query.order_by(Group.name).all()
|
|
||||||
|
|
||||||
# Get statistics for each group
|
|
||||||
group_stats = {}
|
|
||||||
for group in groups:
|
|
||||||
stats = get_group_statistics(group.id)
|
|
||||||
group_stats[group.id] = stats
|
|
||||||
|
|
||||||
return render_template('groups/groups_list.html',
|
|
||||||
groups=groups,
|
|
||||||
group_stats=group_stats)
|
|
||||||
except Exception as e:
|
|
||||||
log_action('error', f'Error loading groups list: {str(e)}')
|
|
||||||
flash('Error loading groups list.', 'danger')
|
|
||||||
return redirect(url_for('main.dashboard'))
|
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.route('/create', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def create_group():
|
|
||||||
"""Create a new group."""
|
|
||||||
if request.method == 'GET':
|
|
||||||
available_content = Content.query.order_by(Content.filename).all()
|
|
||||||
return render_template('groups/create_group.html', available_content=available_content)
|
|
||||||
|
|
||||||
try:
|
|
||||||
name = request.form.get('name', '').strip()
|
|
||||||
description = request.form.get('description', '').strip()
|
|
||||||
content_ids = request.form.getlist('content_ids')
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
if not name or len(name) < 3:
|
|
||||||
flash('Group name must be at least 3 characters long.', 'warning')
|
|
||||||
return redirect(url_for('groups.create_group'))
|
|
||||||
|
|
||||||
# Check if group name exists
|
|
||||||
existing_group = Group.query.filter_by(name=name).first()
|
|
||||||
if existing_group:
|
|
||||||
flash(f'Group "{name}" already exists.', 'warning')
|
|
||||||
return redirect(url_for('groups.create_group'))
|
|
||||||
|
|
||||||
# Create group
|
|
||||||
new_group = Group(
|
|
||||||
name=name,
|
|
||||||
description=description or None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add content to group
|
|
||||||
if content_ids:
|
|
||||||
for content_id in content_ids:
|
|
||||||
content = Content.query.get(int(content_id))
|
|
||||||
if content:
|
|
||||||
new_group.contents.append(content)
|
|
||||||
|
|
||||||
db.session.add(new_group)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
log_action('info', f'Group "{name}" created with {len(content_ids)} content items')
|
|
||||||
flash(f'Group "{name}" created successfully.', 'success')
|
|
||||||
|
|
||||||
return redirect(url_for('groups.groups_list'))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error creating group: {str(e)}')
|
|
||||||
flash('Error creating group. Please try again.', 'danger')
|
|
||||||
return redirect(url_for('groups.create_group'))
|
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.route('/<int:group_id>/edit', methods=['GET', 'POST'])
|
|
||||||
@login_required
|
|
||||||
def edit_group(group_id: int):
|
|
||||||
"""Edit group details."""
|
|
||||||
group = Group.query.get_or_404(group_id)
|
|
||||||
|
|
||||||
if request.method == 'GET':
|
|
||||||
available_content = Content.query.order_by(Content.filename).all()
|
|
||||||
return render_template('groups/edit_group.html',
|
|
||||||
group=group,
|
|
||||||
available_content=available_content)
|
|
||||||
|
|
||||||
try:
|
|
||||||
name = request.form.get('name', '').strip()
|
|
||||||
description = request.form.get('description', '').strip()
|
|
||||||
content_ids = request.form.getlist('content_ids')
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
if not name or len(name) < 3:
|
|
||||||
flash('Group name must be at least 3 characters long.', 'warning')
|
|
||||||
return redirect(url_for('groups.edit_group', group_id=group_id))
|
|
||||||
|
|
||||||
# Check if group name exists (excluding current group)
|
|
||||||
existing_group = Group.query.filter(Group.name == name, Group.id != group_id).first()
|
|
||||||
if existing_group:
|
|
||||||
flash(f'Group name "{name}" is already in use.', 'warning')
|
|
||||||
return redirect(url_for('groups.edit_group', group_id=group_id))
|
|
||||||
|
|
||||||
# Update group
|
|
||||||
group.name = name
|
|
||||||
group.description = description or None
|
|
||||||
|
|
||||||
# Update content
|
|
||||||
group.contents = []
|
|
||||||
if content_ids:
|
|
||||||
for content_id in content_ids:
|
|
||||||
content = Content.query.get(int(content_id))
|
|
||||||
if content:
|
|
||||||
group.contents.append(content)
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Clear cache for all players in this group
|
|
||||||
for player in group.players:
|
|
||||||
cache.delete_memoized('get_player_playlist', player.id)
|
|
||||||
|
|
||||||
log_action('info', f'Group "{name}" (ID: {group_id}) updated')
|
|
||||||
flash(f'Group "{name}" updated successfully.', 'success')
|
|
||||||
|
|
||||||
return redirect(url_for('groups.groups_list'))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error updating group: {str(e)}')
|
|
||||||
flash('Error updating group. Please try again.', 'danger')
|
|
||||||
return redirect(url_for('groups.edit_group', group_id=group_id))
|
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.route('/<int:group_id>/delete', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def delete_group(group_id: int):
|
|
||||||
"""Delete a group."""
|
|
||||||
try:
|
|
||||||
group = Group.query.get_or_404(group_id)
|
|
||||||
group_name = group.name
|
|
||||||
|
|
||||||
# Unassign players from group
|
|
||||||
for player in group.players:
|
|
||||||
player.group_id = None
|
|
||||||
cache.delete_memoized('get_player_playlist', player.id)
|
|
||||||
|
|
||||||
db.session.delete(group)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
log_action('info', f'Group "{group_name}" (ID: {group_id}) deleted')
|
|
||||||
flash(f'Group "{group_name}" deleted successfully.', 'success')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error deleting group: {str(e)}')
|
|
||||||
flash('Error deleting group. Please try again.', 'danger')
|
|
||||||
|
|
||||||
return redirect(url_for('groups.groups_list'))
|
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.route('/<int:group_id>/manage')
|
|
||||||
@login_required
|
|
||||||
def manage_group(group_id: int):
|
|
||||||
"""Manage group with player status cards and content."""
|
|
||||||
try:
|
|
||||||
group = Group.query.get_or_404(group_id)
|
|
||||||
|
|
||||||
# Get all players in this group
|
|
||||||
players = group.players.order_by(Player.name).all()
|
|
||||||
|
|
||||||
# Get player status for each player
|
|
||||||
player_statuses = {}
|
|
||||||
for player in players:
|
|
||||||
status_info = get_player_status_info(player.id)
|
|
||||||
player_statuses[player.id] = status_info
|
|
||||||
|
|
||||||
# Get group content
|
|
||||||
contents = group.contents.order_by(Content.position).all()
|
|
||||||
|
|
||||||
# Get available players (not in this group)
|
|
||||||
available_players = Player.query.filter(
|
|
||||||
(Player.group_id == None) | (Player.group_id != group_id)
|
|
||||||
).order_by(Player.name).all()
|
|
||||||
|
|
||||||
# Get available content (not in this group)
|
|
||||||
all_content = Content.query.order_by(Content.filename).all()
|
|
||||||
|
|
||||||
return render_template('groups/manage_group.html',
|
|
||||||
group=group,
|
|
||||||
players=players,
|
|
||||||
player_statuses=player_statuses,
|
|
||||||
contents=contents,
|
|
||||||
available_players=available_players,
|
|
||||||
all_content=all_content)
|
|
||||||
except Exception as e:
|
|
||||||
log_action('error', f'Error loading manage group page: {str(e)}')
|
|
||||||
flash('Error loading manage group page.', 'danger')
|
|
||||||
return redirect(url_for('groups.groups_list'))
|
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.route('/<int:group_id>/fullscreen')
|
|
||||||
def group_fullscreen(group_id: int):
|
|
||||||
"""Display group fullscreen view with all player status cards."""
|
|
||||||
try:
|
|
||||||
group = Group.query.get_or_404(group_id)
|
|
||||||
|
|
||||||
# Get all players in this group
|
|
||||||
players = group.players.order_by(Player.name).all()
|
|
||||||
|
|
||||||
# Get player status for each player
|
|
||||||
player_statuses = {}
|
|
||||||
for player in players:
|
|
||||||
status_info = get_player_status_info(player.id)
|
|
||||||
player_statuses[player.id] = status_info
|
|
||||||
|
|
||||||
return render_template('groups/group_fullscreen.html',
|
|
||||||
group=group,
|
|
||||||
players=players,
|
|
||||||
player_statuses=player_statuses)
|
|
||||||
except Exception as e:
|
|
||||||
log_action('error', f'Error loading group fullscreen: {str(e)}')
|
|
||||||
return "Error loading group fullscreen", 500
|
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.route('/<int:group_id>/add-player', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def add_player_to_group(group_id: int):
|
|
||||||
"""Add a player to a group."""
|
|
||||||
try:
|
|
||||||
group = Group.query.get_or_404(group_id)
|
|
||||||
player_id = request.form.get('player_id')
|
|
||||||
|
|
||||||
if not player_id:
|
|
||||||
flash('No player selected.', 'warning')
|
|
||||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
||||||
|
|
||||||
player = Player.query.get_or_404(int(player_id))
|
|
||||||
player.group_id = group_id
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Clear cache
|
|
||||||
cache.delete_memoized('get_player_playlist', player.id)
|
|
||||||
|
|
||||||
log_action('info', f'Player "{player.name}" added to group "{group.name}"')
|
|
||||||
flash(f'Player "{player.name}" added to group successfully.', 'success')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error adding player to group: {str(e)}')
|
|
||||||
flash('Error adding player to group. Please try again.', 'danger')
|
|
||||||
|
|
||||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.route('/<int:group_id>/remove-player/<int:player_id>', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def remove_player_from_group(group_id: int, player_id: int):
|
|
||||||
"""Remove a player from a group."""
|
|
||||||
try:
|
|
||||||
player = Player.query.get_or_404(player_id)
|
|
||||||
|
|
||||||
if player.group_id != group_id:
|
|
||||||
flash('Player is not in this group.', 'warning')
|
|
||||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
||||||
|
|
||||||
player_name = player.name
|
|
||||||
player.group_id = None
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Clear cache
|
|
||||||
cache.delete_memoized('get_player_playlist', player_id)
|
|
||||||
|
|
||||||
log_action('info', f'Player "{player_name}" removed from group {group_id}')
|
|
||||||
flash(f'Player "{player_name}" removed from group successfully.', 'success')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error removing player from group: {str(e)}')
|
|
||||||
flash('Error removing player from group. Please try again.', 'danger')
|
|
||||||
|
|
||||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.route('/<int:group_id>/add-content', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def add_content_to_group(group_id: int):
|
|
||||||
"""Add content to a group."""
|
|
||||||
try:
|
|
||||||
group = Group.query.get_or_404(group_id)
|
|
||||||
content_ids = request.form.getlist('content_ids')
|
|
||||||
|
|
||||||
if not content_ids:
|
|
||||||
flash('No content selected.', 'warning')
|
|
||||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
||||||
|
|
||||||
# Add content
|
|
||||||
added_count = 0
|
|
||||||
for content_id in content_ids:
|
|
||||||
content = Content.query.get(int(content_id))
|
|
||||||
if content and content not in group.contents:
|
|
||||||
group.contents.append(content)
|
|
||||||
added_count += 1
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Clear cache for all players in this group
|
|
||||||
for player in group.players:
|
|
||||||
cache.delete_memoized('get_player_playlist', player.id)
|
|
||||||
|
|
||||||
log_action('info', f'{added_count} content items added to group "{group.name}"')
|
|
||||||
flash(f'{added_count} content items added successfully.', 'success')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error adding content to group: {str(e)}')
|
|
||||||
flash('Error adding content to group. Please try again.', 'danger')
|
|
||||||
|
|
||||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.route('/<int:group_id>/remove-content/<int:content_id>', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def remove_content_from_group(group_id: int, content_id: int):
|
|
||||||
"""Remove content from a group."""
|
|
||||||
try:
|
|
||||||
group = Group.query.get_or_404(group_id)
|
|
||||||
content = Content.query.get_or_404(content_id)
|
|
||||||
|
|
||||||
if content not in group.contents:
|
|
||||||
flash('Content is not in this group.', 'warning')
|
|
||||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
||||||
|
|
||||||
group.contents.remove(content)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Clear cache for all players in this group
|
|
||||||
for player in group.players:
|
|
||||||
cache.delete_memoized('get_player_playlist', player.id)
|
|
||||||
|
|
||||||
log_action('info', f'Content "{content.filename}" removed from group "{group.name}"')
|
|
||||||
flash('Content removed from group successfully.', 'success')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error removing content from group: {str(e)}')
|
|
||||||
flash('Error removing content from group. Please try again.', 'danger')
|
|
||||||
|
|
||||||
return redirect(url_for('groups.manage_group', group_id=group_id))
|
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.route('/<int:group_id>/reorder-content', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def reorder_group_content(group_id: int):
|
|
||||||
"""Reorder content within a group."""
|
|
||||||
try:
|
|
||||||
group = Group.query.get_or_404(group_id)
|
|
||||||
content_order = request.json.get('order', [])
|
|
||||||
|
|
||||||
# Update positions
|
|
||||||
for idx, content_id in enumerate(content_order):
|
|
||||||
content = Content.query.get(content_id)
|
|
||||||
if content and content in group.contents:
|
|
||||||
content.position = idx
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Clear cache for all players in this group
|
|
||||||
for player in group.players:
|
|
||||||
cache.delete_memoized('get_player_playlist', player.id)
|
|
||||||
|
|
||||||
log_action('info', f'Content reordered for group "{group.name}"')
|
|
||||||
return jsonify({'success': True})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error reordering group content: {str(e)}')
|
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@groups_bp.route('/<int:group_id>/stats')
|
|
||||||
@login_required
|
|
||||||
def group_stats(group_id: int):
|
|
||||||
"""Get group statistics as JSON."""
|
|
||||||
try:
|
|
||||||
stats = get_group_statistics(group_id)
|
|
||||||
return jsonify(stats)
|
|
||||||
except Exception as e:
|
|
||||||
log_action('error', f'Error getting group stats: {str(e)}')
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
+46
-2
@@ -33,7 +33,48 @@ def dashboard():
|
|||||||
storage_mb += os.path.getsize(filepath)
|
storage_mb += os.path.getsize(filepath)
|
||||||
storage_mb = round(storage_mb / (1024 * 1024), 2) # Convert to MB
|
storage_mb = round(storage_mb / (1024 * 1024), 2) # Convert to MB
|
||||||
|
|
||||||
server_logs = get_recent_logs(20)
|
# Recent Activity.
|
||||||
|
#
|
||||||
|
# Player feedback ("Feedback received from ...") is a ~15s heartbeat: it is
|
||||||
|
# ~84% of all log rows, and 18 of the newest 20. Showing it unfiltered
|
||||||
|
# buries the events an operator actually cares about (uploads, logins,
|
||||||
|
# HTTPS changes, deployments), so the two kinds are queried separately and
|
||||||
|
# the card defaults to real activity, with heartbeats available on demand.
|
||||||
|
#
|
||||||
|
# The split is done in SQL rather than by filtering one wide window in
|
||||||
|
# Python: with a fleet of players the heartbeat rate is high enough that a
|
||||||
|
# fixed window would return mostly noise and only a handful of real events.
|
||||||
|
HEARTBEAT_PREFIX = 'Feedback received from'
|
||||||
|
activity_logs = get_recent_logs(25, exclude_prefix=HEARTBEAT_PREFIX)
|
||||||
|
heartbeat_logs = get_recent_logs(25, include_prefix=HEARTBEAT_PREFIX)
|
||||||
|
|
||||||
|
# Per-playlist overview for the dashboard: which playlist holds what, and
|
||||||
|
# which players consume it. This answers "where should I add media / which
|
||||||
|
# playlist needs editing?" without opening each playlist.
|
||||||
|
#
|
||||||
|
# Players are fetched in a single query and grouped in Python rather than
|
||||||
|
# relying on Playlist.players per row, to avoid one query per playlist.
|
||||||
|
playlists = Playlist.query.order_by(Playlist.name).all()
|
||||||
|
players_by_playlist = {}
|
||||||
|
for player in Player.query.order_by(Player.name).all():
|
||||||
|
if player.playlist_id is not None:
|
||||||
|
players_by_playlist.setdefault(player.playlist_id, []).append(player)
|
||||||
|
|
||||||
|
playlist_overview = []
|
||||||
|
for playlist in playlists:
|
||||||
|
assigned = players_by_playlist.get(playlist.id, [])
|
||||||
|
playlist_overview.append({
|
||||||
|
'playlist': playlist,
|
||||||
|
'content_count': playlist.contents.count(),
|
||||||
|
'total_duration': playlist.total_duration,
|
||||||
|
'players': assigned,
|
||||||
|
'player_count': len(assigned),
|
||||||
|
'online_count': sum(1 for p in assigned if p.is_online),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Players with no playlist produce no content on screen, so surface them.
|
||||||
|
unassigned_players = Player.query.filter(Player.playlist_id.is_(None))\
|
||||||
|
.order_by(Player.name).all()
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'dashboard.html',
|
'dashboard.html',
|
||||||
@@ -41,7 +82,10 @@ def dashboard():
|
|||||||
total_playlists=total_playlists,
|
total_playlists=total_playlists,
|
||||||
total_content=total_content,
|
total_content=total_content,
|
||||||
storage_mb=storage_mb,
|
storage_mb=storage_mb,
|
||||||
recent_logs=server_logs
|
recent_logs=activity_logs,
|
||||||
|
heartbeat_logs=heartbeat_logs,
|
||||||
|
playlist_overview=playlist_overview,
|
||||||
|
unassigned_players=unassigned_players
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+177
-109
@@ -1,5 +1,5 @@
|
|||||||
"""Players blueprint for player management and display."""
|
"""Players blueprint for player management and display."""
|
||||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
|
||||||
from flask_login import login_required
|
from flask_login import login_required
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
import secrets
|
import secrets
|
||||||
@@ -28,10 +28,23 @@ def list():
|
|||||||
status_info = get_player_status_info(player.id)
|
status_info = get_player_status_info(player.id)
|
||||||
player_statuses[player.id] = status_info
|
player_statuses[player.id] = status_info
|
||||||
|
|
||||||
|
# Build a JSON-safe dict of deployment statuses for the polling JS
|
||||||
|
import json
|
||||||
|
from datetime import datetime as dt
|
||||||
|
player_statuses_json = {}
|
||||||
|
for player in players:
|
||||||
|
player_statuses_json[str(player.id)] = {
|
||||||
|
'deployment_status': player.deployment_status,
|
||||||
|
'last_deployment_status': player.last_deployment_status,
|
||||||
|
'last_deployment_at': player.last_deployment_at.isoformat() if player.last_deployment_at else None,
|
||||||
|
'last_deployment_message': player.last_deployment_message,
|
||||||
|
}
|
||||||
|
|
||||||
return render_template('players/players_list.html',
|
return render_template('players/players_list.html',
|
||||||
players=players,
|
players=players,
|
||||||
playlists=playlists,
|
playlists=playlists,
|
||||||
player_statuses=player_statuses)
|
player_statuses=player_statuses,
|
||||||
|
player_statuses_json=json.dumps(player_statuses_json))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_action('error', f'Error loading players list: {str(e)}')
|
log_action('error', f'Error loading players list: {str(e)}')
|
||||||
flash('Error loading players list.', 'danger')
|
flash('Error loading players list.', 'danger')
|
||||||
@@ -41,7 +54,7 @@ def list():
|
|||||||
@players_bp.route('/add', methods=['GET', 'POST'])
|
@players_bp.route('/add', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def add_player():
|
def add_player():
|
||||||
"""Add a new player."""
|
"""Add a new player with optional SSH deployment."""
|
||||||
if request.method == 'GET':
|
if request.method == 'GET':
|
||||||
playlists = Playlist.query.filter_by(is_active=True).order_by(Playlist.name).all()
|
playlists = Playlist.query.filter_by(is_active=True).order_by(Playlist.name).all()
|
||||||
return render_template('players/add_player.html', playlists=playlists)
|
return render_template('players/add_player.html', playlists=playlists)
|
||||||
@@ -55,6 +68,13 @@ def add_player():
|
|||||||
orientation = request.form.get('orientation', 'Landscape')
|
orientation = request.form.get('orientation', 'Landscape')
|
||||||
playlist_id = request.form.get('playlist_id', '').strip()
|
playlist_id = request.form.get('playlist_id', '').strip()
|
||||||
|
|
||||||
|
# Get SSH deployment info if provided
|
||||||
|
ssh_hostname = request.form.get('ssh_hostname', '').strip()
|
||||||
|
ssh_username = request.form.get('ssh_username', '').strip()
|
||||||
|
ssh_password = request.form.get('ssh_password', '').strip()
|
||||||
|
ssh_port = int(request.form.get('ssh_port', '22')) if request.form.get('ssh_port') else 22
|
||||||
|
deploy_player = request.form.get('deploy_player', '').strip()
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
if not name or len(name) < 3:
|
if not name or len(name) < 3:
|
||||||
flash('Player name must be at least 3 characters long.', 'warning')
|
flash('Player name must be at least 3 characters long.', 'warning')
|
||||||
@@ -102,15 +122,101 @@ def add_player():
|
|||||||
|
|
||||||
log_action('info', f'Player "{name}" (hostname: {hostname}) created')
|
log_action('info', f'Player "{name}" (hostname: {hostname}) created')
|
||||||
|
|
||||||
|
# If deployment requested and SSH credentials provided, trigger background deployment
|
||||||
|
deployment_initiated = False
|
||||||
|
if deploy_player and ssh_hostname and ssh_username and ssh_password:
|
||||||
|
try:
|
||||||
|
from app.utils.background_tasks import background_player_deployment, run_background_task
|
||||||
|
|
||||||
|
# Determine the server address the player should contact.
|
||||||
|
from flask import request as flask_request
|
||||||
|
from app.models.https_config import HTTPSConfig
|
||||||
|
|
||||||
|
server_url = None
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
from app.utils.player_build import get_player_server_settings, BUILD_META_FILENAME
|
||||||
|
meta_path = os.path.join(current_app.instance_path, BUILD_META_FILENAME)
|
||||||
|
build_srv = get_player_server_settings(meta_path)
|
||||||
|
if build_srv:
|
||||||
|
scheme = 'https' if build_srv['use_https'] else 'http'
|
||||||
|
server_url = f"{scheme}://{build_srv['server_ip']}:{build_srv['port']}"
|
||||||
|
except Exception:
|
||||||
|
server_url = None
|
||||||
|
|
||||||
|
if not server_url:
|
||||||
|
https_cfg = HTTPSConfig.get_config()
|
||||||
|
if https_cfg and https_cfg.https_enabled and (https_cfg.domain or https_cfg.ip_address):
|
||||||
|
host = https_cfg.domain or https_cfg.ip_address
|
||||||
|
cfg_port = https_cfg.port or 443
|
||||||
|
server_url = f"https://{host}:{cfg_port}"
|
||||||
|
else:
|
||||||
|
host = flask_request.host
|
||||||
|
hostname_only = host.split(':')[0]
|
||||||
|
if hostname_only in ('localhost', '127.0.0.1', '') or hostname_only.startswith('127.'):
|
||||||
|
from app.utils.ssh_deploy import detect_server_ip
|
||||||
|
detected_ip = detect_server_ip()
|
||||||
|
if detected_ip:
|
||||||
|
port_part = host.split(':', 1)[1] if ':' in host else ''
|
||||||
|
host = f"{detected_ip}:{port_part}" if port_part else detected_ip
|
||||||
|
server_url = f"{flask_request.scheme}://{host}"
|
||||||
|
|
||||||
|
# Mark deployment as "in progress" immediately so the UI polling picks it up
|
||||||
|
from datetime import datetime
|
||||||
|
new_player.deployment_status = 'deploying'
|
||||||
|
new_player.last_deployment_at = datetime.utcnow()
|
||||||
|
new_player.last_deployment_message = 'Deployment in progress...'
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Generate API key for player authentication
|
||||||
|
import hashlib
|
||||||
|
api_key = hashlib.sha256(f'{name}:{hostname}'.encode()).hexdigest()[:32]
|
||||||
|
|
||||||
|
# Start deployment in background thread
|
||||||
|
run_background_task(
|
||||||
|
background_player_deployment,
|
||||||
|
hostname=ssh_hostname,
|
||||||
|
username=ssh_username,
|
||||||
|
password=ssh_password,
|
||||||
|
player_name=name,
|
||||||
|
player_id=new_player.id,
|
||||||
|
port=ssh_port,
|
||||||
|
server_url=server_url,
|
||||||
|
server_api_key=api_key,
|
||||||
|
player_hostname=hostname,
|
||||||
|
quickconnect_code=quickconnect_code,
|
||||||
|
orientation=orientation
|
||||||
|
)
|
||||||
|
deployment_initiated = True
|
||||||
|
log_action('info', f'Background deployment initiated for player "{name}" on {ssh_hostname}')
|
||||||
|
except Exception as deploy_err:
|
||||||
|
log_action('error', f'Failed to initiate background deployment for player "{name}": {str(deploy_err)}')
|
||||||
|
|
||||||
# Flash detailed success message
|
# Flash detailed success message
|
||||||
success_msg = f'''
|
if deployment_initiated:
|
||||||
Player "{name}" created successfully!<br>
|
success_msg = f'''
|
||||||
<strong>Auth Code:</strong> {auth_code}<br>
|
Player "{name}" created successfully!<br>
|
||||||
<strong>Hostname:</strong> {hostname}<br>
|
<strong>Auth Code:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{auth_code}</code><br>
|
||||||
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
<strong>Hostname:</strong> {hostname}<br>
|
||||||
<small>Configure the player with these credentials in app_config.json</small>
|
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
||||||
'''
|
<br>
|
||||||
flash(success_msg, 'success')
|
<strong style="color: #0275d8;">⌛ Deployment in Progress</strong><br>
|
||||||
|
Deploying to <strong>{ssh_hostname}</strong> in background...<br>
|
||||||
|
<small>The deployment status will update automatically on the players list.</small>
|
||||||
|
'''
|
||||||
|
flash(success_msg, 'success')
|
||||||
|
else:
|
||||||
|
success_msg = f'''
|
||||||
|
Player "{name}" created successfully!<br>
|
||||||
|
<strong>Auth Code:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{auth_code}</code><br>
|
||||||
|
<strong>Hostname:</strong> {hostname}<br>
|
||||||
|
<strong>Quick Connect:</strong> {quickconnect_code}<br>
|
||||||
|
<br>
|
||||||
|
<small>Configure the player with these credentials in app_config.json</small>
|
||||||
|
'''
|
||||||
|
flash(success_msg, 'success')
|
||||||
|
if deploy_player:
|
||||||
|
flash('Player was created but deployment could not be started. You can deploy manually from the Manage page.', 'warning')
|
||||||
|
|
||||||
return redirect(url_for('players.list'))
|
return redirect(url_for('players.list'))
|
||||||
|
|
||||||
@@ -378,6 +484,40 @@ def edited_media(player_id: int):
|
|||||||
return redirect(url_for('players.manage_player', player_id=player_id))
|
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||||
|
|
||||||
|
|
||||||
|
@players_bp.route('/<int:player_id>/edited-media-report')
|
||||||
|
@login_required
|
||||||
|
def edited_media_report(player_id: int):
|
||||||
|
"""Display a tabular report of all edited media from this player."""
|
||||||
|
try:
|
||||||
|
player = Player.query.get_or_404(player_id)
|
||||||
|
|
||||||
|
from app.models.player_edit import PlayerEdit
|
||||||
|
from app.models.player_user import PlayerUser
|
||||||
|
|
||||||
|
edited_media = PlayerEdit.query.filter_by(player_id=player_id)\
|
||||||
|
.order_by(PlayerEdit.created_at.desc())\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
# Build user display name mapping
|
||||||
|
user_mappings = {}
|
||||||
|
for edit in edited_media:
|
||||||
|
if edit.user and edit.user not in user_mappings:
|
||||||
|
player_user = PlayerUser.query.filter_by(user_code=edit.user).first()
|
||||||
|
if player_user and player_user.user_name:
|
||||||
|
user_mappings[edit.user] = player_user.user_name
|
||||||
|
else:
|
||||||
|
user_mappings[edit.user] = edit.user
|
||||||
|
|
||||||
|
return render_template('players/edited_media_report.html',
|
||||||
|
player=player,
|
||||||
|
edited_media=edited_media,
|
||||||
|
user_mappings=user_mappings)
|
||||||
|
except Exception as e:
|
||||||
|
log_action('error', f'Error loading edited media report for player {player_id}: {str(e)}')
|
||||||
|
flash('Error loading edited media report.', 'danger')
|
||||||
|
return redirect(url_for('players.manage_player', player_id=player_id))
|
||||||
|
|
||||||
|
|
||||||
@players_bp.route('/<int:player_id>/fullscreen')
|
@players_bp.route('/<int:player_id>/fullscreen')
|
||||||
def player_fullscreen(player_id: int):
|
def player_fullscreen(player_id: int):
|
||||||
"""Display player fullscreen view (no authentication required for players)."""
|
"""Display player fullscreen view (no authentication required for players)."""
|
||||||
@@ -426,27 +566,25 @@ def get_player_playlist(player_id: int) -> List[dict]:
|
|||||||
# Build playlist
|
# Build playlist
|
||||||
playlist = []
|
playlist = []
|
||||||
for content in ordered_content:
|
for content in ordered_content:
|
||||||
|
# For weblinks, serve the actual URL directly; for files, serve static path
|
||||||
|
if content.content_type == 'weblink' and content.url:
|
||||||
|
item_url = content.url
|
||||||
|
else:
|
||||||
|
item_url = url_for('static', filename=f'uploads/{content.filename}')
|
||||||
playlist.append({
|
playlist.append({
|
||||||
'id': content.id,
|
'id': content.id,
|
||||||
'url': url_for('static', filename=f'uploads/{content.filename}'),
|
'url': item_url,
|
||||||
'type': content.content_type,
|
'type': content.content_type,
|
||||||
'duration': getattr(content, '_playlist_duration', content.duration or 10),
|
'duration': getattr(content, '_playlist_duration', content.duration or 10),
|
||||||
'position': getattr(content, '_playlist_position', 0),
|
'position': getattr(content, '_playlist_position', 0),
|
||||||
'muted': getattr(content, '_playlist_muted', True),
|
'muted': getattr(content, '_playlist_muted', True),
|
||||||
|
'audio': 'off' if getattr(content, '_playlist_muted', True) else 'on',
|
||||||
'filename': content.filename
|
'filename': content.filename
|
||||||
})
|
})
|
||||||
|
|
||||||
return playlist
|
return playlist
|
||||||
|
|
||||||
|
|
||||||
@players_bp.route('/<int:player_id>/reorder', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def reorder_content(player_id: int):
|
|
||||||
"""Legacy endpoint - Content reordering now handled in playlist management."""
|
|
||||||
return jsonify({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Content reordering is now managed through playlists. Use the Playlists page to reorder content.'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
|
|
||||||
@players_bp.route('/bulk/delete', methods=['POST'])
|
@players_bp.route('/bulk/delete', methods=['POST'])
|
||||||
@@ -518,95 +656,25 @@ def bulk_assign_playlist():
|
|||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({'success': False, 'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@players_bp.route('/<int:player_id>/playlist/reorder', methods=['POST'])
|
@players_bp.route('/deployment-status')
|
||||||
@login_required
|
@login_required
|
||||||
def reorder_playlist(player_id: int):
|
def deployment_status():
|
||||||
"""Reorder items in player's playlist."""
|
"""Return deployment status for all players (used by polling JS)."""
|
||||||
try:
|
try:
|
||||||
data = request.get_json()
|
players = Player.query.with_entities(
|
||||||
content_id = data.get('content_id')
|
Player.id, Player.deployment_status,
|
||||||
direction = data.get('direction') # 'up' or 'down'
|
Player.last_deployment_status, Player.last_deployment_at,
|
||||||
|
Player.last_deployment_message
|
||||||
if not content_id or not direction:
|
).all()
|
||||||
return jsonify({'success': False, 'message': 'Missing parameters'}), 400
|
data = {}
|
||||||
|
for p in players:
|
||||||
# Get the content item
|
data[p.id] = {
|
||||||
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
|
'deployment_status': p.deployment_status,
|
||||||
if not content:
|
'last_deployment_status': p.last_deployment_status,
|
||||||
return jsonify({'success': False, 'message': 'Content not found'}), 404
|
'last_deployment_at': p.last_deployment_at.isoformat() if p.last_deployment_at else None,
|
||||||
|
'last_deployment_message': p.last_deployment_message,
|
||||||
# Get all content for this player, ordered by position
|
}
|
||||||
all_content = Content.query.filter_by(player_id=player_id)\
|
return jsonify(data)
|
||||||
.order_by(Content.position, Content.uploaded_at).all()
|
|
||||||
|
|
||||||
# Find current index
|
|
||||||
current_index = None
|
|
||||||
for idx, item in enumerate(all_content):
|
|
||||||
if item.id == content_id:
|
|
||||||
current_index = idx
|
|
||||||
break
|
|
||||||
|
|
||||||
if current_index is None:
|
|
||||||
return jsonify({'success': False, 'message': 'Content not in playlist'}), 404
|
|
||||||
|
|
||||||
# Swap positions
|
|
||||||
if direction == 'up' and current_index > 0:
|
|
||||||
# Swap with previous item
|
|
||||||
all_content[current_index].position, all_content[current_index - 1].position = \
|
|
||||||
all_content[current_index - 1].position, all_content[current_index].position
|
|
||||||
elif direction == 'down' and current_index < len(all_content) - 1:
|
|
||||||
# Swap with next item
|
|
||||||
all_content[current_index].position, all_content[current_index + 1].position = \
|
|
||||||
all_content[current_index + 1].position, all_content[current_index].position
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
cache.delete_memoized(get_player_playlist, player_id)
|
|
||||||
|
|
||||||
log_action('info', f'Reordered playlist for player {player_id}')
|
|
||||||
return jsonify({'success': True})
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
log_action('error', f'Error fetching deployment status: {str(e)}')
|
||||||
log_action('error', f'Error reordering playlist: {str(e)}')
|
return jsonify({'error': str(e)}), 500
|
||||||
return jsonify({'success': False, 'message': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@players_bp.route('/<int:player_id>/playlist/remove', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def remove_from_playlist(player_id: int):
|
|
||||||
"""Remove content from player's playlist."""
|
|
||||||
try:
|
|
||||||
data = request.get_json()
|
|
||||||
content_id = data.get('content_id')
|
|
||||||
|
|
||||||
if not content_id:
|
|
||||||
return jsonify({'success': False, 'message': 'Missing content_id'}), 400
|
|
||||||
|
|
||||||
# Get the content item
|
|
||||||
content = Content.query.filter_by(id=content_id, player_id=player_id).first()
|
|
||||||
if not content:
|
|
||||||
return jsonify({'success': False, 'message': 'Content not found'}), 404
|
|
||||||
|
|
||||||
filename = content.filename
|
|
||||||
|
|
||||||
# Delete from database
|
|
||||||
db.session.delete(content)
|
|
||||||
|
|
||||||
# Increment playlist version
|
|
||||||
player = Player.query.get(player_id)
|
|
||||||
if player:
|
|
||||||
player.playlist_version += 1
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
# Clear cache
|
|
||||||
cache.delete_memoized(get_player_playlist, player_id)
|
|
||||||
|
|
||||||
log_action('info', f'Removed "{filename}" from player {player_id} playlist (version {player.playlist_version})')
|
|
||||||
return jsonify({'success': True, 'message': f'Removed "{filename}" from playlist'})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error removing from playlist: {str(e)}')
|
|
||||||
return jsonify({'success': False, 'message': str(e)}), 500
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
"""Playlist blueprint for managing player playlists."""
|
|
||||||
from flask import (Blueprint, render_template, request, redirect, url_for,
|
|
||||||
flash, jsonify, current_app)
|
|
||||||
from flask_login import login_required
|
|
||||||
from sqlalchemy import desc, update
|
|
||||||
import os
|
|
||||||
|
|
||||||
from app.extensions import db, cache
|
|
||||||
from app.models import Player, Content, Playlist
|
|
||||||
from app.models.playlist import playlist_content
|
|
||||||
from app.utils.logger import log_action
|
|
||||||
|
|
||||||
playlist_bp = Blueprint('playlist', __name__, url_prefix='/playlist')
|
|
||||||
|
|
||||||
|
|
||||||
@playlist_bp.route('/<int:player_id>')
|
|
||||||
@login_required
|
|
||||||
def manage_playlist(player_id: int):
|
|
||||||
"""Manage playlist for a specific player."""
|
|
||||||
player = Player.query.get_or_404(player_id)
|
|
||||||
|
|
||||||
# Get content from player's assigned playlist
|
|
||||||
playlist_items = []
|
|
||||||
if player.playlist_id:
|
|
||||||
playlist = Playlist.query.get(player.playlist_id)
|
|
||||||
if playlist:
|
|
||||||
playlist_items = playlist.get_content_ordered()
|
|
||||||
|
|
||||||
# Get available content (all content not in current playlist)
|
|
||||||
all_content = Content.query.all()
|
|
||||||
playlist_content_ids = {item.id for item in playlist_items}
|
|
||||||
available_content = [c for c in all_content if c.id not in playlist_content_ids]
|
|
||||||
|
|
||||||
return render_template('playlist/manage_playlist.html',
|
|
||||||
player=player,
|
|
||||||
playlist_content=playlist_items,
|
|
||||||
available_content=available_content)
|
|
||||||
|
|
||||||
|
|
||||||
@playlist_bp.route('/<int:player_id>/add', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def add_to_playlist(player_id: int):
|
|
||||||
"""Add content to player's playlist."""
|
|
||||||
player = Player.query.get_or_404(player_id)
|
|
||||||
|
|
||||||
if not player.playlist_id:
|
|
||||||
flash('Player has no playlist assigned.', 'warning')
|
|
||||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
|
||||||
|
|
||||||
try:
|
|
||||||
content_id = request.form.get('content_id', type=int)
|
|
||||||
duration = request.form.get('duration', type=int, default=10)
|
|
||||||
|
|
||||||
if not content_id:
|
|
||||||
flash('Please select content.', 'warning')
|
|
||||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
|
||||||
|
|
||||||
content = Content.query.get_or_404(content_id)
|
|
||||||
playlist = Playlist.query.get(player.playlist_id)
|
|
||||||
|
|
||||||
# Get max position
|
|
||||||
from sqlalchemy import select, func
|
|
||||||
max_pos = db.session.execute(
|
|
||||||
select(func.max(playlist_content.c.position)).where(
|
|
||||||
playlist_content.c.playlist_id == playlist.id
|
|
||||||
)
|
|
||||||
).scalar() or 0
|
|
||||||
|
|
||||||
# Add to playlist_content association table
|
|
||||||
stmt = playlist_content.insert().values(
|
|
||||||
playlist_id=playlist.id,
|
|
||||||
content_id=content.id,
|
|
||||||
position=max_pos + 1,
|
|
||||||
duration=duration
|
|
||||||
)
|
|
||||||
db.session.execute(stmt)
|
|
||||||
|
|
||||||
# Increment playlist version
|
|
||||||
playlist.increment_version()
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
log_action('info', f'Added "{content.filename}" to playlist for player "{player.name}"')
|
|
||||||
flash(f'Added "{content.filename}" to playlist.', 'success')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error adding to playlist: {str(e)}')
|
|
||||||
flash('Error adding to playlist.', 'danger')
|
|
||||||
|
|
||||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
|
||||||
|
|
||||||
|
|
||||||
@playlist_bp.route('/<int:player_id>/remove/<int:content_id>', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def remove_from_playlist(player_id: int, content_id: int):
|
|
||||||
"""Remove content from player's playlist."""
|
|
||||||
player = Player.query.get_or_404(player_id)
|
|
||||||
|
|
||||||
if not player.playlist_id:
|
|
||||||
flash('Player has no playlist assigned.', 'danger')
|
|
||||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
|
||||||
|
|
||||||
try:
|
|
||||||
content = Content.query.get_or_404(content_id)
|
|
||||||
playlist = Playlist.query.get(player.playlist_id)
|
|
||||||
filename = content.filename
|
|
||||||
|
|
||||||
# Remove from playlist_content association table
|
|
||||||
from sqlalchemy import delete
|
|
||||||
stmt = delete(playlist_content).where(
|
|
||||||
(playlist_content.c.playlist_id == playlist.id) &
|
|
||||||
(playlist_content.c.content_id == content_id)
|
|
||||||
)
|
|
||||||
db.session.execute(stmt)
|
|
||||||
|
|
||||||
# Reorder remaining content
|
|
||||||
from sqlalchemy import select
|
|
||||||
remaining = db.session.execute(
|
|
||||||
select(playlist_content.c.content_id, playlist_content.c.position).where(
|
|
||||||
playlist_content.c.playlist_id == playlist.id
|
|
||||||
).order_by(playlist_content.c.position)
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
for idx, row in enumerate(remaining, start=1):
|
|
||||||
stmt = update(playlist_content).where(
|
|
||||||
(playlist_content.c.playlist_id == playlist.id) &
|
|
||||||
(playlist_content.c.content_id == row.content_id)
|
|
||||||
).values(position=idx)
|
|
||||||
db.session.execute(stmt)
|
|
||||||
|
|
||||||
# Increment playlist version
|
|
||||||
playlist.increment_version()
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
log_action('info', f'Removed "{filename}" from playlist for player "{player.name}"')
|
|
||||||
flash(f'Removed "{filename}" from playlist.', 'success')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error removing from playlist: {str(e)}')
|
|
||||||
flash('Error removing from playlist.', 'danger')
|
|
||||||
|
|
||||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
|
||||||
|
|
||||||
|
|
||||||
@playlist_bp.route('/<int:player_id>/reorder', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def reorder_playlist(player_id: int):
|
|
||||||
"""Reorder playlist items."""
|
|
||||||
player = Player.query.get_or_404(player_id)
|
|
||||||
|
|
||||||
if not player.playlist_id:
|
|
||||||
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
playlist = Playlist.query.get(player.playlist_id)
|
|
||||||
|
|
||||||
# Get new order from JSON
|
|
||||||
data = request.get_json()
|
|
||||||
content_ids = data.get('content_ids', [])
|
|
||||||
|
|
||||||
if not content_ids:
|
|
||||||
return jsonify({'success': False, 'message': 'No content IDs provided'}), 400
|
|
||||||
|
|
||||||
# Update positions in association table
|
|
||||||
for idx, content_id in enumerate(content_ids, start=1):
|
|
||||||
stmt = update(playlist_content).where(
|
|
||||||
(playlist_content.c.playlist_id == playlist.id) &
|
|
||||||
(playlist_content.c.content_id == content_id)
|
|
||||||
).values(position=idx)
|
|
||||||
db.session.execute(stmt)
|
|
||||||
|
|
||||||
# Increment playlist version
|
|
||||||
playlist.increment_version()
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
log_action('info', f'Reordered playlist for player "{player.name}" (version {playlist.version})')
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'message': 'Playlist reordered successfully',
|
|
||||||
'version': playlist.version
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error reordering playlist: {str(e)}')
|
|
||||||
return jsonify({'success': False, 'message': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@playlist_bp.route('/<int:player_id>/update-duration/<int:content_id>', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def update_duration(player_id: int, content_id: int):
|
|
||||||
"""Update content duration in playlist."""
|
|
||||||
player = Player.query.get_or_404(player_id)
|
|
||||||
|
|
||||||
if not player.playlist_id:
|
|
||||||
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
playlist = Playlist.query.get(player.playlist_id)
|
|
||||||
content = Content.query.get_or_404(content_id)
|
|
||||||
|
|
||||||
duration = request.form.get('duration', type=int)
|
|
||||||
|
|
||||||
if not duration or duration < 1:
|
|
||||||
return jsonify({'success': False, 'message': 'Invalid duration'}), 400
|
|
||||||
|
|
||||||
# Update duration in association table
|
|
||||||
stmt = update(playlist_content).where(
|
|
||||||
(playlist_content.c.playlist_id == playlist.id) &
|
|
||||||
(playlist_content.c.content_id == content_id)
|
|
||||||
).values(duration=duration)
|
|
||||||
db.session.execute(stmt)
|
|
||||||
|
|
||||||
# Increment playlist version
|
|
||||||
playlist.increment_version()
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
log_action('info', f'Updated duration for "{content.filename}" in player "{player.name}" playlist')
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'message': 'Duration updated',
|
|
||||||
'version': playlist.version
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error updating duration: {str(e)}')
|
|
||||||
return jsonify({'success': False, 'message': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@playlist_bp.route('/<int:player_id>/update-muted/<int:content_id>', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def update_muted(player_id: int, content_id: int):
|
|
||||||
"""Update content muted setting in playlist."""
|
|
||||||
player = Player.query.get_or_404(player_id)
|
|
||||||
|
|
||||||
if not player.playlist_id:
|
|
||||||
return jsonify({'success': False, 'message': 'Player has no playlist'}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
playlist = Playlist.query.get(player.playlist_id)
|
|
||||||
content = Content.query.get_or_404(content_id)
|
|
||||||
|
|
||||||
muted = request.form.get('muted', 'true').lower() == 'true'
|
|
||||||
|
|
||||||
# Update muted in association table
|
|
||||||
stmt = update(playlist_content).where(
|
|
||||||
(playlist_content.c.playlist_id == playlist.id) &
|
|
||||||
(playlist_content.c.content_id == content_id)
|
|
||||||
).values(muted=muted)
|
|
||||||
db.session.execute(stmt)
|
|
||||||
|
|
||||||
# Increment playlist version
|
|
||||||
playlist.increment_version()
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
log_action('info', f'Updated muted={muted} for "{content.filename}" in player "{player.name}" playlist')
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'message': 'Audio setting updated',
|
|
||||||
'muted': muted,
|
|
||||||
'version': playlist.version
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error updating muted setting: {str(e)}')
|
|
||||||
return jsonify({'success': False, 'message': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@playlist_bp.route('/<int:player_id>/clear', methods=['POST'])
|
|
||||||
@login_required
|
|
||||||
def clear_playlist(player_id: int):
|
|
||||||
"""Clear all content from player's playlist."""
|
|
||||||
player = Player.query.get_or_404(player_id)
|
|
||||||
|
|
||||||
if not player.playlist_id:
|
|
||||||
flash('Player has no playlist assigned.', 'warning')
|
|
||||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
|
||||||
|
|
||||||
try:
|
|
||||||
playlist = Playlist.query.get(player.playlist_id)
|
|
||||||
|
|
||||||
# Delete all content from playlist
|
|
||||||
from sqlalchemy import delete
|
|
||||||
stmt = delete(playlist_content).where(
|
|
||||||
playlist_content.c.playlist_id == playlist.id
|
|
||||||
)
|
|
||||||
db.session.execute(stmt)
|
|
||||||
|
|
||||||
# Increment playlist version
|
|
||||||
playlist.increment_version()
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
cache.clear()
|
|
||||||
|
|
||||||
log_action('info', f'Cleared playlist for player "{player.name}"')
|
|
||||||
flash('Playlist cleared successfully.', 'success')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error clearing playlist: {str(e)}')
|
|
||||||
flash('Error clearing playlist.', 'danger')
|
|
||||||
|
|
||||||
return redirect(url_for('playlist.manage_playlist', player_id=player_id))
|
|
||||||
@@ -48,6 +48,10 @@ class Config:
|
|||||||
DEFAULT_ADMIN_USER = os.getenv('ADMIN_USER', 'admin')
|
DEFAULT_ADMIN_USER = os.getenv('ADMIN_USER', 'admin')
|
||||||
DEFAULT_ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'Initial01!')
|
DEFAULT_ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'Initial01!')
|
||||||
|
|
||||||
|
# Player deployment — staged code directory and default repo
|
||||||
|
PLAYER_CODE_DIR = os.getenv('PLAYER_CODE_DIR', '/app/data/player')
|
||||||
|
PLAYER_REPO_URL = os.getenv('PLAYER_REPO_URL', '')
|
||||||
|
|
||||||
|
|
||||||
class DevelopmentConfig(Config):
|
class DevelopmentConfig(Config):
|
||||||
"""Development configuration"""
|
"""Development configuration"""
|
||||||
@@ -91,6 +95,7 @@ class ProductionConfig(Config):
|
|||||||
|
|
||||||
# Security
|
# Security
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
|
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||||
WTF_CSRF_ENABLED = True
|
WTF_CSRF_ENABLED = True
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from flask_bcrypt import Bcrypt
|
|||||||
from flask_login import LoginManager
|
from flask_login import LoginManager
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
from flask_caching import Cache
|
from flask_caching import Cache
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
# Initialize extensions (will be bound to app in create_app)
|
# Initialize extensions (will be bound to app in create_app)
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
@@ -14,6 +15,7 @@ bcrypt = Bcrypt()
|
|||||||
login_manager = LoginManager()
|
login_manager = LoginManager()
|
||||||
migrate = Migrate()
|
migrate = Migrate()
|
||||||
cache = Cache()
|
cache = Cache()
|
||||||
|
cors = CORS()
|
||||||
|
|
||||||
# Configure login manager
|
# Configure login manager
|
||||||
login_manager.login_view = 'auth.login'
|
login_manager.login_view = 'auth.login'
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Models package for digiserver-v2."""
|
"""Models package for digiserver-v2."""
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.player import Player
|
from app.models.player import Player
|
||||||
from app.models.group import Group, group_content
|
|
||||||
from app.models.playlist import Playlist, playlist_content
|
from app.models.playlist import Playlist, playlist_content
|
||||||
from app.models.content import Content
|
from app.models.content import Content
|
||||||
from app.models.server_log import ServerLog
|
from app.models.server_log import ServerLog
|
||||||
@@ -13,7 +12,6 @@ from app.models.https_config import HTTPSConfig
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
'User',
|
'User',
|
||||||
'Player',
|
'Player',
|
||||||
'Group',
|
|
||||||
'Playlist',
|
'Playlist',
|
||||||
'Content',
|
'Content',
|
||||||
'ServerLog',
|
'ServerLog',
|
||||||
@@ -21,6 +19,5 @@ __all__ = [
|
|||||||
'PlayerEdit',
|
'PlayerEdit',
|
||||||
'PlayerUser',
|
'PlayerUser',
|
||||||
'HTTPSConfig',
|
'HTTPSConfig',
|
||||||
'group_content',
|
|
||||||
'playlist_content',
|
'playlist_content',
|
||||||
]
|
]
|
||||||
|
|||||||
+53
-5
@@ -21,9 +21,16 @@ class Content(db.Model):
|
|||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
filename = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
filename = db.Column(db.String(255), nullable=False, unique=True, index=True)
|
||||||
|
# The pristine original filename as uploaded. `filename` gets overwritten
|
||||||
|
# with an edited_media/... path when a player edits the content, so this
|
||||||
|
# column preserves the original so it can always be referenced/restored.
|
||||||
|
original_filename = db.Column(db.String(255), nullable=True, index=True)
|
||||||
content_type = db.Column(db.String(50), nullable=False, index=True)
|
content_type = db.Column(db.String(50), nullable=False, index=True)
|
||||||
duration = db.Column(db.Integer, default=10, nullable=True)
|
duration = db.Column(db.Integer, default=10, nullable=True)
|
||||||
file_size = db.Column(db.BigInteger, nullable=True)
|
file_size = db.Column(db.BigInteger, nullable=True)
|
||||||
|
# For 'weblink' content this holds the web page URL to display on the player.
|
||||||
|
# NULL for file-based content (image/video/pdf).
|
||||||
|
url = db.Column(db.String(2048), nullable=True)
|
||||||
description = db.Column(db.Text, nullable=True)
|
description = db.Column(db.Text, nullable=True)
|
||||||
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow,
|
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow,
|
||||||
nullable=False, index=True)
|
nullable=False, index=True)
|
||||||
@@ -31,8 +38,6 @@ class Content(db.Model):
|
|||||||
# Relationships - many-to-many with playlists
|
# Relationships - many-to-many with playlists
|
||||||
playlists = db.relationship('Playlist', secondary='playlist_content',
|
playlists = db.relationship('Playlist', secondary='playlist_content',
|
||||||
back_populates='contents', lazy='dynamic')
|
back_populates='contents', lazy='dynamic')
|
||||||
groups = db.relationship('Group', secondary='group_content',
|
|
||||||
back_populates='contents', lazy='dynamic')
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""String representation of Content."""
|
"""String representation of Content."""
|
||||||
@@ -46,9 +51,26 @@ class Content(db.Model):
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def group_count(self) -> int:
|
def original_display_name(self) -> str:
|
||||||
"""Get number of groups containing this content."""
|
"""Name of the original (unedited) file for display purposes."""
|
||||||
return self.groups.count()
|
return self.original_filename or self.filename
|
||||||
|
|
||||||
|
@property
|
||||||
|
def original_media_path(self) -> str:
|
||||||
|
"""Path (relative to uploads/) where the original unedited file lives.
|
||||||
|
|
||||||
|
When a player edits content the original file is archived under
|
||||||
|
edited_media/<id>/original_<name>. If never edited, the original IS the
|
||||||
|
current file.
|
||||||
|
"""
|
||||||
|
if self.original_filename and self.original_filename != self.filename:
|
||||||
|
return f"edited_media/{self.id}/original_{self.original_filename}"
|
||||||
|
return self.filename
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_media_path(self) -> str:
|
||||||
|
"""Path (relative to uploads/) of the current/latest version to display."""
|
||||||
|
return self.filename
|
||||||
|
|
||||||
def is_image(self) -> bool:
|
def is_image(self) -> bool:
|
||||||
"""Check if content is an image."""
|
"""Check if content is an image."""
|
||||||
@@ -61,3 +83,29 @@ class Content(db.Model):
|
|||||||
def is_pdf(self) -> bool:
|
def is_pdf(self) -> bool:
|
||||||
"""Check if content is a PDF."""
|
"""Check if content is a PDF."""
|
||||||
return self.content_type == 'pdf'
|
return self.content_type == 'pdf'
|
||||||
|
|
||||||
|
def is_weblink(self) -> bool:
|
||||||
|
"""Check if content is a web link (URL) rather than an uploaded file."""
|
||||||
|
return self.content_type == 'weblink'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_player_edits(self) -> bool:
|
||||||
|
"""Whether this content has been edited on a player."""
|
||||||
|
return self.edits.count() > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def original_display_name(self) -> str:
|
||||||
|
"""Display name of the original (unedited) file."""
|
||||||
|
return self.original_filename or self.filename
|
||||||
|
|
||||||
|
@property
|
||||||
|
def original_media_path(self) -> str:
|
||||||
|
"""uploads/-relative path of the pristine original file.
|
||||||
|
|
||||||
|
After a player edit the original is archived at
|
||||||
|
edited_media/<id>/original_<name>; before any edit it is simply the
|
||||||
|
current file.
|
||||||
|
"""
|
||||||
|
if self.original_filename and self.filename != self.original_filename:
|
||||||
|
return f"edited_media/{self.id}/original_{self.original_filename}"
|
||||||
|
return self.filename
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
"""Group model for organizing players and content."""
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from app.extensions import db
|
|
||||||
|
|
||||||
|
|
||||||
# Association table for many-to-many relationship between groups and content
|
|
||||||
group_content = db.Table('group_content',
|
|
||||||
db.Column('group_id', db.Integer, db.ForeignKey('group.id'), primary_key=True),
|
|
||||||
db.Column('content_id', db.Integer, db.ForeignKey('content.id'), primary_key=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Group(db.Model):
|
|
||||||
"""Group model for organizing players with shared content.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: Primary key
|
|
||||||
name: Unique group name
|
|
||||||
description: Optional group description
|
|
||||||
created_at: Group creation timestamp
|
|
||||||
updated_at: Last modification timestamp
|
|
||||||
"""
|
|
||||||
__tablename__ = 'group'
|
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
name = db.Column(db.String(100), nullable=False, unique=True, index=True)
|
|
||||||
description = db.Column(db.Text, nullable=True)
|
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
|
|
||||||
onupdate=datetime.utcnow, nullable=False)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
contents = db.relationship('Content', secondary=group_content,
|
|
||||||
back_populates='groups', lazy='dynamic')
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
"""String representation of Group."""
|
|
||||||
return f'<Group {self.name} (ID={self.id})>'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@property
|
|
||||||
def content_count(self) -> int:
|
|
||||||
"""Get number of content items in this group."""
|
|
||||||
return self.contents.count()
|
|
||||||
|
|
||||||
def add_player(self, player) -> None:
|
|
||||||
"""Add a player to this group.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
player: Player instance to add
|
|
||||||
"""
|
|
||||||
player.group_id = self.id
|
|
||||||
self.updated_at = datetime.utcnow()
|
|
||||||
|
|
||||||
def remove_player(self, player) -> None:
|
|
||||||
"""Remove a player from this group.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
player: Player instance to remove
|
|
||||||
"""
|
|
||||||
if player.group_id == self.id:
|
|
||||||
player.group_id = None
|
|
||||||
self.updated_at = datetime.utcnow()
|
|
||||||
@@ -19,7 +19,7 @@ class Player(db.Model):
|
|||||||
orientation: Display orientation (Landscape/Portrait)
|
orientation: Display orientation (Landscape/Portrait)
|
||||||
status: Current player status (online, offline, error)
|
status: Current player status (online, offline, error)
|
||||||
last_seen: Last activity timestamp
|
last_seen: Last activity timestamp
|
||||||
playlist_version: Version number for playlist synchronization
|
playlist_id: Assigned playlist (sync version comes from Playlist.version)
|
||||||
created_at: Player creation timestamp
|
created_at: Player creation timestamp
|
||||||
"""
|
"""
|
||||||
__tablename__ = 'player'
|
__tablename__ = 'player'
|
||||||
@@ -41,6 +41,12 @@ class Player(db.Model):
|
|||||||
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
|
playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='SET NULL'),
|
||||||
nullable=True, index=True)
|
nullable=True, index=True)
|
||||||
|
|
||||||
|
# Deployment tracking
|
||||||
|
deployment_status = db.Column(db.String(50), default='pending', nullable=True) # pending, deployed, failed
|
||||||
|
last_deployment_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
last_deployment_status = db.Column(db.String(50), nullable=True) # success, failed
|
||||||
|
last_deployment_message = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
playlist = db.relationship('Playlist', back_populates='players')
|
playlist = db.relationship('Playlist', back_populates='players')
|
||||||
feedback = db.relationship('PlayerFeedback', back_populates='player',
|
feedback = db.relationship('PlayerFeedback', back_populates='player',
|
||||||
|
|||||||
@@ -95,6 +95,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Build Player Files Card (Admin Only) -->
|
||||||
|
<div class="card management-card" style="background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);">
|
||||||
|
<h2>🚀 Build Player Files</h2>
|
||||||
|
<p>Clone/refresh the player code from Gitea and configure the server address for deployment</p>
|
||||||
|
<div class="card-actions">
|
||||||
|
<a href="{{ url_for('admin.build_player') }}" class="btn btn-primary">
|
||||||
|
Build Player Files
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Logo Customization Card (Admin Only) -->
|
<!-- Logo Customization Card (Admin Only) -->
|
||||||
<div class="card management-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
|
<div class="card management-card" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
|
||||||
<h2>🎨 Logo Customization</h2>
|
<h2>🎨 Logo Customization</h2>
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Build Player Files - DigiServer v2{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="page-header">
|
||||||
|
<a href="{{ url_for('admin.admin_panel') }}" class="back-link">← Back to Admin Panel</a>
|
||||||
|
<h1>🧰 Build Player Files for Deployment</h1>
|
||||||
|
<p style="color: #6c757d; margin-top: 6px;">
|
||||||
|
Pull the latest player source from a repository onto this server and bake the
|
||||||
|
configuration it needs to talk to this server. SSH deployments ship exactly
|
||||||
|
this staged copy, so the version you build here is what players run.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current staged code status -->
|
||||||
|
<div class="card status-card">
|
||||||
|
<h2>Current Staged Player Code</h2>
|
||||||
|
{% if code_status.available %}
|
||||||
|
<p><span class="badge badge-success">✅ Ready</span></p>
|
||||||
|
<ul style="line-height: 1.8;">
|
||||||
|
<li><strong>Version (git):</strong> <code>{{ code_status.version }}</code></li>
|
||||||
|
<li><strong>Size:</strong> {{ code_status.size }}</li>
|
||||||
|
{% if code_status.updated_str %}
|
||||||
|
<li><strong>Last updated:</strong> {{ code_status.updated_str }}</li>
|
||||||
|
{% endif %}
|
||||||
|
<li><strong>Path:</strong> <code>{{ code_status.path }}</code></li>
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>
|
||||||
|
<span class="badge badge-warning">⚠️ Not staged</span>
|
||||||
|
{{ code_status.reason }}
|
||||||
|
</p>
|
||||||
|
<p style="color: #6c757d;">Path: <code>{{ code_status.path }}</code></p>
|
||||||
|
{% endif %}
|
||||||
|
{% if settings.built_at %}
|
||||||
|
<p style="color: #6c757d; margin-top: 8px;">
|
||||||
|
Last build saved: {{ settings.built_at }}
|
||||||
|
{% if settings.built_by %}by {{ settings.built_by }}{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live build progress (updated by polling below) -->
|
||||||
|
<div class="card" id="build-progress" style="display: none;">
|
||||||
|
<h2>Build progress</h2>
|
||||||
|
<p id="build-progress-text" style="margin: 0;">
|
||||||
|
<span class="badge badge-warning" id="build-progress-badge">⏳ Running</span>
|
||||||
|
<span id="build-progress-step"></span>
|
||||||
|
</p>
|
||||||
|
<p id="build-progress-message" style="color: #6c757d; margin-top: 8px;"></p>
|
||||||
|
<div style="margin-top: 10px;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="window.location.reload()">
|
||||||
|
🔄 Refresh page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style="color: #6c757d; font-size: 13px; margin-top: 10px;">
|
||||||
|
The clone takes a couple of minutes. You can leave this page and come back.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('admin.build_player_action') }}">
|
||||||
|
<!-- Repository -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>1. Player Files Repository</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="repo_url">Git Repository URL</label>
|
||||||
|
<input type="text" id="repo_url" name="repo_url" class="form-control"
|
||||||
|
value="{{ settings.repo_url }}"
|
||||||
|
placeholder="https://gitea.example.com/org/Kiwy-Signage.git">
|
||||||
|
<small style="color: #6c757d;">Cloned/refreshed into the staged directory on this server.</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="branch">Branch</label>
|
||||||
|
<input type="text" id="branch" name="branch" class="form-control"
|
||||||
|
value="{{ settings.branch }}" placeholder="main">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server configuration -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>2. Player → Server Configuration</h2>
|
||||||
|
<p style="color: #6c757d;">
|
||||||
|
The player contacts the DigiServer API directly at
|
||||||
|
<code>{scheme}://{server}:{port}/api/...</code> (no <code>/digiserver</code> path).
|
||||||
|
Enter the address players can reach this server on.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="server_ip">Server IP / Domain</label>
|
||||||
|
<input type="text" id="server_ip" name="server_ip" class="form-control"
|
||||||
|
value="{{ settings.server_ip }}" placeholder="signage.example.com or 192.168.0.50">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="port">Port</label>
|
||||||
|
<input type="number" id="port" name="port" class="form-control"
|
||||||
|
value="{{ settings.port }}" min="1" max="65535">
|
||||||
|
<small style="color: #6c757d;">Use 443 for HTTPS, 80 for plain HTTP, or your custom port (e.g. 8080).</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||||
|
<input type="checkbox" name="use_https" {% if settings.use_https %}checked{% endif %}>
|
||||||
|
Use HTTPS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||||
|
<input type="checkbox" name="verify_ssl" {% if settings.verify_ssl %}checked{% endif %}>
|
||||||
|
Verify SSL certificate
|
||||||
|
</label>
|
||||||
|
<small style="color: #6c757d;">Leave off for self-signed certificates.</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="orientation">Orientation</label>
|
||||||
|
<select id="orientation" name="orientation" class="form-control">
|
||||||
|
<option value="Landscape" {% if settings.orientation == 'Landscape' %}selected{% endif %}>Landscape</option>
|
||||||
|
<option value="Portrait" {% if settings.orientation == 'Portrait' %}selected{% endif %}>Portrait</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="max_resolution">Max Resolution</label>
|
||||||
|
<input type="text" id="max_resolution" name="max_resolution" class="form-control"
|
||||||
|
value="{{ settings.max_resolution }}" placeholder="1920x1080">
|
||||||
|
</div>
|
||||||
|
<p style="color: #6c757d; font-size: 13px;">
|
||||||
|
ℹ️ The per-player <strong>screen name</strong> and <strong>quick-connect code</strong>
|
||||||
|
are filled in automatically for each device during SSH deployment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>3. Build</h2>
|
||||||
|
<div class="card-actions" style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
|
<button type="submit" name="action" value="build_and_config"
|
||||||
|
class="btn btn-primary" id="btn-build-all">
|
||||||
|
⬇️ Build files & write config
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="action" value="build_files"
|
||||||
|
class="btn btn-secondary" id="btn-build-files">
|
||||||
|
Build files only
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="action" value="save_config" class="btn btn-secondary">
|
||||||
|
Write config only
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Poll the build status so the admin sees live progress instead of a request
|
||||||
|
// that appears to hang (the clone takes ~1-2 minutes).
|
||||||
|
(function () {
|
||||||
|
const statusUrl = "{{ url_for('admin.build_player_status') }}";
|
||||||
|
const panel = document.getElementById('build-progress');
|
||||||
|
const badge = document.getElementById('build-progress-badge');
|
||||||
|
const step = document.getElementById('build-progress-step');
|
||||||
|
const message = document.getElementById('build-progress-message');
|
||||||
|
const bAll = document.getElementById('btn-build-all');
|
||||||
|
const bFiles = document.getElementById('btn-build-files');
|
||||||
|
let sawRunning = false;
|
||||||
|
|
||||||
|
function render(s) {
|
||||||
|
const state = s.state || 'idle';
|
||||||
|
|
||||||
|
if (state === 'running') {
|
||||||
|
sawRunning = true;
|
||||||
|
panel.style.display = 'block';
|
||||||
|
badge.className = 'badge badge-warning';
|
||||||
|
badge.textContent = '⏳ Running';
|
||||||
|
step.textContent = s.step || '';
|
||||||
|
message.textContent = '';
|
||||||
|
if (bAll) { bAll.disabled = true; bAll.textContent = '⏳ Building…'; }
|
||||||
|
if (bFiles) { bFiles.disabled = true; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reached a terminal state.
|
||||||
|
if (state === 'success' || state === 'error') {
|
||||||
|
panel.style.display = 'block';
|
||||||
|
if (state === 'success') {
|
||||||
|
badge.className = 'badge badge-success';
|
||||||
|
badge.textContent = '✅ Build complete';
|
||||||
|
} else {
|
||||||
|
badge.className = 'badge badge-danger';
|
||||||
|
badge.textContent = '❌ Build failed';
|
||||||
|
}
|
||||||
|
step.textContent = '';
|
||||||
|
message.textContent = s.message || '';
|
||||||
|
if (sawRunning) {
|
||||||
|
// Reload once so the staged-version panel reflects the new build.
|
||||||
|
setTimeout(function () { window.location.reload(); }, 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function poll() {
|
||||||
|
fetch(statusUrl, { headers: { 'Accept': 'application/json' }, cache: 'no-store' })
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (s) { if (s) render(s); })
|
||||||
|
.catch(function () { /* transient — keep polling */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
poll();
|
||||||
|
setInterval(poll, 3000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
@@ -160,93 +160,35 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nginx Status Card -->
|
<!-- Caddy Reverse Proxy Status Card -->
|
||||||
<div class="card nginx-status-card">
|
<div class="card nginx-status-card">
|
||||||
<h2>🔧 Nginx Reverse Proxy Status</h2>
|
<h2>🔧 Caddy Reverse Proxy Status</h2>
|
||||||
{% if nginx_status.available %}
|
<div class="nginx-status-content">
|
||||||
<div class="nginx-status-content">
|
<div class="status-item">
|
||||||
<div class="status-item">
|
<strong>Reverse Proxy:</strong>
|
||||||
<strong>Status:</strong>
|
<span class="badge badge-success">✅ Caddy</span>
|
||||||
<span class="badge badge-success">✅ Nginx Configured</span>
|
</div>
|
||||||
</div>
|
<div class="status-item">
|
||||||
|
<strong>SSL/TLS:</strong>
|
||||||
<div class="status-item">
|
{% if config and config.https_enabled %}
|
||||||
<strong>Configuration Path:</strong>
|
<span class="badge badge-success">🔒 Enabled — auto-managed by Caddy</span>
|
||||||
<code>{{ nginx_status.path }}</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if nginx_status.ssl_enabled %}
|
|
||||||
<div class="status-item">
|
|
||||||
<strong>SSL/TLS:</strong>
|
|
||||||
<span class="badge badge-success">🔒 Enabled</span>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="status-item">
|
<span class="badge badge-warning">⚠️ HTTP only — enable HTTPS above</span>
|
||||||
<strong>SSL/TLS:</strong>
|
|
||||||
<span class="badge badge-warning">⚠️ Not Configured</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if nginx_status.http_ports %}
|
|
||||||
<div class="status-item">
|
|
||||||
<strong>HTTP Ports:</strong>
|
|
||||||
<code>{{ nginx_status.http_ports|join(', ') }}</code>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if nginx_status.https_ports %}
|
|
||||||
<div class="status-item">
|
|
||||||
<strong>HTTPS Ports:</strong>
|
|
||||||
<code>{{ nginx_status.https_ports|join(', ') }}</code>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if nginx_status.server_names %}
|
|
||||||
<div class="status-item">
|
|
||||||
<strong>Server Names:</strong>
|
|
||||||
{% for name in nginx_status.server_names %}
|
|
||||||
<code>{{ name }}</code>{% if not loop.last %}<br>{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if nginx_status.upstream_servers %}
|
|
||||||
<div class="status-item">
|
|
||||||
<strong>Upstream Servers:</strong>
|
|
||||||
{% for server in nginx_status.upstream_servers %}
|
|
||||||
<code>{{ server }}</code>{% if not loop.last %}<br>{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if nginx_status.ssl_protocols %}
|
|
||||||
<div class="status-item">
|
|
||||||
<strong>SSL Protocols:</strong>
|
|
||||||
<code>{{ nginx_status.ssl_protocols|join(', ') }}</code>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if nginx_status.client_max_body_size %}
|
|
||||||
<div class="status-item">
|
|
||||||
<strong>Max Body Size:</strong>
|
|
||||||
<code>{{ nginx_status.client_max_body_size }}</code>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if nginx_status.gzip_enabled %}
|
|
||||||
<div class="status-item">
|
|
||||||
<strong>Gzip Compression:</strong>
|
|
||||||
<span class="badge badge-success">✅ Enabled</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
<div class="status-item">
|
||||||
<div class="status-disabled">
|
<strong>Certificate Provider:</strong>
|
||||||
<p>⚠️ <strong>Nginx configuration not accessible</strong></p>
|
<code>Let's Encrypt (automatic)</code>
|
||||||
<p>Error: {{ nginx_status.error|default('Unknown error') }}</p>
|
|
||||||
<p style="font-size: 12px; color: #666;">Path checked: {{ nginx_status.path }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
<div class="status-item">
|
||||||
|
<strong>Admin API:</strong>
|
||||||
|
<code>caddy:2019</code>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<strong>Upstream:</strong>
|
||||||
|
<code>digiserver-app:5000</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Information Section -->
|
<!-- Information Section -->
|
||||||
|
|||||||
@@ -181,7 +181,7 @@
|
|||||||
<div class="login-container">
|
<div class="login-container">
|
||||||
<!-- Logo Section (Left - 2/3) -->
|
<!-- Logo Section (Left - 2/3) -->
|
||||||
<div class="logo-section">
|
<div class="logo-section">
|
||||||
<img src="{{ url_for('static', filename='uploads/login_logo.png') }}?v={{ range(1, 999999) | random }}"
|
<img src="{{ url_for('static', filename='uploads/login_logo.png') }}?v={{ logo_version }}"
|
||||||
alt="DigiServer Logo"
|
alt="DigiServer Logo"
|
||||||
onerror="this.style.display='none';">
|
onerror="this.style.display='none';">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -376,7 +376,7 @@
|
|||||||
<header>
|
<header>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>
|
<h1>
|
||||||
<img src="{{ url_for('static', filename='uploads/header_logo.png?v=1') }}" alt="DigiServer" style="height: 32px; width: auto; margin-right: 8px;" onerror="this.style.display='none';" onload="this.style.display='inline';">
|
<img src="{{ url_for('static', filename='uploads/header_logo.png') }}?v={{ logo_version }}" alt="DigiServer" style="height: 32px; width: auto; margin-right: 8px;" onerror="this.style.display='none';" onload="this.style.display='inline';">
|
||||||
DigiServer
|
DigiServer
|
||||||
</h1>
|
</h1>
|
||||||
<nav>
|
<nav>
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Content Library - DigiServer v2{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
|
||||||
<h1>Content Library</h1>
|
|
||||||
<a href="{{ url_for('content.upload_content') }}" class="btn btn-success">+ Upload Content</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if content_list %}
|
|
||||||
<div class="card">
|
|
||||||
<div style="margin-bottom: 15px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
|
|
||||||
<strong>Total Files:</strong> {{ content_list|length }} |
|
|
||||||
<strong>Total Assignments:</strong> {% set total = namespace(count=0) %}{% for item in content_list %}{% set total.count = total.count + item.player_count %}{% endfor %}{{ total.count }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<thead>
|
|
||||||
<tr style="background: #f8f9fa; text-align: left;">
|
|
||||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">File Name</th>
|
|
||||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Type</th>
|
|
||||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Duration</th>
|
|
||||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Size</th>
|
|
||||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Assigned To</th>
|
|
||||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Uploaded</th>
|
|
||||||
<th style="padding: 12px; border-bottom: 2px solid #dee2e6;">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for item in content_list %}
|
|
||||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
|
||||||
<td style="padding: 12px;">
|
|
||||||
<strong>{{ item.filename }}</strong>
|
|
||||||
</td>
|
|
||||||
<td style="padding: 12px;">
|
|
||||||
{% if item.content_type == 'image' %}
|
|
||||||
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📷 Image</span>
|
|
||||||
{% elif item.content_type == 'video' %}
|
|
||||||
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">🎬 Video</span>
|
|
||||||
{% elif item.content_type == 'pdf' %}
|
|
||||||
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📄 PDF</span>
|
|
||||||
{% elif item.content_type == 'presentation' %}
|
|
||||||
<span style="background: #ffc107; color: black; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📊 PPT</span>
|
|
||||||
{% else %}
|
|
||||||
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">📁 Other</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td style="padding: 12px;">
|
|
||||||
{{ item.duration }}s
|
|
||||||
</td>
|
|
||||||
<td style="padding: 12px;">
|
|
||||||
{{ item.file_size }} MB
|
|
||||||
</td>
|
|
||||||
<td style="padding: 12px;">
|
|
||||||
{% if item.player_count == 0 %}
|
|
||||||
<span style="color: #6c757d; font-style: italic;">Not assigned</span>
|
|
||||||
{% else %}
|
|
||||||
<div style="max-height: 100px; overflow-y: auto;">
|
|
||||||
{% for player in item.players %}
|
|
||||||
<div style="margin-bottom: 5px;">
|
|
||||||
<strong>{{ player.name }}</strong>
|
|
||||||
{% if player.group %}
|
|
||||||
<span style="color: #6c757d; font-size: 12px;">({{ player.group }})</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<div style="margin-top: 5px;">
|
|
||||||
<span style="background: #007bff; color: white; padding: 2px 6px; border-radius: 3px; font-size: 11px;">
|
|
||||||
{{ item.player_count }} player{% if item.player_count != 1 %}s{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td style="padding: 12px;">
|
|
||||||
<small style="color: #6c757d;">{{ item.uploaded_at | localtime }}</small>
|
|
||||||
</td>
|
|
||||||
<td style="padding: 12px;">
|
|
||||||
{% if item.player_count > 0 %}
|
|
||||||
{% set first_player = item.players[0] %}
|
|
||||||
<a href="{{ url_for('players.player_page', player_id=first_player.id) }}"
|
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
title="Manage Playlist for {{ first_player.name }}"
|
|
||||||
style="margin-bottom: 5px;">
|
|
||||||
📝 Manage Playlist
|
|
||||||
</a>
|
|
||||||
{% if item.player_count > 1 %}
|
|
||||||
<button onclick="showAllPlayers('{{ item.filename|replace("'", "\\'") }}', {{ item.players|tojson }})"
|
|
||||||
class="btn btn-info btn-sm"
|
|
||||||
title="View all players with this content">
|
|
||||||
👥 View All ({{ item.player_count }})
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
<button onclick="deleteContent('{{ item.filename|replace("'", "\\'") }}')"
|
|
||||||
class="btn btn-danger btn-sm"
|
|
||||||
title="Delete this content from all playlists"
|
|
||||||
style="margin-top: 5px;">
|
|
||||||
🗑️ Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px;">
|
|
||||||
ℹ️ No content uploaded yet. <a href="{{ url_for('content.upload_content') }}" style="color: #0c5460; text-decoration: underline;">Upload your first content</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal for viewing all players -->
|
|
||||||
<div id="playersModal" class="modal" style="display: none;">
|
|
||||||
<div class="modal-content" style="max-width: 600px; margin: 100px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
|
|
||||||
<h2 id="modalTitle" style="margin-bottom: 20px; color: #2c3e50;">Players with this content</h2>
|
|
||||||
<div id="playersList" style="max-height: 400px; overflow-y: auto;"></div>
|
|
||||||
<div style="text-align: center; margin-top: 20px;">
|
|
||||||
<button type="button" class="btn" onclick="closePlayersModal()">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.modal {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 9999;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function showAllPlayers(filename, players) {
|
|
||||||
document.getElementById('modalTitle').textContent = 'Players with: ' + filename;
|
|
||||||
|
|
||||||
const playersList = document.getElementById('playersList');
|
|
||||||
playersList.innerHTML = '<table style="width: 100%; border-collapse: collapse;">';
|
|
||||||
playersList.innerHTML += '<thead><tr style="background: #f8f9fa;"><th style="padding: 10px; text-align: left;">Player Name</th><th style="padding: 10px; text-align: left;">Group</th><th style="padding: 10px; text-align: left;">Action</th></tr></thead><tbody>';
|
|
||||||
|
|
||||||
players.forEach(player => {
|
|
||||||
playersList.innerHTML += `
|
|
||||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
|
||||||
<td style="padding: 10px;"><strong>${player.name}</strong></td>
|
|
||||||
<td style="padding: 10px;">${player.group || '-'}</td>
|
|
||||||
<td style="padding: 10px;">
|
|
||||||
<a href="/players/${player.id}" class="btn btn-sm" style="background: #007bff; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px;">
|
|
||||||
Manage Playlist
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
playersList.innerHTML += '</tbody></table>';
|
|
||||||
|
|
||||||
document.getElementById('playersModal').style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePlayersModal() {
|
|
||||||
document.getElementById('playersModal').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteContent(filename) {
|
|
||||||
if (confirm(`Are you sure you want to delete "${filename}"?\n\nThis will remove it from ALL player playlists!`)) {
|
|
||||||
fetch('/content/delete-by-filename', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
filename: filename
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
alert(`Successfully deleted "${filename}" from ${data.deleted_count} playlist(s)`);
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
alert('Error deleting content: ' + data.message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
alert('Error deleting content: ' + error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal when clicking outside
|
|
||||||
window.onclick = function(event) {
|
|
||||||
const modal = document.getElementById('playersModal');
|
|
||||||
if (event.target == modal) {
|
|
||||||
closePlayersModal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -326,11 +326,11 @@
|
|||||||
<div class="media-library" style="max-height: 350px; overflow-y: auto;">
|
<div class="media-library" style="max-height: 350px; overflow-y: auto;">
|
||||||
{% if media_files %}
|
{% if media_files %}
|
||||||
{% for media in media_files %}
|
{% for media in media_files %}
|
||||||
<div class="media-item" title="{{ media.filename }}">
|
<div class="media-item" title="{{ media.original_display_name }}">
|
||||||
{% if media.content_type == 'image' %}
|
{% if media.content_type == 'image' %}
|
||||||
<div class="media-thumbnail" style="width: 100%; height: 100px; overflow: hidden; border-radius: 6px; margin-bottom: 8px; background: #f0f0f0; display: flex; align-items: center; justify-content: center;">
|
<div class="media-thumbnail" style="width: 100%; height: 100px; overflow: hidden; border-radius: 6px; margin-bottom: 8px; background: #f0f0f0; display: flex; align-items: center; justify-content: center;">
|
||||||
<img src="{{ url_for('static', filename='uploads/' + media.filename) }}"
|
<img src="{{ url_for('static', filename='uploads/' + media.current_media_path) }}"
|
||||||
alt="{{ media.filename }}"
|
alt="{{ media.original_display_name }}"
|
||||||
style="max-width: 100%; max-height: 100%; object-fit: cover;"
|
style="max-width: 100%; max-height: 100%; object-fit: cover;"
|
||||||
onerror="this.style.display='none'; this.parentElement.innerHTML='<span style=\'font-size: 48px;\'>📷</span>'">
|
onerror="this.style.display='none'; this.parentElement.innerHTML='<span style=\'font-size: 48px;\'>📷</span>'">
|
||||||
</div>
|
</div>
|
||||||
@@ -347,7 +347,7 @@
|
|||||||
📁
|
📁
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="media-name" style="font-size: 11px; line-height: 1.3;">{{ media.filename[:25] }}{% if media.filename|length > 25 %}...{% endif %}</div>
|
<div class="media-name" style="font-size: 11px; line-height: 1.3;">{{ media.original_display_name[:25] }}{% if media.original_display_name|length > 25 %}...{% endif %}</div>
|
||||||
<div style="font-size: 10px; color: #999; margin-top: 4px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
<div style="font-size: 10px; color: #999; margin-top: 4px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Edit Content{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container">
|
|
||||||
<h2>Edit Content</h2>
|
|
||||||
<p>Edit content functionality - placeholder</p>
|
|
||||||
<a href="{{ url_for('content.list') }}" class="btn btn-secondary">Back to Content</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -97,6 +97,67 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Duration spinner control */
|
||||||
|
.duration-spinner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-display {
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-spinner button {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-spinner button:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-spinner button:active {
|
||||||
|
background: #e0e0e0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-spinner button.btn-increase {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-spinner button.btn-decrease {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.audio-checkbox {
|
.audio-checkbox {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -154,6 +215,36 @@
|
|||||||
body.dark-mode .available-content {
|
body.dark-mode .available-content {
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode for duration spinner */
|
||||||
|
body.dark-mode .duration-display {
|
||||||
|
background: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .duration-spinner button {
|
||||||
|
background: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .duration-spinner button:hover {
|
||||||
|
background: #4a5568;
|
||||||
|
border-color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .duration-spinner button:active {
|
||||||
|
background: #5a6a78;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .duration-spinner button.btn-increase {
|
||||||
|
color: #48bb78;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .duration-spinner button.btn-decrease {
|
||||||
|
color: #f56565;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container" style="max-width: 1400px;">
|
<div class="container" style="max-width: 1400px;">
|
||||||
@@ -223,14 +314,41 @@
|
|||||||
</td>
|
</td>
|
||||||
<td><span class="drag-handle">⋮⋮</span></td>
|
<td><span class="drag-handle">⋮⋮</span></td>
|
||||||
<td>{{ loop.index }}</td>
|
<td>{{ loop.index }}</td>
|
||||||
<td>{{ content.filename }}</td>
|
<td>
|
||||||
|
{% if content.content_type == 'weblink' %}
|
||||||
|
<a href="{{ content.url }}" target="_blank" rel="noopener noreferrer">{{ content.url }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ content.original_display_name }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if content.content_type == 'image' %}📷 Image
|
{% if content.content_type == 'image' %}📷 Image
|
||||||
{% elif content.content_type == 'video' %}🎥 Video
|
{% elif content.content_type == 'video' %}🎥 Video
|
||||||
{% elif content.content_type == 'pdf' %}📄 PDF
|
{% elif content.content_type == 'pdf' %}📄 PDF
|
||||||
|
{% elif content.content_type == 'weblink' %}🔗 Web Link
|
||||||
{% else %}📁 Other{% endif %}
|
{% else %}📁 Other{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ content._playlist_duration or content.duration }}s</td>
|
<td>
|
||||||
|
<div class="duration-spinner">
|
||||||
|
<button type="button"
|
||||||
|
class="btn-decrease"
|
||||||
|
onclick="event.stopPropagation(); changeDuration({{ content.id }}, -1)"
|
||||||
|
onmousedown="event.stopPropagation()"
|
||||||
|
title="Decrease duration by 1 second">
|
||||||
|
⬇️
|
||||||
|
</button>
|
||||||
|
<div class="duration-display" id="duration-display-{{ content.id }}">
|
||||||
|
{{ content._playlist_duration or content.duration }}s
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn-increase"
|
||||||
|
onclick="event.stopPropagation(); changeDuration({{ content.id }}, 1)"
|
||||||
|
onmousedown="event.stopPropagation()"
|
||||||
|
title="Increase duration by 1 second">
|
||||||
|
⬆️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if content.content_type == 'video' %}
|
{% if content.content_type == 'video' %}
|
||||||
<label class="audio-toggle">
|
<label class="audio-toggle">
|
||||||
@@ -290,6 +408,22 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-bottom: 20px;">➕ Add Content</h2>
|
<h2 style="margin-bottom: 20px;">➕ Add Content</h2>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid #e0e0e0;">
|
||||||
|
<h3 style="margin-bottom: 12px; font-size: 16px;">🔗 Add Web Link</h3>
|
||||||
|
<form method="POST"
|
||||||
|
action="{{ url_for('content.add_weblink_to_playlist', playlist_id=playlist.id) }}">
|
||||||
|
<input type="url" name="url" required
|
||||||
|
placeholder="https://example.com/dashboard"
|
||||||
|
style="width: 100%; padding: 8px; margin-bottom: 8px; box-sizing: border-box;">
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center;">
|
||||||
|
<label style="font-size: 13px; color: #666;">Duration (s):</label>
|
||||||
|
<input type="number" name="duration" value="30" min="1"
|
||||||
|
style="width: 80px; padding: 6px;">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">+ Add Link</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if available_content %}
|
{% if available_content %}
|
||||||
<div class="available-content">
|
<div class="available-content">
|
||||||
{% for content in available_content %}
|
{% for content in available_content %}
|
||||||
@@ -300,7 +434,7 @@
|
|||||||
{% elif content.content_type == 'video' %}🎥
|
{% elif content.content_type == 'video' %}🎥
|
||||||
{% elif content.content_type == 'pdf' %}📄
|
{% elif content.content_type == 'pdf' %}📄
|
||||||
{% else %}📁{% endif %}
|
{% else %}📁{% endif %}
|
||||||
{{ content.filename }}
|
{{ content.original_display_name }}
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size: 12px; color: #999;">
|
<div style="font-size: 12px; color: #999;">
|
||||||
{{ content.file_size_mb }} MB
|
{{ content.file_size_mb }} MB
|
||||||
@@ -413,6 +547,58 @@ function saveOrder() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Change duration with spinner buttons
|
||||||
|
function changeDuration(contentId, change) {
|
||||||
|
const displayElement = document.getElementById(`duration-display-${contentId}`);
|
||||||
|
const currentText = displayElement.textContent;
|
||||||
|
const currentDuration = parseInt(currentText);
|
||||||
|
const newDuration = currentDuration + change;
|
||||||
|
|
||||||
|
// Validate duration (minimum 1 second)
|
||||||
|
if (newDuration < 1) {
|
||||||
|
alert('Duration must be at least 1 second');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update display immediately for visual feedback
|
||||||
|
displayElement.style.opacity = '0.7';
|
||||||
|
displayElement.textContent = newDuration + 's';
|
||||||
|
|
||||||
|
// Save to server
|
||||||
|
const playlistId = {{ playlist.id }};
|
||||||
|
const url = `/content/playlist/${playlistId}/update-duration/${contentId}`;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('duration', newDuration);
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
console.log('Duration updated successfully');
|
||||||
|
displayElement.style.opacity = '1';
|
||||||
|
displayElement.style.color = '#28a745';
|
||||||
|
setTimeout(() => {
|
||||||
|
displayElement.style.color = '';
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
// Revert on error
|
||||||
|
displayElement.textContent = currentDuration + 's';
|
||||||
|
displayElement.style.opacity = '1';
|
||||||
|
alert('Error updating duration: ' + (data.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
// Revert on error
|
||||||
|
displayElement.textContent = currentDuration + 's';
|
||||||
|
displayElement.style.opacity = '1';
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Error updating duration');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function toggleAudio(contentId, enabled) {
|
function toggleAudio(contentId, enabled) {
|
||||||
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
|
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
|
||||||
const playlistId = {{ playlist.id }};
|
const playlistId = {{ playlist.id }};
|
||||||
|
|||||||
@@ -275,11 +275,11 @@
|
|||||||
<div class="media-card">
|
<div class="media-card">
|
||||||
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
<button class="delete-btn" onclick="confirmDelete({{ media.id }}, '{{ media.filename }}', {{ media.playlists.count() }}, {{ media.edit_count }})" title="Delete">🗑️</button>
|
||||||
<div class="media-thumbnail">
|
<div class="media-thumbnail">
|
||||||
<img src="{{ url_for('static', filename='uploads/' + media.filename) }}"
|
<img src="{{ url_for('static', filename='uploads/' + media.current_media_path) }}"
|
||||||
alt="{{ media.filename }}"
|
alt="{{ media.original_display_name }}"
|
||||||
onerror="this.style.display='none'; this.parentElement.innerHTML='<span class=\'media-icon\'>📷</span>'">
|
onerror="this.style.display='none'; this.parentElement.innerHTML='<span class=\'media-icon\'>📷</span>'">
|
||||||
</div>
|
</div>
|
||||||
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
<div class="media-filename" title="{{ media.original_display_name }}">{{ media.original_display_name }}</div>
|
||||||
<div class="media-info">
|
<div class="media-info">
|
||||||
<span class="type-badge image">Image</span>
|
<span class="type-badge image">Image</span>
|
||||||
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||||
@@ -308,7 +308,7 @@
|
|||||||
<div class="media-thumbnail">
|
<div class="media-thumbnail">
|
||||||
<span class="media-icon">🎥</span>
|
<span class="media-icon">🎥</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
<div class="media-filename" title="{{ media.original_display_name }}">{{ media.original_display_name }}</div>
|
||||||
<div class="media-info">
|
<div class="media-info">
|
||||||
<span class="type-badge video">Video</span>
|
<span class="type-badge video">Video</span>
|
||||||
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
<div class="media-thumbnail">
|
<div class="media-thumbnail">
|
||||||
<span class="media-icon">📄</span>
|
<span class="media-icon">📄</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
<div class="media-filename" title="{{ media.original_display_name }}">{{ media.original_display_name }}</div>
|
||||||
<div class="media-info">
|
<div class="media-info">
|
||||||
<span class="type-badge pdf">PDF</span>
|
<span class="type-badge pdf">PDF</span>
|
||||||
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||||
@@ -366,7 +366,7 @@
|
|||||||
<div class="media-thumbnail">
|
<div class="media-thumbnail">
|
||||||
<span class="media-icon">📊</span>
|
<span class="media-icon">📊</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
<div class="media-filename" title="{{ media.original_display_name }}">{{ media.original_display_name }}</div>
|
||||||
<div class="media-info">
|
<div class="media-info">
|
||||||
<span class="type-badge pptx">PPTX</span>
|
<span class="type-badge pptx">PPTX</span>
|
||||||
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||||
@@ -395,7 +395,7 @@
|
|||||||
<div class="media-thumbnail">
|
<div class="media-thumbnail">
|
||||||
<span class="media-icon">📁</span>
|
<span class="media-icon">📁</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="media-filename" title="{{ media.filename }}">{{ media.filename }}</div>
|
<div class="media-filename" title="{{ media.original_display_name }}">{{ media.original_display_name }}</div>
|
||||||
<div class="media-info">
|
<div class="media-info">
|
||||||
<span class="type-badge">{{ media.content_type }}</span>
|
<span class="type-badge">{{ media.content_type }}</span>
|
||||||
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
<div style="margin-top: 5px;">{{ "%.1f"|format(media.file_size_mb) }} MB</div>
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Upload Content - DigiServer v2{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container" style="max-width: 1200px;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
|
||||||
<h1>Upload Content</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="upload-form" method="POST" enctype="multipart/form-data" onsubmit="handleFormSubmit(event)">
|
|
||||||
<input type="hidden" name="return_url" value="{{ return_url or url_for('content.content_list') }}">
|
|
||||||
|
|
||||||
<div class="card" style="margin-bottom: 20px;">
|
|
||||||
<h3 style="margin-bottom: 15px;">Select Player</h3>
|
|
||||||
<div>
|
|
||||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Player:</label>
|
|
||||||
<select name="player_id" id="player_id" class="form-control" required>
|
|
||||||
<option value="" disabled {% if not selected_player_id %}selected{% endif %}>Select a Player</option>
|
|
||||||
{% for player in players %}
|
|
||||||
<option value="{{ player.id }}" {% if selected_player_id == player.id %}selected{% endif %}>
|
|
||||||
{{ player.name }} - {{ player.location or 'No location' }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="margin-bottom: 20px;">
|
|
||||||
<h3 style="margin-bottom: 15px;">Media Details</h3>
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
|
|
||||||
<div>
|
|
||||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Media Type:</label>
|
|
||||||
<select name="media_type" id="media_type" class="form-control" required onchange="handleMediaTypeChange()">
|
|
||||||
<option value="image">Image (JPG, PNG, GIF)</option>
|
|
||||||
<option value="video">Video (MP4, AVI, MOV)</option>
|
|
||||||
<option value="pdf">PDF Document</option>
|
|
||||||
<option value="ppt">PowerPoint (PPT/PPTX)</option>
|
|
||||||
</select>
|
|
||||||
<small style="color: #6c757d; display: block; margin-top: 5px;" id="media-type-hint">
|
|
||||||
Images will be displayed as-is
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Duration (seconds):</label>
|
|
||||||
<input type="number" name="duration" id="duration" class="form-control" required min="1" value="10">
|
|
||||||
<small style="color: #6c757d; display: block; margin-top: 5px;">
|
|
||||||
How long to display each image/slide (videos use actual length)
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Files:</label>
|
|
||||||
<input type="file" name="files" id="files" class="form-control" multiple required
|
|
||||||
accept="image/*,video/*,.pdf,.ppt,.pptx" onchange="handleFileChange()">
|
|
||||||
<small style="color: #6c757d; display: block; margin-top: 5px;">
|
|
||||||
Select multiple files. Supported: JPG, PNG, GIF, MP4, PDF, PPT, PPTX
|
|
||||||
</small>
|
|
||||||
<div id="file-list" style="margin-top: 10px;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center;">
|
|
||||||
<button type="submit" id="submit-button" class="btn btn-success" style="padding: 10px 30px; font-size: 16px;">
|
|
||||||
📤 Upload Files
|
|
||||||
</button>
|
|
||||||
<a href="{{ return_url or url_for('content.content_list') }}" class="btn" style="padding: 10px 30px; font-size: 16px;">
|
|
||||||
← Back
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal for Status Updates -->
|
|
||||||
<div id="statusModal" class="modal" style="display: none;">
|
|
||||||
<div class="modal-content" style="max-width: 800px; margin: 50px auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);">
|
|
||||||
<h2 style="margin-bottom: 20px; color: #2c3e50;">Processing Files</h2>
|
|
||||||
|
|
||||||
<div style="margin-bottom: 20px;">
|
|
||||||
<p id="status-message" style="font-size: 16px; color: #555;">Uploading and processing your files. Please wait...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Progress Bar -->
|
|
||||||
<div style="margin-bottom: 30px;">
|
|
||||||
<label style="display: block; margin-bottom: 10px; font-weight: bold;">File Processing Progress</label>
|
|
||||||
<div style="width: 100%; height: 30px; background: #e9ecef; border-radius: 5px; overflow: hidden;">
|
|
||||||
<div id="progress-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #007bff, #0056b3); transition: width 0.3s ease; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 14px;">
|
|
||||||
0%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; margin-top: 20px;">
|
|
||||||
<button type="button" class="btn" onclick="closeModal()" disabled id="close-modal-btn">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.modal {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 9999;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let progressInterval = null;
|
|
||||||
let sessionId = null;
|
|
||||||
let returnUrl = '{{ return_url or url_for("content.content_list") }}';
|
|
||||||
|
|
||||||
function generateSessionId() {
|
|
||||||
return 'upload_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFormSubmit(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
sessionId = generateSessionId();
|
|
||||||
const form = document.getElementById('upload-form');
|
|
||||||
let sessionInput = document.getElementById('session_id_input');
|
|
||||||
if (!sessionInput) {
|
|
||||||
sessionInput = document.createElement('input');
|
|
||||||
sessionInput.type = 'hidden';
|
|
||||||
sessionInput.name = 'session_id';
|
|
||||||
sessionInput.id = 'session_id_input';
|
|
||||||
form.appendChild(sessionInput);
|
|
||||||
}
|
|
||||||
sessionInput.value = sessionId;
|
|
||||||
|
|
||||||
showStatusModal();
|
|
||||||
|
|
||||||
const formData = new FormData(form);
|
|
||||||
|
|
||||||
fetch(form.action, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
.then(response => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Upload failed');
|
|
||||||
}
|
|
||||||
console.log('Form submitted successfully');
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Form submission error:', error);
|
|
||||||
document.getElementById('status-message').textContent = 'Upload failed: ' + error.message;
|
|
||||||
document.getElementById('progress-bar').style.background = '#dc3545';
|
|
||||||
document.getElementById('close-modal-btn').disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function showStatusModal() {
|
|
||||||
const modal = document.getElementById('statusModal');
|
|
||||||
modal.style.display = 'block';
|
|
||||||
|
|
||||||
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 converting video. This may take several minutes...';
|
|
||||||
break;
|
|
||||||
case 'pdf':
|
|
||||||
statusMessage.textContent = 'Uploading and converting PDF to images...';
|
|
||||||
break;
|
|
||||||
case 'ppt':
|
|
||||||
statusMessage.textContent = 'Uploading and converting PowerPoint to images...';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
statusMessage.textContent = 'Uploading and processing your files. Please wait...';
|
|
||||||
}
|
|
||||||
|
|
||||||
pollUploadProgress();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
const modal = document.getElementById('statusModal');
|
|
||||||
modal.style.display = 'none';
|
|
||||||
|
|
||||||
if (progressInterval) {
|
|
||||||
clearInterval(progressInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.href = returnUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pollUploadProgress() {
|
|
||||||
progressInterval = setInterval(() => {
|
|
||||||
fetch(`/api/upload-progress/${sessionId}`)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
const progressBar = document.getElementById('progress-bar');
|
|
||||||
progressBar.style.width = `${data.progress}%`;
|
|
||||||
progressBar.textContent = `${data.progress}%`;
|
|
||||||
|
|
||||||
document.getElementById('status-message').textContent = data.message;
|
|
||||||
|
|
||||||
if (data.status === 'complete' || data.status === 'error') {
|
|
||||||
clearInterval(progressInterval);
|
|
||||||
progressInterval = null;
|
|
||||||
|
|
||||||
const closeBtn = document.getElementById('close-modal-btn');
|
|
||||||
closeBtn.disabled = false;
|
|
||||||
|
|
||||||
if (data.status === 'complete') {
|
|
||||||
progressBar.style.background = '#28a745';
|
|
||||||
setTimeout(() => closeModal(), 2000);
|
|
||||||
} else if (data.status === 'error') {
|
|
||||||
progressBar.style.background = '#dc3545';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => console.error('Error fetching progress:', error));
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function handleMediaTypeChange() {
|
|
||||||
const mediaType = document.getElementById('media_type').value;
|
|
||||||
const hint = document.getElementById('media-type-hint');
|
|
||||||
|
|
||||||
switch(mediaType) {
|
|
||||||
case 'image':
|
|
||||||
hint.textContent = 'Images will be displayed as-is';
|
|
||||||
break;
|
|
||||||
case 'video':
|
|
||||||
hint.textContent = 'Videos will be converted to optimized format';
|
|
||||||
break;
|
|
||||||
case 'pdf':
|
|
||||||
hint.textContent = 'PDF will be converted to images (one per page)';
|
|
||||||
break;
|
|
||||||
case 'ppt':
|
|
||||||
hint.textContent = 'PowerPoint will be converted to images (one per slide)';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileChange() {
|
|
||||||
const filesInput = document.getElementById('files');
|
|
||||||
const fileList = document.getElementById('file-list');
|
|
||||||
const mediaType = document.getElementById('media_type').value;
|
|
||||||
const durationInput = document.getElementById('duration');
|
|
||||||
|
|
||||||
fileList.innerHTML = '';
|
|
||||||
if (filesInput.files.length > 0) {
|
|
||||||
fileList.innerHTML = '<strong>Selected files:</strong><ul style="margin: 5px 0; padding-left: 20px;">';
|
|
||||||
for (let i = 0; i < filesInput.files.length; i++) {
|
|
||||||
const file = filesInput.files[i];
|
|
||||||
const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
|
|
||||||
fileList.innerHTML += `<li>${file.name} (${sizeMB} MB)</li>`;
|
|
||||||
}
|
|
||||||
fileList.innerHTML += '</ul>';
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
video.src = URL.createObjectURL(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -317,6 +317,66 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Web Link Card ─────────────────────────────────────────────────────── -->
|
||||||
|
<div class="card" style="margin-top: 20px;">
|
||||||
|
<h2 style="margin-bottom: 15px; font-size: 18px; display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
🌐 Add Web Page Link
|
||||||
|
</h2>
|
||||||
|
<p style="color: #6c757d; font-size: 13px; margin-bottom: 16px;">
|
||||||
|
Add a website URL to display on the player (e.g. a dashboard, live feed, or any public web page).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="weblink-form" method="POST" action="{{ request.script_root }}/content/add-weblink">
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
||||||
|
|
||||||
|
<!-- URL -->
|
||||||
|
<div class="form-group" style="grid-column: 1 / -1;">
|
||||||
|
<label for="wl-url">Web Page URL <span style="color:#e53e3e;">*</span></label>
|
||||||
|
<input type="url" id="wl-url" name="url" class="form-control"
|
||||||
|
placeholder="https://example.com" required>
|
||||||
|
<small style="color:#6c757d; font-size:11px;">Must start with http:// or https://</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="wl-description">Label / Description</label>
|
||||||
|
<input type="text" id="wl-description" name="description" class="form-control"
|
||||||
|
placeholder="e.g. Live Dashboard">
|
||||||
|
<small style="color:#6c757d; font-size:11px;">Optional — shown in the media library</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duration -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="wl-duration">Display Duration (seconds)</label>
|
||||||
|
<input type="number" id="wl-duration" name="duration" class="form-control"
|
||||||
|
value="30" min="5" max="3600">
|
||||||
|
<small style="color:#6c757d; font-size:11px;">How long to show the page per loop</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Playlist -->
|
||||||
|
<div class="form-group" style="grid-column: 1 / -1;">
|
||||||
|
<label for="wl-playlist">Add to Playlist (Optional)</label>
|
||||||
|
<select id="wl-playlist" name="playlist_id" class="form-control">
|
||||||
|
<option value="">-- Media Library Only --</option>
|
||||||
|
{% for playlist in playlists %}
|
||||||
|
<option value="{{ playlist.id }}">
|
||||||
|
{{ playlist.name }} ({{ playlist.orientation }}) — {{ playlist.content_count }} items
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; align-items:center; gap:12px; margin-top:8px;">
|
||||||
|
<button type="submit" class="btn-upload" id="wl-submit-btn"
|
||||||
|
style="display:inline-flex; align-items:center; gap:0.5rem; padding:10px 24px;">
|
||||||
|
🌐 Add Web Link
|
||||||
|
</button>
|
||||||
|
<span id="wl-status" style="font-size:13px; display:none;"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const uploadZone = document.getElementById('upload-zone');
|
const uploadZone = document.getElementById('upload-zone');
|
||||||
const fileInput = document.getElementById('file-input');
|
const fileInput = document.getElementById('file-input');
|
||||||
@@ -509,6 +569,44 @@
|
|||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
uploadBtn.innerHTML = '⏳ Uploading...';
|
uploadBtn.innerHTML = '⏳ Uploading...';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Web Link form — AJAX submit ────────────────────────────────────────
|
||||||
|
const wlForm = document.getElementById('weblink-form');
|
||||||
|
const wlBtn = document.getElementById('wl-submit-btn');
|
||||||
|
const wlStatus = document.getElementById('wl-status');
|
||||||
|
|
||||||
|
wlForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
wlBtn.disabled = true;
|
||||||
|
wlBtn.textContent = '⏳ Adding…';
|
||||||
|
wlStatus.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(wlForm.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
body: new FormData(wlForm),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
wlStatus.style.color = '#38a169';
|
||||||
|
wlStatus.textContent = '✓ ' + data.message;
|
||||||
|
wlForm.reset();
|
||||||
|
document.getElementById('wl-duration').value = 30;
|
||||||
|
} else {
|
||||||
|
wlStatus.style.color = '#e53e3e';
|
||||||
|
wlStatus.textContent = '✗ ' + (data.error || 'Failed to add web link.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
wlStatus.style.color = '#e53e3e';
|
||||||
|
wlStatus.textContent = '✗ Network error — please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
wlStatus.style.display = 'inline';
|
||||||
|
wlBtn.disabled = false;
|
||||||
|
wlBtn.innerHTML = '🌐 Add Web Link';
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+435
-66
@@ -43,98 +43,467 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="dash-grid">
|
||||||
<h2>Quick Actions</h2>
|
<!-- ── Main column: where content actually lives ─────────────────────── -->
|
||||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem;">
|
<div class="dash-main">
|
||||||
<a href="{{ url_for('players.add_player') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
|
<div class="card">
|
||||||
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
<h2 style="display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;">
|
||||||
Add Player
|
<span style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
</a>
|
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 24px; height: 24px;">
|
||||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
|
Playlist Overview
|
||||||
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
</span>
|
||||||
Create Playlist
|
<a href="{{ url_for('content.content_list') }}" class="btn btn-sm btn-primary">Manage</a>
|
||||||
</a>
|
</h2>
|
||||||
<a href="{{ url_for('content.content_list') }}" class="btn btn-success" style="display: flex; align-items: center; gap: 0.5rem;">
|
<p class="secondary-text" style="font-size: 0.9rem; margin-top: -0.5rem; margin-bottom: 1rem;">
|
||||||
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" style="width: 18px; height: 18px; filter: brightness(0) invert(1);">
|
Pick a playlist to add media to or edit. Players listed here are the devices
|
||||||
Upload Media
|
that will show it.
|
||||||
</a>
|
</p>
|
||||||
{% if current_user.is_admin %}
|
|
||||||
<a href="{{ url_for('admin.admin_panel') }}" class="btn">Admin Panel</a>
|
{% if playlist_overview %}
|
||||||
|
{% for item in playlist_overview %}
|
||||||
|
<div class="playlist-row">
|
||||||
|
<div class="playlist-row-head">
|
||||||
|
<div style="min-width: 0;">
|
||||||
|
<a href="{{ url_for('content.manage_playlist_content', playlist_id=item.playlist.id) }}"
|
||||||
|
class="playlist-name">{{ item.playlist.name }}</a>
|
||||||
|
<div class="secondary-text" style="font-size: 0.82rem; margin-top: 2px;">
|
||||||
|
{{ item.content_count }} item{{ '' if item.content_count == 1 else 's' }}
|
||||||
|
· {{ item.total_duration }}s
|
||||||
|
· v{{ item.playlist.version }}
|
||||||
|
·
|
||||||
|
{% if item.player_count %}
|
||||||
|
<span style="color: #27ae60; font-weight: 600;">
|
||||||
|
{{ item.online_count }}/{{ item.player_count }} online
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #e67e22; font-weight: 600;">no players</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('content.manage_playlist_content', playlist_id=item.playlist.id) }}"
|
||||||
|
class="btn btn-sm">Edit</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.players %}
|
||||||
|
<div class="player-chips">
|
||||||
|
{% for player in item.players %}
|
||||||
|
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||||
|
class="player-chip {{ 'is-online' if player.is_online else 'is-offline' }}"
|
||||||
|
title="{{ player.name }}{% if player.location %} — {{ player.location }}{% endif %} ({{ 'online' if player.is_online else 'offline' }})">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span class="player-chip-name">{{ player.name }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-hint">
|
||||||
|
No players assigned — content here is not shown on any screen yet.
|
||||||
|
<a href="{{ url_for('players.list') }}">Assign a player →</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-hint">
|
||||||
|
No playlists yet.
|
||||||
|
<a href="{{ url_for('content.content_list') }}">Create your first playlist →</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if unassigned_players %}
|
||||||
|
<div class="unassigned-note">
|
||||||
|
<strong>{{ unassigned_players | length }} player{{ '' if unassigned_players | length == 1 else 's' }}
|
||||||
|
not assigned to any playlist:</strong>
|
||||||
|
{% for player in unassigned_players %}
|
||||||
|
<a href="{{ url_for('players.manage_player', player_id=player.id) }}">{{ player.name }}</a>{{ ', ' if not loop.last else '' }}
|
||||||
|
{% endfor %}
|
||||||
|
— these will not display content until a playlist is assigned.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity sits under Playlist Overview: both describe what
|
||||||
|
the server and its players are actually doing. -->
|
||||||
|
{% if recent_logs or heartbeat_logs %}
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;">
|
||||||
|
<span>Recent Activity</span>
|
||||||
|
<span class="log-tabs">
|
||||||
|
<button type="button" class="log-tab is-active" data-log-tab="activity">
|
||||||
|
Activity <span class="log-count">{{ recent_logs | length }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="log-tab" data-log-tab="heartbeat">
|
||||||
|
Player heartbeats <span class="log-count">{{ heartbeat_logs | length }}</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="log-pane" data-log-pane="activity">
|
||||||
|
{% if recent_logs %}
|
||||||
|
{% for log in recent_logs %}
|
||||||
|
<div class="log-item">
|
||||||
|
<span class="log-level" style="color: {% if log.level == 'error' %}#e74c3c{% elif log.level == 'warning' %}#f39c12{% elif log.level == 'debug' %}#718096{% else %}#27ae60{% endif %};">
|
||||||
|
[{{ log.level.upper() }}]
|
||||||
|
</span>
|
||||||
|
<span class="log-message">{{ log.message }}</span>
|
||||||
|
<small class="secondary-text log-time">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-hint">No activity recorded yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="log-pane" data-log-pane="heartbeat" hidden>
|
||||||
|
{% if heartbeat_logs %}
|
||||||
|
<p class="empty-hint" style="margin-top: 0;">
|
||||||
|
Routine player status reports — newest first.
|
||||||
|
</p>
|
||||||
|
{% for log in heartbeat_logs %}
|
||||||
|
<div class="log-item">
|
||||||
|
<span class="log-level" style="color: #27ae60;">[{{ log.level.upper() }}]</span>
|
||||||
|
<span class="log-message">{{ log.message }}</span>
|
||||||
|
<small class="secondary-text log-time">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-hint">No player heartbeats recorded yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
<!-- ── Right sidebar: small, fixed-width helper cards ─────────────────── -->
|
||||||
<h2 style="display: flex; align-items: center; gap: 0.5rem;">
|
<aside class="dash-side">
|
||||||
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 24px; height: 24px;">
|
<div class="card card-compact">
|
||||||
Workflow Guide
|
<h3 class="compact-title">Quick Actions</h3>
|
||||||
</h2>
|
<div class="compact-actions">
|
||||||
<div class="workflow-guide">
|
<a href="{{ url_for('players.add_player') }}" class="btn btn-sm btn-success compact-btn">
|
||||||
<ol style="line-height: 2; margin: 0; padding-left: 1.5rem;">
|
<img src="{{ url_for('static', filename='icons/monitor.svg') }}" alt="" class="compact-btn-icon">
|
||||||
<li><strong>Create a Playlist</strong> - Group your content into themed collections</li>
|
Add Player
|
||||||
<li><strong>Upload Media</strong> - Add images, videos, or PDFs to your media library</li>
|
</a>
|
||||||
<li><strong>Add Content to Playlist</strong> - Build your playlist with drag-and-drop ordering</li>
|
<a href="{{ url_for('content.content_list') }}" class="btn btn-sm btn-success compact-btn">
|
||||||
<li><strong>Add Player</strong> - Register physical display devices</li>
|
<img src="{{ url_for('static', filename='icons/playlist.svg') }}" alt="" class="compact-btn-icon">
|
||||||
<li><strong>Assign Playlist</strong> - Connect players to their playlists</li>
|
Create Playlist
|
||||||
<li><strong>Players Auto-Download</strong> - Devices fetch and display content automatically</li>
|
</a>
|
||||||
</ol>
|
<a href="{{ url_for('content.upload_media_page') }}" class="btn btn-sm btn-success compact-btn">
|
||||||
</div>
|
<img src="{{ url_for('static', filename='icons/upload.svg') }}" alt="" class="compact-btn-icon">
|
||||||
|
Upload Media
|
||||||
|
</a>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('admin.admin_panel') }}" class="btn btn-sm compact-btn">
|
||||||
|
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" class="compact-btn-icon">
|
||||||
|
Admin Panel
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-compact">
|
||||||
|
<h3 class="compact-title">
|
||||||
|
<img src="{{ url_for('static', filename='icons/info.svg') }}" alt="" style="width: 18px; height: 18px;">
|
||||||
|
Workflow Guide
|
||||||
|
</h3>
|
||||||
|
<div class="workflow-guide">
|
||||||
|
<ol>
|
||||||
|
<li><strong>Create a Playlist</strong></li>
|
||||||
|
<li><strong>Upload Media</strong></li>
|
||||||
|
<li><strong>Add Content</strong></li>
|
||||||
|
<li><strong>Add Player</strong></li>
|
||||||
|
<li><strong>Assign Playlist</strong></li>
|
||||||
|
<li><strong>Auto-Download</strong></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Dashboard two-column layout: main content + narrow right sidebar.
|
||||||
|
`minmax(0, 1fr)` keeps long playlist/player names from widening the grid. */
|
||||||
|
.dash-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 220px;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.dash-side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
/* Stick the sidebar while the playlist card scrolls, on tall screens. */
|
||||||
|
position: sticky;
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact card used by the sidebar widgets. */
|
||||||
|
.card-compact {
|
||||||
|
padding: 0.85rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.card-compact:hover {
|
||||||
|
/* The global .card lift is distracting on small utility cards. */
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.compact-title {
|
||||||
|
margin: 0 0 0.6rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.compact-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-align: left;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.compact-btn-icon {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
/* The Admin Panel button keeps the default (non-success) background, so its
|
||||||
|
icon must stay in the button's text colour instead of being forced white. */
|
||||||
|
.compact-btn:not(.btn-success) .compact-btn-icon {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
body.dark-mode .compact-btn:not(.btn-success) .compact-btn-icon {
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-guide {
|
.workflow-guide {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.workflow-guide ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Playlist overview rows ───────────────────────────────────────────── */
|
||||||
|
.playlist-row {
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.playlist-row:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.playlist-row-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.playlist-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.playlist-name:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.player-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.player-chip:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.player-chip-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.player-chip.is-online .status-dot { background: #27ae60; }
|
||||||
|
.player-chip.is-offline .status-dot { background: #cbd5e0; }
|
||||||
|
body.dark-mode .player-chip.is-offline .status-dot { background: #718096; }
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.unassigned-note {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 1rem;
|
padding: 0.7rem 0.85rem;
|
||||||
background: #f8f9fa;
|
border-radius: 8px;
|
||||||
border-radius: 4px;
|
border-left: 4px solid #e67e22;
|
||||||
border: 1px solid #e2e8f0;
|
background: rgba(230, 126, 34, 0.1);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.unassigned-note a {
|
||||||
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .workflow-guide {
|
/* Sidebar collapses below the main column when space runs out. */
|
||||||
background: #1a202c;
|
@media (max-width: 900px) {
|
||||||
border: 1px solid #4a5568;
|
.dash-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.dash-side {
|
||||||
|
position: static;
|
||||||
|
/* Narrow screens: let the small cards sit side by side. */
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.dash-side .card-compact {
|
||||||
|
flex: 1 1 200px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shared helpers used by the cards below. */
|
||||||
.secondary-text {
|
.secondary-text {
|
||||||
color: #7f8c8d;
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .secondary-text {
|
/* ── Recent Activity: tabbed list, scrollable so it cannot dominate ────── */
|
||||||
color: #a0aec0;
|
.log-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.log-tab {
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.25rem 0.7rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.log-tab:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
.log-tab.is-active {
|
||||||
|
background: var(--primary-gradient);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.log-count {
|
||||||
|
background: rgba(0, 0, 0, 0.12);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.log-tab.is-active .log-count {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cap the height and scroll: a fixed number of rows stay readable. */
|
||||||
|
.log-pane {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
.log-item {
|
.log-item {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem 0.25rem;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.log-item:last-child {
|
||||||
body.dark-mode .log-item {
|
border-bottom: none;
|
||||||
border-bottom: 1px solid #4a5568;
|
}
|
||||||
|
.log-level {
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.log-message {
|
||||||
|
/* Long messages wrap instead of pushing the timestamp off-screen. */
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.log-time {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
{% if recent_logs %}
|
|
||||||
<div class="card">
|
|
||||||
<h2>Recent Activity</h2>
|
|
||||||
<div style="margin-top: 1rem;">
|
|
||||||
{% for log in recent_logs %}
|
|
||||||
<div class="log-item">
|
|
||||||
<span style="color: {% if log.level == 'error' %}#e74c3c{% elif log.level == 'warning' %}#f39c12{% else %}#27ae60{% endif %}; font-weight: bold;">
|
|
||||||
[{{ log.level.upper() }}]
|
|
||||||
</span>
|
|
||||||
{{ log.message }}
|
|
||||||
<small class="secondary-text" style="float: right;">{{ log.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>System Status</h2>
|
<h2>System Status</h2>
|
||||||
<p>✅ All systems operational</p>
|
<p>✅ All systems operational</p>
|
||||||
<p>� Playlist-centric architecture active</p>
|
<p>📋 Playlist-centric architecture active</p>
|
||||||
<p>🔄 Groups removed - Streamlined workflow</p>
|
<p>🔄 Groups removed - Streamlined workflow</p>
|
||||||
<p>⚡ DigiServer v2.0</p>
|
<p>⚡ DigiServer v2.0</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* Recent Activity tabs. Toggles the `hidden` attribute so the panes work even
|
||||||
|
if this script is blocked — the activity pane is simply always shown. */
|
||||||
|
(function () {
|
||||||
|
var tabs = document.querySelectorAll('.log-tab');
|
||||||
|
var panes = document.querySelectorAll('.log-pane');
|
||||||
|
if (!tabs.length) return;
|
||||||
|
|
||||||
|
tabs.forEach(function (tab) {
|
||||||
|
tab.addEventListener('click', function () {
|
||||||
|
var target = tab.getAttribute('data-log-tab');
|
||||||
|
|
||||||
|
tabs.forEach(function (t) {
|
||||||
|
t.classList.toggle('is-active', t === tab);
|
||||||
|
});
|
||||||
|
panes.forEach(function (p) {
|
||||||
|
p.hidden = p.getAttribute('data-log-pane') !== target;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Create Group - DigiServer v2{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h1>Create Group</h1>
|
|
||||||
<div class="card">
|
|
||||||
<form method="POST">
|
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<label>Group Name</label>
|
|
||||||
<input type="text" name="name" required style="width: 100%; padding: 0.5rem;">
|
|
||||||
</div>
|
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<label>Description (optional)</label>
|
|
||||||
<textarea name="description" rows="3" style="width: 100%; padding: 0.5rem;"></textarea>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-success">Create Group</button>
|
|
||||||
<a href="{{ url_for('groups.groups_list') }}" class="btn">Cancel</a>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Edit Group{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container">
|
|
||||||
<h2>Edit Group</h2>
|
|
||||||
<p>Edit group functionality - placeholder</p>
|
|
||||||
<a href="{{ url_for('groups.list') }}" class="btn btn-secondary">Back to Groups</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Group Fullscreen{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container">
|
|
||||||
<h2>Group Fullscreen View</h2>
|
|
||||||
<p>Fullscreen group view - placeholder</p>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Groups - DigiServer v2{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<h1>Groups</h1>
|
|
||||||
<div class="card">
|
|
||||||
<p>Groups list view - Template in progress</p>
|
|
||||||
<a href="{{ url_for('groups.create_group') }}" class="btn btn-success">Create New Group</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Manage Group{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container">
|
|
||||||
<h2>Manage Group</h2>
|
|
||||||
<p>Manage group functionality - placeholder</p>
|
|
||||||
<a href="{{ url_for('groups.list') }}" class="btn btn-secondary">Back to Groups</a>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% if player.deployment_status == 'deploying' %}
|
||||||
|
<span class="deploy-badge deploying" id="deploying-badge-{{ player.id }}">
|
||||||
|
<span class="spinner"></span>Deploying...
|
||||||
|
</span>
|
||||||
|
{% elif player.deployment_status == 'deployed' %}
|
||||||
|
<span class="deploy-badge deployed" title="{{ player.last_deployment_message or 'Deployed successfully' }}">
|
||||||
|
✅ Deployed
|
||||||
|
{% if player.last_deployment_at %}
|
||||||
|
<span class="deploy-timestamp">{{ player.last_deployment_at | localtime }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% elif player.deployment_status == 'failed' %}
|
||||||
|
<span class="deploy-badge failed deploy-tooltip" title="{{ player.last_deployment_message or 'Deployment failed' }}">
|
||||||
|
❌ Failed
|
||||||
|
{% if player.last_deployment_at %}
|
||||||
|
<span class="deploy-timestamp">{{ player.last_deployment_at | localtime }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% elif player.deployment_status == 'pending' %}
|
||||||
|
<span class="deploy-badge pending" title="Awaiting deployment">
|
||||||
|
⏳ Pending
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
@@ -4,134 +4,155 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<style>
|
<style>
|
||||||
.form-group {
|
.form-group { margin-bottom: 1rem; }
|
||||||
margin-bottom: 1rem;
|
.form-group label { font-weight: bold; display: block; margin-bottom: 0.5rem; }
|
||||||
}
|
body.dark-mode .form-group label { color: #e2e8f0; }
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
font-weight: bold;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .form-group label {
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
width: 100%;
|
width: 100%; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px;
|
||||||
padding: 0.5rem;
|
box-sizing: border-box;
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body.dark-mode .form-control {
|
body.dark-mode .form-control {
|
||||||
background: #1a202c;
|
background: #1a202c; border-color: #4a5568; color: #e2e8f0;
|
||||||
border-color: #4a5568;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
}
|
||||||
|
body.dark-mode .form-control:focus { border-color: #7c3aed; outline: none; }
|
||||||
|
|
||||||
body.dark-mode .form-control:focus {
|
.form-help { color: #6c757d; font-size: 0.875rem; }
|
||||||
border-color: #7c3aed;
|
body.dark-mode .form-help { color: #718096; }
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-help {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .form-help {
|
|
||||||
color: #718096;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem; padding-bottom: 0.5rem; border-bottom: 2px solid;
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
border-bottom: 2px solid;
|
|
||||||
}
|
}
|
||||||
|
.section-header.blue { border-color: #007bff; }
|
||||||
|
body.dark-mode .section-header.blue { border-color: #667eea; }
|
||||||
|
.section-header.green { border-color: #28a745; }
|
||||||
|
body.dark-mode .section-header.green { border-color: #48bb78; }
|
||||||
|
.section-header.yellow { border-color: #ffc107; }
|
||||||
|
body.dark-mode .section-header.yellow { border-color: #ecc94b; }
|
||||||
|
.section-header.purple { border-color: #9b59b6; }
|
||||||
|
body.dark-mode .section-header.purple { border-color: #b794f6; }
|
||||||
|
|
||||||
.section-header.blue {
|
body.dark-mode h1, body.dark-mode h3, body.dark-mode h4 { color: #e2e8f0; }
|
||||||
border-color: #007bff;
|
body.dark-mode p { color: #a0aec0; }
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .section-header.blue {
|
|
||||||
border-color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header.green {
|
|
||||||
border-color: #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .section-header.green {
|
|
||||||
border-color: #48bb78;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header.yellow {
|
|
||||||
border-color: #ffc107;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .section-header.yellow {
|
|
||||||
border-color: #ecc94b;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode h1,
|
|
||||||
body.dark-mode h3,
|
|
||||||
body.dark-mode h4 {
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode p {
|
|
||||||
color: #a0aec0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-box {
|
.info-box {
|
||||||
background-color: #e7f3ff;
|
background-color: #e7f3ff; border-left: 4px solid #007bff; padding: 1rem; margin: 2rem 0;
|
||||||
border-left: 4px solid #007bff;
|
|
||||||
padding: 1rem;
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
}
|
||||||
|
body.dark-mode .info-box { background-color: #1a365d; border-left-color: #667eea; }
|
||||||
|
.info-box h4 { margin-top: 0; color: #007bff; }
|
||||||
|
body.dark-mode .info-box h4 { color: #667eea; }
|
||||||
|
.info-box code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
|
||||||
|
body.dark-mode .info-box code { background: #2d3748; color: #e2e8f0; }
|
||||||
|
body.dark-mode small { color: #718096; }
|
||||||
|
|
||||||
body.dark-mode .info-box {
|
/* Mode chooser cards */
|
||||||
background-color: #1a365d;
|
.mode-chooser {
|
||||||
border-left-color: #667eea;
|
display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 700px) { .mode-chooser { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
.info-box h4 {
|
.mode-card {
|
||||||
margin-top: 0;
|
border: 2px solid #ddd; border-radius: 8px; padding: 1.5rem;
|
||||||
color: #007bff;
|
cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
background: #fff; text-align: center;
|
||||||
}
|
}
|
||||||
|
body.dark-mode .mode-card { background: #2d3748; border-color: #4a5568; }
|
||||||
|
|
||||||
body.dark-mode .info-box h4 {
|
.mode-card:hover { border-color: #007bff; box-shadow: 0 4px 12px rgba(0,123,255,0.15); }
|
||||||
color: #667eea;
|
body.dark-mode .mode-card:hover { border-color: #667eea; }
|
||||||
}
|
|
||||||
|
|
||||||
.info-box code {
|
.mode-card.active { border-color: #007bff; box-shadow: 0 4px 12px rgba(0,123,255,0.2); }
|
||||||
background: #f4f4f4;
|
body.dark-mode .mode-card.active { border-color: #667eea; box-shadow: 0 4px 12px rgba(102,126,234,0.25); }
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .info-box code {
|
.mode-card.active-deploy { border-color: #28a745; box-shadow: 0 4px 12px rgba(40,167,69,0.2); }
|
||||||
background: #2d3748;
|
body.dark-mode .mode-card.active-deploy { border-color: #48bb78; }
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode small {
|
.mode-icon { font-size: 2.5rem; margin-bottom: 0.5rem; }
|
||||||
color: #718096;
|
.mode-title { font-size: 1.15rem; font-weight: bold; margin-bottom: 0.4rem; }
|
||||||
|
body.dark-mode .mode-title { color: #e2e8f0; }
|
||||||
|
.mode-desc { font-size: 0.875rem; color: #6c757d; }
|
||||||
|
body.dark-mode .mode-desc { color: #a0aec0; }
|
||||||
|
|
||||||
|
/* Panel visibility */
|
||||||
|
.mode-panel { display: none; }
|
||||||
|
.mode-panel.active { display: block; }
|
||||||
|
|
||||||
|
/* SSH section */
|
||||||
|
.ssh-section {
|
||||||
|
background-color: #f8f9fa; padding: 1.5rem; border-radius: 6px; margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
body.dark-mode .ssh-section { background-color: #1e2a38; }
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
padding: 1rem; border-radius: 4px; margin-top: 1rem; display: none;
|
||||||
|
}
|
||||||
|
.connection-status.success {
|
||||||
|
background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; display: block;
|
||||||
|
}
|
||||||
|
.connection-status.error {
|
||||||
|
background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; display: block;
|
||||||
|
}
|
||||||
|
body.dark-mode .connection-status.success { background-color: #22543d; border-color: #2f855a; color: #9ae6b4; }
|
||||||
|
body.dark-mode .connection-status.error { background-color: #742a2a; border-color: #c53030; color: #fc8181; }
|
||||||
|
|
||||||
|
/* Deploy-form hidden until SSH verified */
|
||||||
|
.deploy-player-form { display: none; }
|
||||||
|
.deploy-player-form.active { display: block; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem; margin-right: 0.5rem; margin-bottom: 0.5rem;
|
||||||
|
border: none; border-radius: 4px; cursor: pointer; font-size: 0.9rem;
|
||||||
|
text-decoration: none; display: inline-block;
|
||||||
|
}
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-primary { background-color: #007bff; color: white; }
|
||||||
|
.btn-primary:hover:not(:disabled) { background-color: #0056b3; }
|
||||||
|
.btn-success { background-color: #28a745; color: white; }
|
||||||
|
.btn-success:hover:not(:disabled) { background-color: #218838; }
|
||||||
|
.btn-secondary { background-color: #6c757d; color: white; }
|
||||||
|
.btn-secondary:hover:not(:disabled) { background-color: #5a6268; }
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
display: inline-block; width: 1rem; height: 1rem;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3); border-radius: 50%;
|
||||||
|
border-top-color: white; animation: spin 1s linear infinite; margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.row2col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||||
|
@media (max-width: 768px) { .row2col { grid-template-columns: 1fr; } }
|
||||||
</style>
|
</style>
|
||||||
<div class="container" style="max-width: 800px; margin-top: 2rem;">
|
|
||||||
|
<div class="container" style="max-width: 960px; margin-top: 2rem;">
|
||||||
<h1>Add New Player</h1>
|
<h1>Add New Player</h1>
|
||||||
<p style="color: #6c757d; margin-bottom: 2rem;">
|
<p style="color: #6c757d; margin-bottom: 1.5rem;">
|
||||||
Create a new digital signage player with authentication credentials
|
Choose how you want to add the player to the system.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="card">
|
<!-- ───────────────────────────── Mode selector ───────────────────────────── -->
|
||||||
|
<div class="mode-chooser">
|
||||||
|
<div class="mode-card active" id="card_manual" onclick="selectMode('manual')">
|
||||||
|
<div class="mode-icon">📋</div>
|
||||||
|
<div class="mode-title">Manual Registration</div>
|
||||||
|
<div class="mode-desc">
|
||||||
|
Register the player in the database and wait for the client to connect.
|
||||||
|
You configure <code>app_config.json</code> on the device yourself.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mode-card" id="card_deploy" onclick="selectMode('deploy')">
|
||||||
|
<div class="mode-icon">🚀</div>
|
||||||
|
<div class="mode-title">Create & Deploy</div>
|
||||||
|
<div class="mode-desc">
|
||||||
|
Register the player <em>and</em> automatically push the Linux player
|
||||||
|
code to the target host via SSH.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─────────────────────── Mode 1 — Manual Registration ─────────────────── -->
|
||||||
|
<div class="card mode-panel active" id="panel_manual">
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<h3 class="section-header blue" style="margin-top: 0;">
|
<h3 class="section-header blue" style="margin-top: 0;">Basic Information</h3>
|
||||||
Basic Information
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Display Name *</label>
|
<label>Display Name *</label>
|
||||||
@@ -145,31 +166,28 @@
|
|||||||
<input type="text" name="hostname" required class="form-control"
|
<input type="text" name="hostname" required class="form-control"
|
||||||
placeholder="e.g., office-player-001">
|
placeholder="e.g., office-player-001">
|
||||||
<small class="form-help">
|
<small class="form-help">
|
||||||
Unique identifier for this player (must match screen_name in player config)
|
Unique identifier — must match <code>screen_name</code> in the player's
|
||||||
|
<code>app_config.json</code>
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Location</label>
|
<label>Location</label>
|
||||||
<input type="text" name="location" class="form-control"
|
<input type="text" name="location" class="form-control"
|
||||||
placeholder="e.g., Main Office - Reception Area">
|
placeholder="e.g., Main Office – Reception Area">
|
||||||
<small class="form-help">Physical location of the player (optional)</small>
|
<small class="form-help">Physical location of the player (optional)</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="section-header green">
|
<h3 class="section-header green">Authentication</h3>
|
||||||
Authentication
|
|
||||||
</h3>
|
|
||||||
<p class="form-help" style="margin-bottom: 1rem;">
|
<p class="form-help" style="margin-bottom: 1rem;">
|
||||||
Choose one authentication method (Quick Connect recommended for easy setup)
|
Choose one authentication method (Quick Connect recommended for easy setup)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Password</label>
|
<label>Password</label>
|
||||||
<input type="password" name="password" id="password" class="form-control"
|
<input type="password" name="password" class="form-control"
|
||||||
placeholder="Leave empty to use Quick Connect only">
|
placeholder="Leave empty to use Quick Connect only">
|
||||||
<small class="form-help">
|
<small class="form-help">Secure password for player authentication (optional if using Quick Connect)</small>
|
||||||
Secure password for player authentication (optional if using Quick Connect)
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -177,13 +195,11 @@
|
|||||||
<input type="text" name="quickconnect_code" required class="form-control"
|
<input type="text" name="quickconnect_code" required class="form-control"
|
||||||
placeholder="e.g., OFFICE123">
|
placeholder="e.g., OFFICE123">
|
||||||
<small class="form-help">
|
<small class="form-help">
|
||||||
Easy pairing code for quick setup (must match quickconnect_key in player config)
|
Easy pairing code — must match <code>quickconnect_key</code> in player config
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="section-header yellow">
|
<h3 class="section-header yellow">Display Settings</h3>
|
||||||
Display Settings
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Orientation</label>
|
<label>Orientation</label>
|
||||||
@@ -199,7 +215,9 @@
|
|||||||
<select name="playlist_id" class="form-control">
|
<select name="playlist_id" class="form-control">
|
||||||
<option value="">No Playlist (Unassigned)</option>
|
<option value="">No Playlist (Unassigned)</option>
|
||||||
{% for playlist in playlists %}
|
{% for playlist in playlists %}
|
||||||
<option value="{{ playlist.id }}">{{ playlist.name }} ({{ playlist.orientation }}) - {{ playlist.content_count }} items</option>
|
<option value="{{ playlist.id }}">
|
||||||
|
{{ playlist.name }} ({{ playlist.orientation }}) – {{ playlist.content_count }} items
|
||||||
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<small class="form-help">Assign player to a playlist (optional)</small>
|
<small class="form-help">Assign player to a playlist (optional)</small>
|
||||||
@@ -208,16 +226,16 @@
|
|||||||
<div class="info-box">
|
<div class="info-box">
|
||||||
<h4>📋 Setup Instructions</h4>
|
<h4>📋 Setup Instructions</h4>
|
||||||
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
|
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
|
||||||
<li>Create the player with the form above</li>
|
<li>Create the player with the form above.</li>
|
||||||
<li>Note the generated <strong>Auth Code</strong> (shown after creation)</li>
|
<li>Note the generated <strong>Auth Code</strong> shown after creation.</li>
|
||||||
<li>Configure the player's <code>app_config.json</code> with:
|
<li>Configure the player's <code>app_config.json</code>:
|
||||||
<ul style="margin-top: 0.5rem;">
|
<ul style="margin-top: 0.5rem;">
|
||||||
<li><code>server_ip</code>: Your server address</li>
|
<li><code>server_ip</code> — your server address</li>
|
||||||
<li><code>screen_name</code>: Same as <strong>Hostname</strong> above</li>
|
<li><code>screen_name</code> — same as <strong>Hostname</strong> above</li>
|
||||||
<li><code>quickconnect_key</code>: Same as <strong>Quick Connect Code</strong> above</li>
|
<li><code>quickconnect_key</code> — same as <strong>Quick Connect Code</strong> above</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>Start the player - it will authenticate automatically</li>
|
<li>Start the player — it will authenticate automatically.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -225,11 +243,257 @@
|
|||||||
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
|
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
|
||||||
✓ Create Player
|
✓ Create Player
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('players.list') }}" class="btn" style="padding: 0.75rem 2rem; margin-left: 1rem;">
|
<a href="{{ url_for('players.list') }}" class="btn btn-secondary"
|
||||||
|
style="padding: 0.75rem 2rem; margin-left: 0.5rem;">
|
||||||
Cancel
|
Cancel
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ──────────────────────── Mode 2 — Create & Deploy ────────────────────── -->
|
||||||
|
<div class="card mode-panel" id="panel_deploy">
|
||||||
|
|
||||||
|
<!-- Step 1 — SSH connection test -->
|
||||||
|
<div class="ssh-section">
|
||||||
|
<h3 class="section-header purple" style="margin-top: 0;">
|
||||||
|
🔌 Step 1 — SSH Connection
|
||||||
|
</h3>
|
||||||
|
<p class="form-help">
|
||||||
|
Test SSH connectivity to the target Linux host before proceeding.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="row2col">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Target Hostname / IP *</label>
|
||||||
|
<input type="text" id="ssh_hostname" class="form-control"
|
||||||
|
placeholder="e.g., 192.168.1.100">
|
||||||
|
<small class="form-help">IP address or hostname of the target machine</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SSH Port</label>
|
||||||
|
<input type="number" id="ssh_port" class="form-control"
|
||||||
|
value="22" min="1" max="65535">
|
||||||
|
<small class="form-help">SSH port (default: 22)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row2col">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SSH Username *</label>
|
||||||
|
<input type="text" id="ssh_username" class="form-control"
|
||||||
|
placeholder="e.g., pi or ubuntu">
|
||||||
|
<small class="form-help">SSH login username</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>SSH Password *</label>
|
||||||
|
<input type="password" id="ssh_password" class="form-control"
|
||||||
|
placeholder="SSH password">
|
||||||
|
<small class="form-help">SSH login password</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" id="test_ssh_btn" class="btn btn-primary"
|
||||||
|
onclick="testSSHConnection()">
|
||||||
|
✓ Test SSH Connection
|
||||||
|
</button>
|
||||||
|
<button type="button" id="clear_ssh_btn" class="btn btn-secondary"
|
||||||
|
onclick="clearSSHForm()" style="display: none;">
|
||||||
|
🔄 Clear
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="connection_status" class="connection-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2 — Player info (shown after successful SSH test) -->
|
||||||
|
<div id="deploy_player_form_section" class="deploy-player-form">
|
||||||
|
<form method="POST" id="add_player_form">
|
||||||
|
<!-- Hidden SSH fields -->
|
||||||
|
<input type="hidden" id="form_ssh_hostname" name="ssh_hostname" value="">
|
||||||
|
<input type="hidden" id="form_ssh_username" name="ssh_username" value="">
|
||||||
|
<input type="hidden" id="form_ssh_password" name="ssh_password" value="">
|
||||||
|
<input type="hidden" id="form_ssh_port" name="ssh_port" value="">
|
||||||
|
<input type="hidden" name="deploy_player" value="1">
|
||||||
|
|
||||||
|
<h3 class="section-header blue">Step 2 — Basic Information</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Display Name *</label>
|
||||||
|
<input type="text" name="name" required class="form-control"
|
||||||
|
placeholder="e.g., Office Reception Player">
|
||||||
|
<small class="form-help">Friendly name for the player</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Hostname *</label>
|
||||||
|
<input type="text" name="hostname" required class="form-control"
|
||||||
|
placeholder="e.g., office-player-001">
|
||||||
|
<small class="form-help">
|
||||||
|
Unique identifier — will be written to <code>app_config.json</code>
|
||||||
|
on the remote host automatically
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Location</label>
|
||||||
|
<input type="text" name="location" class="form-control"
|
||||||
|
placeholder="e.g., Main Office – Reception Area">
|
||||||
|
<small class="form-help">Physical location (optional)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-header green">Authentication</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" name="password" class="form-control"
|
||||||
|
placeholder="Leave empty to use Quick Connect only">
|
||||||
|
<small class="form-help">Secure password (optional if using Quick Connect)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Quick Connect Code *</label>
|
||||||
|
<input type="text" name="quickconnect_code" required class="form-control"
|
||||||
|
placeholder="e.g., OFFICE123">
|
||||||
|
<small class="form-help">Easy pairing code — deployed automatically to remote config</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-header yellow">Display Settings</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Orientation</label>
|
||||||
|
<select name="orientation" class="form-control">
|
||||||
|
<option value="Landscape" selected>Landscape</option>
|
||||||
|
<option value="Portrait">Portrait</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-help">Display orientation for the player</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Assign Playlist</label>
|
||||||
|
<select name="playlist_id" class="form-control">
|
||||||
|
<option value="">No Playlist (Unassigned)</option>
|
||||||
|
{% for playlist in playlists %}
|
||||||
|
<option value="{{ playlist.id }}">
|
||||||
|
{{ playlist.name }} ({{ playlist.orientation }}) – {{ playlist.content_count }} items
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="form-help">Assign player to a playlist (optional)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<h4>🚀 What Happens Next</h4>
|
||||||
|
<ol style="margin: 0.5rem 0; padding-left: 1.5rem;">
|
||||||
|
<li><strong>Player Record</strong> is created in the database with an Auth Code.</li>
|
||||||
|
<li><strong>Player Code</strong> from the Kiwy-Signage repository is pushed to
|
||||||
|
<strong id="deploy_host_info">the target host</strong> via SSH.</li>
|
||||||
|
<li><strong>Installation Scripts</strong> run on the remote host in the background.</li>
|
||||||
|
<li><strong>Config</strong> (<code>app_config.json</code>) is written automatically
|
||||||
|
with the server address, hostname and Quick Connect Code.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #ddd;">
|
||||||
|
<button type="submit" class="btn btn-success" style="padding: 0.75rem 2rem;">
|
||||||
|
⚙️ Create & Deploy Player
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('players.list') }}" class="btn btn-secondary"
|
||||||
|
style="padding: 0.75rem 2rem; margin-left: 0.5rem;">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div><!-- /panel_deploy -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Mode switching ─────────────────────────────────────────────────────────
|
||||||
|
function selectMode(mode) {
|
||||||
|
// cards
|
||||||
|
document.getElementById('card_manual').classList.remove('active', 'active-deploy');
|
||||||
|
document.getElementById('card_deploy').classList.remove('active', 'active-deploy');
|
||||||
|
if (mode === 'manual') {
|
||||||
|
document.getElementById('card_manual').classList.add('active');
|
||||||
|
} else {
|
||||||
|
document.getElementById('card_deploy').classList.add('active-deploy');
|
||||||
|
}
|
||||||
|
|
||||||
|
// panels
|
||||||
|
document.getElementById('panel_manual').classList.toggle('active', mode === 'manual');
|
||||||
|
document.getElementById('panel_deploy').classList.toggle('active', mode === 'deploy');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSH flow ───────────────────────────────────────────────────────────────
|
||||||
|
function testSSHConnection() {
|
||||||
|
const hostname = document.getElementById('ssh_hostname').value.trim();
|
||||||
|
const username = document.getElementById('ssh_username').value.trim();
|
||||||
|
const password = document.getElementById('ssh_password').value.trim();
|
||||||
|
const port = parseInt(document.getElementById('ssh_port').value) || 22;
|
||||||
|
|
||||||
|
if (!hostname || !username || !password) {
|
||||||
|
alert('Please fill in all SSH connection fields.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('test_ssh_btn');
|
||||||
|
const statusDiv = document.getElementById('connection_status');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="loading-spinner"></span>Testing connection…';
|
||||||
|
|
||||||
|
fetch('{{ url_for("api.test_ssh_connection") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({hostname, username, password, port})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
statusDiv.className = 'connection-status ' + (data.success ? 'success' : 'error');
|
||||||
|
statusDiv.innerHTML = '<strong>' + (data.success ? '✓ Connected!' : '✗ Connection Failed')
|
||||||
|
+ '</strong><br>' + data.message;
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Lock SSH fields
|
||||||
|
['ssh_hostname','ssh_username','ssh_password','ssh_port'].forEach(id => {
|
||||||
|
document.getElementById(id).disabled = true;
|
||||||
|
});
|
||||||
|
document.getElementById('test_ssh_btn').style.display = 'none';
|
||||||
|
document.getElementById('clear_ssh_btn').style.display = 'inline-block';
|
||||||
|
|
||||||
|
// Copy credentials to hidden form fields
|
||||||
|
document.getElementById('form_ssh_hostname').value = hostname;
|
||||||
|
document.getElementById('form_ssh_username').value = username;
|
||||||
|
document.getElementById('form_ssh_password').value = password;
|
||||||
|
document.getElementById('form_ssh_port').value = port;
|
||||||
|
|
||||||
|
// Reveal player form and update host label
|
||||||
|
document.getElementById('deploy_player_form_section').classList.add('active');
|
||||||
|
document.getElementById('deploy_host_info').textContent = hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '✓ Test SSH Connection';
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
statusDiv.className = 'connection-status error';
|
||||||
|
statusDiv.innerHTML = '<strong>✗ Error:</strong> ' + err.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '✓ Test SSH Connection';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSSHForm() {
|
||||||
|
['ssh_hostname','ssh_username','ssh_password','ssh_port'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.value = id === 'ssh_port' ? '22' : '';
|
||||||
|
el.disabled = false;
|
||||||
|
});
|
||||||
|
document.getElementById('test_ssh_btn').style.display = 'inline-block';
|
||||||
|
document.getElementById('clear_ssh_btn').style.display = 'none';
|
||||||
|
document.getElementById('connection_status').className = 'connection-status';
|
||||||
|
document.getElementById('deploy_player_form_section').classList.remove('active');
|
||||||
|
document.getElementById('add_player_form').reset();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -333,7 +333,7 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-header-title">
|
<div class="card-header-title">
|
||||||
<span class="card-header-icon">▶</span>
|
<span class="card-header-icon">▶</span>
|
||||||
<span>📄 {{ original_content.filename if original_content else data.original_name }}</span>
|
<span>📄 {{ original_content.original_display_name if original_content else data.original_name }}</span>
|
||||||
<span style="font-size: 0.9rem; color: #64748b; font-weight: normal;">
|
<span style="font-size: 0.9rem; color: #64748b; font-weight: normal;">
|
||||||
({{ data.versions|length + 1 }} version{{ 's' if (data.versions|length + 1) > 1 else '' }})
|
({{ data.versions|length + 1 }} version{{ 's' if (data.versions|length + 1) > 1 else '' }})
|
||||||
</span>
|
</span>
|
||||||
@@ -420,10 +420,10 @@
|
|||||||
{% if original_content %}
|
{% if original_content %}
|
||||||
<div class="version-item"
|
<div class="version-item"
|
||||||
id="version-{{ content_id }}-original"
|
id="version-{{ content_id }}-original"
|
||||||
onclick="event.stopPropagation(); selectVersion({{ content_id }}, 'original', '{{ original_content.filename }}', 'System', 'N/A', '{{ original_content.uploaded_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/' ~ original_content.filename) }}')">
|
onclick="event.stopPropagation(); selectVersion({{ content_id }}, 'original', '{{ original_content.original_display_name }}', 'System', 'N/A', '{{ original_content.uploaded_at | localtime('%Y-%m-%d %H:%M') }}', '{{ url_for('static', filename='uploads/' ~ original_content.original_media_path) }}')">
|
||||||
<div class="version-thumbnail">
|
<div class="version-thumbnail">
|
||||||
{% if original_content.filename.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
{% if original_content.original_display_name.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')) %}
|
||||||
<img src="{{ url_for('static', filename='uploads/' ~ original_content.filename) }}"
|
<img src="{{ url_for('static', filename='uploads/' ~ original_content.original_media_path) }}"
|
||||||
alt="Original">
|
alt="Original">
|
||||||
{% else %}
|
{% else %}
|
||||||
<div style="color: white; font-size: 2rem;">📄</div>
|
<div style="color: white; font-size: 2rem;">📄</div>
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Edited Media Report - {{ player.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.report-container {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .summary-stat {
|
||||||
|
background: #1a202c;
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat .stat-value {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat .stat-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .report-table-wrapper {
|
||||||
|
background: #1a202c;
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table thead {
|
||||||
|
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table thead th {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table tbody tr {
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .report-table tbody tr {
|
||||||
|
border-bottom-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table tbody tr:hover {
|
||||||
|
background: rgba(124, 58, 237, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .report-table tbody tr:hover {
|
||||||
|
background: rgba(124, 58, 237, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table tbody tr:nth-child(even) {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .report-table tbody tr:nth-child(even) {
|
||||||
|
background: #162032;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table tbody td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table .col-user {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .report-table .col-user {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table .col-filename {
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #475569;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .report-table .col-filename {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table .col-date {
|
||||||
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table .col-link a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
background: #7c3aed;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s, transform 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table .col-link a:hover {
|
||||||
|
background: #6d28d9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .report-table .col-link a {
|
||||||
|
background: #6d28d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .report-table .col-link a:hover {
|
||||||
|
background: #5b21b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-table .col-version {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .version-badge {
|
||||||
|
background: rgba(124, 58, 237, 0.2);
|
||||||
|
color: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data .icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-data p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #64748b;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-btn:hover {
|
||||||
|
background: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: white !important;
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
.report-table-wrapper {
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.report-table thead {
|
||||||
|
background: #333 !important;
|
||||||
|
}
|
||||||
|
.btn, .print-btn, nav, .back-link {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="report-container">
|
||||||
|
<div class="report-header">
|
||||||
|
<a href="{{ url_for('players.manage_player', player_id=player.id) }}"
|
||||||
|
class="btn back-link"
|
||||||
|
style="background: #6c757d; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||||
|
← Back to Player
|
||||||
|
</a>
|
||||||
|
<h1>
|
||||||
|
<img src="{{ url_for('static', filename='icons/edit.svg') }}" alt="" style="width: 28px; height: 28px;">
|
||||||
|
Edited Media Report — {{ player.name }}
|
||||||
|
</h1>
|
||||||
|
<button class="print-btn" onclick="window.print()">
|
||||||
|
🖨️ Print / PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if edited_media %}
|
||||||
|
{% set total_edits = edited_media|length %}
|
||||||
|
{% set unique_files = edited_media|map(attribute='content_id')|unique|list|length %}
|
||||||
|
{% set unique_users = edited_media|selectattr('user')|map(attribute='user')|unique|list|length %}
|
||||||
|
|
||||||
|
<div class="report-summary">
|
||||||
|
<div class="summary-stat">
|
||||||
|
<span class="stat-value">{{ total_edits }}</span>
|
||||||
|
<span class="stat-label">Total Edits</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-stat">
|
||||||
|
<span class="stat-value">{{ unique_files }}</span>
|
||||||
|
<span class="stat-label">Files Edited</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-stat">
|
||||||
|
<span class="stat-value">{{ unique_users }}</span>
|
||||||
|
<span class="stat-label">Editors</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="report-table-wrapper">
|
||||||
|
<table class="report-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 22%;">User</th>
|
||||||
|
<th style="width: 30%;">Edited File</th>
|
||||||
|
<th style="width: 15%;">Version</th>
|
||||||
|
<th style="width: 18%;">Date</th>
|
||||||
|
<th style="width: 15%;">Link</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for edit in edited_media %}
|
||||||
|
<tr>
|
||||||
|
<td class="col-user">
|
||||||
|
{% if edit.user %}
|
||||||
|
{% set display_name = user_mappings.get(edit.user, edit.user) %}
|
||||||
|
👤 {{ display_name }}
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #94a3b8;">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="col-filename">
|
||||||
|
📄 {{ edit.new_name }}
|
||||||
|
</td>
|
||||||
|
<td class="col-version">
|
||||||
|
<span class="version-badge">v{{ edit.version }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-date">
|
||||||
|
{% if edit.time_of_modification %}
|
||||||
|
{{ edit.time_of_modification | localtime('%Y-%m-%d %H:%M') }}
|
||||||
|
{% elif edit.created_at %}
|
||||||
|
{{ edit.created_at | localtime('%Y-%m-%d %H:%M') }}
|
||||||
|
{% else %}
|
||||||
|
<span style="color: #94a3b8;">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="col-link">
|
||||||
|
<a href="{{ url_for('static', filename='uploads/edited_media/' ~ edit.content_id ~ '/' ~ edit.new_name) }}"
|
||||||
|
target="_blank"
|
||||||
|
title="Open {{ edit.new_name }}">
|
||||||
|
🔗 Open File
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="no-data">
|
||||||
|
<div class="icon">📋</div>
|
||||||
|
<p style="font-size: 1.1rem; font-weight: 500;">No edited media found</p>
|
||||||
|
<p>This player has not submitted any edited media yet.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -646,13 +646,22 @@ document.addEventListener('keydown', function(event) {
|
|||||||
Edited Media on the Player
|
Edited Media on the Player
|
||||||
</h2>
|
</h2>
|
||||||
{% if edited_media %}
|
{% if edited_media %}
|
||||||
<a href="{{ url_for('players.edited_media', player_id=player.id) }}"
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
class="btn"
|
<a href="{{ url_for('players.edited_media', player_id=player.id) }}"
|
||||||
style="background: #7c3aed; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; transition: background 0.2s;"
|
class="btn"
|
||||||
onmouseover="this.style.background='#6d28d9'"
|
style="background: #7c3aed; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; transition: background 0.2s;"
|
||||||
onmouseout="this.style.background='#7c3aed'">
|
onmouseover="this.style.background='#6d28d9'"
|
||||||
📋 View All Edited Media
|
onmouseout="this.style.background='#7c3aed'">
|
||||||
</a>
|
📋 View All Edited Media
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('players.edited_media_report', player_id=player.id) }}"
|
||||||
|
class="btn"
|
||||||
|
style="background: #059669; color: white; padding: 0.5rem 1rem; text-decoration: none; border-radius: 6px; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.5rem; transition: background 0.2s;"
|
||||||
|
onmouseover="this.style.background='#047857'"
|
||||||
|
onmouseout="this.style.background='#059669'">
|
||||||
|
📊 Launch Report
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.5rem;">Latest 3 edited files with their most recent versions</p>
|
<p style="color: #6c757d; font-size: 0.9rem; margin-top: 0.5rem;">Latest 3 edited files with their most recent versions</p>
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ player.name }} - DigiServer v2{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container" style="max-width: 1400px;">
|
|
||||||
<!-- Header -->
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
|
||||||
<div>
|
|
||||||
<h1>{{ player.name }}</h1>
|
|
||||||
<div style="margin-top: 10px;">
|
|
||||||
{% if status_info.online %}
|
|
||||||
<span style="background: #28a745; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
|
|
||||||
🟢 Online
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span style="background: #6c757d; color: white; padding: 5px 12px; border-radius: 3px; font-size: 14px; margin-right: 10px;">
|
|
||||||
⚫ Offline
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
<span style="color: #6c757d; font-size: 14px;">
|
|
||||||
Last seen: {{ status_info.last_seen_ago }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a href="{{ url_for('players.edit_player', player_id=player.id) }}" class="btn btn-primary">
|
|
||||||
✏️ Edit Player
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}" class="btn btn-success">
|
|
||||||
🎬 Manage Playlist
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('players.list') }}" class="btn">
|
|
||||||
← Back to Players
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content Grid -->
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px;">
|
|
||||||
<!-- Player Information Card -->
|
|
||||||
<div class="card">
|
|
||||||
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
|
|
||||||
📋 Player Information
|
|
||||||
</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
|
||||||
<td style="padding: 10px; font-weight: bold; width: 40%;">Display Name:</td>
|
|
||||||
<td style="padding: 10px;">{{ player.name }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
|
||||||
<td style="padding: 10px; font-weight: bold;">Hostname:</td>
|
|
||||||
<td style="padding: 10px;">
|
|
||||||
<code style="background: #f8f9fa; padding: 3px 8px; border-radius: 3px;">{{ player.hostname }}</code>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
|
||||||
<td style="padding: 10px; font-weight: bold;">Location:</td>
|
|
||||||
<td style="padding: 10px;">{{ player.location or '-' }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
|
||||||
<td style="padding: 10px; font-weight: bold;">Orientation:</td>
|
|
||||||
<td style="padding: 10px;">{{ player.orientation or 'Landscape' }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding: 10px; font-weight: bold;">Created:</td>
|
|
||||||
<td style="padding: 10px;">{{ player.created_at | localtime }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Authentication Details Card -->
|
|
||||||
<div class="card">
|
|
||||||
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
|
|
||||||
🔐 Authentication Details
|
|
||||||
</h3>
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
|
||||||
<td style="padding: 10px; font-weight: bold; width: 40%;">Password Set:</td>
|
|
||||||
<td style="padding: 10px;">
|
|
||||||
{% if player.password_hash %}
|
|
||||||
<span style="color: #28a745;">✓ Yes</span>
|
|
||||||
{% else %}
|
|
||||||
<span style="color: #dc3545;">✗ No</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
|
||||||
<td style="padding: 10px; font-weight: bold;">Quick Connect Code:</td>
|
|
||||||
<td style="padding: 10px;">
|
|
||||||
{% if player.quickconnect_code %}
|
|
||||||
<span style="color: #28a745;">✓ Yes</span>
|
|
||||||
{% else %}
|
|
||||||
<span style="color: #dc3545;">✗ No</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
|
||||||
<td style="padding: 10px; font-weight: bold;">Auth Code:</td>
|
|
||||||
<td style="padding: 10px;">
|
|
||||||
{% if player.auth_code %}
|
|
||||||
<span style="color: #28a745;">✓ Yes</span>
|
|
||||||
<form method="POST" action="{{ url_for('players.regenerate_auth_code', player_id=player.id) }}" style="display: inline; margin-left: 10px;">
|
|
||||||
<button type="submit" class="btn btn-sm" style="background: #ffc107; padding: 3px 8px;"
|
|
||||||
onclick="return confirm('Regenerate auth code? The player will need to authenticate again.')">
|
|
||||||
🔄 Regenerate
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
<span style="color: #dc3545;">✗ No</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" style="padding: 15px 10px;">
|
|
||||||
<a href="{{ url_for('players.edit_player', player_id=player.id) }}"
|
|
||||||
class="btn btn-primary" style="width: 100%; text-align: center;">
|
|
||||||
✏️ Edit Authentication Settings
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Playlist Management Card -->
|
|
||||||
<div class="card" style="margin-bottom: 20px;">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
|
||||||
<h3 style="margin: 0;">🎬 Playlist Management</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if playlist %}
|
|
||||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; margin-bottom: 15px;">
|
|
||||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px;">
|
|
||||||
<div>
|
|
||||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Items</div>
|
|
||||||
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ playlist|length }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Total Duration</div>
|
|
||||||
<div style="font-size: 24px; font-weight: bold; color: #333;">
|
|
||||||
{% set total_duration = namespace(value=0) %}
|
|
||||||
{% for item in playlist %}
|
|
||||||
{% set total_duration.value = total_duration.value + (item.duration or 10) %}
|
|
||||||
{% endfor %}
|
|
||||||
{{ total_duration.value }}s
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style="font-size: 12px; color: #6c757d; margin-bottom: 5px;">Playlist Version</div>
|
|
||||||
<div style="font-size: 24px; font-weight: bold; color: #333;">{{ player.playlist_version }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<a href="{{ url_for('playlist.manage_playlist', player_id=player.id) }}"
|
|
||||||
class="btn btn-primary"
|
|
||||||
style="display: inline-block; width: 100%; text-align: center; padding: 15px; font-size: 16px;">
|
|
||||||
🎬 Open Playlist Manager
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{% if not playlist %}
|
|
||||||
<div style="background: #fff3cd; border: 1px solid #ffc107; color: #856404; padding: 15px; border-radius: 5px; text-align: center; margin-top: 15px;">
|
|
||||||
⚠️ No content in playlist. Open the playlist manager to add content.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Player Activity Log Card -->
|
|
||||||
<div class="card">
|
|
||||||
<h3 style="margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #dee2e6;">
|
|
||||||
📊 Recent Activity & Feedback
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{% if recent_feedback %}
|
|
||||||
<div style="max-height: 400px; overflow-y: auto;">
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
|
||||||
<thead style="position: sticky; top: 0; background: white;">
|
|
||||||
<tr style="background: #f8f9fa; text-align: left;">
|
|
||||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Time</th>
|
|
||||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Status</th>
|
|
||||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Message</th>
|
|
||||||
<th style="padding: 10px; border-bottom: 2px solid #dee2e6;">Error</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for feedback in recent_feedback %}
|
|
||||||
<tr style="border-bottom: 1px solid #dee2e6;">
|
|
||||||
<td style="padding: 10px; white-space: nowrap;">
|
|
||||||
<small style="color: #6c757d;">{{ feedback.timestamp | localtime('%Y-%m-%d %H:%M:%S') }}</small>
|
|
||||||
</td>
|
|
||||||
<td style="padding: 10px;">
|
|
||||||
{% if feedback.status == 'playing' %}
|
|
||||||
<span style="background: #28a745; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">▶️ Playing</span>
|
|
||||||
{% elif feedback.status == 'idle' %}
|
|
||||||
<span style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">⏸️ Idle</span>
|
|
||||||
{% elif feedback.status == 'error' %}
|
|
||||||
<span style="background: #dc3545; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">❌ Error</span>
|
|
||||||
{% else %}
|
|
||||||
<span style="background: #007bff; color: white; padding: 3px 8px; border-radius: 3px; font-size: 12px;">{{ feedback.status }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td style="padding: 10px;">
|
|
||||||
{{ feedback.message or '-' }}
|
|
||||||
</td>
|
|
||||||
<td style="padding: 10px;">
|
|
||||||
{% if feedback.error %}
|
|
||||||
<span style="color: #dc3545; font-family: monospace; font-size: 12px;">{{ feedback.error[:50] }}...</span>
|
|
||||||
{% else %}
|
|
||||||
-
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div style="background: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; padding: 15px; border-radius: 5px; text-align: center;">
|
|
||||||
ℹ️ No activity logs yet. The player will send feedback once it starts playing content.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -125,6 +125,82 @@
|
|||||||
body.dark-mode .info-box a {
|
body.dark-mode .info-box a {
|
||||||
color: #90cdf4;
|
color: #90cdf4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Deployment status */
|
||||||
|
.deploy-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.deploy-badge.pending {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
body.dark-mode .deploy-badge.pending {
|
||||||
|
background: #4a3800;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
.deploy-badge.deployed {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
body.dark-mode .deploy-badge.deployed {
|
||||||
|
background: #1a4d2e;
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
.deploy-badge.failed {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
body.dark-mode .deploy-badge.failed {
|
||||||
|
background: #4a1a1a;
|
||||||
|
color: #fc8181;
|
||||||
|
}
|
||||||
|
.deploy-badge.deploying {
|
||||||
|
background: #cce5ff;
|
||||||
|
color: #004085;
|
||||||
|
animation: pulse-bg 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
body.dark-mode .deploy-badge.deploying {
|
||||||
|
background: #1a365d;
|
||||||
|
color: #90cdf4;
|
||||||
|
}
|
||||||
|
.deploy-badge .spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid rgba(0,64,133,0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: #004085;
|
||||||
|
animation: deploy-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
body.dark-mode .deploy-badge .spinner {
|
||||||
|
border-color: rgba(144,205,244,0.3);
|
||||||
|
border-top-color: #90cdf4;
|
||||||
|
}
|
||||||
|
@keyframes deploy-spin { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes pulse-bg {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.6; }
|
||||||
|
}
|
||||||
|
.deploy-timestamp {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6c757d;
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
body.dark-mode .deploy-timestamp {
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
.deploy-tooltip {
|
||||||
|
cursor: help;
|
||||||
|
border-bottom: 1px dashed #aaa;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
@@ -142,13 +218,14 @@
|
|||||||
<th>Location</th>
|
<th>Location</th>
|
||||||
<th>Orientation</th>
|
<th>Orientation</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Deployment</th>
|
||||||
<th>Last Seen</th>
|
<th>Last Seen</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for player in players %}
|
{% for player in players %}
|
||||||
<tr>
|
<tr id="player-row-{{ player.id }}">
|
||||||
<td>
|
<td>
|
||||||
<strong>{{ player.name }}</strong>
|
<strong>{{ player.name }}</strong>
|
||||||
</td>
|
</td>
|
||||||
@@ -168,6 +245,9 @@
|
|||||||
<span class="status-badge offline">Offline</span>
|
<span class="status-badge offline">Offline</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td id="deploy-cell-{{ player.id }}">
|
||||||
|
{% include "players/_deploy_badge.html" %}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if player.last_seen %}
|
{% if player.last_seen %}
|
||||||
{{ player.last_seen | localtime }}
|
{{ player.last_seen | localtime }}
|
||||||
@@ -192,4 +272,88 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Deployment status polling ────────────────────────────────────────────
|
||||||
|
(function() {
|
||||||
|
var POLL_INTERVAL = 5000; // 5 seconds
|
||||||
|
var polling = false;
|
||||||
|
|
||||||
|
// Initial check: any badge that isn't yet "Deployed" yet (deploying,
|
||||||
|
// pending, or failed). We keep polling so that players which send
|
||||||
|
// feedback auto-transition from "pending" to "deployed".
|
||||||
|
var activeBadges = document.querySelectorAll(
|
||||||
|
'.deploy-badge.deploying, .deploy-badge.pending, .deploy-badge.failed'
|
||||||
|
);
|
||||||
|
if (activeBadges.length > 0) {
|
||||||
|
polling = true;
|
||||||
|
schedulePoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePoll() {
|
||||||
|
setTimeout(pollDeploymentStatus, POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollDeploymentStatus() {
|
||||||
|
if (!polling) return;
|
||||||
|
|
||||||
|
fetch('{{ url_for("players.deployment_status") }}')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
var anyActive = false;
|
||||||
|
|
||||||
|
for (var playerId in data) {
|
||||||
|
if (!data.hasOwnProperty(playerId)) continue;
|
||||||
|
var status = data[playerId];
|
||||||
|
var cell = document.getElementById('deploy-cell-' + playerId);
|
||||||
|
if (!cell) continue;
|
||||||
|
|
||||||
|
var ds = status.deployment_status;
|
||||||
|
var lds = status.last_deployment_status;
|
||||||
|
var msg = status.last_deployment_message || '';
|
||||||
|
var ts = status.last_deployment_at
|
||||||
|
? new Date(status.last_deployment_at + 'Z').toLocaleString()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (ds === 'deployed') {
|
||||||
|
cell.innerHTML = '<span class="deploy-badge deployed" title="' + escapeHtml(msg) + '">\u2705 Deployed<span class="deploy-timestamp">' + ts + '</span></span>';
|
||||||
|
} else if (ds === 'failed') {
|
||||||
|
// Terminal state - no need to keep polling for this one
|
||||||
|
cell.innerHTML = '<span class="deploy-badge failed deploy-tooltip" title="' + escapeHtml(msg) + '">\u274c Failed<span class="deploy-timestamp">' + ts + '</span></span>';
|
||||||
|
} else if (ds === 'deploying') {
|
||||||
|
anyActive = true;
|
||||||
|
if (!cell.querySelector('.deploying')) {
|
||||||
|
cell.innerHTML = '<span class="deploy-badge deploying"><span class="spinner"></span>Deploying...</span>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// pending / null - deployment not confirmed yet.
|
||||||
|
// Keep polling: the player may start sending feedback,
|
||||||
|
// which flips the status to "deployed" on the server.
|
||||||
|
anyActive = true;
|
||||||
|
if (!cell.querySelector('.pending')) {
|
||||||
|
cell.innerHTML = '<span class="deploy-badge pending" title="Awaiting deployment">\u23f3 Pending</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anyActive) {
|
||||||
|
schedulePoll();
|
||||||
|
} else {
|
||||||
|
polling = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
// Retry
|
||||||
|
if (polling) schedulePoll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,857 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Manage Playlist - {{ player.name }} - DigiServer v2{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<style>
|
|
||||||
.playlist-container {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-info-card {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 25px;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 25px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-info-card h1 {
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-info-card p {
|
|
||||||
margin: 5px 0;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-section {
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 25px;
|
|
||||||
margin-bottom: 25px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
border-bottom: 2px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 24px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-table thead {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-table th {
|
|
||||||
padding: 12px;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #555;
|
|
||||||
border-bottom: 2px solid #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-table td {
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-table tr:hover {
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.draggable-row {
|
|
||||||
cursor: move;
|
|
||||||
}
|
|
||||||
|
|
||||||
.draggable-row.dragging {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-handle {
|
|
||||||
cursor: grab;
|
|
||||||
font-size: 18px;
|
|
||||||
color: #999;
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-handle:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: #5568d3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success {
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-success:hover {
|
|
||||||
background: #218838;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover {
|
|
||||||
background: #c82333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #6c757d;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #5a6268;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-sm {
|
|
||||||
padding: 5px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-content-form {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #ced4da;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #667eea;
|
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 15px;
|
|
||||||
margin-top: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-item {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.9;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-icon {
|
|
||||||
font-size: 48px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration-input {
|
|
||||||
width: 70px !important;
|
|
||||||
padding: 5px 8px !important;
|
|
||||||
text-align: center;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
background: white !important;
|
|
||||||
border: 2px solid #ced4da !important;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration-input:hover {
|
|
||||||
border-color: #667eea !important;
|
|
||||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration-input:focus {
|
|
||||||
border-color: #667eea !important;
|
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
|
||||||
background: white !important;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save-duration-btn {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
animation: fadeIn 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.8);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-type-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-image {
|
|
||||||
background: #e3f2fd;
|
|
||||||
color: #1976d2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-video {
|
|
||||||
background: #f3e5f5;
|
|
||||||
color: #7b1fa2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-pdf {
|
|
||||||
background: #ffebee;
|
|
||||||
color: #c62828;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode support */
|
|
||||||
body.dark-mode .playlist-section {
|
|
||||||
background: #2d3748;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .section-header h2 {
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .section-header {
|
|
||||||
border-bottom-color: #4a5568;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .playlist-table thead {
|
|
||||||
background: #1a202c;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .playlist-table th {
|
|
||||||
color: #cbd5e0;
|
|
||||||
border-bottom-color: #4a5568;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .playlist-table td {
|
|
||||||
border-bottom-color: #4a5568;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .playlist-table tr:hover {
|
|
||||||
background: #1a202c;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .form-control {
|
|
||||||
background: #1a202c;
|
|
||||||
border-color: #4a5568;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .form-control:focus {
|
|
||||||
border-color: #667eea;
|
|
||||||
background: #2d3748;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .add-content-form {
|
|
||||||
background: #1a202c;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .form-group label {
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .empty-state {
|
|
||||||
color: #718096;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .drag-handle {
|
|
||||||
color: #718096;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .duration-input {
|
|
||||||
background: #1a202c !important;
|
|
||||||
border-color: #4a5568 !important;
|
|
||||||
color: #e2e8f0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .duration-input:hover {
|
|
||||||
border-color: #667eea !important;
|
|
||||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .duration-input:focus {
|
|
||||||
background: #2d3748 !important;
|
|
||||||
border-color: #667eea !important;
|
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .badge-image {
|
|
||||||
background: #1e3a5f;
|
|
||||||
color: #64b5f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .badge-video {
|
|
||||||
background: #4a1e5a;
|
|
||||||
color: #ce93d8;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .badge-pdf {
|
|
||||||
background: #5a1e1e;
|
|
||||||
color: #ef5350;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Audio toggle styles */
|
|
||||||
.audio-toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-checkbox {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-label {
|
|
||||||
font-size: 20px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-checkbox + .audio-label .audio-on {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-checkbox + .audio-label .audio-off {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-checkbox:checked + .audio-label .audio-on {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-checkbox:checked + .audio-label .audio-off {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-label:hover {
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="playlist-container">
|
|
||||||
<!-- Player Info Card -->
|
|
||||||
<div class="player-info-card">
|
|
||||||
<h1>🎬 {{ player.name }}</h1>
|
|
||||||
<p>📍 {{ player.location or 'No location' }}</p>
|
|
||||||
<p>🖥️ Hostname: {{ player.hostname }}</p>
|
|
||||||
<p>📊 Status: {{ '🟢 Online' if player.is_online else '🔴 Offline' }}</p>
|
|
||||||
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-label">Playlist Items</div>
|
|
||||||
<div class="stat-value">{{ playlist_content|length }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-label">Playlist Version</div>
|
|
||||||
<div class="stat-value">{{ player.playlist_version }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-label">Total Duration</div>
|
|
||||||
<div class="stat-value">{{ playlist_content|sum(attribute='duration') }}s</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div style="margin-bottom: 20px; display: flex; gap: 10px;">
|
|
||||||
<a href="{{ url_for('players.player_page', player_id=player.id) }}" class="btn btn-secondary">
|
|
||||||
← Back to Player
|
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('content.upload_content', player_id=player.id, return_url=url_for('playlist.manage_playlist', player_id=player.id)) }}"
|
|
||||||
class="btn btn-success">
|
|
||||||
➕ Upload New Content
|
|
||||||
</a>
|
|
||||||
{% if playlist_content %}
|
|
||||||
<form method="POST" action="{{ url_for('playlist.clear_playlist', player_id=player.id) }}"
|
|
||||||
style="display: inline;"
|
|
||||||
onsubmit="return confirm('Are you sure you want to clear the entire playlist?');">
|
|
||||||
<button type="submit" class="btn btn-danger">
|
|
||||||
🗑️ Clear Playlist
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Current Playlist -->
|
|
||||||
<div class="playlist-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2>📋 Current Playlist</h2>
|
|
||||||
<span style="color: #999; font-size: 14px;">
|
|
||||||
Drag and drop to reorder
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if playlist_content %}
|
|
||||||
<table class="playlist-table" id="playlist-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="width: 40px;"></th>
|
|
||||||
<th style="width: 50px;">#</th>
|
|
||||||
<th>Filename</th>
|
|
||||||
<th style="width: 100px;">Type</th>
|
|
||||||
<th style="width: 120px;">Duration (s)</th>
|
|
||||||
<th style="width: 80px;">Audio</th>
|
|
||||||
<th style="width: 100px;">Size</th>
|
|
||||||
<th style="width: 150px;">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="playlist-tbody">
|
|
||||||
{% for content in playlist_content %}
|
|
||||||
<tr class="draggable-row" data-content-id="{{ content.id }}">
|
|
||||||
<td>
|
|
||||||
<span class="drag-handle" draggable="true">⋮⋮</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ loop.index }}</td>
|
|
||||||
<td>{{ content.filename }}</td>
|
|
||||||
<td>
|
|
||||||
{% if content.content_type == 'image' %}
|
|
||||||
<span class="content-type-badge badge-image">📷 Image</span>
|
|
||||||
{% elif content.content_type == 'video' %}
|
|
||||||
<span class="content-type-badge badge-video">🎥 Video</span>
|
|
||||||
{% elif content.content_type == 'pdf' %}
|
|
||||||
<span class="content-type-badge badge-pdf">📄 PDF</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="content-type-badge">📁 {{ content.content_type }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div style="display: flex; align-items: center; gap: 5px;">
|
|
||||||
<input type="number"
|
|
||||||
class="form-control duration-input"
|
|
||||||
id="duration-{{ content.id }}"
|
|
||||||
value="{{ content._playlist_duration }}"
|
|
||||||
min="1"
|
|
||||||
draggable="false"
|
|
||||||
onclick="event.stopPropagation()"
|
|
||||||
onmousedown="event.stopPropagation()"
|
|
||||||
oninput="markDurationChanged({{ content.id }})"
|
|
||||||
onkeypress="if(event.key==='Enter') saveDuration({{ content.id }})">
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn-success btn-sm save-duration-btn"
|
|
||||||
id="save-btn-{{ content.id }}"
|
|
||||||
onclick="event.stopPropagation(); saveDuration({{ content.id }})"
|
|
||||||
onmousedown="event.stopPropagation()"
|
|
||||||
style="display: none;"
|
|
||||||
title="Save duration (or press Enter)">
|
|
||||||
💾
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if content.content_type == 'video' %}
|
|
||||||
<label class="audio-toggle" onclick="event.stopPropagation()">
|
|
||||||
<input type="checkbox"
|
|
||||||
class="audio-checkbox"
|
|
||||||
data-content-id="{{ content.id }}"
|
|
||||||
{{ 'checked' if not content._playlist_muted else '' }}
|
|
||||||
onchange="toggleAudio({{ content.id }}, this.checked)"
|
|
||||||
onclick="event.stopPropagation()">
|
|
||||||
<span class="audio-label">
|
|
||||||
<span class="audio-on">🔊</span>
|
|
||||||
<span class="audio-off">🔇</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{% else %}
|
|
||||||
<span style="color: #999;">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>{{ "%.2f"|format(content.file_size_mb) }} MB</td>
|
|
||||||
<td>
|
|
||||||
<form method="POST"
|
|
||||||
action="{{ url_for('playlist.remove_from_playlist', player_id=player.id, content_id=content.id) }}"
|
|
||||||
style="display: inline;"
|
|
||||||
onsubmit="return confirm('Remove {{ content.filename }} from playlist?');">
|
|
||||||
<button type="submit" class="btn btn-danger btn-sm">
|
|
||||||
✕ Remove
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-state-icon">📭</div>
|
|
||||||
<h3>No content in playlist</h3>
|
|
||||||
<p>Upload content or add existing files to get started</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Content Section -->
|
|
||||||
{% if available_content %}
|
|
||||||
<div class="playlist-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h2>➕ Add Existing Content</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('playlist.add_to_playlist', player_id=player.id) }}"
|
|
||||||
class="add-content-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="content_id">Select Content:</label>
|
|
||||||
<select name="content_id" id="content_id" class="form-control" required>
|
|
||||||
<option value="" disabled selected>Choose content...</option>
|
|
||||||
{% for content in available_content %}
|
|
||||||
<option value="{{ content.id }}">{{ content.filename }} ({{ content.content_type }})</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="duration">Display Duration (seconds):</label>
|
|
||||||
<input type="number"
|
|
||||||
name="duration"
|
|
||||||
id="duration"
|
|
||||||
class="form-control"
|
|
||||||
value="10"
|
|
||||||
min="1"
|
|
||||||
required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-success">
|
|
||||||
➕ Add to Playlist
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let draggedElement = null;
|
|
||||||
|
|
||||||
// Initialize drag and drop
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const tbody = document.getElementById('playlist-tbody');
|
|
||||||
if (!tbody) return;
|
|
||||||
|
|
||||||
// Set up drag handles
|
|
||||||
const dragHandles = tbody.querySelectorAll('.drag-handle');
|
|
||||||
dragHandles.forEach(handle => {
|
|
||||||
handle.addEventListener('dragstart', handleDragStart);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up drop zones on rows
|
|
||||||
const rows = tbody.querySelectorAll('.draggable-row');
|
|
||||||
rows.forEach(row => {
|
|
||||||
row.addEventListener('dragover', handleDragOver);
|
|
||||||
row.addEventListener('drop', handleDrop);
|
|
||||||
row.addEventListener('dragend', handleDragEnd);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent dragging from inputs and buttons
|
|
||||||
const inputs = document.querySelectorAll('.duration-input, button');
|
|
||||||
inputs.forEach(input => {
|
|
||||||
input.addEventListener('mousedown', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
input.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleDragStart(e) {
|
|
||||||
// Get the parent row
|
|
||||||
const row = e.target.closest('.draggable-row');
|
|
||||||
if (!row) return;
|
|
||||||
|
|
||||||
draggedElement = row;
|
|
||||||
row.classList.add('dragging');
|
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
e.dataTransfer.setData('text/html', row.innerHTML);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragOver(e) {
|
|
||||||
if (e.preventDefault) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
e.dataTransfer.dropEffect = 'move';
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(e) {
|
|
||||||
if (e.stopPropagation) {
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (draggedElement !== this) {
|
|
||||||
const tbody = document.getElementById('playlist-tbody');
|
|
||||||
const allRows = [...tbody.querySelectorAll('.draggable-row')];
|
|
||||||
const draggedIndex = allRows.indexOf(draggedElement);
|
|
||||||
const targetIndex = allRows.indexOf(this);
|
|
||||||
|
|
||||||
if (draggedIndex < targetIndex) {
|
|
||||||
this.parentNode.insertBefore(draggedElement, this.nextSibling);
|
|
||||||
} else {
|
|
||||||
this.parentNode.insertBefore(draggedElement, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateRowNumbers();
|
|
||||||
saveOrder();
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragEnd(e) {
|
|
||||||
this.classList.remove('dragging');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRowNumbers() {
|
|
||||||
const rows = document.querySelectorAll('#playlist-tbody tr');
|
|
||||||
rows.forEach((row, index) => {
|
|
||||||
row.querySelector('td:nth-child(2)').textContent = index + 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveOrder() {
|
|
||||||
const rows = document.querySelectorAll('#playlist-tbody .draggable-row');
|
|
||||||
const contentIds = Array.from(rows).map(row => parseInt(row.dataset.contentId));
|
|
||||||
|
|
||||||
fetch('{{ url_for("playlist.reorder_playlist", player_id=player.id) }}', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ content_ids: contentIds })
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
console.log('Playlist reordered successfully');
|
|
||||||
} else {
|
|
||||||
alert('Error reordering playlist: ' + data.message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Error reordering playlist');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function markDurationChanged(contentId) {
|
|
||||||
const saveBtn = document.getElementById(`save-btn-${contentId}`);
|
|
||||||
const input = document.getElementById(`duration-${contentId}`);
|
|
||||||
|
|
||||||
// Show save button if value changed
|
|
||||||
if (input.value !== input.defaultValue) {
|
|
||||||
saveBtn.style.display = 'inline-block';
|
|
||||||
input.style.borderColor = '#ffc107';
|
|
||||||
} else {
|
|
||||||
saveBtn.style.display = 'none';
|
|
||||||
input.style.borderColor = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDuration(contentId) {
|
|
||||||
const inputElement = document.getElementById(`duration-${contentId}`);
|
|
||||||
const saveBtn = document.getElementById(`save-btn-${contentId}`);
|
|
||||||
const duration = parseInt(inputElement.value);
|
|
||||||
|
|
||||||
// Validate duration
|
|
||||||
if (duration < 1) {
|
|
||||||
alert('Duration must be at least 1 second');
|
|
||||||
inputElement.value = inputElement.defaultValue;
|
|
||||||
inputElement.style.borderColor = '';
|
|
||||||
saveBtn.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalValue = inputElement.defaultValue;
|
|
||||||
|
|
||||||
// Visual feedback
|
|
||||||
inputElement.disabled = true;
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
saveBtn.textContent = '⏳';
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('duration', duration);
|
|
||||||
|
|
||||||
const playerId = {{ player.id }};
|
|
||||||
const url = `/playlist/${playerId}/update-duration/${contentId}`;
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
console.log('Duration updated successfully');
|
|
||||||
inputElement.style.borderColor = '#28a745';
|
|
||||||
inputElement.defaultValue = duration;
|
|
||||||
saveBtn.textContent = '✓';
|
|
||||||
|
|
||||||
// Update total duration display
|
|
||||||
updateTotalDuration();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
inputElement.style.borderColor = '';
|
|
||||||
inputElement.disabled = false;
|
|
||||||
saveBtn.style.display = 'none';
|
|
||||||
saveBtn.textContent = '💾';
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
}, 1500);
|
|
||||||
} else {
|
|
||||||
inputElement.style.borderColor = '#dc3545';
|
|
||||||
inputElement.value = originalValue;
|
|
||||||
saveBtn.textContent = '✖';
|
|
||||||
alert('Error updating duration: ' + data.message);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
inputElement.disabled = false;
|
|
||||||
inputElement.style.borderColor = '';
|
|
||||||
saveBtn.style.display = 'none';
|
|
||||||
saveBtn.textContent = '💾';
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
inputElement.style.borderColor = '#dc3545';
|
|
||||||
inputElement.value = originalValue;
|
|
||||||
saveBtn.textContent = '✖';
|
|
||||||
alert('Error updating duration');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
inputElement.disabled = false;
|
|
||||||
inputElement.style.borderColor = '';
|
|
||||||
saveBtn.style.display = 'none';
|
|
||||||
saveBtn.textContent = '💾';
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTotalDuration() {
|
|
||||||
const durationInputs = document.querySelectorAll('.duration-input');
|
|
||||||
let total = 0;
|
|
||||||
durationInputs.forEach(input => {
|
|
||||||
total += parseInt(input.value) || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const statValues = document.querySelectorAll('.stat-value');
|
|
||||||
statValues.forEach((element, index) => {
|
|
||||||
const label = element.parentElement.querySelector('.stat-label');
|
|
||||||
if (label && label.textContent.includes('Total Duration')) {
|
|
||||||
element.textContent = total + 's';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAudio(contentId, enabled) {
|
|
||||||
const muted = !enabled; // Checkbox is "enabled audio", but backend stores "muted"
|
|
||||||
const playerId = {{ player.id }};
|
|
||||||
const url = `/playlist/${playerId}/update-muted/${contentId}`;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('muted', muted ? 'true' : 'false');
|
|
||||||
|
|
||||||
fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
console.log('Audio setting updated:', enabled ? 'Enabled' : 'Muted');
|
|
||||||
} else {
|
|
||||||
alert('Error updating audio setting: ' + data.message);
|
|
||||||
// Revert checkbox on error
|
|
||||||
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
|
|
||||||
if (checkbox) checkbox.checked = !enabled;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Error updating audio setting');
|
|
||||||
// Revert checkbox on error
|
|
||||||
const checkbox = document.querySelector(`.audio-checkbox[data-content-id="${contentId}"]`);
|
|
||||||
if (checkbox) checkbox.checked = !enabled;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
+2
-14
@@ -10,14 +10,7 @@ from app.utils.uploads import (
|
|||||||
get_file_size,
|
get_file_size,
|
||||||
delete_file
|
delete_file
|
||||||
)
|
)
|
||||||
from app.utils.group_player_management import (
|
from app.utils.group_player_management import get_player_status_info
|
||||||
get_player_status_info,
|
|
||||||
get_group_statistics,
|
|
||||||
assign_player_to_group,
|
|
||||||
bulk_assign_players_to_group,
|
|
||||||
get_online_players_count,
|
|
||||||
get_players_by_status
|
|
||||||
)
|
|
||||||
from app.utils.pptx_converter import pptx_to_pdf_libreoffice, validate_pptx_file
|
from app.utils.pptx_converter import pptx_to_pdf_libreoffice, validate_pptx_file
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -36,13 +29,8 @@ __all__ = [
|
|||||||
'clear_upload_progress',
|
'clear_upload_progress',
|
||||||
'get_file_size',
|
'get_file_size',
|
||||||
'delete_file',
|
'delete_file',
|
||||||
# Group/Player Management
|
# Player Management
|
||||||
'get_player_status_info',
|
'get_player_status_info',
|
||||||
'get_group_statistics',
|
|
||||||
'assign_player_to_group',
|
|
||||||
'bulk_assign_players_to_group',
|
|
||||||
'get_online_players_count',
|
|
||||||
'get_players_by_status',
|
|
||||||
# PPTX Converter
|
# PPTX Converter
|
||||||
'pptx_to_pdf_libreoffice',
|
'pptx_to_pdf_libreoffice',
|
||||||
'validate_pptx_file',
|
'validate_pptx_file',
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Background task execution for long-running operations."""
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
from typing import Callable, Any, Dict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run_background_task(task_func: Callable, *args, **kwargs) -> threading.Thread:
|
||||||
|
"""Run a function in a background thread, with a Flask app context pushed."""
|
||||||
|
from flask import current_app
|
||||||
|
# Capture the app instance now (in the request context) so the thread can use it
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
|
||||||
|
def wrapper():
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
logger.info(f"Starting background task: {task_func.__name__}")
|
||||||
|
task_func(*args, **kwargs)
|
||||||
|
logger.info(f"Completed background task: {task_func.__name__}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Background task failed ({task_func.__name__}): {str(e)}", exc_info=True)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=wrapper, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
|
|
||||||
|
|
||||||
|
def background_player_deployment(
|
||||||
|
hostname: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
player_name: str,
|
||||||
|
player_id: int,
|
||||||
|
port: int = 22,
|
||||||
|
server_url: str = None,
|
||||||
|
server_api_key: str = None,
|
||||||
|
player_hostname: str = None,
|
||||||
|
quickconnect_code: str = None,
|
||||||
|
orientation: str = 'Landscape',
|
||||||
|
verify_ssl: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Deploy player code to host in background.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname: SSH hostname/IP
|
||||||
|
username: SSH username
|
||||||
|
password: SSH password
|
||||||
|
player_name: Player name
|
||||||
|
player_id: Player database ID
|
||||||
|
port: SSH port
|
||||||
|
server_url: DigiServer URL for player
|
||||||
|
server_api_key: API key for player
|
||||||
|
player_hostname: Player screen identity used for auth (Player.hostname)
|
||||||
|
quickconnect_code: Quick connect code used for auth
|
||||||
|
orientation: Player orientation (Landscape/Portrait)
|
||||||
|
verify_ssl: Whether the player should verify the server TLS certificate
|
||||||
|
"""
|
||||||
|
from app.utils.ssh_deploy import deploy_player_to_host
|
||||||
|
from app.models import Player
|
||||||
|
from app.extensions import db
|
||||||
|
from app.utils.logger import log_action
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Mark deployment as "in progress" immediately so the UI can show live status
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
if player:
|
||||||
|
player.deployment_status = 'deploying'
|
||||||
|
player.last_deployment_at = datetime.utcnow()
|
||||||
|
player.last_deployment_status = None
|
||||||
|
player.last_deployment_message = 'Deployment in progress...'
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Execute deployment
|
||||||
|
result = deploy_player_to_host(
|
||||||
|
hostname=hostname,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
player_name=player_name,
|
||||||
|
port=port,
|
||||||
|
server_url=server_url,
|
||||||
|
server_api_key=server_api_key,
|
||||||
|
player_hostname=player_hostname,
|
||||||
|
quickconnect_code=quickconnect_code,
|
||||||
|
orientation=orientation,
|
||||||
|
verify_ssl=verify_ssl
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update player with deployment status
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
if player:
|
||||||
|
if result.get('success'):
|
||||||
|
player.deployment_status = 'deployed'
|
||||||
|
player.last_deployment_status = 'success'
|
||||||
|
player.last_deployment_message = result.get('message', 'Deployment successful')
|
||||||
|
log_action('info', f'Background deployment completed for player "{player_name}": {result["message"]}')
|
||||||
|
else:
|
||||||
|
player.deployment_status = 'failed'
|
||||||
|
player.last_deployment_status = 'failed'
|
||||||
|
player.last_deployment_message = result.get('error', result.get('message', 'Deployment failed'))
|
||||||
|
log_action('error', f'Background deployment failed for player "{player_name}": {result.get("error", result.get("message"))}')
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Background deployment error for player '{player_name}': {str(e)}", exc_info=True)
|
||||||
|
player = Player.query.get(player_id)
|
||||||
|
if player:
|
||||||
|
player.deployment_status = 'failed'
|
||||||
|
player.last_deployment_status = 'failed'
|
||||||
|
player.last_deployment_message = f'Deployment crashed: {str(e)}'
|
||||||
|
db.session.commit()
|
||||||
|
log_action('error', f'Background deployment error for player "{player_name}": {str(e)}')
|
||||||
+142
-105
@@ -3,127 +3,157 @@ import os
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.models.https_config import HTTPSConfig
|
from app.models.https_config import HTTPSConfig
|
||||||
|
|
||||||
|
# Shared reverse-proxy snippet used in every Caddy site block
|
||||||
|
_PROXY_SNIPPET = """\
|
||||||
|
reverse_proxy digiserver-app:5000 {
|
||||||
|
header_up Host {host}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
transport http {
|
||||||
|
read_timeout 300s
|
||||||
|
write_timeout 300s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request_body {
|
||||||
|
max_size 2GB
|
||||||
|
}
|
||||||
|
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
header {
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/access.log
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class CaddyConfigGenerator:
|
class CaddyConfigGenerator:
|
||||||
"""Generate Caddyfile configuration based on HTTPSConfig."""
|
"""Generate Caddyfile configuration based on HTTPSConfig."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_caddyfile(config: Optional[HTTPSConfig] = None) -> str:
|
def generate_caddyfile(config: Optional['HTTPSConfig'] = None,
|
||||||
"""Generate complete Caddyfile content.
|
http_fallback: bool = True,
|
||||||
|
http_port: int = 80,
|
||||||
|
https_port: int = 443) -> str:
|
||||||
|
"""Generate a complete Caddyfile.
|
||||||
|
|
||||||
|
Design goals
|
||||||
|
------------
|
||||||
|
* **One HTTP endpoint** (port 80) that always answers, whatever the Host
|
||||||
|
header is — so ``http://<ip>`` and ``http://<hostname>`` both work.
|
||||||
|
* **HTTPS on port 443** for the same names when it is enabled.
|
||||||
|
* If HTTPS is disabled or never configured, port 80 simply serves the
|
||||||
|
app — there is no separate "HTTP mode" to configure.
|
||||||
|
|
||||||
|
Behaviour by configuration
|
||||||
|
--------------------------
|
||||||
|
* HTTPS off, or no address configured → plain HTTP on ``:http_port``.
|
||||||
|
* HTTPS on → the app is served on port 80 for every configured name and
|
||||||
|
on port 443 over TLS. Whether port 80 *serves* or *redirects* to
|
||||||
|
HTTPS is controlled by ``http_fallback``.
|
||||||
|
|
||||||
|
Which certificate each name gets
|
||||||
|
--------------------------------
|
||||||
|
* ``domain`` (when set) → Caddy obtains a certificate automatically
|
||||||
|
(Let's Encrypt/ACME). Only valid for a **publicly resolvable** name.
|
||||||
|
* ``ip_address`` / ``hostname`` → ``tls internal`` (Caddy's local CA).
|
||||||
|
This needs no public DNS and no ACME, which is the right choice for an
|
||||||
|
intranet name such as ``digiserver.sibiusb.harting.intra``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: HTTPSConfig instance or None
|
config: HTTPSConfig instance, or None to load from the database.
|
||||||
|
http_fallback: When True, port 80 keeps *serving* the app alongside
|
||||||
|
HTTPS. This is the resilient default: clients that cannot trust
|
||||||
|
the internal CA (e.g. a Kivy player with ``verify_ssl: true``)
|
||||||
|
are still able to connect. When False, port 80 issues a 301
|
||||||
|
redirect to HTTPS instead.
|
||||||
|
http_port: Port Caddy listens on for plain HTTP (default 80).
|
||||||
|
https_port: Port used to build redirect targets when
|
||||||
|
``http_fallback`` is False (default 443).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Complete Caddyfile content as string
|
The complete Caddyfile as a string.
|
||||||
"""
|
"""
|
||||||
# Get config from database if not provided
|
|
||||||
if config is None:
|
if config is None:
|
||||||
config = HTTPSConfig.get_config()
|
config = HTTPSConfig.get_config()
|
||||||
|
|
||||||
# Base configuration
|
email = (config.email or "admin@localhost") if config else "admin@localhost"
|
||||||
email = "admin@localhost"
|
https_enabled = bool(config.https_enabled) if config else False
|
||||||
if config and config.email:
|
domain = (config.domain or "").strip() if config else ""
|
||||||
email = config.email
|
ip_address = (config.ip_address or "").strip() if config else ""
|
||||||
|
hostname = (config.hostname or "").strip() if config else ""
|
||||||
|
|
||||||
base_config = f"""{{
|
# Every name the server should answer to, in priority order, without
|
||||||
# Global options
|
# duplicates. The IP comes first because it always resolves.
|
||||||
email {email}
|
names: list[str] = []
|
||||||
# Admin API for configuration management (listen on all interfaces)
|
for candidate in (ip_address, hostname, domain):
|
||||||
admin 0.0.0.0:2019
|
if candidate and candidate not in names:
|
||||||
# Uncomment for testing to avoid rate limits
|
names.append(candidate)
|
||||||
# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
|
|
||||||
}}
|
|
||||||
|
|
||||||
# Shared reverse proxy configuration
|
global_block = f"{{\n admin 0.0.0.0:2019\n email {email}\n"
|
||||||
(reverse_proxy_config) {{
|
|
||||||
reverse_proxy digiserver-app:5000 {{
|
|
||||||
header_up Host {{host}}
|
|
||||||
header_up X-Real-IP {{remote_host}}
|
|
||||||
header_up X-Forwarded-Proto {{scheme}}
|
|
||||||
|
|
||||||
# Timeouts for large uploads
|
# ── TLS with no SNI ────────────────────────────────────────────────
|
||||||
transport http {{
|
# Browsers do NOT send SNI when the URL is an IP address (an IP is not
|
||||||
read_timeout 300s
|
# a valid SNI hostname). Without a fallback Caddy would identify such a
|
||||||
write_timeout 300s
|
# connection by the container's own internal IP, match no certificate
|
||||||
}}
|
# and abort the handshake with:
|
||||||
}}
|
# "no certificate available for '<container-ip>'"
|
||||||
|
# `default_sni` makes a SNI-less ClientHello resolve to a name we do
|
||||||
|
# serve, so https://<ip> works in the browser.
|
||||||
|
if https_enabled and not domain and ip_address:
|
||||||
|
global_block += f" default_sni {ip_address}\n"
|
||||||
|
|
||||||
# File upload size limit (2GB)
|
global_block += "}\n\n"
|
||||||
request_body {{
|
|
||||||
max_size 2GB
|
|
||||||
}}
|
|
||||||
|
|
||||||
# Security headers
|
# ── Plain HTTP only: HTTPS disabled, or no address to certify ───────
|
||||||
header {{
|
if not (https_enabled and names):
|
||||||
X-Frame-Options "SAMEORIGIN"
|
return global_block + f":{http_port} {{\n{_PROXY_SNIPPET}}}\n"
|
||||||
X-Content-Type-Options "nosniff"
|
|
||||||
X-XSS-Protection "1; mode=block"
|
|
||||||
}}
|
|
||||||
|
|
||||||
# Logging
|
caddyfile = global_block
|
||||||
log {{
|
|
||||||
output file /var/log/caddy/access.log
|
|
||||||
}}
|
|
||||||
}}
|
|
||||||
|
|
||||||
# Localhost (development/local access)
|
# ── Port 80: catch-all so ANY Host header is answered ──────────────
|
||||||
http://localhost {{
|
# Without this, a request for an unexpected name (e.g. a bare IP when
|
||||||
import reverse_proxy_config
|
# only a hostname is configured) would hit no site block and fail.
|
||||||
}}
|
caddyfile += f":{http_port} {{\n{_PROXY_SNIPPET}}}\n\n"
|
||||||
"""
|
|
||||||
|
|
||||||
# Add main domain/IP configuration if HTTPS is enabled
|
# ── Port 80: explicit per-name blocks ──────────────────────────────
|
||||||
if config and config.https_enabled and config.domain and config.ip_address:
|
for name in names:
|
||||||
# Internal domain configuration
|
if http_fallback:
|
||||||
domain_config = f"""
|
caddyfile += f"http://{name} {{\n{_PROXY_SNIPPET}}}\n\n"
|
||||||
# Internal domain (HTTP only - internal use)
|
else:
|
||||||
http://{config.domain} {{
|
# Redirect to the port the host actually publishes.
|
||||||
import reverse_proxy_config
|
https_url = (f"https://{name}" if https_port == 443
|
||||||
}}
|
else f"https://{name}:{https_port}")
|
||||||
|
caddyfile += f"http://{name} {{\n redir {https_url}{{uri}} 301\n}}\n\n"
|
||||||
|
|
||||||
# Handle IP address access
|
# ── Port 443: TLS listeners ────────────────────────────────────────
|
||||||
http://{config.ip_address} {{
|
for name in names:
|
||||||
import reverse_proxy_config
|
if domain and name == domain:
|
||||||
}}
|
# Public name → let Caddy obtain a real certificate.
|
||||||
"""
|
caddyfile += f"https://{name} {{\n{_PROXY_SNIPPET}}}\n\n"
|
||||||
base_config += domain_config
|
else:
|
||||||
else:
|
# IP or intranet name → Caddy's internal CA.
|
||||||
# Default fallback configuration
|
caddyfile += (f"https://{name} {{\n tls internal\n"
|
||||||
base_config += """
|
f"{_PROXY_SNIPPET}}}\n\n")
|
||||||
# Internal domain (HTTP only - internal use)
|
|
||||||
http://digiserver.sibiusb.harting.intra {
|
|
||||||
import reverse_proxy_config
|
|
||||||
}
|
|
||||||
|
|
||||||
# Handle IP address access
|
return caddyfile
|
||||||
http://10.76.152.164 {
|
|
||||||
import reverse_proxy_config
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Add catch-all for any other HTTP requests
|
|
||||||
base_config += """
|
|
||||||
# Catch-all for any other HTTP requests
|
|
||||||
http://* {
|
|
||||||
import reverse_proxy_config
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
return base_config
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def write_caddyfile(caddyfile_content: str, path: str = '/app/Caddyfile') -> bool:
|
def write_caddyfile(caddyfile_content: str,
|
||||||
|
path: str = '/etc/caddy/Caddyfile') -> bool:
|
||||||
"""Write Caddyfile to disk.
|
"""Write Caddyfile to disk.
|
||||||
|
|
||||||
Args:
|
The default path is /etc/caddy/Caddyfile — the standard location inside
|
||||||
caddyfile_content: Content to write
|
the caddy:2-alpine container when a volume is mounted there.
|
||||||
path: Path to Caddyfile
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
with open(path, 'w') as f:
|
with open(path, 'w') as f:
|
||||||
f.write(caddyfile_content)
|
f.write(caddyfile_content)
|
||||||
return True
|
return True
|
||||||
@@ -133,22 +163,29 @@ http://* {
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def reload_caddy() -> bool:
|
def reload_caddy() -> bool:
|
||||||
"""Reload Caddy configuration without restart.
|
"""Push the current Caddyfile to Caddy via its admin API (/load).
|
||||||
|
|
||||||
Note: Caddy monitoring is handled via file watching. After writing the Caddyfile,
|
Caddy applies the new config live without dropping connections.
|
||||||
Caddy should automatically reload. If it doesn't, you may need to restart the
|
|
||||||
Caddy container manually.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if configuration was written successfully (Caddy will auto-reload)
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Just verify that Caddy is reachable
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
response = urllib.request.urlopen('http://caddy:2019/config/', timeout=2)
|
|
||||||
|
caddyfile_path = '/etc/caddy/Caddyfile'
|
||||||
|
if not os.path.exists(caddyfile_path):
|
||||||
|
print(f"Caddyfile not found at {caddyfile_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
with open(caddyfile_path, 'rb') as f:
|
||||||
|
caddyfile_bytes = f.read()
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
'http://caddy:2019/load',
|
||||||
|
data=caddyfile_bytes,
|
||||||
|
headers={'Content-Type': 'text/caddyfile'},
|
||||||
|
method='POST',
|
||||||
|
)
|
||||||
|
response = urllib.request.urlopen(req, timeout=10)
|
||||||
return response.status == 200
|
return response.status == 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Caddy might not be reachable, but Caddyfile was already written
|
print(f"Caddy reload error: {str(e)}")
|
||||||
# Caddy should reload automatically when it detects file changes
|
|
||||||
print(f"Note: Caddy reload check returned: {str(e)}")
|
|
||||||
return True # Return True anyway since Caddyfile was written
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Group and player management utilities."""
|
"""Player status utilities.
|
||||||
from typing import Dict, List, Optional
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from app.extensions import db
|
Note: the group-management helpers that used to live here were removed along
|
||||||
from app.models import Player, Group, PlayerFeedback
|
with the deprecated Group subsystem (the ``group`` table had no rows and the
|
||||||
from app.utils.logger import log_action
|
``/api/groups`` endpoint had already been archived).
|
||||||
|
"""
|
||||||
|
from typing import Dict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.models import Player, PlayerFeedback
|
||||||
|
|
||||||
|
|
||||||
def get_player_status_info(player_id: int) -> Dict:
|
def get_player_status_info(player_id: int) -> Dict:
|
||||||
@@ -51,142 +54,6 @@ def get_player_status_info(player_id: int) -> Dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_group_statistics(group_id: int) -> Dict:
|
|
||||||
"""Get statistics for a group.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
group_id: Group ID to query
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with group statistics
|
|
||||||
"""
|
|
||||||
group = Group.query.get(group_id)
|
|
||||||
|
|
||||||
if not group:
|
|
||||||
return {
|
|
||||||
'total_players': 0,
|
|
||||||
'online_players': 0,
|
|
||||||
'total_content': 0,
|
|
||||||
'error_count': 0
|
|
||||||
}
|
|
||||||
|
|
||||||
total_players = group.player_count
|
|
||||||
total_content = group.content_count
|
|
||||||
|
|
||||||
# Count online players
|
|
||||||
online_players = 0
|
|
||||||
error_count = 0
|
|
||||||
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
|
|
||||||
|
|
||||||
for player in group.players:
|
|
||||||
if player.last_seen and player.last_seen >= five_min_ago:
|
|
||||||
online_players += 1
|
|
||||||
if player.status == 'error':
|
|
||||||
error_count += 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
'group_id': group_id,
|
|
||||||
'group_name': group.name,
|
|
||||||
'total_players': total_players,
|
|
||||||
'online_players': online_players,
|
|
||||||
'offline_players': total_players - online_players,
|
|
||||||
'total_content': total_content,
|
|
||||||
'error_count': error_count
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def assign_player_to_group(player_id: int, group_id: Optional[int]) -> bool:
|
|
||||||
"""Assign a player to a group or unassign if group_id is None.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
player_id: Player ID to assign
|
|
||||||
group_id: Group ID to assign to, or None to unassign
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if successful, False otherwise
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
player = Player.query.get(player_id)
|
|
||||||
|
|
||||||
if not player:
|
|
||||||
log_action('error', f'Player {player_id} not found')
|
|
||||||
return False
|
|
||||||
|
|
||||||
old_group_id = player.group_id
|
|
||||||
player.group_id = group_id
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
if group_id:
|
|
||||||
group = Group.query.get(group_id)
|
|
||||||
log_action('info', f'Player "{player.name}" assigned to group "{group.name}"')
|
|
||||||
else:
|
|
||||||
log_action('info', f'Player "{player.name}" unassigned from group')
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error assigning player to group: {str(e)}')
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def bulk_assign_players_to_group(player_ids: List[int], group_id: Optional[int]) -> int:
|
|
||||||
"""Assign multiple players to a group.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
player_ids: List of player IDs to assign
|
|
||||||
group_id: Group ID to assign to, or None to unassign
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of players successfully assigned
|
|
||||||
"""
|
|
||||||
count = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
for player_id in player_ids:
|
|
||||||
player = Player.query.get(player_id)
|
|
||||||
if player:
|
|
||||||
player.group_id = group_id
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
if group_id:
|
|
||||||
group = Group.query.get(group_id)
|
|
||||||
log_action('info', f'Bulk assigned {count} players to group "{group.name}"')
|
|
||||||
else:
|
|
||||||
log_action('info', f'Bulk unassigned {count} players from groups')
|
|
||||||
|
|
||||||
return count
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.session.rollback()
|
|
||||||
log_action('error', f'Error bulk assigning players: {str(e)}')
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def get_online_players_count() -> int:
|
|
||||||
"""Get count of online players (seen in last 5 minutes).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of online players
|
|
||||||
"""
|
|
||||||
five_min_ago = datetime.utcnow() - timedelta(minutes=5)
|
|
||||||
return Player.query.filter(Player.last_seen >= five_min_ago).count()
|
|
||||||
|
|
||||||
|
|
||||||
def get_players_by_status(status: str) -> List[Player]:
|
|
||||||
"""Get all players with a specific status.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
status: Status to filter by
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of Player instances
|
|
||||||
"""
|
|
||||||
return Player.query.filter_by(status=status).all()
|
|
||||||
|
|
||||||
|
|
||||||
def _format_time_ago(dt: datetime) -> str:
|
def _format_time_ago(dt: datetime) -> str:
|
||||||
"""Format datetime as 'time ago' string.
|
"""Format datetime as 'time ago' string.
|
||||||
|
|
||||||
|
|||||||
+23
-2
@@ -23,21 +23,42 @@ def log_action(level: str, message: str) -> None:
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
|
||||||
|
|
||||||
def get_recent_logs(limit: int = 20, level: Optional[str] = None) -> list:
|
def get_recent_logs(limit: int = 20, level: Optional[str] = None,
|
||||||
|
exclude_prefix: Optional[str] = None,
|
||||||
|
include_prefix: Optional[str] = None) -> list:
|
||||||
"""Get the most recent log entries.
|
"""Get the most recent log entries.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
limit: Maximum number of logs to return
|
limit: Maximum number of logs to return
|
||||||
level: Optional filter by log level
|
level: Optional filter by log level
|
||||||
|
exclude_prefix: Drop entries whose message starts with this text.
|
||||||
|
Useful for high-frequency noise: player feedback alone is ~84% of
|
||||||
|
all rows, so over-fetching a window and filtering in Python makes
|
||||||
|
the number of *useful* entries depend on heartbeat volume. Filtering
|
||||||
|
in SQL keeps a busy fleet from starving the result.
|
||||||
|
include_prefix: Keep only entries whose message starts with this text.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of ServerLog instances
|
List of ServerLog instances, newest first
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Prefixes are escaped before reaching SQL LIKE (via ``autoescape``), so a
|
||||||
|
literal '%' or '_' inside the prefix matches itself rather than acting
|
||||||
|
as a wildcard.
|
||||||
"""
|
"""
|
||||||
query = ServerLog.query
|
query = ServerLog.query
|
||||||
|
|
||||||
if level:
|
if level:
|
||||||
query = query.filter_by(level=level)
|
query = query.filter_by(level=level)
|
||||||
|
|
||||||
|
if exclude_prefix:
|
||||||
|
query = query.filter(
|
||||||
|
~ServerLog.message.startswith(exclude_prefix, autoescape=True))
|
||||||
|
|
||||||
|
if include_prefix:
|
||||||
|
query = query.filter(
|
||||||
|
ServerLog.message.startswith(include_prefix, autoescape=True))
|
||||||
|
|
||||||
return query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
|
return query.order_by(ServerLog.timestamp.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
"""Nginx configuration reader utility."""
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import Dict, List, Optional, Any
|
|
||||||
|
|
||||||
|
|
||||||
class NginxConfigReader:
|
|
||||||
"""Read and parse Nginx configuration files."""
|
|
||||||
|
|
||||||
def __init__(self, config_path: str = '/etc/nginx/nginx.conf'):
|
|
||||||
"""Initialize Nginx config reader."""
|
|
||||||
self.config_path = config_path
|
|
||||||
self.config_content = None
|
|
||||||
self.is_available = os.path.exists(config_path)
|
|
||||||
|
|
||||||
if self.is_available:
|
|
||||||
try:
|
|
||||||
with open(config_path, 'r') as f:
|
|
||||||
self.config_content = f.read()
|
|
||||||
except Exception as e:
|
|
||||||
self.is_available = False
|
|
||||||
self.error = str(e)
|
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
|
||||||
"""Get Nginx configuration status."""
|
|
||||||
if not self.is_available:
|
|
||||||
return {
|
|
||||||
'available': False,
|
|
||||||
'error': 'Nginx configuration not found',
|
|
||||||
'path': self.config_path
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
'available': True,
|
|
||||||
'path': self.config_path,
|
|
||||||
'file_exists': os.path.exists(self.config_path),
|
|
||||||
'ssl_enabled': self._check_ssl_enabled(),
|
|
||||||
'http_ports': self._extract_http_ports(),
|
|
||||||
'https_ports': self._extract_https_ports(),
|
|
||||||
'upstream_servers': self._extract_upstream_servers(),
|
|
||||||
'server_names': self._extract_server_names(),
|
|
||||||
'ssl_protocols': self._extract_ssl_protocols(),
|
|
||||||
'client_max_body_size': self._extract_client_max_body_size(),
|
|
||||||
'gzip_enabled': self._check_gzip_enabled(),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _check_ssl_enabled(self) -> bool:
|
|
||||||
"""Check if SSL is enabled."""
|
|
||||||
if not self.config_content:
|
|
||||||
return False
|
|
||||||
return 'ssl_certificate' in self.config_content
|
|
||||||
|
|
||||||
def _extract_http_ports(self) -> List[int]:
|
|
||||||
"""Extract HTTP listening ports."""
|
|
||||||
if not self.config_content:
|
|
||||||
return []
|
|
||||||
pattern = r'listen\s+(\d+)'
|
|
||||||
matches = re.findall(pattern, self.config_content)
|
|
||||||
return sorted(list(set(int(p) for p in matches if int(p) < 1000)))
|
|
||||||
|
|
||||||
def _extract_https_ports(self) -> List[int]:
|
|
||||||
"""Extract HTTPS listening ports."""
|
|
||||||
if not self.config_content:
|
|
||||||
return []
|
|
||||||
pattern = r'listen\s+(\d+).*ssl'
|
|
||||||
matches = re.findall(pattern, self.config_content)
|
|
||||||
return sorted(list(set(int(p) for p in matches)))
|
|
||||||
|
|
||||||
def _extract_upstream_servers(self) -> List[str]:
|
|
||||||
"""Extract upstream servers."""
|
|
||||||
if not self.config_content:
|
|
||||||
return []
|
|
||||||
upstream_match = re.search(r'upstream\s+\w+\s*{([^}]+)}', self.config_content)
|
|
||||||
if upstream_match:
|
|
||||||
upstream_content = upstream_match.group(1)
|
|
||||||
servers = re.findall(r'server\s+([^\s;]+)', upstream_content)
|
|
||||||
return servers
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _extract_server_names(self) -> List[str]:
|
|
||||||
"""Extract server names."""
|
|
||||||
if not self.config_content:
|
|
||||||
return []
|
|
||||||
pattern = r'server_name\s+([^;]+);'
|
|
||||||
matches = re.findall(pattern, self.config_content)
|
|
||||||
result = []
|
|
||||||
for match in matches:
|
|
||||||
names = match.strip().split()
|
|
||||||
result.extend(names)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _extract_ssl_protocols(self) -> List[str]:
|
|
||||||
"""Extract SSL protocols."""
|
|
||||||
if not self.config_content:
|
|
||||||
return []
|
|
||||||
pattern = r'ssl_protocols\s+([^;]+);'
|
|
||||||
match = re.search(pattern, self.config_content)
|
|
||||||
if match:
|
|
||||||
return match.group(1).strip().split()
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _extract_client_max_body_size(self) -> Optional[str]:
|
|
||||||
"""Extract client max body size."""
|
|
||||||
if not self.config_content:
|
|
||||||
return None
|
|
||||||
pattern = r'client_max_body_size\s+([^;]+);'
|
|
||||||
match = re.search(pattern, self.config_content)
|
|
||||||
return match.group(1).strip() if match else None
|
|
||||||
|
|
||||||
def _check_gzip_enabled(self) -> bool:
|
|
||||||
"""Check if gzip is enabled."""
|
|
||||||
if not self.config_content:
|
|
||||||
return False
|
|
||||||
return bool(re.search(r'gzip\s+on\s*;', self.config_content))
|
|
||||||
|
|
||||||
|
|
||||||
def get_nginx_status() -> Dict[str, Any]:
|
|
||||||
"""Get Nginx configuration status."""
|
|
||||||
reader = NginxConfigReader()
|
|
||||||
return reader.get_status()
|
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
"""Utilities for building/staging the player files on the server.
|
||||||
|
|
||||||
|
Admins use the "Build player files" admin page to:
|
||||||
|
* clone/refresh the player source code from a git repository into a local
|
||||||
|
staged directory (``PLAYER_CODE_DIR``), and
|
||||||
|
* write a base ``config/app_config.json`` so the staged code already knows how
|
||||||
|
to reach this server.
|
||||||
|
|
||||||
|
The SSH deployment flow then ships this staged directory to player devices, so
|
||||||
|
the version admins build here is exactly what gets deployed.
|
||||||
|
|
||||||
|
Performance note
|
||||||
|
----------------
|
||||||
|
The player repository is large (~200 MB) and a full clone takes ~90 s. Because
|
||||||
|
the build runs inside an HTTP request, that would exceed gunicorn's worker
|
||||||
|
timeout and the worker would be killed mid-clone, leaving a broken checkout.
|
||||||
|
Two mitigations are used together:
|
||||||
|
|
||||||
|
* **Shallow clones** (``--depth 1``) — only the tip of the requested branch is
|
||||||
|
fetched, which is all a deployment needs. Drastically reduces transfer size.
|
||||||
|
* **Background execution** — the admin route starts the build in a daemon
|
||||||
|
thread and the page polls for progress, so no worker ever blocks on git.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from app.utils.ssh_deploy import generate_app_config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Metadata file name stored in the Flask instance folder.
|
||||||
|
BUILD_META_FILENAME = 'player_build.json'
|
||||||
|
|
||||||
|
# Only the tip of the branch is needed to deploy a player, so history is not
|
||||||
|
# fetched. Keeps the transfer small enough to avoid worker timeouts.
|
||||||
|
CLONE_DEPTH = '1'
|
||||||
|
|
||||||
|
# Never let git wait for a human. Without this, a private/renamed repository
|
||||||
|
# makes git block on a username prompt until the worker is killed.
|
||||||
|
GIT_ENV = {
|
||||||
|
'GIT_TERMINAL_PROMPT': '0', # never prompt for credentials
|
||||||
|
'GIT_ASKPASS': 'true', # answer any credential request immediately
|
||||||
|
'GIT_SSH_COMMAND': 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _git_env() -> Dict[str, str]:
|
||||||
|
"""Environment for git subprocesses: inherit the process env plus our flags."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.update(GIT_ENV)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(args, cwd=None, timeout=120) -> subprocess.CompletedProcess:
|
||||||
|
"""Run a git command, never prompting for input.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: git arguments (without the leading 'git').
|
||||||
|
cwd: working directory for the command.
|
||||||
|
timeout: hard cap in seconds. Defaults to 120 to stay within a
|
||||||
|
reasonable window even when running in the foreground.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The completed process. ``returncode`` is 124 on timeout so callers can
|
||||||
|
distinguish a timeout from a normal failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
['git'] + args,
|
||||||
|
cwd=cwd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
env=_git_env(),
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
# Surface timeouts as a normal result so callers do not need try/except.
|
||||||
|
out = e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or '')
|
||||||
|
err = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or '')
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
args=['git'] + list(args), returncode=124,
|
||||||
|
stdout=out, stderr=(err + f'\ngit {" ".join(args)} timed out after {timeout}s').strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_short_head(player_code_dir: str) -> str:
|
||||||
|
"""Return the short git commit of the staged code, or 'unknown'."""
|
||||||
|
result = _run_git(['-C', player_code_dir, 'rev-parse', '--short', 'HEAD'], timeout=10)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result.stdout.strip()
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_checkout(path: str) -> bool:
|
||||||
|
"""True when *path* is a usable git checkout with a resolvable HEAD."""
|
||||||
|
if not os.path.isdir(os.path.join(path, '.git')):
|
||||||
|
return False
|
||||||
|
return get_short_head(path) != 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
def _clone(path: str, repo_url: str, branch: str) -> subprocess.CompletedProcess:
|
||||||
|
"""Shallow-clone a single branch into *path*."""
|
||||||
|
return _run_git([
|
||||||
|
'clone', '--depth', CLONE_DEPTH, '--single-branch',
|
||||||
|
'--branch', branch, repo_url, path,
|
||||||
|
], timeout=600)
|
||||||
|
|
||||||
|
|
||||||
|
def build_player_files(player_code_dir: str, repo_url: str, branch: str = 'main') -> Dict[str, Any]:
|
||||||
|
"""Clone or refresh the player source into ``player_code_dir``.
|
||||||
|
|
||||||
|
Uses a **shallow single-branch clone/update** so only the tip of the wanted
|
||||||
|
branch is transferred. If the directory is a usable checkout it is updated
|
||||||
|
(fetch + hard reset to the branch). A directory that exists but is NOT a
|
||||||
|
usable checkout — e.g. left behind by an interrupted clone — is removed and
|
||||||
|
re-cloned, since updating it can never work.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_code_dir: Destination directory for the staged player code.
|
||||||
|
repo_url: Git repository to pull from.
|
||||||
|
branch: Branch to stage.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``{'success': bool, 'message': str, 'version': str|None, 'branch': str}``
|
||||||
|
"""
|
||||||
|
branch = (branch or 'main').strip()
|
||||||
|
repo_url = (repo_url or '').strip()
|
||||||
|
|
||||||
|
def fail(message: str) -> Dict[str, Any]:
|
||||||
|
return {'success': False, 'message': message,
|
||||||
|
'version': get_short_head(player_code_dir), 'branch': branch}
|
||||||
|
|
||||||
|
if not repo_url:
|
||||||
|
return {'success': False, 'message': 'Repository URL is required.',
|
||||||
|
'version': None, 'branch': branch}
|
||||||
|
|
||||||
|
try:
|
||||||
|
usable = is_valid_checkout(player_code_dir)
|
||||||
|
|
||||||
|
if usable:
|
||||||
|
# Update in place. Depth 1 keeps the update cheap; fetch by ref so
|
||||||
|
# it works on a shallow clone.
|
||||||
|
fetch = _run_git(
|
||||||
|
['-C', player_code_dir, 'fetch', '--depth', CLONE_DEPTH,
|
||||||
|
'--prune', 'origin', branch])
|
||||||
|
if fetch.returncode != 0:
|
||||||
|
return fail(f'git fetch failed: {fetch.stderr.strip() or fetch.stdout.strip()}')
|
||||||
|
|
||||||
|
# Point origin at the requested URL in case it changed.
|
||||||
|
_run_git(['-C', player_code_dir, 'remote', 'set-url', 'origin', repo_url])
|
||||||
|
|
||||||
|
checkout = _run_git(['-C', player_code_dir, 'checkout', branch])
|
||||||
|
if checkout.returncode != 0:
|
||||||
|
return fail(f'git checkout {branch} failed: {checkout.stderr.strip()}')
|
||||||
|
|
||||||
|
reset = _run_git(['-C', player_code_dir, 'reset', '--hard', f'origin/{branch}'])
|
||||||
|
if reset.returncode != 0:
|
||||||
|
return fail(f'git reset failed: {reset.stderr.strip()}')
|
||||||
|
|
||||||
|
action = 'Updated'
|
||||||
|
else:
|
||||||
|
# Fresh clone. A previous attempt may have left a partial directory
|
||||||
|
# (e.g. killed mid-clone) — it must go, or the clone will fail with
|
||||||
|
# "destination path already exists and is not an empty directory".
|
||||||
|
parent = os.path.dirname(player_code_dir.rstrip('/'))
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
if os.path.exists(player_code_dir):
|
||||||
|
logger.info('Removing unusable directory before clone: %s', player_code_dir)
|
||||||
|
shutil.rmtree(player_code_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
clone = _clone(player_code_dir, repo_url, branch)
|
||||||
|
if clone.returncode != 0:
|
||||||
|
# Do not leave a half-written directory behind.
|
||||||
|
shutil.rmtree(player_code_dir, ignore_errors=True)
|
||||||
|
detail = clone.stderr.strip() or clone.stdout.strip()
|
||||||
|
if clone.returncode == 124 or 'timed out' in detail:
|
||||||
|
return fail(f'git clone timed out. The repository may be very '
|
||||||
|
f'large or unreachable: {detail}')
|
||||||
|
return fail(f'git clone failed: {detail}')
|
||||||
|
|
||||||
|
action = 'Cloned'
|
||||||
|
|
||||||
|
version = get_short_head(player_code_dir)
|
||||||
|
logger.info('%s player code from %s (%s) -> %s', action, repo_url, branch, version)
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': f'{action} player code from {branch} (version {version}).',
|
||||||
|
'version': version,
|
||||||
|
'branch': branch,
|
||||||
|
}
|
||||||
|
except Exception as e: # noqa: BLE001 - surface any failure
|
||||||
|
logger.exception('build_player_files failed')
|
||||||
|
# Never leave a broken checkout behind for the next attempt.
|
||||||
|
try:
|
||||||
|
if not is_valid_checkout(player_code_dir):
|
||||||
|
shutil.rmtree(player_code_dir, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return fail(f'Build failed: {str(e)}')
|
||||||
|
|
||||||
|
|
||||||
|
def write_base_config(
|
||||||
|
player_code_dir: str,
|
||||||
|
server_ip: str,
|
||||||
|
port: str,
|
||||||
|
use_https: bool = True,
|
||||||
|
verify_ssl: bool = False,
|
||||||
|
orientation: str = 'Landscape',
|
||||||
|
max_resolution: str = '1920x1080',
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Write a base ``config/app_config.json`` into the staged player code.
|
||||||
|
|
||||||
|
``screen_name`` and ``quickconnect_key`` are left blank on purpose: they are
|
||||||
|
per-player and get filled in by the SSH deploy step for each device.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
config_dir = os.path.join(player_code_dir, 'config')
|
||||||
|
os.makedirs(config_dir, exist_ok=True)
|
||||||
|
content = generate_app_config(
|
||||||
|
server_ip=server_ip,
|
||||||
|
port=str(port),
|
||||||
|
screen_name='',
|
||||||
|
quickconnect_code='',
|
||||||
|
orientation=orientation,
|
||||||
|
use_https=use_https,
|
||||||
|
verify_ssl=verify_ssl,
|
||||||
|
max_resolution=max_resolution,
|
||||||
|
)
|
||||||
|
config_path = os.path.join(config_dir, 'app_config.json')
|
||||||
|
with open(config_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
logger.info('Wrote base player config -> %s', config_path)
|
||||||
|
return {'success': True, 'message': 'Base player config written.', 'path': config_path}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('write_base_config failed')
|
||||||
|
return {'success': False, 'message': f'Failed to write config: {str(e)}'}
|
||||||
|
|
||||||
|
|
||||||
|
def load_build_settings(meta_path: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Load saved build settings from ``meta_path`` (or None if absent/invalid)."""
|
||||||
|
try:
|
||||||
|
if os.path.isfile(meta_path):
|
||||||
|
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('Could not read build settings: %s', e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_build_settings(meta_path: str, data: Dict[str, Any]) -> bool:
|
||||||
|
"""Persist build settings to ``meta_path``."""
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(meta_path), exist_ok=True)
|
||||||
|
with open(meta_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('Could not save build settings: %s', e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_player_server_settings(meta_path: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return the saved server address settings for deployment, if available.
|
||||||
|
|
||||||
|
Returns a dict with ``server_ip``, ``port`` (str), ``use_https`` (bool) and
|
||||||
|
``verify_ssl`` (bool), or None when no usable build settings are saved.
|
||||||
|
"""
|
||||||
|
settings = load_build_settings(meta_path)
|
||||||
|
if not settings:
|
||||||
|
return None
|
||||||
|
server_ip = (settings.get('server_ip') or '').strip()
|
||||||
|
if not server_ip:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'server_ip': server_ip,
|
||||||
|
'port': str(settings.get('port') or ('443' if settings.get('use_https', True) else '80')),
|
||||||
|
'use_https': bool(settings.get('use_https', True)),
|
||||||
|
'verify_ssl': bool(settings.get('verify_ssl', False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_build_record(repo_url, branch, server_ip, port, use_https, verify_ssl,
|
||||||
|
orientation, max_resolution, version, built_by) -> Dict[str, Any]:
|
||||||
|
"""Assemble the metadata record to persist after a build."""
|
||||||
|
return {
|
||||||
|
'repo_url': repo_url,
|
||||||
|
'branch': branch,
|
||||||
|
'server_ip': server_ip,
|
||||||
|
'port': str(port),
|
||||||
|
'use_https': bool(use_https),
|
||||||
|
'verify_ssl': bool(verify_ssl),
|
||||||
|
'orientation': orientation,
|
||||||
|
'max_resolution': max_resolution,
|
||||||
|
'built_version': version,
|
||||||
|
'built_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z',
|
||||||
|
'built_by': built_by,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Background builds
|
||||||
|
#
|
||||||
|
# A full clone/refresh takes far longer than gunicorn's worker timeout, so the
|
||||||
|
# build must not run inside the request. The admin route starts it here and the
|
||||||
|
# page polls `build_state()` for progress.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Serialises writes to _build_state between the request thread and the worker.
|
||||||
|
_build_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Coarse progress for the admin UI. 'state' is one of:
|
||||||
|
# idle | running | success | error
|
||||||
|
_build_state: Dict[str, Any] = {'state': 'idle'}
|
||||||
|
|
||||||
|
|
||||||
|
def get_build_state() -> Dict[str, Any]:
|
||||||
|
"""Return a snapshot of the current/last build for the admin UI."""
|
||||||
|
with _build_lock:
|
||||||
|
return dict(_build_state)
|
||||||
|
|
||||||
|
|
||||||
|
def is_build_running() -> bool:
|
||||||
|
"""True while a build is in progress."""
|
||||||
|
with _build_lock:
|
||||||
|
return _build_state.get('state') == 'running'
|
||||||
|
|
||||||
|
|
||||||
|
def _set_build_state(**fields: Any) -> None:
|
||||||
|
with _build_lock:
|
||||||
|
_build_state.update(fields)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_build_job(app, player_code_dir: str, repo_url: str, branch: str,
|
||||||
|
config_payload: Optional[Dict[str, Any]],
|
||||||
|
meta_path: str, built_by: str) -> None:
|
||||||
|
"""Worker body: build files, optionally write config, then persist settings.
|
||||||
|
|
||||||
|
Runs in a daemon thread with its own Flask app context so it is independent
|
||||||
|
of the request/response cycle that triggered it.
|
||||||
|
"""
|
||||||
|
started = datetime.utcnow()
|
||||||
|
try:
|
||||||
|
_set_build_state(state='running', step='Fetching player source…',
|
||||||
|
started_at=started.isoformat(timespec='seconds') + 'Z',
|
||||||
|
message='', version=None)
|
||||||
|
|
||||||
|
result = build_player_files(player_code_dir, repo_url, branch)
|
||||||
|
version = result.get('version')
|
||||||
|
|
||||||
|
if not result['success']:
|
||||||
|
_set_build_state(state='error', step='', message=result['message'],
|
||||||
|
version=version,
|
||||||
|
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
|
||||||
|
logger.error('Background player build failed: %s', result['message'])
|
||||||
|
return
|
||||||
|
|
||||||
|
# Optional step 2: write the base config.
|
||||||
|
if config_payload:
|
||||||
|
_set_build_state(step='Writing player config…')
|
||||||
|
cfg = write_base_config(player_code_dir=player_code_dir, **config_payload)
|
||||||
|
if not cfg['success']:
|
||||||
|
_set_build_state(state='error', step='', message=cfg['message'],
|
||||||
|
version=version,
|
||||||
|
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
|
||||||
|
logger.error('Background player config write failed: %s', cfg['message'])
|
||||||
|
return
|
||||||
|
result = {**result, 'message': f"{result['message']} {cfg['message']}"}
|
||||||
|
|
||||||
|
if version is None:
|
||||||
|
version = get_short_head(player_code_dir)
|
||||||
|
|
||||||
|
save_build_settings(
|
||||||
|
meta_path,
|
||||||
|
make_build_record(
|
||||||
|
repo_url=repo_url, branch=branch,
|
||||||
|
server_ip=(config_payload or {}).get('server_ip', ''),
|
||||||
|
port=(config_payload or {}).get('port', ''),
|
||||||
|
use_https=(config_payload or {}).get('use_https', False),
|
||||||
|
verify_ssl=(config_payload or {}).get('verify_ssl', False),
|
||||||
|
orientation=(config_payload or {}).get('orientation', 'Landscape'),
|
||||||
|
max_resolution=(config_payload or {}).get('max_resolution', '1920x1080'),
|
||||||
|
version=version, built_by=built_by,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
_set_build_state(state='success', step='', message=result['message'],
|
||||||
|
version=version,
|
||||||
|
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
|
||||||
|
logger.info('Background player build complete (version %s)', version)
|
||||||
|
except Exception as e: # noqa: BLE001 - never kill the thread silently
|
||||||
|
logger.exception('Background player build crashed')
|
||||||
|
_set_build_state(state='error', step='', message=f'Build failed: {e}',
|
||||||
|
finished_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z')
|
||||||
|
|
||||||
|
|
||||||
|
def start_background_build(player_code_dir: str, repo_url: str, branch: str,
|
||||||
|
config_payload: Optional[Dict[str, Any]],
|
||||||
|
meta_path: str, built_by: str) -> bool:
|
||||||
|
"""Start a player build in a daemon thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_code_dir: Where to stage the player source.
|
||||||
|
repo_url: Git repository URL.
|
||||||
|
branch: Branch to stage.
|
||||||
|
config_payload: Keyword args for :func:`write_base_config`, or None to
|
||||||
|
skip writing the config.
|
||||||
|
meta_path: Where to persist the build record.
|
||||||
|
built_by: Username shown in the UI/logs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
False if a build is already running (callers should tell the user),
|
||||||
|
True if a new build was started.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: if called with no Flask application context — the worker
|
||||||
|
thread needs a real app object to push its own context.
|
||||||
|
"""
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
if is_build_running():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Capture the real app object now. `current_app` resolves inside either a
|
||||||
|
# request or a plain application context; the worker thread pushes its own
|
||||||
|
# context later, since the caller's context is gone by then.
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
|
||||||
|
_set_build_state(state='running', step='Starting…', message='', version=None,
|
||||||
|
started_at=datetime.utcnow().isoformat(timespec='seconds') + 'Z',
|
||||||
|
finished_at=None, built_by=built_by,
|
||||||
|
repo_url=repo_url, branch=branch)
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_run_build_job,
|
||||||
|
args=(app, player_code_dir, repo_url, branch, config_payload, meta_path, built_by),
|
||||||
|
name='player-build',
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""
|
||||||
|
Portal SSO middleware for DigiServer v2.
|
||||||
|
|
||||||
|
When the umbrella nginx verifies the portal JWT it sets two headers:
|
||||||
|
X-Auth-Username — the portal username
|
||||||
|
X-Auth-Role — 'admin' or 'user'
|
||||||
|
|
||||||
|
This before_request handler reads those headers and auto-logs in the
|
||||||
|
corresponding local DigiServer user, creating them on first access if
|
||||||
|
needed. The local session is then maintained normally by Flask-Login.
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
from flask import request
|
||||||
|
from flask_login import login_user, current_user
|
||||||
|
|
||||||
|
|
||||||
|
def init_portal_sso(app):
|
||||||
|
"""Register the SSO before_request handler on the given Flask app."""
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def _portal_sso():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return
|
||||||
|
|
||||||
|
username = request.headers.get('X-Auth-Username', '').strip()
|
||||||
|
if not username:
|
||||||
|
return
|
||||||
|
|
||||||
|
role = request.headers.get('X-Auth-Role', 'user').strip()
|
||||||
|
user = _get_or_create_user(username, role)
|
||||||
|
if user:
|
||||||
|
login_user(user, remember=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_user(username, role):
|
||||||
|
from app.models.user import User
|
||||||
|
from app.extensions import db, bcrypt
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
if not user:
|
||||||
|
hashed_pw = bcrypt.generate_password_hash(secrets.token_hex(32)).decode('utf-8')
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
password=hashed_pw,
|
||||||
|
role='admin' if role == 'admin' else 'user',
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""
|
||||||
|
ScriptNameFix WSGI middleware.
|
||||||
|
|
||||||
|
When nginx strips the path prefix before forwarding to a Flask app it also
|
||||||
|
sets the X-Script-Name header (e.g. /digiserver). This middleware reads
|
||||||
|
that header and sets SCRIPT_NAME in the WSGI environ so that Flask's
|
||||||
|
url_for() generates absolute URLs with the correct prefix.
|
||||||
|
|
||||||
|
Usage in the app factory:
|
||||||
|
from app.utils.script_name_fix import ScriptNameFix
|
||||||
|
app.wsgi_app = ScriptNameFix(app.wsgi_app)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptNameFix:
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
def __call__(self, environ, start_response):
|
||||||
|
script_name = environ.get('HTTP_X_SCRIPT_NAME', '').rstrip('/')
|
||||||
|
if script_name:
|
||||||
|
environ['SCRIPT_NAME'] = script_name
|
||||||
|
path_info = environ.get('PATH_INFO', '/')
|
||||||
|
if path_info.startswith(script_name):
|
||||||
|
environ['PATH_INFO'] = path_info[len(script_name):] or '/'
|
||||||
|
return self.app(environ, start_response)
|
||||||
@@ -0,0 +1,753 @@
|
|||||||
|
"""SSH deployment utilities for player provisioning."""
|
||||||
|
import subprocess
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from typing import Tuple, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Pre-staged player code location in container
|
||||||
|
LOCAL_PLAYER_CODE_DIR = '/app/data/player'
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_player_code_status(player_code_dir: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Check status of pre-staged player code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_code_dir: Optional override for the staged code path. Defaults to
|
||||||
|
``LOCAL_PLAYER_CODE_DIR`` (the container location).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with availability, version, and path info
|
||||||
|
"""
|
||||||
|
code_dir = player_code_dir or LOCAL_PLAYER_CODE_DIR
|
||||||
|
try:
|
||||||
|
if not os.path.isdir(code_dir):
|
||||||
|
return {
|
||||||
|
'available': False,
|
||||||
|
'reason': 'Directory not found',
|
||||||
|
'path': code_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if git repository
|
||||||
|
git_dir = os.path.join(code_dir, '.git')
|
||||||
|
if not os.path.isdir(git_dir):
|
||||||
|
return {
|
||||||
|
'available': False,
|
||||||
|
'reason': 'Not a git repository',
|
||||||
|
'path': code_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current git version
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', '-C', code_dir, 'rev-parse', '--short', 'HEAD'],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
version = result.stdout.strip() if result.returncode == 0 else 'unknown'
|
||||||
|
except:
|
||||||
|
version = 'unknown'
|
||||||
|
|
||||||
|
# Get directory size
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['du', '-sh', code_dir],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
size = result.stdout.split()[0] if result.returncode == 0 else 'unknown'
|
||||||
|
except:
|
||||||
|
size = 'unknown'
|
||||||
|
|
||||||
|
return {
|
||||||
|
'available': True,
|
||||||
|
'path': code_dir,
|
||||||
|
'version': version,
|
||||||
|
'size': size,
|
||||||
|
'updated': os.path.getmtime(git_dir),
|
||||||
|
'reason': 'Pre-staged code ready for deployment'
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'Error checking player code status: {str(e)}')
|
||||||
|
return {
|
||||||
|
'available': False,
|
||||||
|
'reason': f'Status check failed: {str(e)}',
|
||||||
|
'path': code_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssh_connection(hostname: str, username: str, password: str, port: int = 22) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Test SSH connection to a remote host.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname: Target hostname or IP
|
||||||
|
username: SSH username
|
||||||
|
password: SSH password
|
||||||
|
port: SSH port (default 22)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status, message, and timestamp
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Use sshpass to test connection without interactive prompt
|
||||||
|
cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-o', 'ConnectTimeout=10',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
'echo "SSH connection successful"'
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': f'SSH connection successful to {hostname}',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'output': result.stdout.strip()
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
error_msg = result.stderr.strip() or result.stdout.strip()
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'SSH connection failed: {error_msg}',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': error_msg
|
||||||
|
}
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'SSH connection timeout to {hostname}',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': 'Connection timeout (10s)'
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'SSH test error: {str(e)}')
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'SSH connection error: {str(e)}',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_player_config(
|
||||||
|
player_name: str,
|
||||||
|
server_url: str,
|
||||||
|
api_key: str,
|
||||||
|
player_id: str = None,
|
||||||
|
location: str = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate player configuration JSON for connecting to DigiServer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_name: Name of the player
|
||||||
|
server_url: DigiServer base URL (e.g., http://localhost/digiserver)
|
||||||
|
api_key: API authentication key
|
||||||
|
player_id: Optional player ID (defaults to player_name)
|
||||||
|
location: Optional player location/description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON configuration string
|
||||||
|
"""
|
||||||
|
config = {
|
||||||
|
"player": {
|
||||||
|
"name": player_name,
|
||||||
|
"id": player_id or player_name,
|
||||||
|
"location": location or "",
|
||||||
|
"version": "2.0"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"url": server_url,
|
||||||
|
"api_endpoint": f"{server_url}/api",
|
||||||
|
"authentication": {
|
||||||
|
"type": "api_key",
|
||||||
|
"key": api_key
|
||||||
|
},
|
||||||
|
"endpoints": {
|
||||||
|
"playlists": f"{server_url}/api/playlists",
|
||||||
|
"content": f"{server_url}/api/content",
|
||||||
|
"schedule": f"{server_url}/api/schedule",
|
||||||
|
"heartbeat": f"{server_url}/api/player/heartbeat",
|
||||||
|
"logs": f"{server_url}/api/player/logs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"audio_enabled": True,
|
||||||
|
"video_enabled": True,
|
||||||
|
"max_resolution": "4K",
|
||||||
|
"refresh_interval": 60,
|
||||||
|
"rotation": "0"
|
||||||
|
},
|
||||||
|
"networking": {
|
||||||
|
"timeout": 30,
|
||||||
|
"retry_count": 3,
|
||||||
|
"retry_delay": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.dumps(config, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_server_ip() -> Optional[str]:
|
||||||
|
"""Best-effort detection of this server's primary LAN IP address.
|
||||||
|
|
||||||
|
Opens a UDP socket toward a public address (no packets are actually sent)
|
||||||
|
and reads the local socket address, which resolves to the IP of the
|
||||||
|
interface used for outbound traffic. Returns None on failure.
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
s = None
|
||||||
|
try:
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.connect(('8.8.8.8', 80))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
if ip and not ip.startswith('127.'):
|
||||||
|
return ip
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if s is not None:
|
||||||
|
try:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback via hostname resolution.
|
||||||
|
try:
|
||||||
|
ip = socket.gethostbyname(socket.gethostname())
|
||||||
|
if ip and not ip.startswith('127.'):
|
||||||
|
return ip
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_server_address(server_url: str) -> Dict[str, Any]:
|
||||||
|
"""Derive the values the player needs from a DigiServer URL.
|
||||||
|
|
||||||
|
The player's config/app_config.json stores server_ip + port + use_https and
|
||||||
|
builds requests as ``{scheme}://{server_ip}:{port}/api/...`` (it does NOT use
|
||||||
|
any URL path prefix such as ``/digiserver``). This helper extracts the host,
|
||||||
|
port and scheme from a server URL and drops any path component.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server_url: e.g. ``https://signage.example.com/digiserver`` or
|
||||||
|
``http://192.168.0.50:8080``
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``server_ip`` (str), ``port`` (str) and ``use_https`` (bool).
|
||||||
|
"""
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(server_url if '://' in (server_url or '') else f'//{server_url}')
|
||||||
|
use_https = (parsed.scheme or 'https') == 'https'
|
||||||
|
host = parsed.hostname or ''
|
||||||
|
port = parsed.port
|
||||||
|
if port is None:
|
||||||
|
port = 443 if use_https else 80
|
||||||
|
return {'server_ip': host, 'port': str(port), 'use_https': use_https}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_app_config(
|
||||||
|
server_ip: str,
|
||||||
|
port: str,
|
||||||
|
screen_name: str,
|
||||||
|
quickconnect_code: str,
|
||||||
|
orientation: str = 'Landscape',
|
||||||
|
use_https: bool = True,
|
||||||
|
verify_ssl: bool = False,
|
||||||
|
max_resolution: str = '1920x1080',
|
||||||
|
) -> str:
|
||||||
|
"""Generate the config/app_config.json the player actually reads.
|
||||||
|
|
||||||
|
This is what binds a deployed player to the real server and its assigned
|
||||||
|
playlist: the player authenticates with ``screen_name`` + ``quickconnect_code``
|
||||||
|
and the server returns the playlist assigned to that player.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server_ip: Server IP or domain the player should contact.
|
||||||
|
port: Server port as a string.
|
||||||
|
screen_name: Player hostname / screen identity (matches Player.hostname).
|
||||||
|
quickconnect_code: Quick connect code (matches Player.quickconnect_code).
|
||||||
|
orientation: Landscape or Portrait.
|
||||||
|
use_https: Whether the player should use HTTPS.
|
||||||
|
verify_ssl: Whether the player should verify the TLS certificate.
|
||||||
|
max_resolution: Maximum playback resolution.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON configuration string.
|
||||||
|
"""
|
||||||
|
config = {
|
||||||
|
'server_ip': server_ip,
|
||||||
|
'port': str(port),
|
||||||
|
'screen_name': screen_name,
|
||||||
|
'quickconnect_key': quickconnect_code,
|
||||||
|
'orientation': orientation or 'Landscape',
|
||||||
|
'touch': 'True',
|
||||||
|
'max_resolution': max_resolution,
|
||||||
|
'edit_feature_enabled': True,
|
||||||
|
'use_https': bool(use_https),
|
||||||
|
'verify_ssl': bool(verify_ssl),
|
||||||
|
}
|
||||||
|
return json.dumps(config, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def deploy_player_to_host(
|
||||||
|
hostname: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
player_name: str,
|
||||||
|
repo_url: str = 'https://gitea.moto-adv.com/ske087/Kiwy-Signage.git',
|
||||||
|
deploy_path: str = None, # Default: /home/[user]/kiwy-signage
|
||||||
|
port: int = 22,
|
||||||
|
server_url: str = None, # DigiServer URL for player to connect to
|
||||||
|
server_api_key: str = None, # API key for player authentication
|
||||||
|
player_hostname: str = None, # Player screen identity (Player.hostname)
|
||||||
|
quickconnect_code: str = None, # Player quick connect code
|
||||||
|
orientation: str = 'Landscape', # Player orientation
|
||||||
|
verify_ssl: bool = False, # Whether the player should verify TLS
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Deploy player code to remote host.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname: Target hostname or IP
|
||||||
|
username: SSH username
|
||||||
|
password: SSH password
|
||||||
|
player_name: Name for the player instance
|
||||||
|
repo_url: Git repository URL
|
||||||
|
deploy_path: Path where to deploy on remote host (default: /home/[user]/kiwy-signage)
|
||||||
|
port: SSH port (default 22)
|
||||||
|
server_url: DigiServer URL for player connection
|
||||||
|
server_api_key: API key for player authentication
|
||||||
|
player_hostname: Player screen identity used for auth (Player.hostname)
|
||||||
|
quickconnect_code: Quick connect code used for auth (Player.quickconnect_code)
|
||||||
|
orientation: Player orientation (Landscape/Portrait)
|
||||||
|
verify_ssl: Whether the player should verify the server TLS certificate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with deployment status and output
|
||||||
|
"""
|
||||||
|
# Set default deployment path to user's home directory
|
||||||
|
if deploy_path is None:
|
||||||
|
deploy_path = f'/home/{username}/kiwy-signage'
|
||||||
|
try:
|
||||||
|
# Step 1: Verify host accessibility
|
||||||
|
test_result = test_ssh_connection(hostname, username, password, port)
|
||||||
|
if not test_result['success']:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': 'Cannot deploy: SSH connection failed',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': test_result['message'],
|
||||||
|
'steps': []
|
||||||
|
}
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
{
|
||||||
|
'step': 'SSH Connection Test',
|
||||||
|
'status': 'completed',
|
||||||
|
'message': 'SSH connection successful',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Step 2: Create deployment directory
|
||||||
|
try:
|
||||||
|
mkdir_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
f'mkdir -p {deploy_path}'
|
||||||
|
]
|
||||||
|
result = subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
steps.append({
|
||||||
|
'step': 'Create Deploy Directory',
|
||||||
|
'status': 'completed' if result.returncode == 0 else 'failed',
|
||||||
|
'message': f'Directory {deploy_path} created',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Create Deploy Directory',
|
||||||
|
'status': 'failed',
|
||||||
|
'message': f'Failed: {str(e)}',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'Deployment failed at step: Create Deploy Directory',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': str(e),
|
||||||
|
'steps': steps
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 3: Deploy code (use local if available, otherwise clone from git)
|
||||||
|
try:
|
||||||
|
code_status = get_local_player_code_status()
|
||||||
|
|
||||||
|
if code_status['available']:
|
||||||
|
# Use pre-staged player code via rsync
|
||||||
|
logger.info(f'Using pre-staged player code (version: {code_status.get("version", "unknown")})')
|
||||||
|
|
||||||
|
rsync_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'rsync', '-avz',
|
||||||
|
'--delete',
|
||||||
|
'-e', f'ssh -o StrictHostKeyChecking=no -p {port}',
|
||||||
|
f'{LOCAL_PLAYER_CODE_DIR}/',
|
||||||
|
f'{username}@{hostname}:{deploy_path}/'
|
||||||
|
]
|
||||||
|
result = subprocess.run(rsync_cmd, capture_output=True, text=True, timeout=300)
|
||||||
|
|
||||||
|
steps.append({
|
||||||
|
'step': 'Deploy Code',
|
||||||
|
'status': 'completed' if result.returncode == 0 else 'failed',
|
||||||
|
'message': f'Code deployed via rsync (version: {code_status.get("version", "local")})',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.warning(f'Rsync failed, falling back to git clone: {result.stderr}')
|
||||||
|
# Fall back to git clone
|
||||||
|
raise Exception('Rsync failed, retrying with git')
|
||||||
|
else:
|
||||||
|
# No local code, clone from repository
|
||||||
|
logger.info(f'No pre-staged code available ({code_status.get("reason", "unknown")}), cloning from repository')
|
||||||
|
raise Exception('Local code not available')
|
||||||
|
|
||||||
|
except Exception as rsync_error:
|
||||||
|
# Fallback: Clone or pull repository
|
||||||
|
try:
|
||||||
|
logger.info(f'Deploying via git: {rsync_error}')
|
||||||
|
|
||||||
|
# Check if repo already exists
|
||||||
|
check_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
f'[ -d {deploy_path}/.git ]'
|
||||||
|
]
|
||||||
|
result = subprocess.run(check_cmd, capture_output=True, text=True, timeout=10)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Repo exists, pull latest
|
||||||
|
git_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
f'cd {deploy_path} && git pull origin main 2>&1'
|
||||||
|
]
|
||||||
|
git_msg = 'Pull latest code'
|
||||||
|
else:
|
||||||
|
# Clone repository
|
||||||
|
git_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
f'git clone {repo_url} {deploy_path} 2>&1'
|
||||||
|
]
|
||||||
|
git_msg = 'Clone repository'
|
||||||
|
|
||||||
|
result = subprocess.run(git_cmd, capture_output=True, text=True, timeout=120)
|
||||||
|
steps.append({
|
||||||
|
'step': 'Deploy Code',
|
||||||
|
'status': 'completed' if result.returncode == 0 else 'failed',
|
||||||
|
'message': f'{git_msg}: {result.stdout.split(chr(10))[0][:100]}',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'Deployment failed at step: Deploy Code',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': result.stderr or result.stdout,
|
||||||
|
'steps': steps
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Deploy Code',
|
||||||
|
'status': 'failed',
|
||||||
|
'message': f'Failed: {str(e)}',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'Deployment failed at step: Deploy Code',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': str(e),
|
||||||
|
'steps': steps
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 3.5: Generate player configuration (config/app_config.json)
|
||||||
|
# This is the file the player actually reads to learn the server address
|
||||||
|
# and its screen identity. Authenticating with that identity is what binds
|
||||||
|
# the player to its assigned playlist on the real server.
|
||||||
|
install_env_prefix = ''
|
||||||
|
try:
|
||||||
|
screen_name = player_hostname or player_name
|
||||||
|
if server_url and screen_name and quickconnect_code:
|
||||||
|
addr = parse_server_address(server_url)
|
||||||
|
app_config_content = generate_app_config(
|
||||||
|
server_ip=addr['server_ip'],
|
||||||
|
port=addr['port'],
|
||||||
|
screen_name=screen_name,
|
||||||
|
quickconnect_code=quickconnect_code,
|
||||||
|
orientation=orientation or 'Landscape',
|
||||||
|
use_https=addr['use_https'],
|
||||||
|
verify_ssl=verify_ssl,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build an env prefix so install.sh's configure_player() also runs
|
||||||
|
# (single, consistent configuration path on the player side).
|
||||||
|
import shlex
|
||||||
|
env_pairs = {
|
||||||
|
'KIWY_SERVER_IP': addr['server_ip'],
|
||||||
|
'KIWY_PORT': addr['port'],
|
||||||
|
'KIWY_SCREEN_NAME': screen_name,
|
||||||
|
'KIWY_QUICKCONNECT': quickconnect_code,
|
||||||
|
'KIWY_ORIENTATION': orientation or 'Landscape',
|
||||||
|
'KIWY_USE_HTTPS': 'true' if addr['use_https'] else 'false',
|
||||||
|
'KIWY_VERIFY_SSL': 'true' if verify_ssl else 'false',
|
||||||
|
}
|
||||||
|
install_env_prefix = ' '.join(
|
||||||
|
f'{k}={shlex.quote(str(v))}' for k, v in env_pairs.items()
|
||||||
|
) + ' '
|
||||||
|
|
||||||
|
# Write config/app_config.json directly (robust even if install.sh
|
||||||
|
# is missing or fails), and clear any stale baked-in auth.
|
||||||
|
remote_cmd = (
|
||||||
|
f'mkdir -p {deploy_path}/config && '
|
||||||
|
f"cat > {deploy_path}/config/app_config.json << 'EOF'\n"
|
||||||
|
f'{app_config_content}\n'
|
||||||
|
f'EOF\n'
|
||||||
|
f'rm -f {deploy_path}/player_auth.json {deploy_path}/src/player_auth.json 2>/dev/null || true'
|
||||||
|
)
|
||||||
|
write_config_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
remote_cmd
|
||||||
|
]
|
||||||
|
result = subprocess.run(write_config_cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
steps.append({
|
||||||
|
'step': 'Configure Player',
|
||||||
|
'status': 'completed' if result.returncode == 0 else 'warning',
|
||||||
|
'message': (
|
||||||
|
f'Wrote config/app_config.json '
|
||||||
|
f'(server {addr["server_ip"]}:{addr["port"]}, screen {screen_name})'
|
||||||
|
),
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Configure Player',
|
||||||
|
'status': 'skipped',
|
||||||
|
'message': 'Missing server_url / player hostname / quickconnect; player not auto-configured',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'Failed to create player config: {str(e)}')
|
||||||
|
steps.append({
|
||||||
|
'step': 'Configure Player',
|
||||||
|
'status': 'warning',
|
||||||
|
'message': f'Failed to write player config: {str(e)}',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Step 4: Run installation script
|
||||||
|
# Before running the install script, grant the SSH user temporary
|
||||||
|
# passwordless sudo so that any 'sudo apt-get / pip install' calls inside
|
||||||
|
# install.sh don't hang waiting for an interactive password prompt.
|
||||||
|
# The sudoers entry is removed automatically after the script finishes.
|
||||||
|
try:
|
||||||
|
sudoers_file = f'/etc/sudoers.d/kiwy_deploy_{username}'
|
||||||
|
setup_sudo_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
(
|
||||||
|
f'echo {password!r} | sudo -S bash -c '
|
||||||
|
f'"echo \\"{username} ALL=(ALL) NOPASSWD:ALL\\" '
|
||||||
|
f'> {sudoers_file} && chmod 440 {sudoers_file}" 2>&1'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
sudo_result = subprocess.run(setup_sudo_cmd, capture_output=True, text=True, timeout=15)
|
||||||
|
sudo_configured = sudo_result.returncode == 0
|
||||||
|
if not sudo_configured:
|
||||||
|
logger.warning(f'Could not configure passwordless sudo: {sudo_result.stderr[:200]}')
|
||||||
|
except Exception as e:
|
||||||
|
sudo_configured = False
|
||||||
|
logger.warning(f'Passwordless sudo setup failed: {str(e)}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build a shell one-liner: run the first install script found.
|
||||||
|
# Priority: install.sh > setup.sh > install_player.sh > any *.sh except start.sh
|
||||||
|
run_install_cmd = (
|
||||||
|
f'cd {deploy_path} && '
|
||||||
|
f'INSTALL_SCRIPT="" && '
|
||||||
|
f'for s in install.sh setup.sh install_player.sh; do '
|
||||||
|
f' if [ -f "$s" ]; then INSTALL_SCRIPT="$s"; break; fi; '
|
||||||
|
f'done && '
|
||||||
|
f'if [ -z "$INSTALL_SCRIPT" ]; then '
|
||||||
|
f' INSTALL_SCRIPT=$(ls *.sh 2>/dev/null | grep -v "^start.sh$" | head -1); '
|
||||||
|
f'fi && '
|
||||||
|
f'if [ -n "$INSTALL_SCRIPT" ]; then '
|
||||||
|
f' chmod +x "$INSTALL_SCRIPT" && '
|
||||||
|
f' echo "Running $INSTALL_SCRIPT" && '
|
||||||
|
f' {install_env_prefix}bash "$INSTALL_SCRIPT" 2>&1; '
|
||||||
|
f' echo "Exit code: $?"; '
|
||||||
|
f'else '
|
||||||
|
f' echo "No install script found"; '
|
||||||
|
f'fi'
|
||||||
|
)
|
||||||
|
install_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
run_install_cmd
|
||||||
|
]
|
||||||
|
result = subprocess.run(install_cmd, capture_output=True, text=True, timeout=600)
|
||||||
|
output = (result.stdout or '').strip()
|
||||||
|
|
||||||
|
if 'No install script found' in output:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Run Installation Script',
|
||||||
|
'status': 'skipped',
|
||||||
|
'message': 'No install script found in deploy directory',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
script_line = next((l for l in output.splitlines() if l.startswith('Running ')), '')
|
||||||
|
script_name = script_line.replace('Running ', '').strip() or 'install script'
|
||||||
|
steps.append({
|
||||||
|
'step': 'Run Installation Script',
|
||||||
|
'status': 'completed' if result.returncode == 0 else 'completed_with_warnings',
|
||||||
|
'message': f'Executed {script_name} (exit {result.returncode})',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.warning(f'Install script exited {result.returncode}: {result.stderr[:200]}')
|
||||||
|
else:
|
||||||
|
logger.info(f'Install script completed successfully on {hostname}')
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Run Installation Script',
|
||||||
|
'status': 'completed_with_warnings',
|
||||||
|
'message': 'Install script timed out after 600s — it may still be running on the device',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Run Installation Script',
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Error running installation: {str(e)}',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
logger.error(f'Installation script error: {str(e)}')
|
||||||
|
finally:
|
||||||
|
# Always clean up the temporary sudoers entry
|
||||||
|
if sudo_configured:
|
||||||
|
try:
|
||||||
|
cleanup_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
f'sudo rm -f {sudoers_file} 2>/dev/null || true'
|
||||||
|
]
|
||||||
|
subprocess.run(cleanup_cmd, capture_output=True, text=True, timeout=10)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Step 5: Start player service (execute start.sh)
|
||||||
|
try:
|
||||||
|
# Check if start.sh exists
|
||||||
|
check_start = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
f'[ -f {deploy_path}/start.sh ]'
|
||||||
|
]
|
||||||
|
start_check = subprocess.run(check_start, capture_output=True, text=True, timeout=10)
|
||||||
|
|
||||||
|
if start_check.returncode == 0:
|
||||||
|
# Make sure start.sh is executable and run it
|
||||||
|
start_cmd = [
|
||||||
|
'sshpass', '-p', password,
|
||||||
|
'ssh', '-o', 'StrictHostKeyChecking=no',
|
||||||
|
'-p', str(port),
|
||||||
|
f'{username}@{hostname}',
|
||||||
|
f'cd {deploy_path} && chmod +x start.sh && bash start.sh 2>&1'
|
||||||
|
]
|
||||||
|
result = subprocess.run(start_cmd, capture_output=True, text=True, timeout=300)
|
||||||
|
|
||||||
|
# Capture first line of output for feedback
|
||||||
|
output_msg = result.stdout.split('\n')[0][:100] if result.stdout else 'Started'
|
||||||
|
|
||||||
|
steps.append({
|
||||||
|
'step': 'Start Player Service',
|
||||||
|
'status': 'completed' if result.returncode == 0 else 'completed_with_warnings',
|
||||||
|
'message': f'Player service started: {output_msg}',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
logger.info(f'Player service started on {hostname} at {deploy_path}')
|
||||||
|
else:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Start Player Service',
|
||||||
|
'status': 'warning',
|
||||||
|
'message': 'start.sh not found - player may require manual startup',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
logger.warning(f'start.sh not found at {deploy_path}/start.sh on {hostname}')
|
||||||
|
except Exception as e:
|
||||||
|
steps.append({
|
||||||
|
'step': 'Start Player Service',
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Error starting player service: {str(e)}',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
logger.error(f'Failed to start player service: {str(e)}')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message': f'Player "{player_name}" deployed successfully to {hostname}',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'deploy_path': deploy_path,
|
||||||
|
'steps': steps
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Deployment error: {str(e)}')
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'Unexpected deployment error: {str(e)}',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'error': str(e),
|
||||||
|
'steps': []
|
||||||
|
}
|
||||||
@@ -16,10 +16,17 @@ echo -e "${BLUE}║ DigiServer Automated Deployment
|
|||||||
echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}"
|
echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Check if docker-compose is available
|
# Check if docker compose is available. Accept either the modern plugin
|
||||||
if ! command -v docker-compose &> /dev/null; then
|
# (`docker compose`) or the standalone v1 binary (`docker-compose`), since not
|
||||||
echo -e "${RED}❌ docker-compose not found!${NC}"
|
# every Docker installation ships the Compose plugin.
|
||||||
echo "Please install docker-compose first"
|
if docker compose version &> /dev/null; then
|
||||||
|
COMPOSE="docker compose"
|
||||||
|
elif command -v docker-compose &> /dev/null; then
|
||||||
|
COMPOSE="docker-compose"
|
||||||
|
echo -e "${YELLOW}⚠️ Using legacy 'docker-compose' (v1); the 'docker compose' plugin is unavailable.${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ docker compose not found!${NC}"
|
||||||
|
echo "Please install the docker compose plugin or docker-compose first"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -30,36 +37,134 @@ if [ ! -f "docker-compose.yml" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# INITIALIZATION: Create data directories and seed the Caddy config
|
||||||
|
# ============================================================================
|
||||||
|
echo -e "${YELLOW}📁 Initializing data directories...${NC}"
|
||||||
|
|
||||||
|
# Create necessary data directories
|
||||||
|
mkdir -p data/instance
|
||||||
|
mkdir -p data/uploads
|
||||||
|
mkdir -p data/caddy-data
|
||||||
|
mkdir -p data/caddy-config
|
||||||
|
mkdir -p data/caddy-logs
|
||||||
|
|
||||||
|
# Seed the Caddyfile. It is bind-mounted as a FILE, so it MUST exist before
|
||||||
|
# `docker compose up` — otherwise Docker creates a directory in its place and
|
||||||
|
# Caddy fails to start.
|
||||||
|
if [ -f "data/Caddyfile" ]; then
|
||||||
|
echo -e " ${GREEN}✓${NC} data/Caddyfile present"
|
||||||
|
elif [ -f "Caddyfile.example" ]; then
|
||||||
|
cp Caddyfile.example data/Caddyfile
|
||||||
|
echo -e " ${GREEN}✓${NC} data/Caddyfile seeded from Caddyfile.example"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}❌ Caddyfile.example not found in repo root!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✅ Data directories initialized${NC}"
|
||||||
|
echo ""
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# CONFIGURATION VARIABLES
|
# CONFIGURATION VARIABLES
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
HOSTNAME="${HOSTNAME:-digiserver}"
|
# NOTE: do NOT use the name HOSTNAME here — it is a bash built-in that already
|
||||||
DOMAIN="${DOMAIN:-digiserver.sibiusb.harting.intra}"
|
# holds the machine's hostname (e.g. "development"), so `${HOSTNAME:-default}`
|
||||||
IP_ADDRESS="${IP_ADDRESS:-10.76.152.164}"
|
# would silently ignore the default and leak the OS hostname into the Caddyfile.
|
||||||
|
SERVER_HOSTNAME="${SERVER_HOSTNAME:-digiserver}"
|
||||||
EMAIL="${EMAIL:-admin@example.com}"
|
EMAIL="${EMAIL:-admin@example.com}"
|
||||||
PORT="${PORT:-443}"
|
|
||||||
|
# Auto-detect the primary LAN IP unless one was supplied explicitly.
|
||||||
|
if [ -z "${IP_ADDRESS:-}" ]; then
|
||||||
|
IP_ADDRESS="$(ip -4 route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[\d.]+' | head -1)"
|
||||||
|
if [ -z "$IP_ADDRESS" ]; then
|
||||||
|
IP_ADDRESS="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -z "$IP_ADDRESS" ]; then
|
||||||
|
echo -e "${RED}❌ Could not determine the server IP. Set IP_ADDRESS=... and re-run.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# HTTPS_MODE selects how TLS is provided:
|
||||||
|
# internal : Caddy's internal CA, IP-only. Needs NO public DNS and NO ACME.
|
||||||
|
# Correct for an intranet name such as digiserver.sibiusb.harting.intra.
|
||||||
|
# acme : Let's Encrypt — requires DOMAIN to be publicly resolvable.
|
||||||
|
# off : plain HTTP only.
|
||||||
|
HTTPS_MODE="${HTTPS_MODE:-internal}"
|
||||||
|
|
||||||
|
case "$HTTPS_MODE" in
|
||||||
|
internal)
|
||||||
|
# An empty DOMAIN is what makes CaddyConfigGenerator choose the
|
||||||
|
# internal-CA path instead of Let's Encrypt.
|
||||||
|
DOMAIN=""
|
||||||
|
;;
|
||||||
|
acme)
|
||||||
|
if [ -z "${DOMAIN:-}" ]; then
|
||||||
|
echo -e "${RED}❌ HTTPS_MODE=acme requires DOMAIN to be set.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
off)
|
||||||
|
DOMAIN=""
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}❌ Invalid HTTPS_MODE '$HTTPS_MODE' (use internal|acme|off).${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Published ports. These MUST match docker-compose.yml, which maps Caddy's
|
||||||
|
# internal 80/443 to these host ports (HTTP_PORT / HTTPS_PORT).
|
||||||
|
HTTP_PORT="${HTTP_PORT:-80}"
|
||||||
|
HTTPS_PORT="${HTTPS_PORT:-443}"
|
||||||
|
|
||||||
echo -e "${BLUE}Configuration:${NC}"
|
echo -e "${BLUE}Configuration:${NC}"
|
||||||
echo " Hostname: $HOSTNAME"
|
echo " Hostname: $SERVER_HOSTNAME"
|
||||||
echo " Domain: $DOMAIN"
|
echo " HTTPS mode: $HTTPS_MODE"
|
||||||
|
echo " Domain: ${DOMAIN:-(none — internal CA)}"
|
||||||
echo " IP Address: $IP_ADDRESS"
|
echo " IP Address: $IP_ADDRESS"
|
||||||
echo " Email: $EMAIL"
|
echo " Email: $EMAIL"
|
||||||
echo " Port: $PORT"
|
echo " HTTP port: $HTTP_PORT"
|
||||||
|
echo " HTTPS port: $HTTPS_PORT"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# STEP 1: Start containers
|
# STEP 1: Build and start containers
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo -e "${YELLOW}📦 [1/6] Starting containers...${NC}"
|
echo -e "${YELLOW}📦 [1/6] Building and starting containers...${NC}"
|
||||||
docker-compose up -d
|
|
||||||
|
# Compose v1 refuses to build unless the buildx plugin is >= 0.17:
|
||||||
|
# "compose build requires buildx 0.17.0 or later"
|
||||||
|
# Distro Packaged Docker ships older buildx (or none). Detect that and fall back
|
||||||
|
# to a plain `docker build` + `up --no-build`, which needs no buildx at all.
|
||||||
|
APP_IMAGE="digiserver-v2-digiserver-app:latest"
|
||||||
|
|
||||||
|
BUILDX_VER="$(docker buildx version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+' | head -1)"
|
||||||
|
BUILDX_OK=0
|
||||||
|
if [ -n "$BUILDX_VER" ]; then
|
||||||
|
_bx_major="${BUILDX_VER%%.*}"
|
||||||
|
_bx_minor="${BUILDX_VER##*.}"
|
||||||
|
if [ "$_bx_major" -gt 0 ] || { [ "$_bx_major" -eq 0 ] && [ "$_bx_minor" -ge 17 ]; }; then
|
||||||
|
BUILDX_OK=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$BUILDX_OK" -eq 1 ]; then
|
||||||
|
echo -e " ${GREEN}✓${NC} buildx ${BUILDX_VER} — building via compose"
|
||||||
|
$COMPOSE up -d --build
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}⚠${NC} buildx ${BUILDX_VER:-not found} (< 0.17) — falling back to 'docker build'"
|
||||||
|
docker build -t "$APP_IMAGE" .
|
||||||
|
$COMPOSE up -d --no-build
|
||||||
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}⏳ Waiting for containers to be healthy...${NC}"
|
echo -e "${YELLOW}⏳ Waiting for containers to be healthy...${NC}"
|
||||||
sleep 10
|
sleep 15
|
||||||
|
|
||||||
# Verify containers are running
|
# Verify containers are running
|
||||||
if ! docker-compose ps | grep -q "Up"; then
|
if ! $COMPOSE ps | grep -q "Up"; then
|
||||||
echo -e "${RED}❌ Containers failed to start!${NC}"
|
echo -e "${RED}❌ Containers failed to start!${NC}"
|
||||||
docker-compose logs
|
$COMPOSE logs
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo -e "${GREEN}✅ Containers started successfully${NC}"
|
echo -e "${GREEN}✅ Containers started successfully${NC}"
|
||||||
@@ -71,13 +176,15 @@ echo ""
|
|||||||
echo -e "${YELLOW}📊 [2/6] Running database migrations...${NC}"
|
echo -e "${YELLOW}📊 [2/6] Running database migrations...${NC}"
|
||||||
|
|
||||||
echo -e " • Creating https_config table..."
|
echo -e " • Creating https_config table..."
|
||||||
docker-compose exec -T digiserver-app python /app/migrations/add_https_config_table.py
|
$COMPOSE exec -T digiserver-app python /app/migrations/add_https_config_table.py
|
||||||
echo -e " • Creating player_user table..."
|
echo -e " • Creating player_user table..."
|
||||||
docker-compose exec -T digiserver-app python /app/migrations/add_player_user_table.py
|
$COMPOSE exec -T digiserver-app python /app/migrations/add_player_user_table.py
|
||||||
echo -e " • Adding email to https_config..."
|
echo -e " • Adding email to https_config..."
|
||||||
docker-compose exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
|
$COMPOSE exec -T digiserver-app python /app/migrations/add_email_to_https_config.py
|
||||||
echo -e " • Migrating player_user global settings..."
|
echo -e " • Migrating player_user global settings..."
|
||||||
docker-compose exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
|
$COMPOSE exec -T digiserver-app python /app/migrations/migrate_player_user_global.py
|
||||||
|
echo -e " • Adding original_filename to content..."
|
||||||
|
$COMPOSE exec -T digiserver-app python /app/migrations/add_original_filename_to_content.py
|
||||||
|
|
||||||
echo -e "${GREEN}✅ All database migrations completed${NC}"
|
echo -e "${GREEN}✅ All database migrations completed${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -85,16 +192,41 @@ echo ""
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# STEP 3: Configure HTTPS
|
# STEP 3: Configure HTTPS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo -e "${YELLOW}🔒 [3/6] Configuring HTTPS...${NC}"
|
echo -e "${YELLOW}🔒 [3/6] Configuring HTTPS (mode: $HTTPS_MODE)...${NC}"
|
||||||
|
|
||||||
docker-compose exec -T digiserver-app python /app/https_manager.py enable \
|
# https_manager.py mirrors the Admin UI code path: persist HTTPSConfig →
|
||||||
"$HOSTNAME" \
|
# regenerate the Caddyfile → hot-reload Caddy → verify the TLS listener and
|
||||||
"$DOMAIN" \
|
# automatically fall back to HTTP if it does not come up.
|
||||||
"$EMAIL" \
|
#
|
||||||
"$IP_ADDRESS" \
|
# `bootstrap` (rather than `enable`) is used deliberately: it records provenance
|
||||||
"$PORT"
|
# via HTTPSConfig.updated_by, so an admin's later change in the UI is not
|
||||||
|
# silently overwritten on the next restart.
|
||||||
|
#
|
||||||
|
# The values computed above are injected with -e so this run uses them instead
|
||||||
|
# of whatever happens to be in the container's .env.
|
||||||
|
#
|
||||||
|
# Exit code 2 means "config applied but Caddy did not reload" — not fatal, so it
|
||||||
|
# must not abort the deployment.
|
||||||
|
set +e
|
||||||
|
if [ "$HTTPS_MODE" = "off" ]; then
|
||||||
|
$COMPOSE exec -T digiserver-app python /app/https_manager.py disable
|
||||||
|
else
|
||||||
|
$COMPOSE exec -T \
|
||||||
|
-e HOSTNAME_INTERNAL="$SERVER_HOSTNAME" \
|
||||||
|
-e HOST_IP="$IP_ADDRESS" \
|
||||||
|
-e DOMAIN="$DOMAIN" \
|
||||||
|
-e SSL_EMAIL="$EMAIL" \
|
||||||
|
-e HTTPS_PORT="$HTTPS_PORT" \
|
||||||
|
digiserver-app python /app/https_manager.py bootstrap
|
||||||
|
fi
|
||||||
|
HTTPS_RC=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
echo -e "${GREEN}✅ HTTPS configured successfully${NC}"
|
case "$HTTPS_RC" in
|
||||||
|
0) echo -e "${GREEN}✅ HTTPS configured${NC}" ;;
|
||||||
|
2) echo -e "${YELLOW}⚠️ HTTPS config applied but Caddy did not reload — restart the caddy container.${NC}" ;;
|
||||||
|
*) echo -e "${YELLOW}⚠️ HTTPS not configured (or verification failed and it fell back to HTTP); continuing.${NC}" ;;
|
||||||
|
esac
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -102,20 +234,21 @@ echo ""
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo -e "${YELLOW}🔍 [4/6] Verifying database setup...${NC}"
|
echo -e "${YELLOW}🔍 [4/6] Verifying database setup...${NC}"
|
||||||
|
|
||||||
docker-compose exec -T digiserver-app python -c "
|
$COMPOSE exec -T digiserver-app python -c "
|
||||||
from app.app import create_app
|
from app.app import create_app
|
||||||
|
from app.extensions import db
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
inspector = inspect(app.extensions.db.engine)
|
inspector = inspect(db.engine)
|
||||||
tables = inspector.get_table_names()
|
tables = inspector.get_table_names()
|
||||||
print(' Database tables:')
|
print(' Database tables:')
|
||||||
for table in sorted(tables):
|
for table in sorted(tables):
|
||||||
print(f' ✓ {table}')
|
print(f' ✓ {table}')
|
||||||
print(f'')
|
print(f'')
|
||||||
print(f' ✅ Total tables: {len(tables)}')
|
print(f' ✅ Total tables: {len(tables)}')
|
||||||
" 2>/dev/null || echo " ⚠️ Database verification skipped"
|
" || echo " ⚠️ Database verification skipped"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -123,7 +256,7 @@ echo ""
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo -e "${YELLOW}🔧 [5/6] Verifying Caddy configuration...${NC}"
|
echo -e "${YELLOW}🔧 [5/6] Verifying Caddy configuration...${NC}"
|
||||||
|
|
||||||
docker-compose exec -T caddy caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1
|
$COMPOSE exec -T caddy caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ]; then
|
||||||
echo -e " ${GREEN}✅ Caddy configuration is valid${NC}"
|
echo -e " ${GREEN}✅ Caddy configuration is valid${NC}"
|
||||||
else
|
else
|
||||||
@@ -137,7 +270,7 @@ echo ""
|
|||||||
echo -e "${YELLOW}📋 [6/6] Displaying configuration summary...${NC}"
|
echo -e "${YELLOW}📋 [6/6] Displaying configuration summary...${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
docker-compose exec -T digiserver-app python /app/https_manager.py status
|
$COMPOSE exec -T digiserver-app python /app/https_manager.py status
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}"
|
echo -e "${GREEN}╔════════════════════════════════════════════════════════════════╗${NC}"
|
||||||
@@ -145,21 +278,57 @@ echo -e "${GREEN}║ 🎉 Deployment Complete!
|
|||||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}"
|
echo -e "${GREEN}╚════════════════════════════════════════════════════════════════╝${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# Build host:port suffixes, omitting the default ports for readability.
|
||||||
|
_http_url="http://$IP_ADDRESS"
|
||||||
|
[ "$HTTP_PORT" != "80" ] && _http_url="http://$IP_ADDRESS:$HTTP_PORT"
|
||||||
|
_https_url="https://$IP_ADDRESS"
|
||||||
|
[ "$HTTPS_PORT" != "443" ] && _https_url="https://$IP_ADDRESS:$HTTPS_PORT"
|
||||||
|
|
||||||
echo -e "${BLUE}📍 Access Points:${NC}"
|
echo -e "${BLUE}📍 Access Points:${NC}"
|
||||||
echo " 🔒 https://$HOSTNAME"
|
echo -e " 🌐 ${_http_url} (always available)"
|
||||||
echo " 🔒 https://$IP_ADDRESS"
|
case "$HTTPS_MODE" in
|
||||||
echo " 🔒 https://$DOMAIN"
|
internal)
|
||||||
|
echo -e " 🔒 ${_https_url} (internal CA — see note below)"
|
||||||
|
echo -e " 🔒 https://$SERVER_HOSTNAME (needs DNS or an /etc/hosts entry)"
|
||||||
|
;;
|
||||||
|
acme)
|
||||||
|
echo -e " 🔒 https://$DOMAIN"
|
||||||
|
;;
|
||||||
|
off)
|
||||||
|
echo -e " ℹ️ HTTPS disabled (HTTPS_MODE=off)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo -e "${BLUE}📝 Default Credentials:${NC}"
|
if [ "$HTTPS_MODE" = "internal" ]; then
|
||||||
echo " Username: admin"
|
echo -e "${YELLOW}⚠️ Internal CA certificate notice:${NC}"
|
||||||
echo " Password: admin123 (⚠️ CHANGE IN PRODUCTION)"
|
echo " The TLS certificate is signed by Caddy's LOCAL CA, which is not in any"
|
||||||
|
echo " client's trust store. Browsers will warn and players will reject it"
|
||||||
|
echo " unless you either:"
|
||||||
|
echo " a) install the root CA on each device, or"
|
||||||
|
echo " b) use the plain HTTP endpoint above (simplest for players)."
|
||||||
|
echo ""
|
||||||
|
echo " Export the root CA with:"
|
||||||
|
echo " $COMPOSE cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BLUE}📝 Administrator Account:${NC}"
|
||||||
|
if [ -f ".deployment-credentials" ]; then
|
||||||
|
echo " Credentials are in .deployment-credentials (chmod 600)."
|
||||||
|
else
|
||||||
|
echo " Username: ${ADMIN_USERNAME:-admin}"
|
||||||
|
echo " Password: see ADMIN_PASSWORD in .env (or the container's ADMIN_PASSWORD)"
|
||||||
|
fi
|
||||||
|
if grep -qs '^ADMIN_PASSWORD=admin123' .env 2>/dev/null; then
|
||||||
|
echo -e " ${RED}⚠️ ADMIN_PASSWORD is still the default — change it now!${NC}"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo -e "${BLUE}📚 Documentation:${NC}"
|
echo -e "${BLUE}📚 Documentation:${NC}"
|
||||||
echo " • DEPLOYMENT_COMMANDS.md - Detailed docker exec commands"
|
echo " • docs/07-deployment.md - Deployment + HTTPS details"
|
||||||
echo " • HTTPS_CONFIGURATION.md - HTTPS setup details"
|
echo " • docs/06-utils-services.md - Caddy / https_manager internals"
|
||||||
echo " • setup_https.sh - Manual configuration script"
|
echo " • Caddyfile.example - Caddy template (seeded into data/)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo -e "${YELLOW}Next Steps:${NC}"
|
echo -e "${YELLOW}Next Steps:${NC}"
|
||||||
@@ -170,5 +339,5 @@ echo "4. Configure your players and content"
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo -e "${BLUE}📞 Support:${NC}"
|
echo -e "${BLUE}📞 Support:${NC}"
|
||||||
echo "For troubleshooting, see DEPLOYMENT_COMMANDS.md section 7"
|
echo "For troubleshooting, see docs/07-deployment.md"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# DigiServer v2 Production Deployment Commands Reference
|
||||||
|
# Use this file as a reference for all deployment-related operations
|
||||||
|
|
||||||
|
echo "📋 DigiServer v2 Production Deployment Reference"
|
||||||
|
echo "================================================="
|
||||||
|
echo ""
|
||||||
|
echo "QUICK START:"
|
||||||
|
echo " 1. Set environment variables"
|
||||||
|
echo " 2. Create .env file"
|
||||||
|
echo " 3. Run: docker-compose up -d"
|
||||||
|
echo ""
|
||||||
|
echo "Available commands below (copy/paste as needed):"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SECTION: INITIAL SETUP
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
echo "=== SECTION 1: INITIAL SETUP ==="
|
||||||
|
echo ""
|
||||||
|
echo "Generate Secret Key:"
|
||||||
|
echo ' python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
||||||
|
echo ""
|
||||||
|
echo "Create environment file from template:"
|
||||||
|
echo " cp .env.example .env"
|
||||||
|
echo " nano .env # Edit with your values"
|
||||||
|
echo ""
|
||||||
|
echo "Required .env variables:"
|
||||||
|
echo " SECRET_KEY=<generated-32-char-key>"
|
||||||
|
echo " ADMIN_USERNAME=admin"
|
||||||
|
echo " ADMIN_PASSWORD=<strong-password>"
|
||||||
|
echo " ADMIN_EMAIL=admin@company.com"
|
||||||
|
echo " DOMAIN=your-domain.com"
|
||||||
|
echo " EMAIL=admin@your-domain.com"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SECTION: DOCKER OPERATIONS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
echo "=== SECTION 2: DOCKER OPERATIONS ==="
|
||||||
|
echo ""
|
||||||
|
echo "Build images:"
|
||||||
|
echo " docker-compose build"
|
||||||
|
echo ""
|
||||||
|
echo "Start services:"
|
||||||
|
echo " docker-compose up -d"
|
||||||
|
echo ""
|
||||||
|
echo "Stop services:"
|
||||||
|
echo " docker-compose down"
|
||||||
|
echo ""
|
||||||
|
echo "Restart services:"
|
||||||
|
echo " docker-compose restart"
|
||||||
|
echo ""
|
||||||
|
echo "View container status:"
|
||||||
|
echo " docker-compose ps"
|
||||||
|
echo ""
|
||||||
|
echo "View logs (live):"
|
||||||
|
echo " docker-compose logs -f digiserver-app"
|
||||||
|
echo ""
|
||||||
|
echo "View logs (last 100 lines):"
|
||||||
|
echo " docker-compose logs --tail=100 digiserver-app"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SECTION: DATABASE OPERATIONS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
echo "=== SECTION 3: DATABASE OPERATIONS ==="
|
||||||
|
echo ""
|
||||||
|
echo "Initialize database (first deployment only):"
|
||||||
|
echo " docker-compose exec digiserver-app flask db upgrade"
|
||||||
|
echo ""
|
||||||
|
echo "Run database migrations:"
|
||||||
|
echo " docker-compose exec digiserver-app flask db upgrade head"
|
||||||
|
echo ""
|
||||||
|
echo "Create new migration (after model changes):"
|
||||||
|
echo " docker-compose exec digiserver-app flask db migrate -m 'description'"
|
||||||
|
echo ""
|
||||||
|
echo "Backup database:"
|
||||||
|
echo " docker-compose exec digiserver-app cp instance/dashboard.db /backup/dashboard.db.bak"
|
||||||
|
echo ""
|
||||||
|
echo "Restore database:"
|
||||||
|
echo " docker-compose exec digiserver-app cp /backup/dashboard.db.bak instance/dashboard.db"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SECTION: VERIFICATION & TESTING
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
echo "=== SECTION 4: VERIFICATION & TESTING ==="
|
||||||
|
echo ""
|
||||||
|
echo "Health check:"
|
||||||
|
echo " curl -k https://your-domain.com/api/health"
|
||||||
|
echo ""
|
||||||
|
echo "Check CORS headers (should see Access-Control-Allow-*):"
|
||||||
|
echo " curl -i -k https://your-domain.com/api/playlists"
|
||||||
|
echo ""
|
||||||
|
echo "Check HTTPS only (should redirect):"
|
||||||
|
echo " curl -i http://your-domain.com/"
|
||||||
|
echo ""
|
||||||
|
echo "Test certificate:"
|
||||||
|
echo " openssl s_client -connect your-domain.com:443 -showcerts"
|
||||||
|
echo ""
|
||||||
|
echo "Check TLS certificate (Caddy internal CA root):"
|
||||||
|
echo " openssl x509 -enddate -noout -in data/caddy-data/caddy/pki/authorities/local/root.crt"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SECTION: TROUBLESHOOTING
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
echo "=== SECTION 5: TROUBLESHOOTING ==="
|
||||||
|
echo ""
|
||||||
|
echo "View full container logs:"
|
||||||
|
echo " docker-compose logs digiserver-app"
|
||||||
|
echo ""
|
||||||
|
echo "Execute command in container:"
|
||||||
|
echo " docker-compose exec digiserver-app bash"
|
||||||
|
echo ""
|
||||||
|
echo "Check container resources:"
|
||||||
|
echo " docker stats"
|
||||||
|
echo ""
|
||||||
|
echo "Remove and rebuild from scratch:"
|
||||||
|
echo " docker-compose down -v"
|
||||||
|
echo " docker-compose build --no-cache"
|
||||||
|
echo " docker-compose up -d"
|
||||||
|
echo ""
|
||||||
|
echo "Check disk space:"
|
||||||
|
echo " du -sh data/"
|
||||||
|
echo ""
|
||||||
|
echo "View network configuration:"
|
||||||
|
echo " docker-compose exec digiserver-app netstat -tuln"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SECTION: MAINTENANCE
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
echo "=== SECTION 6: MAINTENANCE ==="
|
||||||
|
echo ""
|
||||||
|
echo "Clean up unused Docker resources:"
|
||||||
|
echo " docker system prune -a"
|
||||||
|
echo ""
|
||||||
|
echo "Backup entire application:"
|
||||||
|
echo " tar -czf digiserver-backup-\$(date +%Y%m%d).tar.gz ."
|
||||||
|
echo ""
|
||||||
|
echo "Update Docker images:"
|
||||||
|
echo " docker-compose pull"
|
||||||
|
echo " docker-compose up -d"
|
||||||
|
echo ""
|
||||||
|
echo "Rebuild and redeploy:"
|
||||||
|
echo " docker-compose down"
|
||||||
|
echo " docker-compose build --no-cache"
|
||||||
|
echo " docker-compose up -d"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SECTION: MONITORING
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
echo "=== SECTION 7: MONITORING ==="
|
||||||
|
echo ""
|
||||||
|
echo "Monitor containers in real-time:"
|
||||||
|
echo " watch -n 1 docker-compose ps"
|
||||||
|
echo ""
|
||||||
|
echo "Monitor resource usage:"
|
||||||
|
echo " docker stats --no-stream"
|
||||||
|
echo ""
|
||||||
|
echo "Check application errors:"
|
||||||
|
echo " docker-compose logs --since 10m digiserver-app | grep ERROR"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SECTION: GIT OPERATIONS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
echo "=== SECTION 8: GIT OPERATIONS ==="
|
||||||
|
echo ""
|
||||||
|
echo "Check deployment status:"
|
||||||
|
echo " git status"
|
||||||
|
echo ""
|
||||||
|
echo "View deployment history:"
|
||||||
|
echo " git log --oneline -5"
|
||||||
|
echo ""
|
||||||
|
echo "Commit deployment changes:"
|
||||||
|
echo " git add ."
|
||||||
|
echo " git commit -m 'Deployment configuration'"
|
||||||
|
echo ""
|
||||||
|
echo "Tag release:"
|
||||||
|
echo " git tag -a v2.0.0 -m 'Production release'"
|
||||||
|
echo " git push --tags"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SECTION: EMERGENCY PROCEDURES
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
echo "=== SECTION 9: EMERGENCY PROCEDURES ==="
|
||||||
|
echo ""
|
||||||
|
echo "Kill stuck container:"
|
||||||
|
echo " docker-compose kill digiserver-app"
|
||||||
|
echo ""
|
||||||
|
echo "Restore from backup:"
|
||||||
|
echo " docker-compose down"
|
||||||
|
echo " cp /backup/dashboard.db.bak data/instance/dashboard.db"
|
||||||
|
echo " docker-compose up -d"
|
||||||
|
echo ""
|
||||||
|
echo "Rollback to previous version:"
|
||||||
|
echo " git checkout v1.9.0"
|
||||||
|
echo " docker-compose down"
|
||||||
|
echo " docker-compose build"
|
||||||
|
echo " docker-compose up -d"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# SECTION: QUICK REFERENCE
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
echo "=== SECTION 10: QUICK REFERENCE ALIASES ==="
|
||||||
|
echo ""
|
||||||
|
echo "Add these to your ~/.bashrc for quick access:"
|
||||||
|
echo ""
|
||||||
|
cat << 'EOF'
|
||||||
|
alias ds-start='docker-compose up -d'
|
||||||
|
alias ds-stop='docker-compose down'
|
||||||
|
alias ds-logs='docker-compose logs -f digiserver-app'
|
||||||
|
alias ds-health='curl -k https://your-domain/api/health'
|
||||||
|
alias ds-status='docker-compose ps'
|
||||||
|
alias ds-bash='docker-compose exec digiserver-app bash'
|
||||||
|
EOF
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DONE
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
echo "=== END OF REFERENCE ==="
|
||||||
|
echo ""
|
||||||
|
echo "For detailed documentation, see:"
|
||||||
|
echo " - PRODUCTION_DEPLOYMENT_GUIDE.md"
|
||||||
|
echo " - DEPLOYMENT_READINESS_SUMMARY.md"
|
||||||
|
echo " - old_code_documentation/"
|
||||||
|
echo ""
|
||||||
+59
-15
@@ -5,17 +5,56 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: digiserver-v2
|
container_name: digiserver-v2
|
||||||
# Don't expose directly; use Caddy reverse proxy instead
|
# Don't expose directly; use Caddy reverse proxy instead
|
||||||
|
# Port 5000 is also mapped directly for dev/testing access when Caddy isn't running
|
||||||
expose:
|
expose:
|
||||||
- "5000"
|
- "5000"
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app
|
# Code is in the Docker image - no volume mount needed
|
||||||
|
# Only mount persistent data folders:
|
||||||
- ./data/instance:/app/instance
|
- ./data/instance:/app/instance
|
||||||
- ./data/uploads:/app/app/static/uploads
|
- ./data/uploads:/app/app/static/uploads
|
||||||
|
# Staged player source (git clone). Persisted so a rebuild of the app
|
||||||
|
# container does not throw away the ~140 MB staged checkout, and so the
|
||||||
|
# SSH deployment step has something to ship.
|
||||||
|
- ./data/player:/app/data/player
|
||||||
|
# The app GENERATES the Caddyfile (env bootstrap at startup, and the
|
||||||
|
# Admin → HTTPS Configuration page at runtime) then asks Caddy to reload.
|
||||||
|
# It therefore needs write access to the same file Caddy reads, so this
|
||||||
|
# must be mounted here as well as in the caddy service.
|
||||||
|
# The file is host-owned (uid 1000 == appuser), so writes succeed.
|
||||||
|
- ./data/Caddyfile:/etc/caddy/Caddyfile:rw
|
||||||
environment:
|
environment:
|
||||||
- FLASK_ENV=production
|
- FLASK_ENV=production
|
||||||
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this}
|
- SECRET_KEY=${SECRET_KEY:-your-secret-key-change-this}
|
||||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# Deploy-time TLS bootstrap (all optional).
|
||||||
|
#
|
||||||
|
# If HOSTNAME_INTERNAL and HOST_IP are BOTH set, the container configures
|
||||||
|
# Caddy for HTTPS using that address at startup — no manual step needed.
|
||||||
|
# If either is missing, the app stays on the plain-HTTP fallback and you
|
||||||
|
# can enable HTTPS later from Admin → HTTPS Configuration (which reloads
|
||||||
|
# Caddy live, no restart required).
|
||||||
|
#
|
||||||
|
# Leave DOMAIN empty for Caddy's internal CA. That needs NO public DNS
|
||||||
|
# and NO ACME, which is the right choice for an intranet name that is not
|
||||||
|
# resolvable from the internet.
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
- HOSTNAME_INTERNAL=${HOSTNAME_INTERNAL:-}
|
||||||
|
- HOST_IP=${HOST_IP:-}
|
||||||
|
- DOMAIN=${DOMAIN:-}
|
||||||
|
- SSL_EMAIL=${SSL_EMAIL:-}
|
||||||
|
# Externally published ports (must match the caddy service mappings below).
|
||||||
|
# They are used to build correct HTTP→HTTPS redirect targets.
|
||||||
|
- HTTP_PORT=${HTTP_PORT:-80}
|
||||||
|
- HTTPS_PORT=${HTTPS_PORT:-443}
|
||||||
|
# Set to "false" to serve TLS only and redirect plain HTTP to HTTPS.
|
||||||
|
- HTTPS_HTTP_FALLBACK=${HTTPS_HTTP_FALLBACK:-true}
|
||||||
|
# Post-bootstrap check: probe HTTPS and fall back to HTTP if it is broken.
|
||||||
|
- HTTPS_VERIFY=${HTTPS_VERIFY:-true}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/').read()"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/').read()"]
|
||||||
@@ -26,22 +65,27 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- digiserver-network
|
- digiserver-network
|
||||||
|
|
||||||
# Nginx reverse proxy with HTTPS support
|
# Caddy reverse proxy.
|
||||||
nginx:
|
# Port 80 → always answers, for both the IP and the hostname.
|
||||||
image: nginx:alpine
|
# Port 443 → HTTPS when configured; plain HTTP is served alongside it by
|
||||||
container_name: digiserver-nginx
|
# default so clients that cannot trust the internal CA still work.
|
||||||
|
# Ports are configurable so the stack also works on a host where 80/443 are
|
||||||
|
# already taken (e.g. HTTP_PORT=8080 HTTPS_PORT=8443).
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: digiserver-caddy
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "${HTTP_PORT:-80}:80"
|
||||||
- "443:443"
|
- "${HTTPS_PORT:-443}:443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
# The app container regenerates this file on startup (env bootstrap) and
|
||||||
- ./nginx-custom-domains.conf:/etc/nginx/conf.d/custom-domains.conf:rw
|
# whenever HTTPS is changed in the Admin UI, then hot-reloads Caddy via
|
||||||
- ./data/nginx-ssl:/etc/nginx/ssl:ro
|
# its admin API (http://caddy:2019/load). Because the file on disk is
|
||||||
- ./data/nginx-logs:/var/log/nginx
|
# always current, a plain restart of Caddy picks up the latest config.
|
||||||
- ./data/certbot:/var/www/certbot:ro # For Let's Encrypt ACME challenges
|
- ./data/Caddyfile:/etc/caddy/Caddyfile:rw
|
||||||
environment:
|
- ./data/caddy-data:/data
|
||||||
- DOMAIN=${DOMAIN:-localhost}
|
- ./data/caddy-config:/config
|
||||||
- EMAIL=${EMAIL:-admin@localhost}
|
- ./data/caddy-logs:/var/log/caddy
|
||||||
depends_on:
|
depends_on:
|
||||||
digiserver-app:
|
digiserver-app:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|||||||
+133
-15
@@ -3,50 +3,168 @@ set -e
|
|||||||
|
|
||||||
echo "Starting DigiServer v2..."
|
echo "Starting DigiServer v2..."
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pin the database explicitly.
|
||||||
|
#
|
||||||
|
# The migration scripts and the application must target the SAME SQLite file.
|
||||||
|
# Without this, they do not: the config classes default to different files
|
||||||
|
# (dev.db for DevelopmentConfig, dashboard.db for ProductionConfig), and most
|
||||||
|
# migration scripts call create_app() without an argument, which selects the
|
||||||
|
# *development* config. The app itself runs as create_app('production').
|
||||||
|
# Exporting DATABASE_URL makes every code path below resolve to the same
|
||||||
|
# database, regardless of which config gets loaded.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
export DATABASE_URL="${DATABASE_URL:-sqlite:////app/instance/dashboard.db}"
|
||||||
|
export FLASK_ENV="${FLASK_ENV:-production}"
|
||||||
|
|
||||||
|
echo "Database: ${DATABASE_URL}"
|
||||||
|
|
||||||
# Create necessary directories
|
# Create necessary directories
|
||||||
mkdir -p /app/instance
|
mkdir -p /app/instance
|
||||||
mkdir -p /app/app/static/uploads
|
mkdir -p /app/app/static/uploads
|
||||||
|
# Staged player source (bind-mounted from ./data/player). Created here so the
|
||||||
|
# container works even if the volume was not pre-created on the host.
|
||||||
|
mkdir -p /app/data/player
|
||||||
|
|
||||||
# Initialize database if it doesn't exist
|
# ---------------------------------------------------------------------------
|
||||||
if [ ! -f /app/instance/dashboard.db ]; then
|
# Ensure the schema exists and bootstrap the admin user.
|
||||||
echo "Initializing database..."
|
#
|
||||||
python -c "
|
# Both operations are idempotent, so this runs on every container start:
|
||||||
|
# db.create_all() only creates missing tables, and the admin block creates the
|
||||||
|
# user if absent or otherwise refreshes its password from the environment.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo "Ensuring database schema and admin user..."
|
||||||
|
python -c "
|
||||||
from app.app import create_app
|
from app.app import create_app
|
||||||
from app.extensions import db, bcrypt
|
from app.extensions import db, bcrypt
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
import os
|
||||||
|
|
||||||
app = create_app('production')
|
app = create_app('production')
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
# Create or update admin user from environment variables
|
|
||||||
import os
|
|
||||||
admin_username = os.getenv('ADMIN_USERNAME', 'admin')
|
admin_username = os.getenv('ADMIN_USERNAME', 'admin')
|
||||||
admin_password = os.getenv('ADMIN_PASSWORD', 'admin123')
|
admin_password = os.getenv('ADMIN_PASSWORD', 'admin123')
|
||||||
|
|
||||||
admin = User.query.filter_by(username=admin_username).first()
|
admin = User.query.filter_by(username=admin_username).first()
|
||||||
|
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
|
||||||
if not admin:
|
if not admin:
|
||||||
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
|
|
||||||
admin = User(username=admin_username, password=hashed, role='admin')
|
admin = User(username=admin_username, password=hashed, role='admin')
|
||||||
db.session.add(admin)
|
db.session.add(admin)
|
||||||
db.session.commit()
|
|
||||||
print(f'✅ Admin user created ({admin_username})')
|
print(f'✅ Admin user created ({admin_username})')
|
||||||
else:
|
else:
|
||||||
# Update password if it exists
|
# Keep the stored password in sync with the environment.
|
||||||
hashed = bcrypt.generate_password_hash(admin_password).decode('utf-8')
|
|
||||||
admin.password = hashed
|
admin.password = hashed
|
||||||
db.session.commit()
|
|
||||||
print(f'✅ Admin user password updated ({admin_username})')
|
print(f'✅ Admin user password updated ({admin_username})')
|
||||||
|
db.session.commit()
|
||||||
"
|
"
|
||||||
echo "Database initialized!"
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Apply schema migrations.
|
||||||
|
#
|
||||||
|
# Every migration script is idempotent (it guards against duplicate columns and
|
||||||
|
# already-migrated tables), so this is safe to run on every start and upgrades
|
||||||
|
# databases created by older releases.
|
||||||
|
#
|
||||||
|
# ORDER MATTERS: migrations that create/repair a table must run before the ones
|
||||||
|
# that alter that table (e.g. add_player_user_table.py before
|
||||||
|
# migrate_player_user_global.py, add_https_config_table.py before
|
||||||
|
# add_email_to_https_config.py).
|
||||||
|
#
|
||||||
|
# Migrations are deliberately NON-FATAL: a failure is logged and startup
|
||||||
|
# continues, so one bad migration cannot strand the container in a restart
|
||||||
|
# loop. Look for the WARNING lines in the logs if something looks wrong.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
MIGRATIONS=(
|
||||||
|
"add_https_config_table.py"
|
||||||
|
"add_player_user_table.py"
|
||||||
|
"add_email_to_https_config.py"
|
||||||
|
"migrate_player_user_global.py"
|
||||||
|
"add_url_to_content.py"
|
||||||
|
"add_original_filename_to_content.py"
|
||||||
|
"add_deployment_fields_to_player.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Running database migrations..."
|
||||||
|
FAILED_MIGRATIONS=()
|
||||||
|
|
||||||
|
for migration in "${MIGRATIONS[@]}"; do
|
||||||
|
migration_path="/app/migrations/${migration}"
|
||||||
|
|
||||||
|
if [ ! -f "$migration_path" ]; then
|
||||||
|
echo "⚠️ Skipping missing migration: ${migration}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " • ${migration}"
|
||||||
|
if ! python "$migration_path"; then
|
||||||
|
echo "⚠️ WARNING: migration failed: ${migration}"
|
||||||
|
FAILED_MIGRATIONS+=("${migration}")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#FAILED_MIGRATIONS[@]} -gt 0 ]; then
|
||||||
|
echo "⚠️ WARNING: ${#FAILED_MIGRATIONS[@]} migration(s) failed: ${FAILED_MIGRATIONS[*]}"
|
||||||
|
echo "⚠️ Starting anyway — check the errors above."
|
||||||
|
else
|
||||||
|
echo "✅ All database migrations applied."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bootstrap HTTPS from environment variables.
|
||||||
|
#
|
||||||
|
# HOSTNAME_INTERNAL + HOST_IP (compose → env) configure Caddy for HTTPS at
|
||||||
|
# startup, so a fresh deployment is reachable over TLS without a manual step.
|
||||||
|
# When either is unset this is a deliberate NO-OP: the app stays on plain HTTP
|
||||||
|
# and HTTPS can be enabled later from Admin → HTTPS Configuration, which
|
||||||
|
# reloads Caddy live.
|
||||||
|
#
|
||||||
|
# ORDERING MATTERS: gunicorn is started FIRST (below) and only then is HTTPS
|
||||||
|
# configured. The bootstrap probes https://…/api/health to decide whether the
|
||||||
|
# certificate really works; if it ran before the app was listening, Caddy would
|
||||||
|
# return 502 and the probe would wrongly conclude HTTPS was broken.
|
||||||
|
#
|
||||||
|
# Non-fatal: if this fails the app still runs, and the admin page remains the
|
||||||
|
# fallback path for configuring HTTPS.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
bootstrap_https() {
|
||||||
|
echo "Checking HTTPS bootstrap..."
|
||||||
|
if ! python /app/https_manager.py bootstrap; then
|
||||||
|
echo "⚠️ WARNING: HTTPS bootstrap failed — continuing; configure HTTPS from the admin UI."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
|
# --timeout is a safety net for any remaining synchronous long operation. The
|
||||||
|
# player build no longer blocks a worker (it runs in a background thread), but
|
||||||
|
# a generous margin avoids surprise worker kills during large uploads or
|
||||||
|
# dependency installs triggered from the admin UI.
|
||||||
echo "Starting Gunicorn..."
|
echo "Starting Gunicorn..."
|
||||||
exec gunicorn \
|
gunicorn \
|
||||||
--bind 0.0.0.0:5000 \
|
--bind 0.0.0.0:5000 \
|
||||||
--workers 4 \
|
--workers 4 \
|
||||||
--timeout 120 \
|
--timeout 300 \
|
||||||
--access-logfile - \
|
--access-logfile - \
|
||||||
--error-logfile - \
|
--error-logfile - \
|
||||||
"app.app:create_app('production')"
|
"app.app:create_app('production')" &
|
||||||
|
GUNICORN_PID=$!
|
||||||
|
|
||||||
|
# Wait for the app to answer before configuring HTTPS, then run the bootstrap.
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if python -c "
|
||||||
|
import sys, urllib.request
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen('http://127.0.0.1:5000/health', timeout=2)
|
||||||
|
except Exception:
|
||||||
|
sys.exit(1)
|
||||||
|
" 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
bootstrap_https
|
||||||
|
|
||||||
|
# Keep the container attached to gunicorn so signals and healthchecks behave.
|
||||||
|
wait $GUNICORN_PID
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# 01 · System Architecture
|
||||||
|
|
||||||
|
> Generated from the Graphify knowledge graph (41 communities) and source analysis of `digiserver-v2`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. System Overview
|
||||||
|
|
||||||
|
DigiServer v2 is a Flask application serving **three audiences**:
|
||||||
|
|
||||||
|
1. **Humans (admins)** — manage content, playlists, players, users, HTTPS via the web UI.
|
||||||
|
2. **Players (physical signage)** — poll the REST API for playlists, send status feedback, upload edited media.
|
||||||
|
3. **Operators** — deploy/update player software remotely over SSH.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Browser["Web UI (Admins)"]
|
||||||
|
A[Login]
|
||||||
|
B[Dashboard / Admin / Content / Players]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph App["digiserver-app (Flask :5000)"]
|
||||||
|
C[Gunicorn x4]
|
||||||
|
D[7 Blueprints]
|
||||||
|
E[SQLAlchemy + SQLite]
|
||||||
|
F[Cache / Background tasks]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Players["Signage Players"]
|
||||||
|
G[Player 1]
|
||||||
|
H[Player N]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Proxy["Reverse Proxy"]
|
||||||
|
I[Caddy :80/:443]
|
||||||
|
J[Umbrella nginx gateway - optional SSO]
|
||||||
|
end
|
||||||
|
|
||||||
|
I -->|reverse_proxy digiserver-app:5000| C
|
||||||
|
J -.X-Auth-Username .-> C
|
||||||
|
D --> E
|
||||||
|
G <-->|"/api/*"| D
|
||||||
|
H <-->|"/api/*"| D
|
||||||
|
Browser --> I
|
||||||
|
C -.SSH deploy/rsync.-> G
|
||||||
|
C -.SSH deploy/rsync.-> H
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Layered Architecture
|
||||||
|
|
||||||
|
Graphify assigns each node a **level** (0 = entry/global → 3 = utility):
|
||||||
|
|
||||||
|
| Layer | Level | Components |
|
||||||
|
|---|---|---|
|
||||||
|
| **L0 — Entry / Global** | 0 | `create_app()`, `app.py`, config classes, error handlers, CLI commands |
|
||||||
|
| **L1 — Strategic / Core** | 1 | Blueprint route handlers (players, content, api, admin), model classes |
|
||||||
|
| **L2 — Implementation** | 2 | Playlist management helpers, processing helpers, model methods |
|
||||||
|
| **L3 — Utility** | 3 | `logger.py`, `ssh_deploy.py`, `caddy_manager.py`, `uploads.py`, `pptx_converter.py`, migrations |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph L0["L0 · Entry"]
|
||||||
|
create_app["create_app()"]
|
||||||
|
ProxyFix
|
||||||
|
ScriptNameFix
|
||||||
|
portal_sso["init_portal_sso()"]
|
||||||
|
end
|
||||||
|
subgraph L1["L1 · Blueprints + Models"]
|
||||||
|
bp["main · auth · admin · players · content · playlist · api"]
|
||||||
|
models["User Player Content Playlist PlayerEdit PlayerFeedback PlayerUser HTTPSConfig ServerLog"]
|
||||||
|
end
|
||||||
|
subgraph L2["L2 · Implementation"]
|
||||||
|
bg["run_background_task()"]
|
||||||
|
dep["deploy_player_to_host()"]
|
||||||
|
caddy["CaddyConfigGenerator"]
|
||||||
|
pb["build_player_files() / write_base_config()"]
|
||||||
|
end
|
||||||
|
subgraph L3["L3 · Utility"]
|
||||||
|
log["log_action()"]
|
||||||
|
up["uploads.py"]
|
||||||
|
pptx["pptx_converter.py"]
|
||||||
|
end
|
||||||
|
create_app --> bp
|
||||||
|
create_app --> models
|
||||||
|
bp --> log
|
||||||
|
bp --> bg
|
||||||
|
bp --> dep
|
||||||
|
bp --> caddy
|
||||||
|
bp --> pb
|
||||||
|
bp --> up
|
||||||
|
bp --> pptx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Component Map (files → communities)
|
||||||
|
|
||||||
|
Graphify clustered the code into **41 communities**. The 12 meaningful ones are shown below (communities 13–40 are mostly migrations, isolated helper functions, and archived code).
|
||||||
|
|
||||||
|
| Community | Domain (derived) | Files | Role |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **C0** (90) | **Authentication + audit** | `blueprints/auth.py`, `models/server_log.py`, `utils/logger.py`, `old_code_documentation/blueprint_groups.py` | Auth flows + audit logging |
|
||||||
|
| **C1** (80) | **Admin + HTTPS + player users** | `blueprints/admin.py`, `models/https_config.py`, `models/player_user.py`, `utils/caddy_manager.py`, `migrations/add_player_user_table.py` | Admin panel, Caddy HTTPS generation, editing-user registry |
|
||||||
|
| **C2** (68) | **Playlist & content workflows** | `blueprints/content.py`, `blueprints/playlist.py`, `models/playlist.py` | Modern playlist-centric content management + legacy redirects |
|
||||||
|
| **C3** (62) | **Player API + edits** | `blueprints/api.py`, `models/player_edit.py` | Player-facing REST, edited-media pipeline |
|
||||||
|
| **C4** (55) | **Application core** | `app.py`, `config.py`, `models/user.py`, `utils/portal_sso.py`, `utils/script_name_fix.py` | App factory, config, auth identity, middleware |
|
||||||
|
| **C5** (54) | **Content & player models** | `models/content.py`, `models/player.py`, `models/player_feedback.py`, `blueprints/main.py` | Media model, player model, feedback, dashboard |
|
||||||
|
| **C6** (51) | **Player management UI** | `blueprints/players.py`, `migrations/add_https_config_table.py` | Player CRUD, manage page, deployment polling |
|
||||||
|
| **C7** (42) | ***(removed)*** | — | Groups model + legacy nginx reader were deleted during sanitization |
|
||||||
|
| **C8** (24) | **Dev tooling** | `old_code_documentation/test_edit_media_api.py`, `Colors`, integrity checker | Test/analysis utilities |
|
||||||
|
| **C9** (21) | **Upload processing** | `utils/uploads.py` | Upload progress, video/image processing |
|
||||||
|
| **C10** (20) | **Deployment** | `utils/background_tasks.py`, `utils/ssh_deploy.py` | Background SSH deployment engine |
|
||||||
|
| **C11** (19) | **Player build/staging** | `utils/player_build.py` | Stage player source code on server |
|
||||||
|
| **C12** (8) | **PPTX conversion** | `utils/pptx_converter.py` | LibreOffice PPTX → PDF → PNG |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
C0["C0 · Auth & logging"]
|
||||||
|
C1["C1 · Admin & HTTPS"]
|
||||||
|
C2["C2 · Playlists & content"]
|
||||||
|
C3["C3 · Player API & edits"]
|
||||||
|
C4["C4 · App core"]
|
||||||
|
C5["C5 · Content/player models"]
|
||||||
|
C6["C6 · Player management UI"]
|
||||||
|
C9["C9 · Upload processing"]
|
||||||
|
C10["C10 · Deployment"]
|
||||||
|
C11["C11 · Player build"]
|
||||||
|
C12["C12 · PPTX conversion"]
|
||||||
|
|
||||||
|
C1 -->|27 edges| C3
|
||||||
|
C2 -->|25 edges| C3
|
||||||
|
C2 -->|25 edges| C0
|
||||||
|
C3 -->|25 edges| C1
|
||||||
|
C1 -->|21 edges| C0
|
||||||
|
C6 -->|16 edges| C1
|
||||||
|
C6 -->|16 edges| C3
|
||||||
|
C3 -->|14 edges| C0
|
||||||
|
C6 -->|14 edges| C0
|
||||||
|
C5 -->|9 edges| C3
|
||||||
|
C9 -->|5 edges| C0
|
||||||
|
C1 -->|3 edges| C11
|
||||||
|
C10 --> C6
|
||||||
|
C11 --> C10
|
||||||
|
C12 --> C9
|
||||||
|
```
|
||||||
|
|
||||||
|
*(edge weights from `graphify-out/metadata.json` communityLinks)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Middleware & Request Pipeline
|
||||||
|
|
||||||
|
Every request flows through three layers before reaching a blueprint:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
R["Raw request"] --> PF["werkzeug ProxyFix"]
|
||||||
|
PF --> SF["ScriptNameFix"]
|
||||||
|
SF --> SSO["portal_sso (before_request)"]
|
||||||
|
SSO --> BP["Blueprint route"]
|
||||||
|
BP --> DB[("SQLAlchemy / SQLite")]
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **ProxyFix** — trusts `X-Forwarded-For/Proto/Host/Port` from Caddy/nginx (1 hop).
|
||||||
|
2. **ScriptNameFix** — reads `X-Script-Name` (e.g. `/digiserver`) so `url_for()` produces correct prefixed URLs behind an umbrella nginx gateway.
|
||||||
|
3. **Portal SSO** (`portal_sso.py`) — if the umbrella nginx sends `X-Auth-Username` / `X-Auth-Role`, automatically create + log in the local user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Key Architectural Decisions
|
||||||
|
|
||||||
|
| Decision | Implementation |
|
||||||
|
|---|---|
|
||||||
|
| **Application factory** | `create_app(config_name)` — clean testability, per-env config |
|
||||||
|
| **Blueprint isolation** | 7 blueprints with clear URL prefixes; API separate from UI |
|
||||||
|
| **Playlist versioning** | `Playlist.version` incremented on every mutation → players poll `/api/playlist-version/<id>` for refresh |
|
||||||
|
| **In-memory caching** | Flask-Caching `@cache.memoize(300)` on playlist builders; `cache.clear()` on mutations |
|
||||||
|
| **DB-backed audit log** | `log_action()` writes `ServerLog` rows; surfaced on dashboard & admin |
|
||||||
|
| **Two auth systems** | Humans: Flask-Login (`User`). Players: `auth_code` Bearer + `quickconnect_code` (bcrypt) |
|
||||||
|
| **Remote provisioning** | SSH + rsync/git staged code → write `app_config.json` → run install/start scripts |
|
||||||
|
| **HTTPS automation** | `CaddyConfigGenerator` writes `Caddyfile`; reloads Caddy via admin API on `:2019` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Runtime Dependencies (requirements.txt)
|
||||||
|
|
||||||
|
**Runtime:** Flask 3.1, Werkzeug 3.1, SQLAlchemy 2.0.37, Flask-SQLAlchemy 3.1.1, Flask-Migrate, Flask-Bcrypt, Flask-Login, Flask-Caching, Flask-Cors, Flask-Talisman, pdf2image, Pillow, ffmpeg-python, python-magic, bcrypt, cryptography, gunicorn 23, psutil, python-dotenv.
|
||||||
|
|
||||||
|
**System binaries (Docker):** `poppler-utils`, `ffmpeg`, `libmagic1`, `LibreOffice` (core/impress/writer), `fonts-noto-color-emoji`, `sshpass`, `openssh-client`, `rsync`, `git`.
|
||||||
|
|
||||||
|
**Dev:** black, flake8, pytest, pytest-cov. *(`gevent` is commented out — incompatible with Python 3.13.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Next: [02 · Knowledge Graph](02-knowledge-graph.md)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# 02 · The Graphify Knowledge Graph
|
||||||
|
|
||||||
|
This document explains the **interactive knowledge graph** generated by the [Graphify](https://marketplace.visualstudio.com/items?itemName=anytechiestudio.graphify-vscode) extension, and how to use it to explore and maintain DigiServer v2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What Was Generated
|
||||||
|
|
||||||
|
Running `Graphify: Build Knowledge Graph` on the project root produced `graphify-out/`:
|
||||||
|
|
||||||
|
| Artifact | Description |
|
||||||
|
|---|---|
|
||||||
|
| `graph.html` | **Interactive visualizer** — zoom/pan/filter nodes, click to jump to source |
|
||||||
|
| `graph.json` | Raw graph data (nodes, links, communities, heat) |
|
||||||
|
| `GRAPH_REPORT.md` | God nodes, surprising connections, community list, knowledge gaps |
|
||||||
|
| `COMPASS.md` | Token-optimized architecture summary (god nodes, layers) |
|
||||||
|
| `DOMAINS.md` | Community ID → domain table |
|
||||||
|
| `intelligence.json` | AI-detected intelligence (god nodes, surprising links) |
|
||||||
|
| `graph.compact.txt` | Compact graph dump, ideal for LLM context |
|
||||||
|
| `wiki/` | Markdown articles per community + per god-node, with Mermaid diagrams |
|
||||||
|
|
||||||
|
**Graph statistics:**
|
||||||
|
|
||||||
|
```
|
||||||
|
631 nodes · 1162 edges · 41 communities
|
||||||
|
Extraction: 56% EXTRACTED · 44% INFERRED (avg confidence 0.62)
|
||||||
|
Node types: 313 FUNCTION · 300 FILE · 18 CLASS
|
||||||
|
Link types: 311 uses · 290 calls · 275 rationale_for · 243 contains · 39 method · 4 inherits
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Opening the Visualizer
|
||||||
|
|
||||||
|
1. In VS Code, click the **Graphify** activity-bar icon (sidebar).
|
||||||
|
2. Click **Build Knowledge Graph** (or right-click any folder → *Graphify: Build Knowledge Graph*).
|
||||||
|
3. Once built, click **Open Interactive Visualizer** — or open `graphify-out/graph.html` directly in a browser.
|
||||||
|
|
||||||
|
> ⚠️ The visualizer opens best inside the VS Code webview (activity bar). Opening `graph.html` via `file://` may show a minor console error but still renders the graph data (nodes/edges/communities listed in the sidebar).
|
||||||
|
|
||||||
|
**View modes:** Deep Dive · Full Detail · Heatmap · Simulation (component-removal impact).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. God Nodes — The Core Abstractions
|
||||||
|
|
||||||
|
These are the most-connected nodes (graph centrality). They are the architectural "load-bearing" abstractions:
|
||||||
|
|
||||||
|
| Rank | Node | Degree | File | Why it matters |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `log_action()` | 116 | `app/utils/logger.py` | Every important action across all blueprints logs here |
|
||||||
|
| 2 | `PlayerEdit` | 99 | `app/models/player_edit.py` | Central record of on-player media edits |
|
||||||
|
| 3 | `PlayerUser` | 74 | `app/models/player_user.py` | Maps player edit user codes to names |
|
||||||
|
| 4 | `Playlist` | 33 | `app/models/playlist.py` | Core content organization + version sync |
|
||||||
|
| 5 | `CaddyConfigGenerator` | 31 | `app/utils/caddy_manager.py` | HTTPS Caddyfile generation/reload |
|
||||||
|
| 6 | `HTTPSConfig` | 30 | `app/models/https_config.py` | Persisted HTTPS settings |
|
||||||
|
| 7 | `Content` | 24 | `app/models/content.py` | Media model used by everything |
|
||||||
|
| 8 | `User` | 19 | `app/models/user.py` | Human accounts (admin/user/viewer) |
|
||||||
|
| 9 | `create_app()` | 13 | `app/app.py` | Application factory |
|
||||||
|
| 10 | `models/__init__.py` | 13 | `app/models/__init__.py` | Package export hub |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Heatmap — Hottest Code
|
||||||
|
|
||||||
|
`heat` (0–1) reflects how central/complex a node is in the graph. The hottest functions drive most of the system's behaviour:
|
||||||
|
|
||||||
|
| Heat | Function | File |
|
||||||
|
|---|---|---|
|
||||||
|
| 0.620 | `receive_edited_media()` | `app/blueprints/api.py` |
|
||||||
|
| 0.614 | `deploy_player_to_host()` | `app/utils/ssh_deploy.py` |
|
||||||
|
| 0.592 | `add_player()` | `app/blueprints/players.py` |
|
||||||
|
| 0.537 | `upload_media()` | `app/blueprints/content.py` |
|
||||||
|
| 0.429 | `manage_player()` | `app/blueprints/players.py` |
|
||||||
|
| 0.425 | `receive_player_feedback()` | `app/blueprints/api.py` |
|
||||||
|
| 0.407 | `update_https_config()` | `app/blueprints/admin.py` |
|
||||||
|
| 0.376 | `process_file_in_background()` | `app/blueprints/content.py` |
|
||||||
|
| 0.365 | `CaddyConfigGenerator` | `app/utils/caddy_manager.py` |
|
||||||
|
| 0.354 | `get_playlist_by_quickconnect()` | `app/blueprints/api.py` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Communities (Clusters)
|
||||||
|
|
||||||
|
Graphify groups related code into communities. See [01-architecture.md §3](01-architecture.md#3-component-map-files--communities) for the full mapping. The largest:
|
||||||
|
|
||||||
|
- **C0 · Auth & logging** (90 nodes) — login/logout/register + audit logging
|
||||||
|
- **C1 · Admin & HTTPS** (80 nodes) — admin panel, Caddy generation, editing users
|
||||||
|
- **C2 · Playlists & content** (68 nodes) — the modern content/playlist workflow
|
||||||
|
- **C3 · Player API & edits** (62 nodes) — player-facing REST + edited-media pipeline
|
||||||
|
- **C4 · Application core** (55 nodes) — `create_app`, config, user, middleware
|
||||||
|
- **C5 · Content/player models** (54 nodes) — `Content`, `Player`, feedback, dashboard
|
||||||
|
|
||||||
|
Each community has a wiki article with a **Mermaid class diagram**, key concepts, source files, and an audit trail (extracted vs inferred edges). Example: `graphify-out/wiki/Community_4.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Surprising Connections (AI-detected)
|
||||||
|
|
||||||
|
Graphify flags links that are non-obvious. Examples from `GRAPH_REPORT.md`:
|
||||||
|
|
||||||
|
- `reset_user_password()` → `log_action()` — admin password resets are audited
|
||||||
|
- `upload_header_logo()` → `log_action()` — logo changes are audited
|
||||||
|
- `delete_editing_user()` → `log_action()` — editing-user deletion is audited
|
||||||
|
- `delete_playlist()` → `log_action()` — playlist deletion is audited
|
||||||
|
- `Main playlist management page` uses `PlayerEdit` — the content UI surfaces edit counts
|
||||||
|
|
||||||
|
**Pattern:** virtually every destructive/admin action flows through `log_action()`, which is why it is the #1 god node.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Known Gaps in the Graph
|
||||||
|
|
||||||
|
- **178 isolated nodes** (≤1 connection) — mostly standalone migration scripts and model helper methods.
|
||||||
|
- **Thin communities 13–40** — single-file migrations, isolated utility functions, and archived `old_code_documentation/` scripts. These are not "broken" — they are intentionally decoupled.
|
||||||
|
- The graph does **not** index templates or static assets (Python AST extraction only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Querying the Graph (CLI)
|
||||||
|
|
||||||
|
The `anytechie-graphify` engine (installed in `.venv`) ships a CLI for graph-aware questions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Shortest path between two nodes
|
||||||
|
python -m graphify path "Content" "Playlist"
|
||||||
|
|
||||||
|
# Explain a node and its neighbours
|
||||||
|
python -m graphify explain "deploy_player_to_host()"
|
||||||
|
|
||||||
|
# BFS traversal to answer a question (no LLM)
|
||||||
|
python -m graphify query "how is a playlist synced to a player?"
|
||||||
|
|
||||||
|
# Grounded chat over the graph (requires an LLM API key)
|
||||||
|
python -m graphify ask "what happens when a player uploads edited media?"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Keeping the Graph Fresh
|
||||||
|
|
||||||
|
- **After code changes:** `Graphify: Build Knowledge Graph` again (or run `python -m graphify update <path>`).
|
||||||
|
- **Git hooks:** `python -m graphify hook install` auto-rebuilds on `post-commit`/`post-checkout`.
|
||||||
|
- The graph is stored in `graphify-out/` (not committed to git by default).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Next: [03 · Data Model](03-data-model.md)
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# 03 · Data Model
|
||||||
|
|
||||||
|
DigiServer v2 uses **SQLAlchemy 2.0** with **SQLite** (production: `instance/dashboard.db`, dev: `instance/dev.db`, tests: in-memory). All models live in `app/models/` and are re-exported from `app/models/__init__.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Entity-Relationship Overview
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
user ||--o{ server_log : "writes"
|
||||||
|
player ||--o{ player_feedback : "sends (cascade)"
|
||||||
|
player ||--o{ player_edit : "edits (cascade)"
|
||||||
|
player }o--o| playlist : "assigned_to"
|
||||||
|
content ||--o{ player_edit : "edited (cascade)"
|
||||||
|
content ||--o{ player_feedback : "playing"
|
||||||
|
playlist ||--o{ content : "playlist_content M2M (position,duration,muted,edit_on_player_enabled)"
|
||||||
|
player_user ||--o{ player_edit : "user_code"
|
||||||
|
https_config ||--o| https_config : "singleton row"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Tables & Columns
|
||||||
|
|
||||||
|
### `user` — human accounts
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | Integer | PK |
|
||||||
|
| `username` | String(80) | unique, NOT NULL, indexed |
|
||||||
|
| `password` | String(120) | bcrypt hash, NOT NULL |
|
||||||
|
| `role` | String(20) | default `'user'`; also `'admin'`, `'viewer'`; indexed |
|
||||||
|
| `theme` | String(20) | default `'light'` |
|
||||||
|
| `created_at` | DateTime | NOT NULL |
|
||||||
|
| `last_login` | DateTime | nullable |
|
||||||
|
|
||||||
|
Key methods: `is_admin` (property), `update_last_login()`. Mixin: `UserMixin` (Flask-Login).
|
||||||
|
|
||||||
|
### `player` — signage devices
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | Integer | PK |
|
||||||
|
| `name` | String(255) | NOT NULL |
|
||||||
|
| `hostname` | String(255) | unique, NOT NULL, indexed |
|
||||||
|
| `location` | String(255) | nullable |
|
||||||
|
| `auth_code` | String(255) | unique, NOT NULL, indexed (legacy Bearer auth) |
|
||||||
|
| `password_hash` | String(255) | NOT NULL (bcrypt) |
|
||||||
|
| `quickconnect_code` | String(255) | nullable, bcrypt-hashed |
|
||||||
|
| `orientation` | String(16) | default `'Landscape'` |
|
||||||
|
| `status` | String(50) | default `'offline'`, indexed |
|
||||||
|
| `last_seen` | DateTime | indexed |
|
||||||
|
| `last_heartbeat` | DateTime | indexed |
|
||||||
|
| `created_at` | DateTime | NOT NULL |
|
||||||
|
| `playlist_id` | Integer | FK → `playlist.id` ON DELETE SET NULL, indexed |
|
||||||
|
| `deployment_status` | String(50) | default `'pending'`; pending/deploying/deployed/failed |
|
||||||
|
| `last_deployment_at` | DateTime | nullable |
|
||||||
|
| `last_deployment_status` | String(50) | nullable |
|
||||||
|
| `last_deployment_message` | Text | nullable |
|
||||||
|
|
||||||
|
Relationships: `playlist`, `feedback` (→ PlayerFeedback, cascade delete-orphan).
|
||||||
|
Methods: `is_online` (5-min window), `update_status()`, `set_password()/check_password()`, `set_quickconnect_code()/check_quickconnect_code()`, static `authenticate(hostname, password, quickconnect_code)`.
|
||||||
|
|
||||||
|
### `content` — media items (also weblinks)
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | Integer | PK |
|
||||||
|
| `filename` | String(255) | unique, NOT NULL, indexed; may point to `edited_media/<id>/...` after a player edit |
|
||||||
|
| `original_filename` | String(255) | nullable, indexed — pristine upload name |
|
||||||
|
| `content_type` | String(50) | NOT NULL, indexed; image/video/pdf/pptx/weblink/other |
|
||||||
|
| `duration` | Integer | default 10, nullable |
|
||||||
|
| `file_size` | BigInteger | nullable |
|
||||||
|
| `url` | String(2048) | nullable — target URL for `weblink` content |
|
||||||
|
| `description` | Text | nullable |
|
||||||
|
| `uploaded_at` | DateTime | NOT NULL, indexed |
|
||||||
|
|
||||||
|
Relationships: `playlists` (M2M via `playlist_content`).
|
||||||
|
Properties/methods: `file_size_mb`, `original_display_name`, `original_media_path`, `current_media_path`, `is_image()/is_video()/is_pdf()/is_weblink()`, `has_player_edits`.
|
||||||
|
|
||||||
|
### `playlist` — ordered collections of content
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | Integer | PK |
|
||||||
|
| `name` | String(100) | unique, NOT NULL, indexed |
|
||||||
|
| `description` | Text | nullable |
|
||||||
|
| `orientation` | String(20) | default `'Landscape'` |
|
||||||
|
| `version` | Integer | default 1, NOT NULL — **sync detection** |
|
||||||
|
| `is_active` | Boolean | default True |
|
||||||
|
| `created_at` | DateTime | NOT NULL |
|
||||||
|
| `updated_at` | DateTime | NOT NULL, onupdate |
|
||||||
|
|
||||||
|
Methods: `player_count`, `content_count`, `total_duration` (properties), `increment_version()`, `get_content_ordered()`.
|
||||||
|
|
||||||
|
### `playlist_content` — association table (M2M playlist ↔ content)
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `playlist_id` | Integer | FK → `playlist.id` CASCADE, composite PK |
|
||||||
|
| `content_id` | Integer | FK → `content.id` CASCADE, composite PK |
|
||||||
|
| `position` | Integer | default 0 — ordering |
|
||||||
|
| `duration` | Integer | default 10 — per-playlist override |
|
||||||
|
| `muted` | Boolean | default True |
|
||||||
|
| `edit_on_player_enabled` | Boolean | default False — whether player-side editing allowed |
|
||||||
|
|
||||||
|
### `player_edit` — on-player media edits
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | Integer | PK |
|
||||||
|
| `player_id` | Integer | FK → `player.id` CASCADE, indexed |
|
||||||
|
| `content_id` | Integer | FK → `content.id` CASCADE, indexed |
|
||||||
|
| `original_name` | String(255) | NOT NULL |
|
||||||
|
| `new_name` | String(255) | NOT NULL |
|
||||||
|
| `version` | Integer | default 1 |
|
||||||
|
| `user` | String(255) | nullable (user code) |
|
||||||
|
| `time_of_modification` | DateTime | nullable |
|
||||||
|
| `metadata_path` | String(512) | nullable |
|
||||||
|
| `edited_file_path` | String(512) | NOT NULL |
|
||||||
|
| `created_at` | DateTime | NOT NULL, indexed |
|
||||||
|
|
||||||
|
Relationships: `player`, `content`. Method: `to_dict()`.
|
||||||
|
|
||||||
|
### `player_feedback` — status messages from players
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | Integer | PK |
|
||||||
|
| `player_id` | Integer | FK → `player.id`, NOT NULL, indexed |
|
||||||
|
| `status` | String(50) | default `'unknown'` |
|
||||||
|
| `current_content_id` | Integer | FK → `content.id`, nullable |
|
||||||
|
| `message` | Text | nullable |
|
||||||
|
| `error` | Text | nullable |
|
||||||
|
| `timestamp` | DateTime | NOT NULL, indexed |
|
||||||
|
|
||||||
|
Properties/classmethod: `is_error`, `age_seconds`, `get_latest_for_player()`.
|
||||||
|
|
||||||
|
### `player_user` — mapping of player edit user codes
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | Integer | PK |
|
||||||
|
| `user_code` | String(255) | **globally unique**, NOT NULL, indexed |
|
||||||
|
| `user_name` | String(255) | nullable |
|
||||||
|
| `created_at` | DateTime | NOT NULL |
|
||||||
|
| `updated_at` | DateTime | NOT NULL, onupdate |
|
||||||
|
|
||||||
|
Method: `to_dict()`. *(Migrated from a per-player table — see `migrate_player_user_global.py`.)*
|
||||||
|
|
||||||
|
### `server_log` — DB-backed audit log
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | Integer | PK |
|
||||||
|
| `level` | String(20) | NOT NULL, indexed, default `'info'` |
|
||||||
|
| `message` | Text | NOT NULL |
|
||||||
|
| `timestamp` | DateTime | NOT NULL, indexed |
|
||||||
|
|
||||||
|
Classmethods: `log_info`, `log_warning`, `log_error`.
|
||||||
|
|
||||||
|
### `https_config` — HTTPS settings (singleton row)
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | Integer | PK |
|
||||||
|
| `https_enabled` | Boolean | default False |
|
||||||
|
| `hostname` | String(255) | nullable |
|
||||||
|
| `domain` | String(255) | nullable |
|
||||||
|
| `ip_address` | String(45) | nullable (IPv6-capable) |
|
||||||
|
| `email` | String(255) | nullable (Let's Encrypt contact) |
|
||||||
|
| `port` | Integer | default 443 |
|
||||||
|
| `created_at` | DateTime | NOT NULL |
|
||||||
|
| `updated_at` | DateTime | NOT NULL, onupdate |
|
||||||
|
| `updated_by` | String(255) | nullable |
|
||||||
|
|
||||||
|
Classmethods: `get_config()` (first row), `create_or_update(...)`. Method: `to_dict()`.
|
||||||
|
|
||||||
|
### `group` + `group_content` — **REMOVED**
|
||||||
|
|
||||||
|
The `Group` model, the `group_content` association table, `Content.groups` /
|
||||||
|
`Content.group_count`, and the group-management utility functions were **deleted**
|
||||||
|
during the code sanitization pass (the feature was archived and the table had no
|
||||||
|
rows). `Player` never had a `group_id` column. See
|
||||||
|
[SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Relationship Summary
|
||||||
|
|
||||||
|
| Relationship | Cardinality | FK / Mechanism |
|
||||||
|
|---|---|---|
|
||||||
|
| `playlist` → `content` | M:N | `playlist_content` (positioned, with extras) |
|
||||||
|
| `player` → `playlist` | N:1 | `player.playlist_id` (ON DELETE SET NULL) |
|
||||||
|
| `player` → `player_feedback` | 1:N | `player_feedback.player_id` (cascade) |
|
||||||
|
| `player` → `player_edit` | 1:N | `player_edit.player_id` (cascade) |
|
||||||
|
| `content` → `player_edit` | 1:N | `player_edit.content_id` (cascade) |
|
||||||
|
| `player_user` → `player_edit` | 1:N | `player_edit.user` = `player_user.user_code` (logical) |
|
||||||
|
| `https_config` | 1 row | singleton via `get_config()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Next: [04 · Application Core](04-application-core.md)
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# 04 · Application Core
|
||||||
|
|
||||||
|
Covers the application factory, configuration, extensions, middleware, CLI commands, context processors, and template layer. This is **Community 4** in the Graphify knowledge graph.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Application Factory — `app/app.py`
|
||||||
|
|
||||||
|
The entire app is constructed by `create_app(config_name=None)`:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["create_app()"] --> B["Set instance_path"]
|
||||||
|
B --> C["Load config: Dev / Prod / Test"]
|
||||||
|
C --> D["ProxyFix middleware"]
|
||||||
|
D --> E["ScriptNameFix middleware"]
|
||||||
|
E --> F["init extensions: db bcrypt login migrate cache cors"]
|
||||||
|
F --> G["configure_login_manager()"]
|
||||||
|
G --> H["register_blueprints()"]
|
||||||
|
H --> I["register_error_handlers()"]
|
||||||
|
I --> J["register_commands()"]
|
||||||
|
J --> K["register_context_processors()"]
|
||||||
|
K --> L["register_template_filters()"]
|
||||||
|
L --> M["init_portal_sso(app)"]
|
||||||
|
M --> N["db.create_all() (idempotent)"]
|
||||||
|
N --> O["return app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blueprint registration
|
||||||
|
|
||||||
|
```python
|
||||||
|
def register_blueprints(app):
|
||||||
|
from app.blueprints.main import main_bp
|
||||||
|
from app.blueprints.auth import auth_bp
|
||||||
|
from app.blueprints.admin import admin_bp
|
||||||
|
from app.blueprints.players import players_bp
|
||||||
|
from app.blueprints.content import content_bp
|
||||||
|
from app.blueprints.playlist import playlist_bp
|
||||||
|
from app.blueprints.api import api_bp
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: `content_old.py` (legacy content routes) was **deleted** during the code
|
||||||
|
> sanitization pass — see [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md). The
|
||||||
|
> legacy `playlist.py` blueprint remains (redirect-only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Configuration — `app/config.py`
|
||||||
|
|
||||||
|
Four classes: `Config` (base) → `DevelopmentConfig`, `ProductionConfig`, `TestingConfig`.
|
||||||
|
|
||||||
|
| Setting | Value | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `MAX_CONTENT_LENGTH` | 2 GB | upload cap |
|
||||||
|
| `UPLOAD_FOLDER` | `app/static/uploads` | media |
|
||||||
|
| `UPLOAD_FOLDERLOGO` | `app/static/resurse` | logos |
|
||||||
|
| `ALLOWED_EXTENSIONS` | png jpg jpeg gif bmp mp4 avi mkv mov webm pdf ppt pptx | |
|
||||||
|
| `PERMANENT_SESSION_LIFETIME` | 30 min | |
|
||||||
|
| `SESSION_COOKIE_SECURE` | False (True in prod) | |
|
||||||
|
| `ITEMS_PER_PAGE` | 20 | |
|
||||||
|
| `SERVER_VERSION` | 2.0.0 | shown in UI footer |
|
||||||
|
| `PLAYER_CODE_DIR` | `/app/data/player` | staged player source |
|
||||||
|
| `PLAYER_REPO_URL` | env `PLAYER_REPO_URL` | Kiwy-Signage repo |
|
||||||
|
| DB (dev) | `instance/dev.db` | |
|
||||||
|
| DB (prod) | `instance/dashboard.db` | |
|
||||||
|
| DB (test) | in-memory | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Extensions — `app/extensions.py`
|
||||||
|
|
||||||
|
Centralizes shared singletons:
|
||||||
|
|
||||||
|
```python
|
||||||
|
db = SQLAlchemy()
|
||||||
|
bcrypt = Bcrypt()
|
||||||
|
login_manager = LoginManager() # login_view='auth.login'
|
||||||
|
migrate = Migrate()
|
||||||
|
cache = Cache()
|
||||||
|
cors = CORS()
|
||||||
|
```
|
||||||
|
|
||||||
|
CORS is configured in `create_app` for `/api/*`: all origins, GET/POST/OPTIONS/PUT/DELETE, supports credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Middleware
|
||||||
|
|
||||||
|
### ProxyFix (Werkzeug)
|
||||||
|
`app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)` — trusts one reverse-proxy hop (Caddy / umbrella nginx).
|
||||||
|
|
||||||
|
### ScriptNameFix — `app/utils/script_name_fix.py`
|
||||||
|
`ScriptNameFix` WSGI middleware sets `SCRIPT_NAME` from `HTTP_X_SCRIPT_NAME` so `url_for()` generates correct paths when the app is mounted under a sub-path (e.g. `/digiserver`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Login Manager
|
||||||
|
|
||||||
|
```python
|
||||||
|
login_manager.login_view = 'auth.login'
|
||||||
|
login_manager.login_message = 'Please log in to access this page.'
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
return User.query.get(int(user_id))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Error Handlers
|
||||||
|
|
||||||
|
| Code | Template | Behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| 404 | `errors/404.html` | Not found |
|
||||||
|
| 403 | `errors/403.html` | Forbidden |
|
||||||
|
| 500 | `errors/500.html` | Rolls back DB session |
|
||||||
|
| 413 | `errors/413.html` | Payload too large |
|
||||||
|
| 408 | `errors/408.html` | Request timeout |
|
||||||
|
|
||||||
|
The `/api` blueprint registers its own **JSON** 404/405/500 handlers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. CLI Commands
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `flask init-db` | `db.create_all()` |
|
||||||
|
| `flask create-admin --username X` | Create admin user (prompts for password) |
|
||||||
|
| `flask seed-db` | Seed sample data (blocked in production) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Context Processors
|
||||||
|
|
||||||
|
- **`inject_config`** — exposes `server_version`, `build_date`, `logo_exists` to all templates.
|
||||||
|
- **`inject_user_theme`** — exposes the authenticated user's `theme` (`light`/`dark`) for the UI toggle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Template Filters
|
||||||
|
|
||||||
|
- **`localtime`** — converts naive/UTC datetimes to local time with a configurable `strftime` format (default `%Y-%m-%d %H:%M`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Template & Static Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
app/templates/
|
||||||
|
├── base.html ← main layout (theme, logos, nav)
|
||||||
|
├── dashboard.html
|
||||||
|
├── auth/ login.html, register.html, change_password.html
|
||||||
|
├── admin/ admin.html, user_management.html, leftover_media.html,
|
||||||
|
│ dependencies.html, customize_logos.html, editing_users.html,
|
||||||
|
│ https_config.html, build_player.html
|
||||||
|
├── content/ content_list_new.html, media_library.html,
|
||||||
|
│ upload_media.html, manage_playlist_content.html
|
||||||
|
├── players/ players_list.html, add_player.html, edit_player.html,
|
||||||
|
│ manage_player.html, player_page.html, player_fullscreen.html,
|
||||||
|
│ edited_media.html, edited_media_report.html, _deploy_badge.html
|
||||||
|
└── errors/ 403.html, 404.html, 500.html (+413/408)
|
||||||
|
|
||||||
|
app/static/
|
||||||
|
├── icons/ edit, home, info, monitor, moon, playlist, sun, trash, upload, warning (SVG)
|
||||||
|
├── uploads/ uploaded media + edited_media/<content_id>/ (versioned edits)
|
||||||
|
└── (resurse/ logo storage — referenced by config)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Portal SSO — `app/utils/portal_sso.py`
|
||||||
|
|
||||||
|
`init_portal_sso(app)` registers a `before_request` hook that reads `X-Auth-Username` / `X-Auth-Role` headers (set by the umbrella nginx gateway). If present, it auto-creates and logs in the local `User` (`_get_or_create_user`). This lets the app sit behind an existing corporate SSO portal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Next: [05 · Blueprints & API](05-blueprints-api.md)
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# 05 · Blueprints & REST API
|
||||||
|
|
||||||
|
DigiServer v2 registers **7 active blueprints**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Blueprint Overview
|
||||||
|
|
||||||
|
| Blueprint | URL prefix | Module | Focus |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `main_bp` | `/` | `blueprints/main.py` | Dashboard, health |
|
||||||
|
| `auth_bp` | `/` | `blueprints/auth.py` | Login/logout/register/password |
|
||||||
|
| `admin_bp` | `/admin` | `blueprints/admin.py` | Admin panel, HTTPS, build, users, logos |
|
||||||
|
| `players_bp` | `/players` | `blueprints/players.py` | Player management |
|
||||||
|
| `content_bp` | `/content` | `blueprints/content.py` | Media library + modern playlist management |
|
||||||
|
| `playlist_bp` | `/playlist` | `blueprints/playlist.py` | Legacy per-player playlist (redirects) |
|
||||||
|
| `api_bp` | `/api` | `blueprints/api.py` | Player-facing REST + deployment API |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `main` — Dashboard & Health (`/`)
|
||||||
|
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/` | GET | `dashboard` | Counts (players/playlists/content), storage MB, 20 recent logs (cached 60s except `viewer` role) |
|
||||||
|
| `/health` | GET | `health` | Health JSON: `SELECT 1` DB ping + free disk space |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `auth` — Authentication (`/`)
|
||||||
|
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/login` | GET/POST | `login` | bcrypt login, `remember`, `next` redirect, failed-attempt logging |
|
||||||
|
| `/logout` | GET | `logout` | Logs out + logs action |
|
||||||
|
| `/register` | GET/POST | `register` | Self-registration → default `viewer` role |
|
||||||
|
| `/change-password` | GET/POST | `change_password` | Change own password (verifies current) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `admin` — Admin Panel (`/admin`)
|
||||||
|
|
||||||
|
**Security:** `@login_required` on all routes; `@admin_required` (custom decorator, `role == 'admin'`) on user-management.
|
||||||
|
|
||||||
|
### Users
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/` | GET | `admin_panel` | Stats, recent logs, users, storage |
|
||||||
|
| `/user/create` | POST | `create_user` | Create user (username ≥3, password ≥6) |
|
||||||
|
| `/user/<id>/role` | POST | `change_user_role` | Change role (not own) |
|
||||||
|
| `/user/<id>/delete` | POST | `delete_user` | Delete user (not own) |
|
||||||
|
| `/user/<id>/password` | POST | `reset_user_password` | Admin reset password |
|
||||||
|
| `/users` | GET | `user_management` | User list page |
|
||||||
|
| `/theme` | POST | `change_theme` | light/dark theme |
|
||||||
|
|
||||||
|
### Media housekeeping
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/leftover-media` | GET | `leftover_media` | Content not assigned to any playlist, by type + size |
|
||||||
|
| `/delete-leftover-images` | POST | — | Bulk-delete leftover images (+ archive + PlayerEdit) |
|
||||||
|
| `/delete-leftover-videos` | POST | — | Bulk-delete leftover videos |
|
||||||
|
| `/delete-single-leftover/<id>` | POST | — | Delete one leftover item |
|
||||||
|
|
||||||
|
### System / dependencies
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/system/info` | GET | `system_info` | JSON: platform + psutil (CPU/mem/disk) |
|
||||||
|
| `/dependencies` | GET | `dependencies` | Checks LibreOffice, Poppler, FFmpeg, emoji fonts |
|
||||||
|
| `/install-libreoffice` | POST | — | `sudo -n install_libreoffice.sh` |
|
||||||
|
| `/install-emoji-fonts` | POST | — | `sudo -n install_emoji_fonts.sh` |
|
||||||
|
| `/logo/upload` | POST | `upload_logo` | Upload logo.png |
|
||||||
|
| `/logs/clear` | POST | `clear_logs` | Delete all `ServerLog` rows |
|
||||||
|
| `/customize-logos` | GET | `customize_logos` | Logo customization page |
|
||||||
|
| `/upload-header-logo` | POST | — | Save header_logo.png |
|
||||||
|
| `/upload-login-logo` | POST | — | Save login_logo.png |
|
||||||
|
|
||||||
|
### Editing users
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/editing-users` | GET | `manage_editing_users` | List `PlayerUser` + per-user edit counts |
|
||||||
|
| `/editing-users/<id>/update` | POST | — | Rename editing user |
|
||||||
|
| `/editing-users/<id>/delete` | POST | — | Delete editing user |
|
||||||
|
|
||||||
|
### HTTPS (Caddy)
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/https-config` | GET | `https_config` | HTTPS config page (auto-corrects DB flag if request is HTTPS) |
|
||||||
|
| `/https-config/update` | POST | `update_https_config` | Validate + save config → regenerate Caddyfile → reload Caddy |
|
||||||
|
| `/https-config/status` | GET | `https_config_status` | Config as JSON |
|
||||||
|
|
||||||
|
### Player build & deployment
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/build-player` | GET | `build_player` | Build page + staged-code status |
|
||||||
|
| `/build-player` | POST | `build_player_action` | `build_files` / `save_config` / `build_and_config` → clone/refresh code, write `app_config.json`, persist `instance/player_build.json` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `players` — Player Management (`/players`)
|
||||||
|
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/` and `/list` | GET | `list` | Player list + live deployment-status polling data |
|
||||||
|
| `/add` | GET/POST | `add_player` | Create player; optional **background SSH deployment** |
|
||||||
|
| `/bulk/delete` | POST | — | JSON bulk delete |
|
||||||
|
| `/bulk/assign-playlist` | POST | — | JSON bulk assign to playlist |
|
||||||
|
| `/deployment-status` | GET | — | JSON deployment status for all players (polling) |
|
||||||
|
| `/<int:player_id>` | GET | — | Redirect to `manage_player` |
|
||||||
|
| `/<id>/edit` | GET/POST | — | Edit name/location (legacy) |
|
||||||
|
| `/<id>/delete` | POST | — | Delete player + feedback |
|
||||||
|
| `/<id>/regenerate-auth` | POST | — | New auth code |
|
||||||
|
| `/<id>/manage` | GET/POST | `manage_player` | Manage page: `update_credentials` / `assign_playlist`; feedback, edits, status |
|
||||||
|
| `/<id>/edited-media` | GET | `edited_media` | All edited media + user mapping |
|
||||||
|
| `/<id>/edited-media-report` | GET | `edited_media_report` | Tabular report |
|
||||||
|
| `/<id>/fullscreen` | GET | — | Player fullscreen (NO auth; optional `?auth=` check), cached playlist |
|
||||||
|
| `/<id>/reorder` | POST | — | Legacy stub → 400 "use Playlists page" |
|
||||||
|
| `/<id>/playlist/reorder` | POST | — | Legacy per-player reorder (JSON up/down) |
|
||||||
|
| `/<id>/playlist/remove` | POST | — | Legacy removal (bumps `playlist_version`) |
|
||||||
|
|
||||||
|
Helper: `get_player_playlist(player_id)` — `@cache.memoize(300)` builds playlist dicts (`url`, `type`, `duration`, `position`, `muted`, `audio`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `content` — Media Library & Modern Playlist Management (`/content`)
|
||||||
|
|
||||||
|
> This is the **primary workflow** in v2 (1,519 lines).
|
||||||
|
|
||||||
|
### Library & uploads
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/` | GET | `content_list` | Main page `content_list_new.html`: playlists, last-3 media, counts, players |
|
||||||
|
| `/media-library` | GET | `media_library` | All media by type + edit counts |
|
||||||
|
| `/media/<id>/delete` | POST | `delete_media` | Delete file + archive + edits; removes from playlists + bumps versions |
|
||||||
|
| `/upload-media-page` | GET | `upload_media_page` | Upload page (playlist selector) |
|
||||||
|
| `/upload-media` | POST | `upload_media` | Core upload: images optimized, videos validated, **PDF/PPTX → Full-HD PNGs**, large files → background threads |
|
||||||
|
|
||||||
|
### Web links
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/add-weblink` | POST | `add_weblink` | Create weblink Content (http/https validated), optional playlist add |
|
||||||
|
| `/playlist/<id>/add-weblink` | POST | — | Create weblink + append to playlist |
|
||||||
|
|
||||||
|
### Playlist management
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/playlist/create` | POST | — | Create playlist (name, description, orientation) |
|
||||||
|
| `/playlist/<id>/delete` | POST | — | Delete + unassign players |
|
||||||
|
| `/playlist/<id>/manage` | GET | `manage_playlist_content` | Ordered content + available library (no weblinks) |
|
||||||
|
| `/playlist/<id>/add-content` | POST | — | Add library content at next position |
|
||||||
|
| `/playlist/<id>/remove-content/<content_id>` | POST | — | Remove content (deletes orphan weblinks) |
|
||||||
|
| `/playlist/<id>/bulk-remove` | POST | — | JSON bulk remove |
|
||||||
|
| `/playlist/<id>/reorder` | POST | — | JSON reorder (position = index) |
|
||||||
|
| `/playlist/<id>/update-muted/<content_id>` | POST | — | Toggle muted |
|
||||||
|
| `/playlist/<id>/update-edit-enabled/<content_id>` | POST | — | Toggle `edit_on_player_enabled` |
|
||||||
|
| `/playlist/<id>/update-duration/<content_id>` | POST | — | Per-playlist duration (≥1s) |
|
||||||
|
| `/player/<id>/assign-playlist` | POST | — | Assign/unassign player to playlist |
|
||||||
|
|
||||||
|
**All playlist mutations** call `Playlist.increment_version()` and `cache.clear()`.
|
||||||
|
|
||||||
|
### Processing helpers (module-level)
|
||||||
|
`process_image_file`, `process_video_file_extended` (ffprobe), `process_pdf_file` (pdf2image @300 DPI → `_pageNNN.png`), `process_presentation_file` (LibreOffice → PDF → pdftoppm → Full-HD PNG), `create_fullhd_image`, `resize_image_to_fullhd`, `optimize_image_to_fullhd`, `process_file_in_background` (thread wrapper).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `playlist` — Legacy Per-Player Routes (`/playlist`)
|
||||||
|
|
||||||
|
Redirects/legacy — kept for compatibility:
|
||||||
|
| Route | Methods | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `/<int:player_id>` | GET | Redirect to modern manage page |
|
||||||
|
| `/<id>/add` | POST | Add content to player's playlist |
|
||||||
|
| `/<id>/remove/<content_id>` | POST | Remove + renumber |
|
||||||
|
| `/<id>/reorder` | POST | JSON reorder from `content_ids` |
|
||||||
|
| `/<id>/update-duration/<content_id>` | POST | Update duration |
|
||||||
|
| `/<id>/update-muted/<content_id>` | POST | Update muted |
|
||||||
|
| `/<id>/clear` | POST | Clear all items |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `api` — Player-Facing REST + Deployment API (`/api`)
|
||||||
|
|
||||||
|
**Custom decorators:** `rate_limit(max_requests, window)` (in-memory, IP/Bearer keyed) and `verify_player_auth` (validates Bearer `auth_code` → sets `request.player`).
|
||||||
|
|
||||||
|
### Health & auth
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/health` | GET | `health_check` | API health (`version: 2.0.0`) |
|
||||||
|
| `/certificate` | GET | — | TLS certificate test |
|
||||||
|
| `/auth/player` | POST | `authenticate_player` | hostname + password/quickconnect → auth_code, playlist_id, orientation; sets online |
|
||||||
|
| `/auth/verify` | POST | `verify_auth_code` | Verify auth code → player info |
|
||||||
|
|
||||||
|
### Playlist delivery
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/playlists` | GET | `get_playlist_by_quickconnect` | Kivy-compatible: `?hostname=&quickconnect_code=` → playlist + version + hashed quickconnect |
|
||||||
|
| `/playlists/<player_id>` | GET | `get_player_playlist` | Bearer-authed fetch (cached) |
|
||||||
|
| `/playlist-version/<player_id>` | GET | `get_playlist_version` | Lightweight refresh-poll version check |
|
||||||
|
|
||||||
|
**Payload keys the players expect** (from `get_cached_playlist`): `file_name`, `type`, `duration`, `position`, `url`, `description`, `edit_on_player_enabled`, `muted`, `audio` (`"on"`/`"off"`).
|
||||||
|
|
||||||
|
### Feedback & monitoring
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/player-feedback` | POST | `receive_player_feedback` | Status (playing/paused/error/restarting); infers player; **auto-marks deployment `deployed`** |
|
||||||
|
| `/player-status/<player_id>` | GET | `get_player_status` | Online (5-min), latest feedback |
|
||||||
|
| `/system-info` | GET | — | Counts: players online/total, content, 24h logs |
|
||||||
|
| `/content` | GET | — | List content with counts |
|
||||||
|
| `/logs` | GET | `get_logs` | Query logs (`limit`/`level`/`since`) |
|
||||||
|
|
||||||
|
### Edited media
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/player-edit-media` | POST | `receive_edited_media` | Bearer-authed multipart upload of edited image + metadata; versionizes to `edited_media/<content_id>/`; records `PlayerEdit`; auto-creates `PlayerUser`; repoints `Content.filename`; bumps playlist version; clears cache |
|
||||||
|
|
||||||
|
### SSH deployment
|
||||||
|
| Route | Methods | Endpoint | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/deploy/test-ssh` | POST | `test_ssh_connection` | Test SSH (sshpass/ssh) |
|
||||||
|
| `/deploy/player` | POST | `deploy_player` | Full remote deploy (`deploy_player_to_host`); API key = `sha256(name:hostname)[:32]` |
|
||||||
|
|
||||||
|
**Error handlers:** JSON 404 / 405 / 500.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. API Call Flows (Mermaid)
|
||||||
|
|
||||||
|
### Player bootstrap (auth → playlist)
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant P as Player
|
||||||
|
participant A as /api
|
||||||
|
P->>A: POST /api/auth/player {hostname, password|quickconnect}
|
||||||
|
A->>A: Player.authenticate()
|
||||||
|
A-->>P: {auth_code, playlist_id, orientation}
|
||||||
|
P->>A: GET /api/playlists/<id> (Bearer auth_code)
|
||||||
|
A-->>P: playlist items + version
|
||||||
|
loop refresh poll
|
||||||
|
P->>A: GET /api/playlist-version/<id>
|
||||||
|
A-->>P: {version}
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edited media upload
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant P as Player
|
||||||
|
participant A as /api
|
||||||
|
participant DB as SQLite
|
||||||
|
P->>A: POST /api/player-edit-media (Bearer, multipart image + metadata)
|
||||||
|
A->>DB: locate Content (filename / edited_media/<id>/ regex / last PlayerEdit)
|
||||||
|
A->>DB: move original → edited_media/<id>/original_*
|
||||||
|
A->>DB: save new version + side-car JSON
|
||||||
|
A->>DB: get_or_create PlayerUser (user_code)
|
||||||
|
A->>DB: create PlayerEdit
|
||||||
|
A->>DB: repoint Content.filename → latest edit (preserve original_filename)
|
||||||
|
A->>DB: Playlist.increment_version()
|
||||||
|
A-->>P: 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Admin as Admin UI
|
||||||
|
participant S as Server (app)
|
||||||
|
participant T as Background task
|
||||||
|
participant H as Player host
|
||||||
|
Admin->>S: add player / deploy
|
||||||
|
S->>T: background_player_deployment()
|
||||||
|
T->>H: sshpass test_ssh_connection()
|
||||||
|
T->>H: rsync staged code (or git clone/pull)
|
||||||
|
T->>H: write config/app_config.json
|
||||||
|
T->>H: temp passwordless sudo → install.sh → start.sh
|
||||||
|
T->>H: cleanup sudoers
|
||||||
|
T-->>S: update player.deployment_status = deployed|failed
|
||||||
|
Admin->>S: GET /players/deployment-status (poll)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Next: [06 · Utils & Services](06-utils-services.md)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# 06 · Utilities & Services
|
||||||
|
|
||||||
|
All shared services live in `app/utils/`. This document details each module, its main functions, and how they fit together.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview Table
|
||||||
|
|
||||||
|
| Module | Community | Responsibility | Key symbols |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `logger.py` | C0 | DB-backed audit logging | `log_action()`, `get_recent_logs()`, `clear_old_logs()` |
|
||||||
|
| `group_player_management.py` | C0 | Player status reporting | `get_player_status_info()` |
|
||||||
|
| `caddy_manager.py` | C1 | HTTPS Caddyfile generation | `CaddyConfigGenerator`, `write_caddyfile()`, `reload_caddy()` |
|
||||||
|
| `background_tasks.py` | C10 | Async task execution | `run_background_task()`, `background_player_deployment()` |
|
||||||
|
| `ssh_deploy.py` | C10 | Remote player provisioning | `deploy_player_to_host()`, `test_ssh_connection()`, `generate_player_config()` |
|
||||||
|
| `player_build.py` | C11 | Stage player source code | `build_player_files()`, `write_base_config()`, `load/save_build_settings()` |
|
||||||
|
| `pptx_converter.py` | C12 | PPTX → PDF → PNG | `pptx_to_pdf_libreoffice()`, `validate_pptx_file()`, `cleanup_libreoffice_processes()` |
|
||||||
|
| `uploads.py` | C9 | Upload progress + file ops | `get/set/clear_upload_progress()`, `save_uploaded_file()`, `process_video_file()` |
|
||||||
|
| `portal_sso.py` | C4 | SSO auto-login | `init_portal_sso()`, `_get_or_create_user()` |
|
||||||
|
| `script_name_fix.py` | C4 | WSGI sub-path middleware | `ScriptNameFix` |
|
||||||
|
|
||||||
|
> `nginx_config_reader.py` (C7) was **removed** during the sanitization pass — the
|
||||||
|
> reverse proxy is Caddy. See [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. `logger.py` — Audit Logging ⭐ God Node (116 edges)
|
||||||
|
|
||||||
|
The most-connected module in the system. Every meaningful action across all blueprints is recorded here.
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `log_action(level, message)` | Create + commit a `ServerLog` row |
|
||||||
|
| `log_info(message)` / `log_warning(message)` / `log_error(message)` | Convenience wrappers |
|
||||||
|
| `get_recent_logs(limit, level)` | Query recent logs (used by dashboard/admin) |
|
||||||
|
| `clear_old_logs(days)` | Housekeeping |
|
||||||
|
|
||||||
|
**Usage pattern:** admin actions (`reset_user_password`, `upload_header_logo`, `delete_editing_user`, `delete_playlist`, login failures, etc.) all funnel through `log_action()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `background_tasks.py` — Async Execution
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `run_background_task(func, *args)` | Spawns a **daemon thread** with a Flask app context pushed, then runs `func` |
|
||||||
|
| `background_player_deployment(...)` | Runs SSH deployment in background; updates `player.deployment_status` (`deploying → deployed|failed`) |
|
||||||
|
|
||||||
|
Used by `players.add_player` to avoid blocking the HTTP request during long remote installs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `ssh_deploy.py` — Remote Player Provisioning ⭐ (heat 0.614)
|
||||||
|
|
||||||
|
The deployment engine. Full pipeline implemented by `deploy_player_to_host(...)`:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["deploy_player_to_host()"] --> B["test_ssh_connection() (sshpass)"]
|
||||||
|
B --> C["mkdir remote dir"]
|
||||||
|
C --> D{"staged code present?"}
|
||||||
|
D -- yes --> E["rsync pre-staged code"]
|
||||||
|
D -- no --> F["git clone / pull"]
|
||||||
|
E --> G["write config/app_config.json"]
|
||||||
|
F --> G
|
||||||
|
G --> H["temp passwordless sudo"]
|
||||||
|
H --> I["run install.sh"]
|
||||||
|
I --> J["run start.sh"]
|
||||||
|
J --> K["cleanup sudoers"]
|
||||||
|
K --> L["return steps[]"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Other helpers:
|
||||||
|
- `get_local_player_code_status()` — inspect staged code state.
|
||||||
|
- `detect_server_ip()` / `parse_server_address()` — resolve the server address players should reach.
|
||||||
|
- `generate_player_config()` / `generate_app_config()` — write player's `config/app_config.json` with `server_ip`, `port`, `screen_name`, `quickconnect_key`, `orientation`, `use_https`, `verify_ssl`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `caddy_manager.py` — HTTPS Automation ⭐ God Node
|
||||||
|
|
||||||
|
`CaddyConfigGenerator` produces the Caddyfile for the reverse proxy and reloads Caddy without restart.
|
||||||
|
|
||||||
|
| Method | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `generate_caddyfile(config, http_fallback=True)` | Pick template by mode: **HTTP-only** (`:80`), **domain** (Let's Encrypt), or **IP-only** (internal CA `tls internal`). Includes `reverse_proxy digiserver-app:5000`, 2 GB body limit, gzip, security headers. `http_fallback` also serves plain HTTP alongside internal-CA TLS so clients that cannot trust the local CA still work |
|
||||||
|
| `write_caddyfile(content, path=/etc/caddy/Caddyfile)` | Write to disk |
|
||||||
|
| `reload_caddy()` | POST to Caddy admin API `http://caddy:2019/load` |
|
||||||
|
|
||||||
|
Triggered from `admin.update_https_config` (Admin UI) **or** `https_manager.py` (CLI),
|
||||||
|
both of which save `HTTPSConfig` first so the two paths stay in sync.
|
||||||
|
|
||||||
|
> **Internal CA vs Let's Encrypt:** an intranet name (e.g. `*.harting.intra`) is not
|
||||||
|
> resolvable publicly, so ACME challenges cannot succeed. Leaving `domain` empty selects
|
||||||
|
> `tls internal`, which needs no DNS and no external service. See
|
||||||
|
> [07 · Deployment §6](07-deployment.md#6-https-setup-caddy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4b. `https_manager.py` — HTTPS CLI (repo root)
|
||||||
|
|
||||||
|
Command-line equivalent of the Admin HTTPS page, used by `deploy.sh`.
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `enable <hostname> <domain> <email> <ip> [port]` | Persist `HTTPSConfig`, regenerate + write the Caddyfile, hot-reload Caddy. Empty `<domain>` → internal CA. `--redirect-only` to disable the HTTP fallback; `--no-https` for HTTP only |
|
||||||
|
| `disable` | Turn HTTPS off (HTTP only) |
|
||||||
|
| `status` | Print the stored configuration and resolved mode |
|
||||||
|
|
||||||
|
Exit codes: `0` success · `1` bad args/config · `2` config applied but Caddy did not reload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `player_build.py` — Staging Player Code
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `build_player_files(dir, repo_url, branch)` | Clone or `fetch + reset` the player repository into `PLAYER_CODE_DIR` |
|
||||||
|
| `write_base_config(...)` | Write `config/app_config.json` (blank screen / quickconnect) |
|
||||||
|
| `load_build_settings()` / `save_build_settings()` | JSON at `instance/player_build.json` |
|
||||||
|
| `get_player_server_settings` / `make_build_record` / `get_short_head` | Build metadata helpers |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `uploads.py` — Upload & Media Processing
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `get_upload_progress` / `set_upload_progress` / `clear_upload_progress` | In-memory per-file progress (for the upload page) |
|
||||||
|
| `save_uploaded_file` | Save a multipart upload |
|
||||||
|
| `process_video_file` | FFmpeg → H.264 main, 30 fps, ≤1080p, faststart |
|
||||||
|
| `process_pdf_file` | PDF processing (stub — real logic in content.py) |
|
||||||
|
| `get_file_size` / `delete_file` | FS helpers |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `pptx_converter.py` — LibreOffice Integration
|
||||||
|
|
||||||
|
| Function | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `pptx_to_pdf_libreoffice(pptx_path, output_dir)` | Headless LibreOffice → PDF (300 s timeout) |
|
||||||
|
| `validate_pptx_file()` | Validate a file is a real PPTX |
|
||||||
|
| `cleanup_libreoffice_processes()` | `pkill soffice` — clean hanging processes |
|
||||||
|
|
||||||
|
Used by the upload pipeline: **PPTX → PDF → PNG slides (Full HD)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `portal_sso.py` & `script_name_fix.py` — Gateway Integration
|
||||||
|
|
||||||
|
- **`portal_sso.py`** — `before_request` reads `X-Auth-Username`/`X-Auth-Role` from the umbrella nginx and auto-logs-in the local user (creating it on first arrival). See [04 · Application Core §11](04-application-core.md#11-portal-sso--apputilsportal_sso_py).
|
||||||
|
- **`script_name_fix.py`** — WSGI middleware mapping `X-Script-Name` → `SCRIPT_NAME` so `url_for()` is correct behind a path-prefixed gateway.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. `group_player_management.py` — Player Status
|
||||||
|
|
||||||
|
Only `get_player_status_info(player_id)` remains: it returns the online flag
|
||||||
|
(5-minute window), status, last-seen plus a humanised "time ago", and the latest
|
||||||
|
`PlayerFeedback`. Used by `players.list` and `players.manage_player`.
|
||||||
|
|
||||||
|
The group helpers (`get_group_statistics`, `assign_player_to_group`,
|
||||||
|
`bulk_assign_players_to_group`) and the status-list helpers
|
||||||
|
(`get_online_players_count`, `get_players_by_status`) were **removed** with the
|
||||||
|
archived Group subsystem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Next: [07 · Deployment](07-deployment.md)
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
# 07 · Deployment
|
||||||
|
|
||||||
|
DigiServer v2 runs as a **Docker Compose** stack (app + Caddy) with optional remote SSH provisioning of players.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Host["Docker Host"]
|
||||||
|
subgraph Net["digiserver-network"]
|
||||||
|
APP["digiserver-app\nFlask + Gunicorn :5000"]
|
||||||
|
CAD["caddy:2-alpine\n:80 / :443"]
|
||||||
|
end
|
||||||
|
VOL1["./data/instance → /app/instance\n(SQLite DBs)"]
|
||||||
|
VOL2["./data/uploads → /app/app/static/uploads"]
|
||||||
|
VOL3["./data/Caddyfile → /etc/caddy/Caddyfile"]
|
||||||
|
VOL4["./data/caddy-data → /data\n./data/caddy-config → /config"]
|
||||||
|
end
|
||||||
|
Browser["Browser"] --> CAD
|
||||||
|
CAD --> APP
|
||||||
|
APP --> VOL1
|
||||||
|
APP --> VOL2
|
||||||
|
CAD --> VOL3
|
||||||
|
CAD --> VOL4
|
||||||
|
APP -.SSH/rsync.-> Players["Remote player hosts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `docker-compose.yml`
|
||||||
|
|
||||||
|
### Service `digiserver-app`
|
||||||
|
- **Build:** `.` (Dockerfile, `python:3.13-slim`)
|
||||||
|
- **Ports:** `5000:5000` (direct dev access; also `expose: 5000`)
|
||||||
|
- **Volumes:** `./data/instance:/app/instance`, `./data/uploads:/app/app/static/uploads`
|
||||||
|
- **Env:** `FLASK_ENV=production`, `SECRET_KEY`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`
|
||||||
|
- **Healthcheck:** HTTP GET `http://localhost:5000/` (30s interval, 40s start)
|
||||||
|
- **Restart:** `unless-stopped`
|
||||||
|
|
||||||
|
### Service `caddy`
|
||||||
|
- **Image:** `caddy:2-alpine`
|
||||||
|
- **Ports:** `8080:80`, `8443:443`
|
||||||
|
- **Volumes:** `./data/Caddyfile:/etc/caddy/Caddyfile:rw`, `./data/caddy-data:/data`, `./data/caddy-config:/config`, `./data/caddy-logs:/var/log/caddy`
|
||||||
|
- **Depends on:** app (service started)
|
||||||
|
- **Healthcheck:** `wget` on port 80
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Dockerfile
|
||||||
|
|
||||||
|
| Stage | Content |
|
||||||
|
|---|---|
|
||||||
|
| Base | `python:3.13-slim` |
|
||||||
|
| System deps | `poppler-utils`, `ffmpeg`, `libmagic1`, `sudo`, `fonts-noto-color-emoji`, `LibreOffice` (core/impress/writer), `sshpass`, `openssh-client`, `rsync`, `git` |
|
||||||
|
| Python | `COPY requirements.txt` → `pip install` (cached layer), then `COPY . .` |
|
||||||
|
| App config | `FLASK_APP=app.app:create_app`, `FLASK_ENV=production`, `EXPOSE 5000` |
|
||||||
|
| User | Non-root `appuser` (UID 1000) with **passwordless sudo** limited to `apt-get`, `install_libreoffice.sh`, `install_emoji_fonts.sh` |
|
||||||
|
| Runtime | `HEALTHCHECK` (HTTP 5000), `ENTRYPOINT /docker-entrypoint.sh` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `docker-entrypoint.sh`
|
||||||
|
|
||||||
|
1. **Pin the database**: export `DATABASE_URL` (default `sqlite:////app/instance/dashboard.db`) so migrations and the app resolve to the *same* file. Required because the config classes default to different files (`dev.db` vs `dashboard.db`) and most migration scripts call `create_app()` without an argument (→ development config), while the app runs `create_app('production')`.
|
||||||
|
2. Create `/app/instance` and `/app/app/static/uploads`.
|
||||||
|
3. Ensure schema + admin user — runs on **every** start (idempotent): `db.create_all()`, then create the admin from `ADMIN_USERNAME` / `ADMIN_PASSWORD` or refresh its password.
|
||||||
|
4. Run the **migration chain** (idempotent, ordered — table-creating migrations run before those that alter them):
|
||||||
|
|
||||||
|
```
|
||||||
|
add_https_config_table.py
|
||||||
|
add_player_user_table.py
|
||||||
|
add_email_to_https_config.py
|
||||||
|
migrate_player_user_global.py
|
||||||
|
add_url_to_content.py
|
||||||
|
add_original_filename_to_content.py
|
||||||
|
add_deployment_fields_to_player.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations are **non-fatal**: failures are logged as `⚠️ WARNING` and startup continues, so one bad migration can't strand the container in a restart loop.
|
||||||
|
|
||||||
|
5. Start **Gunicorn**: `--bind 0.0.0.0:5000 --workers 4 --timeout 120 app.app:create_app('production')`.
|
||||||
|
|
||||||
|
> Because migrations now run automatically on startup, `deploy.sh` step 4 is redundant (harmless — the scripts are idempotent).
|
||||||
|
|
||||||
|
### Data layout (bind mounts)
|
||||||
|
|
||||||
|
| Host path | Container path | Contents |
|
||||||
|
|---|---|---|
|
||||||
|
| `data/instance` | `/app/instance` | SQLite DB (`dashboard.db`), `player_build.json` |
|
||||||
|
| `data/uploads` | `/app/app/static/uploads` | Media files + `edited_media/` |
|
||||||
|
| `data/Caddyfile` | `/etc/caddy/Caddyfile` | Reverse-proxy config (**a file, not a directory**) |
|
||||||
|
| `data/caddy-data` | `/data` | Caddy state (instance UUID, certificates) |
|
||||||
|
| `data/caddy-config` | `/config` | Caddy autosave |
|
||||||
|
| `data/caddy-logs` | `/var/log/caddy` | Access logs |
|
||||||
|
|
||||||
|
> ⚠️ **`data/Caddyfile` must exist before `docker compose up`.** It is bind-mounted as a *file*;
|
||||||
|
> if it is missing, Docker creates a **directory** in its place and Caddy fails to start.
|
||||||
|
> `deploy.sh` seeds it from the version-controlled `Caddyfile.example`. For a manual start:
|
||||||
|
> ```
|
||||||
|
> mkdir -p data/instance data/uploads data/caddy-data data/caddy-config data/caddy-logs
|
||||||
|
> cp Caddyfile.example data/Caddyfile
|
||||||
|
> docker compose up -d --build
|
||||||
|
> ```
|
||||||
|
|
||||||
|
> ℹ️ The legacy `data/nginx-*` and `data/certbot` folders are **obsolete** — the reverse proxy is
|
||||||
|
> Caddy. They are no longer created by `deploy.sh`.
|
||||||
|
|
||||||
|
### Clean start (wipe all runtime data)
|
||||||
|
|
||||||
|
`data/` is **gitignored**, so wiping it is irreversible. To reset to a pristine deployment:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose down
|
||||||
|
docker rmi digiserver-v2-digiserver-app:latest # drop stale image
|
||||||
|
docker image prune -f && docker builder prune -a -f # reclaim build cache
|
||||||
|
rm -rf data # WIPES db, uploads, certs
|
||||||
|
mkdir -p data/instance data/uploads data/caddy-data data/caddy-config data/caddy-logs
|
||||||
|
cp Caddyfile.example data/Caddyfile
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ Files under `data/caddy-*` are created **root-owned** by the Caddy container, so a plain
|
||||||
|
> `rm -rf data` may fail with *Permission denied*. Remove them via a helper container:
|
||||||
|
> ```
|
||||||
|
> docker run --rm -v "$PWD/data:/data" caddy:2-alpine sh -c 'rm -rf /data/caddy-config /data/caddy-data'
|
||||||
|
> ```
|
||||||
|
> Avoid `docker system prune --volumes` — this host also holds volumes for **other** projects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `deploy.sh` (One-Shot Deployment)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Detect compose: `docker compose` (plugin) or `docker-compose` (v1 fallback)
|
||||||
|
— stored in $COMPOSE and used for every subsequent call
|
||||||
|
2. Create data/ subdirs (instance, uploads, caddy-data, caddy-config, caddy-logs)
|
||||||
|
and seed data/Caddyfile from Caddyfile.example
|
||||||
|
3. $COMPOSE up -d + verify containers "Up"
|
||||||
|
4. Run migration scripts (add_https_config_table, add_player_user_table,
|
||||||
|
add_email_to_https_config, migrate_player_user_global,
|
||||||
|
add_original_filename_to_content)
|
||||||
|
↳ NOTE: the container entrypoint already applies all seven on startup.
|
||||||
|
This step is idempotent and therefore redundant.
|
||||||
|
5. /app/https_manager.py enable <hostname> <domain> <email> <ip> <port>
|
||||||
|
↳ Exit code 2 ("config applied, Caddy not reloaded") is non-fatal.
|
||||||
|
6. Verify DB tables via SQLAlchemy inspector; caddy validate;
|
||||||
|
https_manager.py status; print access URLs + default creds
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration variables
|
||||||
|
|
||||||
|
| Variable | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `HOSTNAME` | `digiserver` | Display hostname |
|
||||||
|
| `HTTPS_MODE` | `internal` | `internal` \| `acme` \| `off` |
|
||||||
|
| `DOMAIN` | *(empty)* | Required only when `HTTPS_MODE=acme` |
|
||||||
|
| `IP_ADDRESS` | **auto-detected** | Primary LAN IP (override if needed) |
|
||||||
|
| `EMAIL` | `admin@example.com` | ACME account email (unused by internal CA) |
|
||||||
|
| `PORT` | `8443` | Externally published HTTPS port |
|
||||||
|
|
||||||
|
> If `IP_ADDRESS` is unset, `deploy.sh` auto-detects it
|
||||||
|
> (`ip -4 route get 1.1.1.1` → `src` address, falling back to `hostname -I`).
|
||||||
|
> The old hard-coded defaults (`10.76.152.164`, a `.intra` domain) were wrong for
|
||||||
|
> most hosts and have been removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. HTTPS Setup (Caddy)
|
||||||
|
|
||||||
|
Three equivalent entry points drive the **same** code path
|
||||||
|
(`HTTPSConfig` + `CaddyConfigGenerator`) so CLI, env bootstrap and UI cannot diverge:
|
||||||
|
|
||||||
|
1. **Env bootstrap (deploy time)** — the container entrypoint runs
|
||||||
|
`python /app/https_manager.py bootstrap`, which reads `HOSTNAME_INTERNAL` and
|
||||||
|
`HOST_IP` from the environment and configures Caddy for HTTPS automatically.
|
||||||
|
2. **CLI** — `python /app/https_manager.py enable … | verify | status | disable`
|
||||||
|
3. **UI** — *Admin → HTTPS Configuration* (reloads Caddy live; the ongoing source of truth)
|
||||||
|
|
||||||
|
### Addressing model — one HTTP endpoint, one HTTPS endpoint
|
||||||
|
|
||||||
|
| Port | What it does |
|
||||||
|
|---|---|
|
||||||
|
| **80** | Always answers. A catch-all `:80` block serves **any** Host header, plus explicit blocks for the IP and hostname so both work. |
|
||||||
|
| **443** | HTTPS for the same names, using the internal CA (or ACME for a public domain). |
|
||||||
|
|
||||||
|
If HTTPS is disabled or never configured, port 80 simply serves the app — there is
|
||||||
|
no separate "HTTP mode" to set.
|
||||||
|
|
||||||
|
> ⚠️ **`default_sni` is required for IP access.** Browsers send **no SNI** when the
|
||||||
|
> URL is an IP address (an IP is not a valid SNI hostname). Caddy then identifies the
|
||||||
|
> connection by the container's own internal IP and aborts the handshake with
|
||||||
|
> `no certificate available for '<container-ip>'`. To prevent this, the generator emits
|
||||||
|
> `default_sni <ip>` whenever internal-CA mode is used, so `https://<ip>` works in a
|
||||||
|
> plain browser. This was found by end-to-end testing — see
|
||||||
|
> `docs/tools/test_http_https_runtime.sh`.
|
||||||
|
|
||||||
|
### Mode selection
|
||||||
|
|
||||||
|
| Condition | Result |
|
||||||
|
|---|---|
|
||||||
|
| HTTPS off, or no IP/domain | Plain HTTP on port 80 |
|
||||||
|
| HTTPS on + `ip_address` / `hostname` | `tls internal` per name (no DNS, no ACME) |
|
||||||
|
| HTTPS on + `domain` set | Let's Encrypt for that name |
|
||||||
|
|
||||||
|
### Automatic fallback if HTTPS does not work
|
||||||
|
|
||||||
|
After applying a config, `https_manager.py` probes the HTTPS endpoint
|
||||||
|
(`/api/health`, certificate validation deliberately disabled). If the TLS listener
|
||||||
|
does not come up, the configuration is **automatically reverted to plain HTTP** so a
|
||||||
|
failed certificate can never make the site unreachable:
|
||||||
|
|
||||||
|
```
|
||||||
|
enable HTTPS → reload Caddy → probe https://<host>:<port>/api/health
|
||||||
|
├─ OK → keep HTTPS
|
||||||
|
└─ FAIL → revert to HTTP-only, log a warning
|
||||||
|
```
|
||||||
|
|
||||||
|
Disable the probe with `HTTPS_VERIFY=false` (or `enable --no-verify`).
|
||||||
|
Re-check at any time with `python /app/https_manager.py verify`.
|
||||||
|
|
||||||
|
### Deploy-time bootstrap via `.env`
|
||||||
|
|
||||||
|
Copy `.env.example` → `.env` and set the host address. `docker-compose.yml`
|
||||||
|
forwards these to the app container:
|
||||||
|
|
||||||
|
| Variable | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `HOSTNAME_INTERNAL` | Hostname served (both HTTP and HTTPS) |
|
||||||
|
| `HOST_IP` | IP served and certified |
|
||||||
|
| `DOMAIN` | **Leave empty for an intranet name** → internal CA. Set only for Let's Encrypt |
|
||||||
|
| `SSL_EMAIL` | ACME contact (ignored by internal CA) |
|
||||||
|
| `HTTP_PORT` / `HTTPS_PORT` | Host ports mapped to Caddy's 80/443 (default `80`/`443`) |
|
||||||
|
| `HTTPS_HTTP_FALLBACK` | `true` (default) also serves plain HTTP; `false` redirects instead |
|
||||||
|
| `HTTPS_VERIFY` | `true` (default) probe HTTPS and auto-fall back on failure |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# set HOSTNAME_INTERNAL and HOST_IP
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
> **If either `HOSTNAME_INTERNAL` or `HOST_IP` is missing the bootstrap is a no-op** —
|
||||||
|
> the app starts on plain HTTP and stays reachable. HTTPS can then be enabled from
|
||||||
|
> *Admin → HTTPS Configuration*, which regenerates the Caddyfile and reloads Caddy
|
||||||
|
> **without a restart**.
|
||||||
|
|
||||||
|
### Who owns the config (env vs admin UI)
|
||||||
|
|
||||||
|
The admin UI is the **ongoing source of truth**. The bootstrap runs on every container
|
||||||
|
start, so it guards against silently overwriting an admin's change by tracking
|
||||||
|
provenance in `HTTPSConfig.updated_by`:
|
||||||
|
|
||||||
|
| Current config | Bootstrap behaviour |
|
||||||
|
|---|---|
|
||||||
|
| *(none — first deploy)* | Apply from env ✅ |
|
||||||
|
| Written by env/CLI (`updated_by='deploy.sh'`) | Apply from env ✅ — so editing `HOST_IP` and redeploying works |
|
||||||
|
| Written by a user (`updated_by='<username>'`) | **Skip** — the admin's setting is preserved |
|
||||||
|
|
||||||
|
So an admin change made in the UI survives restarts even while the env vars remain set.
|
||||||
|
|
||||||
|
### Why internal CA (and not Let's Encrypt) for `.intra`
|
||||||
|
|
||||||
|
An intranet name such as `digiserver.sibiusb.harting.intra` is **not resolvable from the
|
||||||
|
public internet**, so Let's Encrypt's HTTP-01/TLS-ALPN challenge cannot succeed. Setting
|
||||||
|
`DOMAIN=` empty makes Caddy sign the certificate itself with its local CA — no external
|
||||||
|
dependency at all.
|
||||||
|
|
||||||
|
**Trust caveat:** the internal CA is not in any client trust store, so browsers show a
|
||||||
|
warning and a Kivy player with `verify_ssl: true` will **reject the connection**. Options:
|
||||||
|
|
||||||
|
1. **Use HTTP** — port 80 always serves the app, so players need no trust configuration.
|
||||||
|
2. **Install the root CA** on each device:
|
||||||
|
```
|
||||||
|
docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-root.crt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
| Host → Container | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `80 → 80` | HTTP (always available) |
|
||||||
|
| `443 → 443` | HTTPS |
|
||||||
|
| `5000 → 5000` | Direct app access (bypasses Caddy; dev/testing) |
|
||||||
|
|
||||||
|
> Ports are configurable via `HTTP_PORT`/`HTTPS_PORT` so the stack also works where
|
||||||
|
> 80/443 are already taken (e.g. `HTTP_PORT=8080 HTTPS_PORT=8443`).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `verify-deployment.sh` — Pre/Post-Deployment Checks
|
||||||
|
|
||||||
|
Sections checked (pass/fail/warn counters):
|
||||||
|
- git status
|
||||||
|
- `.env` / `.env.example`
|
||||||
|
- Docker + Compose versions (plugin **or** v1) + `compose config` syntax
|
||||||
|
- Dockerfile best practices (HEALTHCHECK, non-root, slim base)
|
||||||
|
- `requirements.txt` critical packages + versions
|
||||||
|
- migrations directory
|
||||||
|
- **TLS certificate** — Caddy internal CA expiry (`data/caddy-data/caddy/pki/authorities/local/root.crt`)
|
||||||
|
- Flask config (`ProductionConfig`, `SESSION_COOKIE_SECURE`)
|
||||||
|
- `data/Caddyfile` checks (reverse_proxy, admin API, TLS mode)
|
||||||
|
- runtime container health + live HTTP/HTTPS endpoint probes
|
||||||
|
- security best practices
|
||||||
|
|
||||||
|
> The script now detects `docker compose` (plugin) or `docker-compose` (v1) and warns when
|
||||||
|
> buildx is too old for compose v1 builds — matching `deploy.sh`, which then falls back to
|
||||||
|
> `docker build`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Player Deployment Pipeline
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as Admin
|
||||||
|
participant A as App
|
||||||
|
participant B as Background
|
||||||
|
participant H as Player host
|
||||||
|
U->>A: Add player (name, hostname, password/quickconnect)
|
||||||
|
A->>B: background_player_deployment()
|
||||||
|
B->>B: build/refresh staged code (PLAYER_CODE_DIR)
|
||||||
|
B->>H: sshpass + ssh test
|
||||||
|
B->>H: rsync code (or git clone/pull fallback)
|
||||||
|
B->>H: write config/app_config.json (server_ip, quickconnect, https...)
|
||||||
|
B->>H: temporary passwordless sudo
|
||||||
|
B->>H: ./install.sh && ./start.sh
|
||||||
|
B->>H: remove temp sudoers
|
||||||
|
B-->>A: player.deployment_status = 'deployed' | 'failed'
|
||||||
|
U->>A: poll /players/deployment-status
|
||||||
|
```
|
||||||
|
|
||||||
|
Statuses: `pending → deploying → deployed | failed`, with `last_deployment_at/status/message` persisted on the player row. Receiving player feedback also auto-marks deployment `deployed`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Ports & Volumes Summary
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|---|---|
|
||||||
|
| App port (container) | 5000 |
|
||||||
|
| App port (host, dev) | 5000 |
|
||||||
|
| Caddy HTTP | 8080 → 80 |
|
||||||
|
| Caddy HTTPS | 8443 → 443 |
|
||||||
|
| Caddy admin API | 2019 (container) |
|
||||||
|
| DB volume | `./data/instance` |
|
||||||
|
| Uploads volume | `./data/uploads` |
|
||||||
|
| Caddy data/config/logs | `./data/caddy-*` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Next: [08 · Workflows](08-workflows.md)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# 08 · End-to-End Workflows
|
||||||
|
|
||||||
|
This document traces the main user journeys through the system, tying together blueprints, models, utils, and the Graphify graph.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Content Upload → Playlist → Player
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
U["Admin uploads media"] --> UF["/content/upload-media"]
|
||||||
|
UF -->|image| IMG["optimize_image_to_fullhd"]
|
||||||
|
UF -->|video| VID["validate via ffprobe"]
|
||||||
|
UF -->|pdf| PDF["pdf2image @300dpi → page PNGs"]
|
||||||
|
UF -->|pptx| PPT["LibreOffice → PDF → PNG slides"]
|
||||||
|
UF -->|large file| BG["process_file_in_background (thread)"]
|
||||||
|
UF --> DB[("Content row")]
|
||||||
|
DB --> PLA["/content/playlist/<id>/add-content"]
|
||||||
|
PLA --> PLC["playlist_content (position, duration, muted, edit_on_player_enabled)"]
|
||||||
|
PLC --> VER["Playlist.increment_version() + cache.clear()"]
|
||||||
|
VER --> API["/api/playlists/<id>"]
|
||||||
|
API --> P["Player fetches new playlist"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key files:** `app/blueprints/content.py`, `app/utils/uploads.py`, `app/utils/pptx_converter.py`, `app/models/content.py`, `app/models/playlist.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Playlist Synchronization (the "version" mechanism)
|
||||||
|
|
||||||
|
Every playlist mutation bumps `Playlist.version`. Players detect changes by polling:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as Admin UI
|
||||||
|
participant App
|
||||||
|
participant DB as SQLite
|
||||||
|
participant P as Player
|
||||||
|
|
||||||
|
U->>App: reorder / mute / duration / add / remove
|
||||||
|
App->>DB: mutate playlist_content
|
||||||
|
App->>DB: playlist.increment_version()
|
||||||
|
App->>App: cache.clear()
|
||||||
|
loop while player runs
|
||||||
|
P->>App: GET /api/playlist-version/<player_id>
|
||||||
|
App-->>P: {version: N}
|
||||||
|
alt version changed
|
||||||
|
P->>App: GET /api/playlists/<player_id>
|
||||||
|
App-->>P: new playlist payload
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**Playlist payload items** include: `file_name`, `type`, `duration`, `position`, `url` (weblink or static file), `description`, `edit_on_player_enabled`, `muted`, `audio`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. On-Player Media Editing Pipeline ⭐ (heat 0.620)
|
||||||
|
|
||||||
|
When a signage operator edits an image directly on the player device:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["Player posts edited image"] --> B["POST /api/player-edit-media (Bearer + multipart)"]
|
||||||
|
B --> C{"Locate Content"}
|
||||||
|
C -->|by filename| D["match filename"]
|
||||||
|
C -->|edited_media regex| E["match edited_media/<id>/"]
|
||||||
|
C -->|fallback| F["last PlayerEdit"]
|
||||||
|
D/E/F --> G["Move original → edited_media/<content_id>/original_*"]
|
||||||
|
G --> H["Save new version + side-car metadata JSON"]
|
||||||
|
H --> I{"PlayerUser exists for user_code?"}
|
||||||
|
I -- no --> J["auto-create PlayerUser"]
|
||||||
|
I -- yes --> K["reuse"]
|
||||||
|
J/K --> L["Create PlayerEdit (version, original_name, new_name...)"]
|
||||||
|
L --> M["Repoint Content.filename → latest edit"]
|
||||||
|
M --> N["Preserve original_filename"]
|
||||||
|
N --> O["Playlist.increment_version() + cache.clear()"]
|
||||||
|
O --> P["Player sees updated media on refresh"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this is a god node:** `PlayerEdit` (99 edges) and `PlayerUser` (74 edges) make this the most-connected data flow in the system. See `app/models/player_edit.py`, `app/models/player_user.py`, `app/blueprints/api.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. HTTPS Configuration (Caddy)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["Admin → /admin/https-config"] --> B["POST /admin/https-config/update"]
|
||||||
|
B --> C["Validate + save HTTPSConfig (email, domain, ip, port)"]
|
||||||
|
C --> D["CaddyConfigGenerator.generate_caddyfile()"]
|
||||||
|
D --> E{"Mode"}
|
||||||
|
E -->|HTTP| F[":80 reverse proxy"]
|
||||||
|
E -->|domain| G["Let's Encrypt automatic HTTPS"]
|
||||||
|
E -->|IP| H["Internal CA self-signed"]
|
||||||
|
F/G/H --> I["write_caddyfile(/etc/caddy/Caddyfile)"]
|
||||||
|
I --> J["POST http://caddy:2019/load (hot reload)"]
|
||||||
|
J --> K["HTTPS live without restart"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. User Management & Auditing
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["Admin panel"] --> B["/admin/user/create"]
|
||||||
|
A --> C["/admin/user/<id>/role"]
|
||||||
|
A --> D["/admin/user/<id>/password"]
|
||||||
|
A --> E["/admin/user/<id>/delete"]
|
||||||
|
B/C/D/E --> L["log_action(level, message)"]
|
||||||
|
L --> S[("server_log")]
|
||||||
|
S --> F["dashboard / admin recent logs"]
|
||||||
|
S --> G["/api/logs (limit, level, since)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Roles:** `admin` (full), `user` (manage content/players), `viewer` (read-only dashboard).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Portal SSO (optional corporate gateway)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant B as Browser
|
||||||
|
participant G as Umbrella nginx gateway
|
||||||
|
participant A as DigiServer
|
||||||
|
B->>G: request (authenticated by portal)
|
||||||
|
G->>A: proxy + headers X-Auth-Username, X-Auth-Role
|
||||||
|
A->>A: init_portal_sso before_request
|
||||||
|
A->>A: _get_or_create_user(username, role)
|
||||||
|
A-->>B: dashboard (auto logged-in)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Media Housekeeping (Leftovers)
|
||||||
|
|
||||||
|
Content not assigned to any playlist is flagged as "leftover":
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
L["/admin/leftover-media"] --> T["grouped by type: image/video/pdf/pptx + sizes"]
|
||||||
|
T --> D["/admin/delete-leftover-images|videos"]
|
||||||
|
D --> X["delete file + edited_media archive + PlayerEdit rows"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Next: [09 · Legacy & Migrations](09-legacy-and-migrations.md)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 09 · Migrations & Legacy Components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Migrations
|
||||||
|
|
||||||
|
Migrations are **standalone Python scripts** (run via `docker compose exec ... python`, **not** Alembic revisions — there is no `migrations/versions/`).
|
||||||
|
|
||||||
|
| Script | Table(s) | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| `add_https_config_table.py` | `https_config` | Creates table via `db.create_all()` |
|
||||||
|
| `add_player_user_table.py` | `player_user` | Creates table via `db.create_all()` |
|
||||||
|
| `add_email_to_https_config.py` | `https_config` | `ALTER TABLE ... ADD COLUMN email VARCHAR(255)` (idempotent) |
|
||||||
|
| `add_deployment_fields_to_player.py` | `player` | Adds `deployment_status`, `last_deployment_at`, `last_deployment_status`, `last_deployment_message` (idempotent) |
|
||||||
|
| `add_url_to_content.py` | `content` | `ALTER TABLE ... ADD COLUMN url VARCHAR(2048)` (weblink support) |
|
||||||
|
| `add_original_filename_to_content.py` | `content` | Adds `original_filename` then **backfills**: from first (v1) `PlayerEdit.original_name` for edited content, else `filename` |
|
||||||
|
| `migrate_player_user_global.py` | `player_user` | **Drop & recreate**: removes old `player_id` FK, makes `user_code` globally unique, `user_name` nullable |
|
||||||
|
|
||||||
|
**Order (from `deploy.sh`):**
|
||||||
|
```
|
||||||
|
add_https_config_table.py
|
||||||
|
add_player_user_table.py
|
||||||
|
add_email_to_https_config.py
|
||||||
|
migrate_player_user_global.py
|
||||||
|
add_original_filename_to_content.py
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠ `deploy.sh` also calls `/app/https_manager.py` — **that file is not present in this repo**, so that deployment step needs attention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Legacy / Archived Components
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `app/blueprints/content_old.py` | **DELETED** | Legacy per-player content routes. Removed in the sanitization pass together with its templates (`content_list.html`, `edit_content.html`, `upload_content.html`). |
|
||||||
|
| `app/blueprints/playlist.py` | **Active but legacy** | Per-player playlist routes kept as redirects to the modern content workflow. |
|
||||||
|
| `Group` model + group routes | **DELETED** | `models/group.py`, the `group_content` association, `Content.groups` / `Content.group_count`, and the group utility functions were removed. `Player` never had a `group_id` column. |
|
||||||
|
| `utils/nginx_config_reader.py` | **DELETED** | Legacy nginx parsing — the reverse proxy is Caddy. |
|
||||||
|
| `nginx` stack | **Replaced by Caddy** | `data/nginx.conf`, `data/nginx-custom-domains.conf`, `data/nginx-logs/`, `data/nginx-ssl/` removed with the Caddy migration. `migrate_network.sh` no longer generates self-signed certs — Caddy issues them. |
|
||||||
|
| `https_manager.py` | **Missing** | Referenced by `deploy.sh` but not in repo — likely merged into `CaddyConfigGenerator`. |
|
||||||
|
| `old_code_documentation/` | **Archive** | Full legacy docs, old scripts (`blueprint_groups.py`, `add_muted_column.py`, `fix_player_user_schema.py`, `test_edit_media_*.py`, `check_fix_player.py`, `migrate_add_edit_enabled.py`), deployment guides, HTTPS analysis, player analysis. |
|
||||||
|
| `QUICK_DEPLOYMENT.md`, `deployment-commands-reference.sh` | **Active reference** | Manual deployment notes. |
|
||||||
|
| `docs/legacy code/` | **Snapshot** | Full pre-sanitization copy of the codebase. Excluded from the Docker build via `.dockerignore`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Recommended Cleanup (optional)
|
||||||
|
|
||||||
|
- Remove `old_code_documentation/*.py` scripts that are no longer needed (keep the `.md` docs).
|
||||||
|
- Resolve the missing `https_manager.py` in `deploy.sh` — **done**: `https_manager.py` now exists at the repo root.
|
||||||
|
- Update `verify-deployment.sh` to reference Caddy instead of nginx — **done**.
|
||||||
|
- Consider removing the legacy `playlist.py` blueprint (see [SANITIZATION-REVIEW.md](SANITIZATION-REVIEW.md) batch B1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Back to Index
|
||||||
|
|
||||||
|
- [README · Documentation Home](README.md)
|
||||||
|
- [01 · Architecture](01-architecture.md)
|
||||||
|
- [02 · Knowledge Graph](02-knowledge-graph.md)
|
||||||
|
- [03 · Data Model](03-data-model.md)
|
||||||
|
- [04 · Application Core](04-application-core.md)
|
||||||
|
- [05 · Blueprints & API](05-blueprints-api.md)
|
||||||
|
- [06 · Utils & Services](06-utils-services.md)
|
||||||
|
- [07 · Deployment](07-deployment.md)
|
||||||
|
- [08 · Workflows](08-workflows.md)
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
# DigiServer v2 — Project Documentation
|
||||||
|
|
||||||
|
> **Comprehensive technical documentation generated with [Graphify](https://marketplace.visualstudio.com/items?itemName=anytechiestudio.graphify-vscode)** — an interactive knowledge-graph engine for AI-assisted coding.
|
||||||
|
|
||||||
|
**Version:** 2.0.0 · **Build date:** 2025-11-12 · **Generated:** 2026-08-16
|
||||||
|
|
||||||
|
DigiServer v2 is a **digital signage management server** built with **Flask**. It manages media content (images, videos, PDFs, presentations), organizes it into **playlists**, assigns **players** (physical signage displays), and remotely **deploys and monitors** those players over SSH — including HTTPS/SSL provisioning via Caddy and an on-player photo-editing pipeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂 Documentation Index
|
||||||
|
|
||||||
|
| Document | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| [**01-architecture.md**](01-architecture.md) | System overview, layers, component map, architecture diagrams |
|
||||||
|
| [**02-knowledge-graph.md**](02-knowledge-graph.md) | Graphify graph: 41 communities, god nodes, and how to use the visualizer |
|
||||||
|
| [**03-data-model.md**](03-data-model.md) | Database schema: all 10 tables, relationships, ER diagrams |
|
||||||
|
| [**04-application-core.md**](04-application-core.md) | App factory, config, extensions, middleware, CLI, templates |
|
||||||
|
| [**05-blueprints-api.md**](05-blueprints-api.md) | All 7 blueprints + full REST API reference |
|
||||||
|
| [**06-utils-services.md**](06-utils-services.md) | Background tasks, SSH deploy, Caddy manager, player build, SSO, uploads |
|
||||||
|
| [**07-deployment.md**](07-deployment.md) | Docker, Caddy, deployment pipeline, HTTPS setup, verify script |
|
||||||
|
| [**08-workflows.md**](08-workflows.md) | End-to-end flows: upload, playlists, player edits, deployment, sync |
|
||||||
|
| [**09-legacy-and-migrations.md**](09-legacy-and-migrations.md) | Migration scripts, deprecated/archived components |
|
||||||
|
|
||||||
|
**Auto-generated Graphify artifacts** (regenerate anytime — see [02-knowledge-graph.md](02-knowledge-graph.md)):
|
||||||
|
|
||||||
|
```
|
||||||
|
graphify-out/
|
||||||
|
├── graph.html ← Interactive visualizer (open in browser)
|
||||||
|
├── graph.json ← Raw graph data (631 nodes / 1162 edges)
|
||||||
|
├── GRAPH_REPORT.md ← God nodes, communities, knowledge gaps
|
||||||
|
├── COMPASS.md ← Token-optimized architecture summary
|
||||||
|
├── DOMAINS.md ← Community → domain mapping
|
||||||
|
├── intelligence.json ← AI-detected insights (god nodes, surprises)
|
||||||
|
├── graph.compact.txt ← Compact graph dump for LLM context
|
||||||
|
└── wiki/ ← Per-community + per-god-node Markdown articles
|
||||||
|
├── index.md
|
||||||
|
├── Community_0.md … Community_40.md
|
||||||
|
├── CaddyConfigGenerator.md, Content.md, HTTPSConfig.md,
|
||||||
|
├── PlayerEdit.md, PlayerUser.md, Playlist.md, User.md,
|
||||||
|
├── log_action().md, create_app().md …
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ Quick Facts
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|---|---|
|
||||||
|
| Framework | Flask 3.1 + SQLAlchemy 2.0 (SQLite) + Gunicorn |
|
||||||
|
| Application type | Digital signage content/playlist/player management |
|
||||||
|
| Graph size | **631 nodes · 1162 edges · 41 communities** |
|
||||||
|
| Blueprints | 7 active (`main`, `auth`, `admin`, `players`, `content`, `playlist`, `api`) |
|
||||||
|
| Database tables | 9 (`user`, `player`, `player_edit`, `player_feedback`, `player_user`, `content`, `playlist`, `server_log`, `https_config`) |
|
||||||
|
| Reverse proxy | Caddy 2 (automatic HTTPS / Let's Encrypt) |
|
||||||
|
| Deployment | Docker Compose (app + Caddy) + remote SSH player provisioning |
|
||||||
|
| Key externals | LibreOffice, Poppler (pdf2image), FFmpeg, sshpass/rsync |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 Core Abstractions (Graphify "God Nodes")
|
||||||
|
|
||||||
|
Detected automatically from graph centrality:
|
||||||
|
|
||||||
|
1. `log_action()` — 116 edges · DB-backed audit logging (`app/utils/logger.py`)
|
||||||
|
2. `PlayerEdit` — 99 edges · on-player edit records (`app/models/player_edit.py`)
|
||||||
|
3. `PlayerUser` — 74 edges · player edit user mapping (`app/models/player_user.py`)
|
||||||
|
4. `Playlist` — 33 edges · playlist + versioning (`app/models/playlist.py`)
|
||||||
|
5. `CaddyConfigGenerator` — 31 edges · HTTPS Caddyfile generation (`app/utils/caddy_manager.py`)
|
||||||
|
6. `HTTPSConfig` — 30 edges · HTTPS settings model (`app/models/https_config.py`)
|
||||||
|
7. `Content` — 24 edges · media content model (`app/models/content.py`)
|
||||||
|
8. `User` — 19 edges · admin/user/viewer accounts (`app/models/user.py`)
|
||||||
|
9. `create_app()` — 13 edges · application factory (`app/app.py`)
|
||||||
|
10. `app/models/__init__.py` — 13 edges · models package export
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧭 Quick Navigation
|
||||||
|
|
||||||
|
### The 7 Active Blueprints
|
||||||
|
|
||||||
|
| Blueprint | Prefix | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `main` | `/` | Dashboard + health check |
|
||||||
|
| `auth` | `/` | Login / logout / register / change password |
|
||||||
|
| `admin` | `/admin` | Users, HTTPS config, player build, logos, logs, leftovers |
|
||||||
|
| `players` | `/players` | Player CRUD, manage, edited media, deployment status |
|
||||||
|
| `content` | `/content` | Media library, uploads, playlist management (modern) |
|
||||||
|
| `playlist` | `/playlist` | Legacy per-player playlist routes (redirects) |
|
||||||
|
| `api` | `/api` | Player-facing REST API (auth, playlists, feedback, edits, deploy) |
|
||||||
|
|
||||||
|
### Data Model at a Glance
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
user ||--o{ server_log : ""
|
||||||
|
player ||--o{ player_feedback : "cascade"
|
||||||
|
player ||--o{ player_edit : "cascade"
|
||||||
|
player }o--o| playlist : "assigned"
|
||||||
|
content ||--o{ player_edit : "cascade"
|
||||||
|
content ||--o{ player_feedback : ""
|
||||||
|
playlist ||--o{ content : "playlist_content (M2M)"
|
||||||
|
player_user ||--o{ player_edit : "user_code"
|
||||||
|
https_config ||--|| https_config : "single row config"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Proceed to [01-architecture.md](01-architecture.md)** for the full system overview and diagrams.
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# DigiServer v2 — Code Sanitization Review
|
||||||
|
|
||||||
|
**Generated:** 2026-09-10
|
||||||
|
**Snapshot:** `docs/legacy code/` (1.7 MB, 171 files, 49 Python files) — full restore point.
|
||||||
|
|
||||||
|
Analysed **42 Python files / 240 functions / 104 routes / 32 templates** under `app/` and `migrations/`.
|
||||||
|
|
||||||
|
> Review each section below and reply with the IDs you want deleted (e.g. `A1, A2, B1`).
|
||||||
|
> Nothing is deleted until you confirm. Everything is recoverable from `docs/legacy code/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Status — Applied 2026-09-10
|
||||||
|
|
||||||
|
**Removed (A1, A2, D1, D2):**
|
||||||
|
|
||||||
|
| ID | Removed | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| A1 | `app/blueprints/content_old.py` | + 3 templates orphaned by its removal |
|
||||||
|
| A2 | `app/utils/nginx_config_reader.py` | |
|
||||||
|
| D1 | 5 group functions + their `__init__.py` exports | `get_player_status_info` kept (live) |
|
||||||
|
| D2 | `app/models/group.py`, `Content.groups`, `Content.group_count` | |
|
||||||
|
|
||||||
|
**Extra cleanup triggered by A1/D2:**
|
||||||
|
- Deleted orphaned templates: `upload_content.html` (278), `edit_content.html` (11)
|
||||||
|
- Removed the dead `groups` key from `/api/system-info` and `group_count` from `/api/content`
|
||||||
|
- Removed the `'group_id': getattr(player, 'group_id', None)` compat shim from `/api/player-status`
|
||||||
|
- Updated 8 documentation files
|
||||||
|
|
||||||
|
**Also removed (B1, B2) — applied in a second pass:**
|
||||||
|
|
||||||
|
| ID | Removed | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| B1 | `app/blueprints/playlist.py` (310 LOC) + its registration in `app.py` | Whole legacy blueprint; its only real route redirected to `content.manage_playlist_content` |
|
||||||
|
| B2 | 3 routes in `players.py`: `reorder_content`, `reorder_playlist`, `remove_from_playlist` (~103 LOC) | Two queried nonexistent columns (`Content.player_id`, `Content.position`, `Player.playlist_version`) → guaranteed 500s |
|
||||||
|
|
||||||
|
**Extra cleanup triggered by B1:**
|
||||||
|
- Deleted `content_list.html` (202 lines) — its only remaining reference was `url_for('playlist.manage_playlist')`. It was **already orphaned** (no Python file rendered it) after `content_old.py` was deleted, so it has been removed for real this time.
|
||||||
|
- Deleted `players/player_page.html` (227 lines) — it was **never rendered** by any view (the `players.player_page` route redirects to `manage_player`), so it was dead UI. Corrects the earlier "false positive" note below.
|
||||||
|
|
||||||
|
> ⚠️ **Functional note:** `players.regenerate_auth_code` (`POST /players/<id>/regenerate-auth`) is now
|
||||||
|
> referenced by **no template** — `player_page.html` was its only caller. The endpoint still works if
|
||||||
|
> invoked directly. The equivalent control lives in `manage_player.html` via the quickconnect flow.
|
||||||
|
> Restore `player_page.html` from `docs/legacy code/` if you want that button back.
|
||||||
|
|
||||||
|
**Result (A + B + D combined):**
|
||||||
|
```
|
||||||
|
42 → 38 Python modules 9,311 → 8,296 LOC
|
||||||
|
104 → 82 routes 32 → 28 templates
|
||||||
|
7 → 6 blueprints dead modules: 2 → 0, orphan templates: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verified:** all files compile; smoke test passes on a fresh DB (every API endpoint + key UI route returns
|
||||||
|
< 500); `db.metadata` no longer registers `group`/`group_content`; app boots against a migrated copy of the
|
||||||
|
real `dashboard.db` with all data intact; `app.blueprints` no longer contains `playlist`.
|
||||||
|
|
||||||
|
> ⚠️ **Still open:** the real `data/instance/dashboard.db` predates the `original_filename` migration.
|
||||||
|
> On startup the entrypoint now applies it automatically. To fix locally, run:
|
||||||
|
> ```
|
||||||
|
> DATABASE_URL=sqlite:///$PWD/data/instance/dashboard.db ./.venv/bin/python migrations/add_original_filename_to_content.py
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section A — Dead modules (zero importers)
|
||||||
|
|
||||||
|
| ID | Target | LOC | Evidence | Risk |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **A1** | `app/blueprints/content_old.py` | 500 | `app.py` imports `content.py`; this file's `content_bp` is **never registered**. Superseded "old" content workflow. | **Low** |
|
||||||
|
| **A2** | `app/utils/nginx_config_reader.py` | 120 | Never imported anywhere. Stack migrated nginx → Caddy, so the reader is obsolete. | **Low** |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
app_py["app.py<br/>register_blueprints()"] --> content["content.py<br/>content_bp ✅ ACTIVE"]
|
||||||
|
content_old["content_old.py<br/>content_bp ❌ DEAD"] -.->|never imported| x1[" "]
|
||||||
|
nginx["nginx_config_reader.py<br/>❌ DEAD"] -.->|never imported| x2[" "]
|
||||||
|
caddy["caddy_manager.py<br/>✅ ACTIVE"] --> app_py
|
||||||
|
style content_old fill:#7f1d1d,color:#fff
|
||||||
|
style nginx fill:#7f1d1d,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section B — Legacy duplicate route surface — ✅ **REMOVED**
|
||||||
|
|
||||||
|
Two blueprints exposed **parallel implementations of the same operations**. Only the `content.*`
|
||||||
|
versions were wired to the UI; the legacy ones had no template references.
|
||||||
|
|
||||||
|
| ID | Target | LOC | Evidence | Risk |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **B1** ✅ | `app/blueprints/playlist.py` (whole file + registration) | 310 | Entire file was legacy per-player playlist. Its `manage_playlist` route did nothing but **redirect** to the modern `content.manage_playlist_content`. The other 6 routes had **no template reference**. | **Low–Med** |
|
||||||
|
| **B2** ✅ | 3 routes in `players.py`: `reorder_content`, `reorder_playlist`, `remove_from_playlist` | ~103 | Superseded by `content.*`. Two queried nonexistent columns (`Content.player_id`, `.position`, `Player.playlist_version`) → guaranteed HTTP 500 if called. | **Low** |
|
||||||
|
|
||||||
|
**Duplicate operation matrix**
|
||||||
|
|
||||||
|
| Operation | Modern (LIVE) | Legacy (DEAD) |
|
||||||
|
|---|---|---|
|
||||||
|
| Add content | `content.add_content_to_playlist` | `playlist.add_to_playlist` |
|
||||||
|
| Remove content | `content.remove_content_from_playlist` | `playlist.remove_from_playlist`, `players.remove_from_playlist` ⚠️broken |
|
||||||
|
| Reorder | `content.reorder_playlist_content` | `playlist.reorder_playlist`, `players.reorder_content` ⚠️broken, `players.reorder_playlist` ⚠️broken |
|
||||||
|
| Set duration | `content.update_playlist_content_duration` | `playlist.update_duration` |
|
||||||
|
| Mute audio | `content.update_playlist_content_muted` | `playlist.update_muted` |
|
||||||
|
| Toggle edit | `content.update_playlist_content_edit_enabled` | — |
|
||||||
|
| Clear | — | `playlist.clear_playlist` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section C — Broken code: references to columns that do not exist
|
||||||
|
|
||||||
|
Confirmed against the live DB schema. These raised `AttributeError`/`OperationalError` at runtime.
|
||||||
|
**All resolved by deleting A1 + B2.**
|
||||||
|
|
||||||
|
| ID | Location | Broken reference | Reachable? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **C1** ✅ | `players.py:768,775` (`remove_from_playlist`) | `player.playlist_version` | Via route only (no UI link) |
|
||||||
|
| **C2** ✅ | `players.py:~700` (`reorder_playlist`) | `Content.player_id`, `Content.position` | Via route only (no UI link) |
|
||||||
|
| **C3** | `content_old.py:189-190` | `player.playlist_version` | No (dead module) |
|
||||||
|
| **C4** | `content_old.py:50,57` | `content.player_id`, `player.group` | No (dead module) |
|
||||||
|
|
||||||
|
> ✅ **Resolved by deleting A1 + B2.** If you keep those files, they must be rewritten.
|
||||||
|
|
||||||
|
**Not a bug (verified by hand):** `Content._playlist_duration` /
|
||||||
|
`._playlist_position` / `._playlist_muted` in `api.py` are set **dynamically** by
|
||||||
|
`Playlist.get_content_ordered()`. These are intentional and work correctly — the
|
||||||
|
static analyzer flags them because it only sees model class attributes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section D — Legacy groups subsystem
|
||||||
|
|
||||||
|
Groups are fully deprecated (0 rows, `/api/groups` already commented out), but the code lingers.
|
||||||
|
|
||||||
|
| ID | Target | LOC | Evidence | Risk |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **D1** | 5 group functions in `app/utils/group_player_management.py`: `get_group_statistics`, `assign_player_to_group`, `bulk_assign_players_to_group`, `get_online_players_count`, `get_players_by_status` + their `__init__.py` exports | ~130 | **Zero callers outside the module.** All three group functions reference `player.group_id`, **which does not exist** → broken. | **Low** |
|
||||||
|
| **D2** | `app/models/group.py` (Group model + `group_content` table) | 71 | Retained only because `Content.groups` FK relationship and `Content.group_count` reference it. Requires touching the Content model. | **Medium** |
|
||||||
|
|
||||||
|
> ⚠️ **Keep:** `get_player_status_info()` (top of the same file) is **live** — used at
|
||||||
|
> `players.py:28` and `players.py:432`. Only the group functions should go.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section E — Legacy redirect stubs
|
||||||
|
|
||||||
|
Thin compatibility shims that only redirect to the modern UI. They're harmless but keep dead
|
||||||
|
URL surface alive.
|
||||||
|
|
||||||
|
| ID | Target | Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| **E1** | `players.player_page` (`/players/<id>`) | Body is a single `redirect(url_for('players.manage_player'))`. Still `url_for`-referenced by 2 templates, so keeping it is fine. |
|
||||||
|
| **E2** | `playlist.manage_playlist` (`/playlist/<id>`) | Part of B1 — already covered by deleting that file. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section F — Orphan templates / assets
|
||||||
|
|
||||||
|
| ID | Target | LOC | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| — | (none) | — | After the B1/B2 pass the codebase has **0 orphan templates**. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended batches — ✅ **ALL APPLIED**
|
||||||
|
|
||||||
|
| Batch | Contents | Total removed | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Batch 1 — Safe clean** | **A1, A2** | ~620 LOC | ✅ done |
|
||||||
|
| **Batch 2 — Legacy playlist** | **B1, B2** | ~413 LOC | ✅ done |
|
||||||
|
| **Batch 3 — Groups** | **D1** | ~130 LOC | ✅ done |
|
||||||
|
| **Batch 4 — Group model** | **D2** | ~71 LOC | ✅ done |
|
||||||
|
|
||||||
|
**Every batch was followed by:** compile-all + app-factory boot + route smoke test, so hidden
|
||||||
|
dependencies were caught before moving on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How I verified this (so you can trust it)
|
||||||
|
|
||||||
|
| Check | Method |
|
||||||
|
|---|---|
|
||||||
|
| Module reachability | AST import extraction + `register_blueprint` cross-reference |
|
||||||
|
| Route usage | `url_for('endpoint')` **and** literal path matching against all templates/JS |
|
||||||
|
| Broken columns | Compared every `var.attr` access against live SQLite `PRAGMA table_info` |
|
||||||
|
| Dynamic attributes | Manually inspected `get_content_ordered()` to rule out false positives |
|
||||||
|
| Duplicate bodies | `ast.dump` body hashing across all functions (result: 0 exact duplicates) |
|
||||||
|
|
||||||
|
**Reproduce anytime:**
|
||||||
|
```
|
||||||
|
./.venv/bin/python docs/tools/sanitize_report.py # dead code + broken refs
|
||||||
|
./.venv/bin/python docs/tools/sanitize_audit.py # full function inventory
|
||||||
|
./.venv/bin/python docs/tools/sanitize_templates.py # orphan templates
|
||||||
|
./.venv/bin/python docs/tools/smoke_test.py # post-change smoke test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Rollback / hygiene notes
|
||||||
|
|
||||||
|
1. **`docs/legacy code/` is excluded from the Docker image** (via `legacy code/` and
|
||||||
|
`**/legacy code/` in `.dockerignore`) so it never
|
||||||
|
ships to production or bloats the build.
|
||||||
|
2. It is **not** git-ignored, so it will appear in `git status`. Decide:
|
||||||
|
- commit it as a recovery point, or
|
||||||
|
- add `legacy code/` to `.gitignore` if you'd rather rely on git history.
|
||||||
|
3. Deleting files listed here is **not** recoverable from git unless committed first — the
|
||||||
|
`docs/legacy code/` copy is your safety net.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Architectural Compass - /home/scheianu/digiserver-v2 (2026-08-16)
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This is a token-optimized summary. For deep logic, see GRAPH_REPORT.md.
|
||||||
|
|
||||||
|
## Core Abstractions (God Nodes)
|
||||||
|
1. `log_action()` (116 edges)
|
||||||
|
2. `PlayerEdit` (99 edges)
|
||||||
|
3. `PlayerUser` (74 edges)
|
||||||
|
4. `Playlist` (33 edges)
|
||||||
|
5. `CaddyConfigGenerator` (31 edges)
|
||||||
|
6. `HTTPSConfig` (30 edges)
|
||||||
|
7. `Content` (24 edges)
|
||||||
|
8. `User` (19 edges)
|
||||||
|
9. `create_app()` (13 edges)
|
||||||
|
10. `Models package for digiserver-v2.` (13 edges)
|
||||||
|
|
||||||
|
## System Layers
|
||||||
|
- **L0: Global/Entry**:
|
||||||
|
- **L1: Strategic/Core**:
|
||||||
|
- **L2: Implementation**:
|
||||||
|
- **L3: Utility**:
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# System Domains (Communities)
|
||||||
|
|
||||||
|
| ID | Domain | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | Community 0 | |
|
||||||
|
| 1 | Community 1 | |
|
||||||
|
| 2 | Community 2 | |
|
||||||
|
| 3 | Community 3 | |
|
||||||
|
| 4 | Community 4 | |
|
||||||
|
| 5 | Community 5 | |
|
||||||
|
| 6 | Community 6 | |
|
||||||
|
| 7 | Community 7 | |
|
||||||
|
| 8 | Community 8 | |
|
||||||
|
| 9 | Community 9 | |
|
||||||
|
| 10 | Community 10 | |
|
||||||
|
| 11 | Community 11 | |
|
||||||
|
| 12 | Community 12 | |
|
||||||
|
| 13 | Community 13 | |
|
||||||
|
| 14 | Community 14 | |
|
||||||
|
| 15 | Community 15 | |
|
||||||
|
| 16 | Community 16 | |
|
||||||
|
| 17 | Community 17 | |
|
||||||
|
| 18 | Community 18 | |
|
||||||
|
| 19 | Community 19 | |
|
||||||
|
| 20 | Community 20 | |
|
||||||
|
| 21 | Community 21 | |
|
||||||
|
| 22 | Community 22 | |
|
||||||
|
| 23 | Community 23 | |
|
||||||
|
| 24 | Community 24 | |
|
||||||
|
| 25 | Community 25 | |
|
||||||
|
| 26 | Community 26 | |
|
||||||
|
| 27 | Community 27 | |
|
||||||
|
| 28 | Community 28 | |
|
||||||
|
| 29 | Community 29 | |
|
||||||
|
| 30 | Community 30 | |
|
||||||
|
| 31 | Community 31 | |
|
||||||
|
| 32 | Community 32 | |
|
||||||
|
| 33 | Community 33 | |
|
||||||
|
| 34 | Community 34 | |
|
||||||
|
| 35 | Community 35 | |
|
||||||
|
| 36 | Community 36 | |
|
||||||
|
| 37 | Community 37 | |
|
||||||
|
| 38 | Community 38 | |
|
||||||
|
| 39 | Community 39 | |
|
||||||
|
| 40 | Community 40 | |
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# Graph Report - /home/scheianu/digiserver-v2 (2026-08-16)
|
||||||
|
|
||||||
|
## Corpus Check
|
||||||
|
- 49 files · ~210,805 words
|
||||||
|
- Verdict: corpus is large enough that graph structure adds value.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- 631 nodes · 1162 edges · 41 communities detected
|
||||||
|
- Extraction: 56% EXTRACTED · 44% INFERRED · 0% AMBIGUOUS · INFERRED: 512 edges (avg confidence: 0.62)
|
||||||
|
- Token cost: 0 input · 0 output
|
||||||
|
|
||||||
|
## Community Hubs (Navigation)
|
||||||
|
- [[_COMMUNITY_Community 0|Community 0]]
|
||||||
|
- [[_COMMUNITY_Community 1|Community 1]]
|
||||||
|
- [[_COMMUNITY_Community 2|Community 2]]
|
||||||
|
- [[_COMMUNITY_Community 3|Community 3]]
|
||||||
|
- [[_COMMUNITY_Community 4|Community 4]]
|
||||||
|
- [[_COMMUNITY_Community 5|Community 5]]
|
||||||
|
- [[_COMMUNITY_Community 6|Community 6]]
|
||||||
|
- [[_COMMUNITY_Community 7|Community 7]]
|
||||||
|
- [[_COMMUNITY_Community 8|Community 8]]
|
||||||
|
- [[_COMMUNITY_Community 9|Community 9]]
|
||||||
|
- [[_COMMUNITY_Community 10|Community 10]]
|
||||||
|
- [[_COMMUNITY_Community 11|Community 11]]
|
||||||
|
- [[_COMMUNITY_Community 12|Community 12]]
|
||||||
|
- [[_COMMUNITY_Community 13|Community 13]]
|
||||||
|
- [[_COMMUNITY_Community 14|Community 14]]
|
||||||
|
- [[_COMMUNITY_Community 15|Community 15]]
|
||||||
|
- [[_COMMUNITY_Community 16|Community 16]]
|
||||||
|
- [[_COMMUNITY_Community 17|Community 17]]
|
||||||
|
- [[_COMMUNITY_Community 18|Community 18]]
|
||||||
|
- [[_COMMUNITY_Community 19|Community 19]]
|
||||||
|
- [[_COMMUNITY_Community 20|Community 20]]
|
||||||
|
- [[_COMMUNITY_Community 21|Community 21]]
|
||||||
|
- [[_COMMUNITY_Community 22|Community 22]]
|
||||||
|
- [[_COMMUNITY_Community 23|Community 23]]
|
||||||
|
- [[_COMMUNITY_Community 24|Community 24]]
|
||||||
|
- [[_COMMUNITY_Community 25|Community 25]]
|
||||||
|
- [[_COMMUNITY_Community 26|Community 26]]
|
||||||
|
- [[_COMMUNITY_Community 27|Community 27]]
|
||||||
|
- [[_COMMUNITY_Community 28|Community 28]]
|
||||||
|
- [[_COMMUNITY_Community 29|Community 29]]
|
||||||
|
- [[_COMMUNITY_Community 30|Community 30]]
|
||||||
|
- [[_COMMUNITY_Community 31|Community 31]]
|
||||||
|
- [[_COMMUNITY_Community 32|Community 32]]
|
||||||
|
- [[_COMMUNITY_Community 33|Community 33]]
|
||||||
|
- [[_COMMUNITY_Community 34|Community 34]]
|
||||||
|
- [[_COMMUNITY_Community 35|Community 35]]
|
||||||
|
- [[_COMMUNITY_Community 36|Community 36]]
|
||||||
|
- [[_COMMUNITY_Community 37|Community 37]]
|
||||||
|
- [[_COMMUNITY_Community 38|Community 38]]
|
||||||
|
- [[_COMMUNITY_Community 39|Community 39]]
|
||||||
|
- [[_COMMUNITY_Community 40|Community 40]]
|
||||||
|
|
||||||
|
## God Nodes (most connected - your core abstractions)
|
||||||
|
1. `log_action()` - 116 edges
|
||||||
|
2. `PlayerEdit` - 99 edges
|
||||||
|
3. `PlayerUser` - 74 edges
|
||||||
|
4. `Playlist` - 33 edges
|
||||||
|
5. `CaddyConfigGenerator` - 31 edges
|
||||||
|
6. `HTTPSConfig` - 30 edges
|
||||||
|
7. `Content` - 24 edges
|
||||||
|
8. `User` - 19 edges
|
||||||
|
9. `create_app()` - 13 edges
|
||||||
|
10. `Models package for digiserver-v2.` - 13 edges
|
||||||
|
|
||||||
|
## Surprising Connections (you probably didn't know these)
|
||||||
|
- `reset_user_password()` --calls--> `log_action()` [INFERRED]
|
||||||
|
/home/scheianu/digiserver-v2/app/blueprints/admin.py → /home/scheianu/digiserver-v2/app/utils/logger.py
|
||||||
|
- `upload_header_logo()` --calls--> `log_action()` [INFERRED]
|
||||||
|
/home/scheianu/digiserver-v2/app/blueprints/admin.py → /home/scheianu/digiserver-v2/app/utils/logger.py
|
||||||
|
- `delete_editing_user()` --calls--> `log_action()` [INFERRED]
|
||||||
|
/home/scheianu/digiserver-v2/app/blueprints/admin.py → /home/scheianu/digiserver-v2/app/utils/logger.py
|
||||||
|
- `delete_playlist()` --calls--> `log_action()` [INFERRED]
|
||||||
|
/home/scheianu/digiserver-v2/app/blueprints/content.py → /home/scheianu/digiserver-v2/app/utils/logger.py
|
||||||
|
- `Main playlist management page.` --uses--> `PlayerEdit` [INFERRED]
|
||||||
|
/home/scheianu/digiserver-v2/app/blueprints/content.py → /home/scheianu/digiserver-v2/app/models/player_edit.py
|
||||||
|
|
||||||
|
## Communities
|
||||||
|
|
||||||
|
### Community 0 - "Community 0"
|
||||||
|
|
||||||
|
Cohesion: 0.03
|
||||||
|
Nodes (80): change_password(), login(), logout(), Authentication Blueprint - Login, Logout, Register, register(), add_content_to_group(), add_player_to_group(), delete_group() (+72 more)
|
||||||
|
|
||||||
|
### Community 1 - "Community 1"
|
||||||
|
|
||||||
|
Cohesion: 0.04
|
||||||
|
Nodes (72): Add player_user table for user code mappings., admin_panel(), admin_required(), build_player(), change_theme(), change_user_role(), clear_logs(), create_user() (+64 more)
|
||||||
|
|
||||||
|
### Community 2 - "Community 2"
|
||||||
|
|
||||||
|
Cohesion: 0.04
|
||||||
|
Nodes (61): add_content_to_playlist(), add_weblink(), add_weblink_to_playlist(), assign_player_to_playlist(), bulk_remove_from_playlist(), content_list(), create_fullhd_image(), delete_playlist() (+53 more)
|
||||||
|
|
||||||
|
### Community 3 - "Community 3"
|
||||||
|
|
||||||
|
Cohesion: 0.06
|
||||||
|
Nodes (57): api_not_found(), authenticate_player(), deploy_player(), get_cached_playlist(), get_logs(), get_player_playlist(), get_player_status(), get_playlist_by_quickconnect() (+49 more)
|
||||||
|
|
||||||
|
### Community 4 - "Community 4"
|
||||||
|
|
||||||
|
Cohesion: 0.07
|
||||||
|
Nodes (42): add_muted_column(), Add muted column to playlist_content association table., configure_login_manager(), create_app(), DigiServer v2 - Application Factory Modern Flask application with blueprint arch, Configure Flask-Login, Register error handlers, Register CLI commands (+34 more)
|
||||||
|
|
||||||
|
### Community 5 - "Community 5"
|
||||||
|
|
||||||
|
Cohesion: 0.04
|
||||||
|
Nodes (27): Content, Content model for media files., String representation of Content., Check if content is an image., Check if content is a video., Content model representing media files for display. Attributes:, Check if content is a PDF., Check if content is a web link (URL) rather than an uploaded file. (+19 more)
|
||||||
|
|
||||||
|
### Community 6 - "Community 6"
|
||||||
|
|
||||||
|
Cohesion: 0.05
|
||||||
|
Nodes (45): Add https_config table for HTTPS configuration management., Write Caddyfile to disk. Args: caddyfile_content: C, Reload Caddy configuration without restart. Note: Caddy monitor, Generate a complete Caddyfile. Behaviour: - HTTPS disabled / no, Write Caddyfile to disk. The default path is /etc/caddy/Caddyfile — the, Push the current Caddyfile to Caddy via its admin API (/load). Caddy ap, HTTPSConfig, String representation of HTTPSConfig. (+37 more)
|
||||||
|
|
||||||
|
### Community 7 - "Community 7"
|
||||||
|
|
||||||
|
Cohesion: 0.06
|
||||||
|
Nodes (26): create_group(), delete_media(), Delete a media file and remove it from all playlists., Group, Group model for organizing players and content., Group model for organizing players with shared content. Attributes:, String representation of Group., Add a player to this group. Args: player: Player in (+18 more)
|
||||||
|
|
||||||
|
### Community 8 - "Community 8"
|
||||||
|
|
||||||
|
Cohesion: 0.23
|
||||||
|
Nodes (23): check_database_integrity(), Colors, get_player_auth_code(), get_sample_content(), main(), print_error(), print_info(), print_section() (+15 more)
|
||||||
|
|
||||||
|
### Community 9 - "Community 9"
|
||||||
|
|
||||||
|
Cohesion: 0.11
|
||||||
|
Nodes (20): Get upload progress for a specific upload., upload_content(), upload_progress_status(), clear_upload_progress(), delete_file(), get_file_size(), get_upload_progress(), process_pdf_file() (+12 more)
|
||||||
|
|
||||||
|
### Community 10 - "Community 10"
|
||||||
|
|
||||||
|
Cohesion: 0.12
|
||||||
|
Nodes (18): background_player_deployment(), Background task execution for long-running operations., Run a function in a background thread, with a Flask app context pushed., Deploy player code to host in background. Args: hostname: SSH h, run_background_task(), deploy_player_to_host(), generate_app_config(), generate_player_config() (+10 more)
|
||||||
|
|
||||||
|
### Community 11 - "Community 11"
|
||||||
|
|
||||||
|
Cohesion: 0.15
|
||||||
|
Nodes (18): build_player_action(), Build/refresh the staged player code and/or write its base config., build_player_files(), get_player_server_settings(), get_short_head(), load_build_settings(), make_build_record(), Utilities for building/staging the player files on the server. Admins use the " (+10 more)
|
||||||
|
|
||||||
|
### Community 12 - "Community 12"
|
||||||
|
|
||||||
|
Cohesion: 0.29
|
||||||
|
Nodes (7): cleanup_libreoffice_processes(), pptx_to_pdf_libreoffice(), PowerPoint to PDF converter using LibreOffice., Clean up any hanging LibreOffice processes., Convert PPTX to PDF using LibreOffice for highest quality. This functio, Validate if file is a valid PowerPoint file. Args: filepath: Pa, validate_pptx_file()
|
||||||
|
|
||||||
|
### Community 13 - "Community 13"
|
||||||
|
|
||||||
|
Cohesion: 0.5
|
||||||
|
Nodes (3): migrate(), Migration: Add edit_on_player_enabled column to playlist_content table., Add edit_on_player_enabled column to playlist_content.
|
||||||
|
|
||||||
|
### Community 14 - "Community 14"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Migrate player_user table to remove player_id and make user_code unique globally
|
||||||
|
|
||||||
|
### Community 15 - "Community 15"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Add original_filename column to content table. This preserves the pristine uplo
|
||||||
|
|
||||||
|
### Community 16 - "Community 16"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Add url column to content table for weblink support.
|
||||||
|
|
||||||
|
### Community 17 - "Community 17"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Add deployment tracking columns to player table.
|
||||||
|
|
||||||
|
### Community 18 - "Community 18"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Add email field to https_config table.
|
||||||
|
|
||||||
|
### Community 19 - "Community 19"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Flask extensions initialization Centralized extension management for the applica
|
||||||
|
|
||||||
|
### Community 20 - "Community 20"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Check if feedback indicates an error.
|
||||||
|
|
||||||
|
### Community 21 - "Community 21"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Get age of feedback in seconds.
|
||||||
|
|
||||||
|
### Community 22 - "Community 22"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Get most recent feedback for a player. Args: player
|
||||||
|
|
||||||
|
### Community 23 - "Community 23"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Get the current HTTPS configuration. Returns: HTTPS
|
||||||
|
|
||||||
|
### Community 24 - "Community 24"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Create or update HTTPS configuration. Args: https_e
|
||||||
|
|
||||||
|
### Community 25 - "Community 25"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Check if user has admin role.
|
||||||
|
|
||||||
|
### Community 26 - "Community 26"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Check if player is online (seen in last 5 minutes).
|
||||||
|
|
||||||
|
### Community 27 - "Community 27"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Authenticate a player by hostname and password or quickconnect code.
|
||||||
|
|
||||||
|
### Community 28 - "Community 28"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Create an info level log entry. Args: message: Log
|
||||||
|
|
||||||
|
### Community 29 - "Community 29"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Create a warning level log entry. Args: message: Lo
|
||||||
|
|
||||||
|
### Community 30 - "Community 30"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Create an error level log entry. Args: message: Log
|
||||||
|
|
||||||
|
### Community 31 - "Community 31"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Get number of content items in this group.
|
||||||
|
|
||||||
|
### Community 32 - "Community 32"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Get file size in megabytes.
|
||||||
|
|
||||||
|
### Community 33 - "Community 33"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Get number of groups containing this content.
|
||||||
|
|
||||||
|
### Community 34 - "Community 34"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Name of the original (unedited) file for display purposes.
|
||||||
|
|
||||||
|
### Community 35 - "Community 35"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Path (relative to uploads/) where the original unedited file lives. Whe
|
||||||
|
|
||||||
|
### Community 36 - "Community 36"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Path (relative to uploads/) of the current/latest version to display.
|
||||||
|
|
||||||
|
### Community 37 - "Community 37"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Whether this content has been edited on a player.
|
||||||
|
|
||||||
|
### Community 38 - "Community 38"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): Display name of the original (unedited) file.
|
||||||
|
|
||||||
|
### Community 39 - "Community 39"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (1): uploads/-relative path of the pristine original file. After a player ed
|
||||||
|
|
||||||
|
### Community 40 - "Community 40"
|
||||||
|
|
||||||
|
Cohesion: 1.0
|
||||||
|
Nodes (0):
|
||||||
|
|
||||||
|
## Knowledge Gaps
|
||||||
|
- **178 isolated node(s):** `Migrate player_user table to remove player_id and make user_code unique globally`, `Add original_filename column to content table. This preserves the pristine uplo`, `Add url column to content table for weblink support.`, `Add deployment tracking columns to player table.`, `Add email field to https_config table.` (+173 more)
|
||||||
|
These have ≤1 connection - possible missing edges or undocumented components.
|
||||||
|
- **Thin community `Community 14`** (2 nodes): `migrate_player_user_global.py`, `Migrate player_user table to remove player_id and make user_code unique globally`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 15`** (2 nodes): `Add original_filename column to content table. This preserves the pristine uplo`, `add_original_filename_to_content.py`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 16`** (2 nodes): `Add url column to content table for weblink support.`, `add_url_to_content.py`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 17`** (2 nodes): `Add deployment tracking columns to player table.`, `add_deployment_fields_to_player.py`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 18`** (2 nodes): `Add email field to https_config table.`, `add_email_to_https_config.py`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 19`** (2 nodes): `Flask extensions initialization Centralized extension management for the applica`, `extensions.py`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 20`** (1 nodes): `Check if feedback indicates an error.`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 21`** (1 nodes): `Get age of feedback in seconds.`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 22`** (1 nodes): `Get most recent feedback for a player. Args: player`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 23`** (1 nodes): `Get the current HTTPS configuration. Returns: HTTPS`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 24`** (1 nodes): `Create or update HTTPS configuration. Args: https_e`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 25`** (1 nodes): `Check if user has admin role.`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 26`** (1 nodes): `Check if player is online (seen in last 5 minutes).`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 27`** (1 nodes): `Authenticate a player by hostname and password or quickconnect code.`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 28`** (1 nodes): `Create an info level log entry. Args: message: Log`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 29`** (1 nodes): `Create a warning level log entry. Args: message: Lo`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 30`** (1 nodes): `Create an error level log entry. Args: message: Log`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 31`** (1 nodes): `Get number of content items in this group.`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 32`** (1 nodes): `Get file size in megabytes.`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 33`** (1 nodes): `Get number of groups containing this content.`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 34`** (1 nodes): `Name of the original (unedited) file for display purposes.`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 35`** (1 nodes): `Path (relative to uploads/) where the original unedited file lives. Whe`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 36`** (1 nodes): `Path (relative to uploads/) of the current/latest version to display.`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 37`** (1 nodes): `Whether this content has been edited on a player.`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 38`** (1 nodes): `Display name of the original (unedited) file.`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 39`** (1 nodes): `uploads/-relative path of the pristine original file. After a player ed`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
|
- **Thin community `Community 40`** (1 nodes): `check_fix_player.py`
|
||||||
|
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "home_scheianu_digiserver_v2_app_utils_portal_sso_py", "label": "portal_sso.py", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L1", "complexity": 1, "loc": 1}, {"id": "portal_sso_init_portal_sso", "label": "init_portal_sso()", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L17", "complexity": 4, "loc": 16}, {"id": "portal_sso_get_or_create_user", "label": "_get_or_create_user()", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L35", "complexity": 4, "loc": 18}, {"id": "portal_sso_rationale_1", "label": "Portal SSO middleware for DigiServer v2. When the umbrella nginx verifies the p", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L1"}, {"id": "portal_sso_rationale_18", "label": "Register the SSO before_request handler on the given Flask app.", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L18"}], "edges": [{"source": "home_scheianu_digiserver_v2_app_utils_portal_sso_py", "target": "secrets", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L12", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_utils_portal_sso_py", "target": "flask", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L13", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_utils_portal_sso_py", "target": "flask_login", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L14", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_utils_portal_sso_py", "target": "portal_sso_init_portal_sso", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L17", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_utils_portal_sso_py", "target": "portal_sso_get_or_create_user", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L35", "weight": 1.0}, {"source": "portal_sso_rationale_1", "target": "home_scheianu_digiserver_v2_app_utils_portal_sso_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L1", "weight": 1.0}, {"source": "portal_sso_rationale_18", "target": "portal_sso_init_portal_sso", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L18", "weight": 1.0}], "raw_calls": [{"caller_nid": "portal_sso_get_or_create_user", "callee": "first", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L40"}, {"caller_nid": "portal_sso_get_or_create_user", "callee": "filter_by", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L40"}, {"caller_nid": "portal_sso_get_or_create_user", "callee": "decode", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L42"}, {"caller_nid": "portal_sso_get_or_create_user", "callee": "generate_password_hash", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L42"}, {"caller_nid": "portal_sso_get_or_create_user", "callee": "token_hex", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L42"}, {"caller_nid": "portal_sso_get_or_create_user", "callee": "User", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L43"}, {"caller_nid": "portal_sso_get_or_create_user", "callee": "add", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L48"}, {"caller_nid": "portal_sso_get_or_create_user", "callee": "commit", "source_file": "/home/scheianu/digiserver-v2/app/utils/portal_sso.py", "source_location": "L49"}]}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "home_scheianu_digiserver_v2_migrations_add_https_config_table_py", "label": "add_https_config_table.py", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/migrations/add_https_config_table.py", "source_location": "L1", "complexity": 1, "loc": 1}, {"id": "add_https_config_table_rationale_1", "label": "Add https_config table for HTTPS configuration management.", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/migrations/add_https_config_table.py", "source_location": "L1"}], "edges": [{"source": "home_scheianu_digiserver_v2_migrations_add_https_config_table_py", "target": "sys", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_https_config_table.py", "source_location": "L2", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_migrations_add_https_config_table_py", "target": "app_app", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_https_config_table.py", "source_location": "L5", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_migrations_add_https_config_table_py", "target": "app_extensions", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_https_config_table.py", "source_location": "L6", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_migrations_add_https_config_table_py", "target": "app_models_https_config", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_https_config_table.py", "source_location": "L7", "weight": 1.0}, {"source": "add_https_config_table_rationale_1", "target": "home_scheianu_digiserver_v2_migrations_add_https_config_table_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_https_config_table.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "home_scheianu_digiserver_v2_app_blueprints_init_py", "label": "__init__.py", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/blueprints/__init__.py", "source_location": "L1", "complexity": 1, "loc": 1}, {"id": "init_rationale_1", "label": "Blueprints package initialization", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/blueprints/__init__.py", "source_location": "L1"}], "edges": [{"source": "init_rationale_1", "target": "home_scheianu_digiserver_v2_app_blueprints_init_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/blueprints/__init__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "home_scheianu_digiserver_v2_old_code_documentation_check_fix_player_py", "label": "check_fix_player.py", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/check_fix_player.py", "source_location": "L1", "complexity": 1, "loc": 1}], "edges": [{"source": "home_scheianu_digiserver_v2_old_code_documentation_check_fix_player_py", "target": "app", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/check_fix_player.py", "source_location": "L4", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_old_code_documentation_check_fix_player_py", "target": "app_models", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/check_fix_player.py", "source_location": "L5", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_old_code_documentation_check_fix_player_py", "target": "app_extensions", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/check_fix_player.py", "source_location": "L6", "weight": 1.0}], "raw_calls": []}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "home_scheianu_digiserver_v2_app_models_player_edit_py", "label": "player_edit.py", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L1", "complexity": 1, "loc": 1}, {"id": "player_edit_playeredit", "label": "PlayerEdit", "file_type": "code", "type": "CLASS", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L8", "complexity": 4, "loc": 53}, {"id": "player_edit_playeredit_repr", "label": ".__repr__()", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L42", "complexity": 1, "loc": 3}, {"id": "player_edit_playeredit_to_dict", "label": ".to_dict()", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L46", "complexity": 4, "loc": 15}, {"id": "player_edit_rationale_1", "label": "Player edit model for tracking media edited on players.", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L1"}, {"id": "player_edit_rationale_9", "label": "Player edit model for tracking media files edited on player devices. At", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L9"}, {"id": "player_edit_rationale_43", "label": "String representation of PlayerEdit.", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L43"}, {"id": "player_edit_rationale_47", "label": "Convert to dictionary for API responses.", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L47"}], "edges": [{"source": "home_scheianu_digiserver_v2_app_models_player_edit_py", "target": "datetime", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L2", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_player_edit_py", "target": "typing", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L3", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_player_edit_py", "target": "app_extensions", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L5", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_player_edit_py", "target": "player_edit_playeredit", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L8", "weight": 1.0}, {"source": "player_edit_playeredit", "target": "player_edit_playeredit_repr", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L42", "weight": 1.0}, {"source": "player_edit_playeredit", "target": "player_edit_playeredit_to_dict", "relation": "method", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L46", "weight": 1.0}, {"source": "player_edit_rationale_1", "target": "home_scheianu_digiserver_v2_app_models_player_edit_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L1", "weight": 1.0}, {"source": "player_edit_rationale_9", "target": "player_edit_playeredit", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L9", "weight": 1.0}, {"source": "player_edit_rationale_43", "target": "player_edit_playeredit_repr", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L43", "weight": 1.0}, {"source": "player_edit_rationale_47", "target": "player_edit_playeredit_to_dict", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L47", "weight": 1.0}], "raw_calls": [{"caller_nid": "player_edit_playeredit_to_dict", "callee": "isoformat", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L57"}, {"caller_nid": "player_edit_playeredit_to_dict", "callee": "isoformat", "source_file": "/home/scheianu/digiserver-v2/app/models/player_edit.py", "source_location": "L58"}]}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "home_scheianu_digiserver_v2_migrations_add_deployment_fields_to_player_py", "label": "add_deployment_fields_to_player.py", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/migrations/add_deployment_fields_to_player.py", "source_location": "L1", "complexity": 1, "loc": 1}, {"id": "add_deployment_fields_to_player_rationale_1", "label": "Add deployment tracking columns to player table.", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/migrations/add_deployment_fields_to_player.py", "source_location": "L1"}], "edges": [{"source": "home_scheianu_digiserver_v2_migrations_add_deployment_fields_to_player_py", "target": "sys", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_deployment_fields_to_player.py", "source_location": "L2", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_migrations_add_deployment_fields_to_player_py", "target": "app_app", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_deployment_fields_to_player.py", "source_location": "L5", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_migrations_add_deployment_fields_to_player_py", "target": "app_extensions", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_deployment_fields_to_player.py", "source_location": "L6", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_migrations_add_deployment_fields_to_player_py", "target": "sqlalchemy", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_deployment_fields_to_player.py", "source_location": "L7", "weight": 1.0}, {"source": "add_deployment_fields_to_player_rationale_1", "target": "home_scheianu_digiserver_v2_migrations_add_deployment_fields_to_player_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/migrations/add_deployment_fields_to_player.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "home_scheianu_digiserver_v2_app_models_init_py", "label": "__init__.py", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/app/models/__init__.py", "source_location": "L1", "complexity": 1, "loc": 1}, {"id": "init_rationale_1", "label": "Models package for digiserver-v2.", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/app/models/__init__.py", "source_location": "L1"}], "edges": [{"source": "home_scheianu_digiserver_v2_app_models_init_py", "target": "app_models_user", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_init_py", "target": "app_models_player", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_init_py", "target": "app_models_group", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_init_py", "target": "app_models_playlist", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_init_py", "target": "app_models_content", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/__init__.py", "source_location": "L6", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_init_py", "target": "app_models_server_log", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/__init__.py", "source_location": "L7", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_init_py", "target": "app_models_player_feedback", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/__init__.py", "source_location": "L8", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_init_py", "target": "app_models_player_edit", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/__init__.py", "source_location": "L9", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_init_py", "target": "app_models_player_user", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/__init__.py", "source_location": "L10", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_app_models_init_py", "target": "app_models_https_config", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/__init__.py", "source_location": "L11", "weight": 1.0}, {"source": "init_rationale_1", "target": "home_scheianu_digiserver_v2_app_models_init_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/app/models/__init__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"nodes": [{"id": "home_scheianu_digiserver_v2_old_code_documentation_add_muted_column_py", "label": "add_muted_column.py", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L1", "complexity": 1, "loc": 1}, {"id": "add_muted_column_add_muted_column", "label": "add_muted_column()", "file_type": "code", "type": "FUNCTION", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L6", "complexity": 3, "loc": 25}, {"id": "add_muted_column_rationale_7", "label": "Add muted column to playlist_content association table.", "file_type": "rationale", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L7"}], "edges": [{"source": "home_scheianu_digiserver_v2_old_code_documentation_add_muted_column_py", "target": "app_app", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L3", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_old_code_documentation_add_muted_column_py", "target": "app_extensions", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L4", "weight": 1.0}, {"source": "home_scheianu_digiserver_v2_old_code_documentation_add_muted_column_py", "target": "add_muted_column_add_muted_column", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L6", "weight": 1.0}, {"source": "add_muted_column_rationale_7", "target": "add_muted_column_add_muted_column", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L7", "weight": 1.0}], "raw_calls": [{"caller_nid": "add_muted_column_add_muted_column", "callee": "create_app", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L8"}, {"caller_nid": "add_muted_column_add_muted_column", "callee": "app_context", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L10"}, {"caller_nid": "add_muted_column_add_muted_column", "callee": "fetchall", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L13"}, {"caller_nid": "add_muted_column_add_muted_column", "callee": "execute", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L13"}, {"caller_nid": "add_muted_column_add_muted_column", "callee": "text", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L13"}, {"caller_nid": "add_muted_column_add_muted_column", "callee": "print", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L17"}, {"caller_nid": "add_muted_column_add_muted_column", "callee": "execute", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L21"}, {"caller_nid": "add_muted_column_add_muted_column", "callee": "text", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L21"}, {"caller_nid": "add_muted_column_add_muted_column", "callee": "commit", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L25"}, {"caller_nid": "add_muted_column_add_muted_column", "callee": "print", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L26"}, {"caller_nid": "add_muted_column_add_muted_column", "callee": "print", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L27"}, {"caller_nid": "add_muted_column_add_muted_column", "callee": "rollback", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L29"}, {"caller_nid": "add_muted_column_add_muted_column", "callee": "print", "source_file": "/home/scheianu/digiserver-v2/old_code_documentation/add_muted_column.py", "source_location": "L30"}]}
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user