Initial Commit
This commit is contained in:
commit
e2f743e8bc
11 changed files with 3104 additions and 0 deletions
92
Dockerfile
Normal file
92
Dockerfile
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# =============================================================================
|
||||||
|
# VideoPress — Dockerfile
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Build:
|
||||||
|
# docker build -t videopress .
|
||||||
|
#
|
||||||
|
# Run (quick):
|
||||||
|
# docker run -d \
|
||||||
|
# -p 8080:8080 \
|
||||||
|
# -v /your/video/path:/media \
|
||||||
|
# --name videopress \
|
||||||
|
# videopress
|
||||||
|
#
|
||||||
|
# See docker-compose.yml for a fully-configured example.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ── Stage 1: Python dependency builder ──────────────────────────────────────
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Install build tools needed to compile gevent's C extensions
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
libffi-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install into an isolated prefix so we can copy just the packages
|
||||||
|
RUN pip install --upgrade pip \
|
||||||
|
&& pip install --prefix=/install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# ── System packages: ffmpeg (includes ffprobe) ───────────────────────────────
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ── Copy Python packages from builder ────────────────────────────────────────
|
||||||
|
COPY --from=builder /install /usr/local
|
||||||
|
|
||||||
|
# ── Create a non-root application user ───────────────────────────────────────
|
||||||
|
# UID/GID 1000 is conventional and matches most Linux desktop users,
|
||||||
|
# which makes the volume-mapped files readable without extra chown steps.
|
||||||
|
RUN groupadd --gid 1000 appuser \
|
||||||
|
&& useradd --uid 1000 --gid 1000 --no-create-home --shell /sbin/nologin appuser
|
||||||
|
|
||||||
|
# ── Application code ─────────────────────────────────────────────────────────
|
||||||
|
WORKDIR /app
|
||||||
|
COPY app.py wsgi.py gunicorn.conf.py requirements.txt ./
|
||||||
|
COPY templates/ templates/
|
||||||
|
COPY static/ static/
|
||||||
|
|
||||||
|
# ── Media volume mount point ──────────────────────────────────────────────────
|
||||||
|
# The host directory containing videos is mounted here at runtime.
|
||||||
|
# All file-system access by the application is restricted to this path.
|
||||||
|
RUN mkdir -p /media && chown appuser:appuser /media
|
||||||
|
|
||||||
|
# ── File ownership ────────────────────────────────────────────────────────────
|
||||||
|
RUN chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
# ── Switch to non-root user ───────────────────────────────────────────────────
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# ── Environment defaults (all overridable via docker run -e or compose) ──────
|
||||||
|
# MEDIA_ROOT — the path inside the container where videos are accessed.
|
||||||
|
# Must match the container-side of your volume mount.
|
||||||
|
# PORT — TCP port Gunicorn listens on (exposed below).
|
||||||
|
# LOG_LEVEL — Gunicorn log verbosity (debug | info | warning | error).
|
||||||
|
ENV MEDIA_ROOT=/media \
|
||||||
|
PORT=8080 \
|
||||||
|
LOG_LEVEL=info \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
# ── Expose the web UI port ────────────────────────────────────────────────────
|
||||||
|
# The UI and API share the same port — no separate API port is needed.
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# ── Health check ─────────────────────────────────────────────────────────────
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
||||||
|
CMD python3 -c \
|
||||||
|
"import urllib.request; urllib.request.urlopen('http://localhost:8080/')" \
|
||||||
|
|| exit 1
|
||||||
|
|
||||||
|
# ── Start Gunicorn ────────────────────────────────────────────────────────────
|
||||||
|
CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:app"]
|
||||||
125
README.md
Normal file
125
README.md
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# VideoPress — FFmpeg Video Compressor
|
||||||
|
|
||||||
|
A web-based video compression tool served via **Gunicorn + gevent** (WSGI),
|
||||||
|
containerised with **Docker**. FFmpeg compresses video files to approximately
|
||||||
|
1/3 their original size. All file-system access is restricted to a single
|
||||||
|
configurable media root for security.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start with Docker (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Build the image
|
||||||
|
docker build -t videopress .
|
||||||
|
|
||||||
|
# 2. Run — replace /your/video/path with the real path on your host
|
||||||
|
docker run -d \
|
||||||
|
--name videopress \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-v /your/video/path:/media \
|
||||||
|
videopress
|
||||||
|
|
||||||
|
# 3. Open http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### With docker compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Edit the volume path in docker-compose.yml (or export the env var):
|
||||||
|
export MEDIA_HOST_PATH=/your/video/path
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bare-metal (without Docker)
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
| Tool | Version |
|
||||||
|
|------|---------|
|
||||||
|
| Python | 3.8+ |
|
||||||
|
| FFmpeg + FFprobe | any recent |
|
||||||
|
| pip packages | see requirements.txt |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Python dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Development server (Flask built-in — not for production)
|
||||||
|
./start.sh
|
||||||
|
|
||||||
|
# Production server (Gunicorn + gevent)
|
||||||
|
MEDIA_ROOT=/your/video/path ./start.sh --prod
|
||||||
|
# or on a custom port:
|
||||||
|
MEDIA_ROOT=/your/video/path ./start.sh --prod 9000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security model
|
||||||
|
|
||||||
|
Every API call that accepts a path validates it with `safe_path()` before any
|
||||||
|
OS operation. `safe_path()` resolves symlinks and asserts the result is inside
|
||||||
|
`MEDIA_ROOT`. Requests that attempt directory traversal are rejected with
|
||||||
|
HTTP 403. The container runs as a non-root user (UID 1000).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ──HTTP──▶ Gunicorn (gevent worker)
|
||||||
|
│
|
||||||
|
├─ GET / → index.html
|
||||||
|
├─ GET /api/config → {"media_root": ...}
|
||||||
|
├─ GET /api/browse → directory listing
|
||||||
|
├─ POST /api/scan → ffprobe metadata
|
||||||
|
├─ POST /api/compress/start → launch ffmpeg thread
|
||||||
|
├─ GET /api/compress/progress/<id> ← SSE stream
|
||||||
|
└─ POST /api/compress/cancel/<id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why gevent?** SSE (`/api/compress/progress`) is a long-lived streaming
|
||||||
|
response. Standard Gunicorn sync workers block for its entire duration.
|
||||||
|
Gevent workers use cooperative greenlets so a single worker process can
|
||||||
|
handle many concurrent SSE streams and normal requests simultaneously.
|
||||||
|
|
||||||
|
**Why workers=1?** Job state lives in an in-process Python dict. Multiple
|
||||||
|
worker *processes* would not share it. One gevent worker with many greenlets
|
||||||
|
is sufficient for this workload. To scale to multiple workers, replace the
|
||||||
|
in-process job store with Redis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `MEDIA_ROOT` | `/media` | Root directory the app may access |
|
||||||
|
| `PORT` | `8080` | Port Gunicorn listens on |
|
||||||
|
| `LOG_LEVEL` | `info` | Gunicorn log level |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
```
|
||||||
|
videocompressor/
|
||||||
|
├── app.py ← Flask application + all API routes
|
||||||
|
├── wsgi.py ← Gunicorn entry point (imports app from app.py)
|
||||||
|
├── gunicorn.conf.py ← Gunicorn configuration (gevent, timeout, logging)
|
||||||
|
├── requirements.txt ← Python dependencies
|
||||||
|
├── Dockerfile ← Two-stage Docker build
|
||||||
|
├── docker-compose.yml ← Volume mapping, port, env vars
|
||||||
|
├── start.sh ← Helper script (dev + prod modes)
|
||||||
|
├── README.md
|
||||||
|
├── templates/
|
||||||
|
│ └── index.html
|
||||||
|
└── static/
|
||||||
|
├── css/main.css
|
||||||
|
└── js/app.js
|
||||||
|
```
|
||||||
696
app.js
Normal file
696
app.js
Normal file
|
|
@ -0,0 +1,696 @@
|
||||||
|
/**
|
||||||
|
* VideoPress — Frontend Application
|
||||||
|
* Handles all UI interactions, API calls, and SSE progress streaming.
|
||||||
|
* WCAG 2.2 compliant, fully functional, no stubs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ─── State ──────────────────────────────────────────────────────────────────
|
||||||
|
const state = {
|
||||||
|
scannedFiles: [], // enriched file objects from API
|
||||||
|
selectedPaths: new Set(), // paths of selected files
|
||||||
|
currentJobId: null,
|
||||||
|
eventSource: null,
|
||||||
|
compressionResults: [],
|
||||||
|
browserPath: '/',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── DOM References ──────────────────────────────────────────────────────────
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
// Config section
|
||||||
|
dirInput: $('dir-input'),
|
||||||
|
browseBtn: $('browse-btn'),
|
||||||
|
minSizeInput: $('min-size-input'),
|
||||||
|
suffixInput: $('suffix-input'),
|
||||||
|
scanBtn: $('scan-btn'),
|
||||||
|
scanStatus: $('scan-status'),
|
||||||
|
|
||||||
|
// Browser modal
|
||||||
|
browserModal: $('browser-modal'),
|
||||||
|
browserList: $('browser-list'),
|
||||||
|
browserPath: $('browser-current-path'),
|
||||||
|
closeBrowser: $('close-browser'),
|
||||||
|
browserCancel: $('browser-cancel'),
|
||||||
|
browserSelect: $('browser-select'),
|
||||||
|
|
||||||
|
// Files section
|
||||||
|
sectionFiles: $('section-files'),
|
||||||
|
selectAllBtn: $('select-all-btn'),
|
||||||
|
deselectAllBtn: $('deselect-all-btn'),
|
||||||
|
selectionSummary: $('selection-summary'),
|
||||||
|
fileTbody: $('file-tbody'),
|
||||||
|
compressBtn: $('compress-btn'),
|
||||||
|
|
||||||
|
// Progress section
|
||||||
|
sectionProgress: $('section-progress'),
|
||||||
|
progTotal: $('prog-total'),
|
||||||
|
progDone: $('prog-done'),
|
||||||
|
progStatus: $('prog-status'),
|
||||||
|
overallBar: $('overall-bar'),
|
||||||
|
overallBarFill: $('overall-bar-fill'),
|
||||||
|
overallPct: $('overall-pct'),
|
||||||
|
fileProgressList: $('file-progress-list'),
|
||||||
|
cancelBtn: $('cancel-btn'),
|
||||||
|
|
||||||
|
// Results
|
||||||
|
sectionResults: $('section-results'),
|
||||||
|
resultsContent: $('results-content'),
|
||||||
|
restartBtn: $('restart-btn'),
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
themeToggle: $('theme-toggle'),
|
||||||
|
themeIcon: $('theme-icon'),
|
||||||
|
|
||||||
|
// Screen reader announce
|
||||||
|
srAnnounce: $('sr-announce'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Accessibility Helper ─────────────────────────────────────────────────────
|
||||||
|
function announce(msg) {
|
||||||
|
els.srAnnounce.textContent = '';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
els.srAnnounce.textContent = msg;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Theme Management ─────────────────────────────────────────────────────────
|
||||||
|
function initTheme() {
|
||||||
|
const saved = localStorage.getItem('vp-theme');
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const theme = saved || (prefersDark ? 'dark' : 'light');
|
||||||
|
applyTheme(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
els.themeIcon.textContent = theme === 'dark' ? '☀' : '◑';
|
||||||
|
els.themeToggle.setAttribute('aria-label', `Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`);
|
||||||
|
localStorage.setItem('vp-theme', theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
els.themeToggle.addEventListener('click', () => {
|
||||||
|
const current = document.documentElement.getAttribute('data-theme') || 'light';
|
||||||
|
applyTheme(current === 'dark' ? 'light' : 'dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Directory Browser ────────────────────────────────────────────────────────
|
||||||
|
async function loadBrowserPath(path) {
|
||||||
|
els.browserList.innerHTML = '<p class="browser-loading" aria-live="polite">Loading…</p>';
|
||||||
|
els.browserPath.textContent = path;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/browse?path=${encodeURIComponent(path)}`);
|
||||||
|
if (!resp.ok) throw new Error((await resp.json()).error || 'Error loading directory');
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
state.browserPath = data.current;
|
||||||
|
els.browserPath.textContent = data.current;
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Parent directory link
|
||||||
|
if (data.parent !== null) {
|
||||||
|
html += `
|
||||||
|
<button class="browser-item parent-dir" data-path="${escHtml(data.parent)}" data-is-dir="true">
|
||||||
|
<span class="item-icon" aria-hidden="true">↑</span>
|
||||||
|
<span>.. (parent directory)</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.entries.length === 0 && !data.parent) {
|
||||||
|
html += '<p class="browser-loading">No accessible directories found.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of data.entries) {
|
||||||
|
if (!entry.is_dir) continue;
|
||||||
|
html += `
|
||||||
|
<button class="browser-item" data-path="${escHtml(entry.path)}" data-is-dir="true"
|
||||||
|
role="option" aria-label="Directory: ${escHtml(entry.name)}">
|
||||||
|
<span class="item-icon" aria-hidden="true">📁</span>
|
||||||
|
<span>${escHtml(entry.name)}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (html === '') {
|
||||||
|
html = '<p class="browser-loading">No subdirectories found.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
els.browserList.innerHTML = html;
|
||||||
|
|
||||||
|
// Attach click events
|
||||||
|
els.browserList.querySelectorAll('.browser-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => loadBrowserPath(item.dataset.path));
|
||||||
|
item.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
loadBrowserPath(item.dataset.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
els.browserList.innerHTML = `<p class="browser-error" role="alert">Error: ${escHtml(err.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBrowserModal() {
|
||||||
|
els.browserModal.hidden = false;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
loadBrowserPath(els.dirInput.value || '/');
|
||||||
|
// Focus trap
|
||||||
|
els.closeBrowser.focus();
|
||||||
|
announce('Directory browser opened');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBrowserModal() {
|
||||||
|
els.browserModal.hidden = true;
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
els.browseBtn.focus();
|
||||||
|
announce('Directory browser closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
els.browseBtn.addEventListener('click', openBrowserModal);
|
||||||
|
els.closeBrowser.addEventListener('click', closeBrowserModal);
|
||||||
|
els.browserCancel.addEventListener('click', closeBrowserModal);
|
||||||
|
|
||||||
|
els.browserSelect.addEventListener('click', () => {
|
||||||
|
els.dirInput.value = state.browserPath;
|
||||||
|
closeBrowserModal();
|
||||||
|
announce(`Directory selected: ${state.browserPath}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal on backdrop click
|
||||||
|
els.browserModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === els.browserModal) closeBrowserModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keyboard: close on Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && !els.browserModal.hidden) {
|
||||||
|
closeBrowserModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Scan for Files ───────────────────────────────────────────────────────────
|
||||||
|
els.scanBtn.addEventListener('click', async () => {
|
||||||
|
const directory = els.dirInput.value.trim();
|
||||||
|
const minSize = parseFloat(els.minSizeInput.value);
|
||||||
|
|
||||||
|
if (!directory) {
|
||||||
|
showScanStatus('Please enter a directory path.', 'error');
|
||||||
|
els.dirInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNaN(minSize) || minSize <= 0) {
|
||||||
|
showScanStatus('Please enter a valid minimum size greater than 0.', 'error');
|
||||||
|
els.minSizeInput.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.scanBtn.disabled = true;
|
||||||
|
els.scanBtn.textContent = '⟳ Scanning…';
|
||||||
|
showScanStatus('Scanning directory, please wait…', 'info');
|
||||||
|
announce('Scanning directory for video files, please wait.');
|
||||||
|
|
||||||
|
// Hide previous results
|
||||||
|
els.sectionFiles.hidden = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ directory, min_size_gb: minSize }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
showScanStatus(`Error: ${data.error}`, 'error');
|
||||||
|
announce(`Scan failed: ${data.error}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.scannedFiles = data.files;
|
||||||
|
state.selectedPaths.clear();
|
||||||
|
|
||||||
|
if (data.files.length === 0) {
|
||||||
|
showScanStatus(
|
||||||
|
`No video files larger than ${minSize} GB found in that directory.`,
|
||||||
|
'warn'
|
||||||
|
);
|
||||||
|
announce('No video files found matching your criteria.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showScanStatus(`Found ${data.files.length} file(s).`, 'success');
|
||||||
|
announce(`Scan complete. Found ${data.files.length} video files.`);
|
||||||
|
renderFileTable(data.files);
|
||||||
|
els.sectionFiles.hidden = false;
|
||||||
|
els.sectionFiles.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showScanStatus(`Network error: ${err.message}`, 'error');
|
||||||
|
announce(`Scan error: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
els.scanBtn.disabled = false;
|
||||||
|
els.scanBtn.innerHTML = '<span class="btn-icon-prefix" aria-hidden="true">⊙</span> Scan for Files';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showScanStatus(msg, type) {
|
||||||
|
els.scanStatus.textContent = msg;
|
||||||
|
els.scanStatus.style.color = type === 'error' ? 'var(--text-danger)'
|
||||||
|
: type === 'success' ? 'var(--text-success)'
|
||||||
|
: type === 'warn' ? 'var(--accent)'
|
||||||
|
: 'var(--text-muted)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── File Table Rendering ─────────────────────────────────────────────────────
|
||||||
|
function renderFileTable(files) {
|
||||||
|
let html = '';
|
||||||
|
files.forEach((f, idx) => {
|
||||||
|
const sizeFmt = f.size_gb.toFixed(3);
|
||||||
|
const curBitrate = f.bit_rate_mbps ? `${f.bit_rate_mbps} Mbps` : 'Unknown';
|
||||||
|
const tgtBitrate = f.target_bit_rate_mbps ? `${f.target_bit_rate_mbps} Mbps` : '—';
|
||||||
|
const codec = f.codec || 'unknown';
|
||||||
|
const pathDir = f.path.replace(f.name, '');
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr id="row-${idx}" data-path="${escHtml(f.path)}">
|
||||||
|
<td class="col-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="file-checkbox"
|
||||||
|
id="chk-${idx}"
|
||||||
|
data-path="${escHtml(f.path)}"
|
||||||
|
aria-label="Select ${escHtml(f.name)} for compression"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="col-name">
|
||||||
|
<label for="chk-${idx}" class="file-name-cell" style="cursor:pointer">
|
||||||
|
<span class="file-name">${escHtml(f.name)}</span>
|
||||||
|
<span class="file-path" title="${escHtml(f.path)}">${escHtml(pathDir)}</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td class="col-size">
|
||||||
|
<strong>${sizeFmt}</strong> GB
|
||||||
|
</td>
|
||||||
|
<td class="col-bitrate">
|
||||||
|
<span class="bitrate-badge">${escHtml(curBitrate)}</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-target">
|
||||||
|
<span class="bitrate-badge target">${escHtml(tgtBitrate)}</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-codec">
|
||||||
|
<span class="codec-tag">${escHtml(codec)}</span>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
els.fileTbody.innerHTML = html;
|
||||||
|
|
||||||
|
// Attach change events
|
||||||
|
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
|
||||||
|
chk.addEventListener('change', () => {
|
||||||
|
const path = chk.dataset.path;
|
||||||
|
const row = chk.closest('tr');
|
||||||
|
if (chk.checked) {
|
||||||
|
state.selectedPaths.add(path);
|
||||||
|
row.classList.add('selected');
|
||||||
|
} else {
|
||||||
|
state.selectedPaths.delete(path);
|
||||||
|
row.classList.remove('selected');
|
||||||
|
}
|
||||||
|
updateSelectionUI();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSelectionUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectionUI() {
|
||||||
|
const total = state.scannedFiles.length;
|
||||||
|
const sel = state.selectedPaths.size;
|
||||||
|
els.selectionSummary.textContent = `${sel} of ${total} selected`;
|
||||||
|
els.compressBtn.disabled = sel === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.selectAllBtn.addEventListener('click', () => {
|
||||||
|
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
|
||||||
|
chk.checked = true;
|
||||||
|
state.selectedPaths.add(chk.dataset.path);
|
||||||
|
chk.closest('tr').classList.add('selected');
|
||||||
|
});
|
||||||
|
updateSelectionUI();
|
||||||
|
announce(`All ${state.scannedFiles.length} files selected.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
els.deselectAllBtn.addEventListener('click', () => {
|
||||||
|
els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => {
|
||||||
|
chk.checked = false;
|
||||||
|
chk.closest('tr').classList.remove('selected');
|
||||||
|
});
|
||||||
|
state.selectedPaths.clear();
|
||||||
|
updateSelectionUI();
|
||||||
|
announce('All files deselected.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Start Compression ────────────────────────────────────────────────────────
|
||||||
|
els.compressBtn.addEventListener('click', async () => {
|
||||||
|
const selectedFiles = state.scannedFiles.filter(f => state.selectedPaths.has(f.path));
|
||||||
|
if (selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
const suffix = els.suffixInput.value.trim() || '_new';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
files: selectedFiles.map(f => ({
|
||||||
|
path: f.path,
|
||||||
|
size_bytes: f.size_bytes,
|
||||||
|
target_bit_rate_bps: f.target_bit_rate_bps || 1000000,
|
||||||
|
})),
|
||||||
|
suffix,
|
||||||
|
};
|
||||||
|
|
||||||
|
els.compressBtn.disabled = true;
|
||||||
|
els.compressBtn.textContent = 'Starting…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/compress/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
alert(`Failed to start compression: ${data.error}`);
|
||||||
|
els.compressBtn.disabled = false;
|
||||||
|
els.compressBtn.innerHTML = '<span class="btn-icon-prefix" aria-hidden="true">⚡</span> Compress Selected Files';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.currentJobId = data.job_id;
|
||||||
|
state.compressionResults = [];
|
||||||
|
|
||||||
|
// Show progress section
|
||||||
|
setupProgressSection(selectedFiles);
|
||||||
|
els.sectionProgress.hidden = false;
|
||||||
|
els.sectionProgress.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
announce(`Compression started for ${selectedFiles.length} file(s).`);
|
||||||
|
|
||||||
|
// Start SSE stream
|
||||||
|
startProgressStream(data.job_id, selectedFiles);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
els.compressBtn.disabled = false;
|
||||||
|
els.compressBtn.innerHTML = '<span class="btn-icon-prefix" aria-hidden="true">⚡</span> Compress Selected Files';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Progress Setup ───────────────────────────────────────────────────────────
|
||||||
|
function setupProgressSection(files) {
|
||||||
|
els.progTotal.textContent = files.length;
|
||||||
|
els.progDone.textContent = '0';
|
||||||
|
els.progStatus.textContent = 'Running';
|
||||||
|
setOverallProgress(0);
|
||||||
|
|
||||||
|
// Create per-file items
|
||||||
|
let html = '';
|
||||||
|
files.forEach((f, idx) => {
|
||||||
|
html += `
|
||||||
|
<div class="file-progress-item" id="fpi-${idx}" role="listitem"
|
||||||
|
aria-label="File ${idx+1} of ${files.length}: ${escHtml(f.name)}">
|
||||||
|
<div class="fp-header">
|
||||||
|
<span class="fp-name">${escHtml(f.name)}</span>
|
||||||
|
<span class="fp-status waiting" id="fps-${idx}" aria-live="polite">Waiting</span>
|
||||||
|
</div>
|
||||||
|
<div class="fp-bar-wrap" aria-label="Progress for ${escHtml(f.name)}">
|
||||||
|
<div class="fp-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"
|
||||||
|
id="fpbar-${idx}" aria-label="${escHtml(f.name)} progress">
|
||||||
|
<div class="fp-bar-fill" id="fpfill-${idx}" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="fp-pct" id="fppct-${idx}" aria-hidden="true">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="fp-detail" id="fpdetail-${idx}"></div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
els.fileProgressList.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOverallProgress(pct) {
|
||||||
|
const p = Math.round(pct);
|
||||||
|
els.overallBarFill.style.width = `${p}%`;
|
||||||
|
els.overallBar.setAttribute('aria-valuenow', p);
|
||||||
|
els.overallPct.textContent = `${p}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFileProgress(idx, pct, statusClass, statusText, detail, detailClass) {
|
||||||
|
const fill = $(`fpfill-${idx}`);
|
||||||
|
const bar = $(`fpbar-${idx}`);
|
||||||
|
const pctEl = $(`fppct-${idx}`);
|
||||||
|
const status = $(`fps-${idx}`);
|
||||||
|
const item = $(`fpi-${idx}`);
|
||||||
|
const det = $(`fpdetail-${idx}`);
|
||||||
|
|
||||||
|
if (!fill) return;
|
||||||
|
|
||||||
|
const p = Math.round(pct);
|
||||||
|
fill.style.width = `${p}%`;
|
||||||
|
bar.setAttribute('aria-valuenow', p);
|
||||||
|
pctEl.textContent = `${p}%`;
|
||||||
|
|
||||||
|
status.className = `fp-status ${statusClass}`;
|
||||||
|
status.textContent = statusText;
|
||||||
|
|
||||||
|
item.className = `file-progress-item ${statusClass}`;
|
||||||
|
|
||||||
|
// Toggle animation on bar fill
|
||||||
|
fill.classList.toggle('active', statusClass === 'running');
|
||||||
|
|
||||||
|
if (detail !== undefined) {
|
||||||
|
det.textContent = detail;
|
||||||
|
det.className = `fp-detail ${detailClass || ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SSE Stream Handling ──────────────────────────────────────────────────────
|
||||||
|
function startProgressStream(jobId, files) {
|
||||||
|
if (state.eventSource) {
|
||||||
|
state.eventSource.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.eventSource = new EventSource(`/api/compress/progress/${jobId}`);
|
||||||
|
let doneCount = 0;
|
||||||
|
|
||||||
|
state.eventSource.onmessage = (evt) => {
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(evt.data); }
|
||||||
|
catch { return; }
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'start':
|
||||||
|
els.progStatus.textContent = 'Running';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'file_start':
|
||||||
|
updateFileProgress(data.index, 0, 'running', 'Compressing…', '', '');
|
||||||
|
// Scroll to active item
|
||||||
|
const activeItem = $(`fpi-${data.index}`);
|
||||||
|
if (activeItem) {
|
||||||
|
activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
announce(`Compressing file ${data.index + 1} of ${data.total}: ${files[data.index]?.name || ''}`);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'progress': {
|
||||||
|
const pct = data.percent || 0;
|
||||||
|
let detail = '';
|
||||||
|
if (data.elapsed_secs > 0 && data.duration_secs > 0) {
|
||||||
|
detail = `${fmtTime(data.elapsed_secs)} / ${fmtTime(data.duration_secs)}`;
|
||||||
|
}
|
||||||
|
updateFileProgress(data.index, pct, 'running', 'Compressing…', detail, '');
|
||||||
|
|
||||||
|
// Update overall progress
|
||||||
|
const overallPct = ((doneCount + (pct / 100)) / files.length) * 100;
|
||||||
|
setOverallProgress(overallPct);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'file_done': {
|
||||||
|
doneCount++;
|
||||||
|
els.progDone.textContent = doneCount;
|
||||||
|
const detail = data.reduction_pct
|
||||||
|
? `Saved ${data.reduction_pct}% → ${data.output_size_gb} GB`
|
||||||
|
: 'Complete';
|
||||||
|
updateFileProgress(data.index, 100, 'done', '✓ Done', detail, 'success');
|
||||||
|
setOverallProgress((doneCount / files.length) * 100);
|
||||||
|
state.compressionResults.push({ ...data, status: 'done' });
|
||||||
|
announce(`File complete: ${files[data.index]?.name}. Saved ${data.reduction_pct}%.`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'file_error': {
|
||||||
|
doneCount++;
|
||||||
|
els.progDone.textContent = doneCount;
|
||||||
|
updateFileProgress(data.index, 0, 'error', '✗ Error', data.message, 'error');
|
||||||
|
state.compressionResults.push({ ...data, status: 'error' });
|
||||||
|
announce(`Error compressing file ${files[data.index]?.name}: ${data.message}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'done':
|
||||||
|
state.eventSource.close();
|
||||||
|
els.progStatus.textContent = 'Complete';
|
||||||
|
setOverallProgress(100);
|
||||||
|
els.cancelBtn.disabled = true;
|
||||||
|
announce('All compression operations complete.');
|
||||||
|
showResults('done');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cancelled':
|
||||||
|
state.eventSource.close();
|
||||||
|
els.progStatus.textContent = 'Cancelled';
|
||||||
|
announce('Compression cancelled.');
|
||||||
|
showResults('cancelled');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
state.eventSource.close();
|
||||||
|
els.progStatus.textContent = 'Error';
|
||||||
|
announce(`Compression error: ${data.message}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
state.eventSource.onerror = () => {
|
||||||
|
if (state.eventSource.readyState === EventSource.CLOSED) return;
|
||||||
|
console.error('SSE connection error');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cancel ───────────────────────────────────────────────────────────────────
|
||||||
|
els.cancelBtn.addEventListener('click', async () => {
|
||||||
|
if (!state.currentJobId) return;
|
||||||
|
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'Cancel all compression operations? Any files currently being processed will be deleted.'
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
els.cancelBtn.disabled = true;
|
||||||
|
els.cancelBtn.textContent = 'Cancelling…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/compress/cancel/${state.currentJobId}`, { method: 'POST' });
|
||||||
|
announce('Cancellation requested.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Cancel error:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Results ──────────────────────────────────────────────────────────────────
|
||||||
|
function showResults(finalStatus) {
|
||||||
|
const results = state.compressionResults;
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
if (finalStatus === 'cancelled') {
|
||||||
|
html += `<p style="color:var(--text-muted); margin-bottom: var(--space-md)">
|
||||||
|
Compression was cancelled. Any completed files are listed below.
|
||||||
|
</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0 && finalStatus === 'cancelled') {
|
||||||
|
html += '<p style="color:var(--text-muted)">No files were completed before cancellation.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
results.forEach(r => {
|
||||||
|
if (r.status === 'done') {
|
||||||
|
html += `
|
||||||
|
<div class="result-row">
|
||||||
|
<span class="result-icon">✅</span>
|
||||||
|
<div class="result-info">
|
||||||
|
<div class="result-name">${escHtml(r.filename)}</div>
|
||||||
|
<div class="result-meta">→ ${escHtml(r.output || '')}</div>
|
||||||
|
</div>
|
||||||
|
<span class="result-reduction">-${r.reduction_pct}%</span>
|
||||||
|
</div>`;
|
||||||
|
} else if (r.status === 'error') {
|
||||||
|
html += `
|
||||||
|
<div class="result-row">
|
||||||
|
<span class="result-icon">❌</span>
|
||||||
|
<div class="result-info">
|
||||||
|
<div class="result-name">${escHtml(r.filename)}</div>
|
||||||
|
<div class="result-meta" style="color:var(--text-danger)">${escHtml(r.message)}</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (html === '') {
|
||||||
|
html = '<p style="color:var(--text-muted)">No results to display.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
els.resultsContent.innerHTML = html;
|
||||||
|
els.sectionResults.hidden = false;
|
||||||
|
els.sectionResults.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Restart ──────────────────────────────────────────────────────────────────
|
||||||
|
els.restartBtn.addEventListener('click', () => {
|
||||||
|
// Reset state
|
||||||
|
state.scannedFiles = [];
|
||||||
|
state.selectedPaths.clear();
|
||||||
|
state.currentJobId = null;
|
||||||
|
state.compressionResults = [];
|
||||||
|
if (state.eventSource) { state.eventSource.close(); state.eventSource = null; }
|
||||||
|
|
||||||
|
// Reset UI
|
||||||
|
els.sectionFiles.hidden = true;
|
||||||
|
els.sectionProgress.hidden = true;
|
||||||
|
els.sectionResults.hidden = true;
|
||||||
|
els.fileTbody.innerHTML = '';
|
||||||
|
els.fileProgressList.innerHTML = '';
|
||||||
|
els.scanStatus.textContent = '';
|
||||||
|
els.compressBtn.innerHTML = '<span class="btn-icon-prefix" aria-hidden="true">⚡</span> Compress Selected Files';
|
||||||
|
els.compressBtn.disabled = true;
|
||||||
|
els.cancelBtn.disabled = false;
|
||||||
|
els.cancelBtn.textContent = '✕ Cancel Compression';
|
||||||
|
|
||||||
|
// Scroll to top
|
||||||
|
document.getElementById('section-config').scrollIntoView({ behavior: 'smooth' });
|
||||||
|
els.dirInput.focus();
|
||||||
|
announce('Session reset. Ready to scan again.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function escHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(seconds) {
|
||||||
|
const s = Math.floor(seconds);
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
if (h > 0) return `${h}:${pad(m)}:${pad(sec)}`;
|
||||||
|
return `${m}:${pad(sec)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n) {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Init ─────────────────────────────────────────────────────────────────────
|
||||||
|
initTheme();
|
||||||
486
app.py
Normal file
486
app.py
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Video Compressor Web Application — WSGI/Docker edition
|
||||||
|
See wsgi.py for the Gunicorn entry point.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import Flask, request, jsonify, Response, render_template, stream_with_context
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
MEDIA_ROOT = Path(os.environ.get('MEDIA_ROOT', '/media')).resolve()
|
||||||
|
|
||||||
|
app = Flask(
|
||||||
|
__name__,
|
||||||
|
template_folder=str(BASE_DIR / 'templates'),
|
||||||
|
static_folder=str(BASE_DIR / 'static'),
|
||||||
|
)
|
||||||
|
|
||||||
|
active_jobs: dict = {}
|
||||||
|
job_lock = threading.Lock()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Path-jail helper — every client-supplied path passes through this
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def safe_path(raw: str) -> Path:
|
||||||
|
"""
|
||||||
|
Resolve *raw* and assert it is inside MEDIA_ROOT.
|
||||||
|
Raises PermissionError on any attempt to escape the root.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resolved = Path(raw).resolve()
|
||||||
|
except Exception:
|
||||||
|
raise PermissionError(f"Invalid path: {raw!r}")
|
||||||
|
|
||||||
|
root_str = str(MEDIA_ROOT)
|
||||||
|
path_str = str(resolved)
|
||||||
|
if path_str != root_str and not path_str.startswith(root_str + os.sep):
|
||||||
|
raise PermissionError(
|
||||||
|
f"Access denied: path is outside the allowed media root ({MEDIA_ROOT})."
|
||||||
|
)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FFprobe / filesystem helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
VIDEO_EXTENSIONS = {
|
||||||
|
'.mp4', '.mkv', '.mov', '.avi', '.wmv', '.flv',
|
||||||
|
'.webm', '.m4v', '.mpg', '.mpeg', '.ts', '.mts',
|
||||||
|
'.m2ts', '.vob', '.ogv', '.3gp', '.3g2',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_video_info(filepath: str) -> dict | None:
|
||||||
|
cmd = [
|
||||||
|
'ffprobe', '-v', 'error',
|
||||||
|
'-select_streams', 'v:0',
|
||||||
|
'-show_entries', 'format=duration,bit_rate,size:stream=codec_name,width,height',
|
||||||
|
'-of', 'json',
|
||||||
|
filepath,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
fmt = data.get('format', {})
|
||||||
|
stream = (data.get('streams') or [{}])[0]
|
||||||
|
|
||||||
|
duration = float(fmt.get('duration', 0))
|
||||||
|
bit_rate = int(fmt.get('bit_rate', 0))
|
||||||
|
size_bytes = int(fmt.get('size', 0))
|
||||||
|
codec = stream.get('codec_name', 'unknown')
|
||||||
|
width = stream.get('width', 0)
|
||||||
|
height = stream.get('height', 0)
|
||||||
|
|
||||||
|
if bit_rate == 0 and duration > 0:
|
||||||
|
bit_rate = int((size_bytes * 8) / duration)
|
||||||
|
|
||||||
|
audio_bps = 128_000
|
||||||
|
video_bps = bit_rate - audio_bps if bit_rate > audio_bps else bit_rate
|
||||||
|
target_video_bps = max(int(video_bps / 3), 200_000)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'duration': duration,
|
||||||
|
'bit_rate_bps': bit_rate,
|
||||||
|
'bit_rate_mbps': round(bit_rate / 1_000_000, 2),
|
||||||
|
'target_bit_rate_bps': target_video_bps,
|
||||||
|
'target_bit_rate_mbps': round(target_video_bps / 1_000_000, 2),
|
||||||
|
'size_bytes': size_bytes,
|
||||||
|
'size_gb': round(size_bytes / (1024 ** 3), 3),
|
||||||
|
'codec': codec,
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def list_video_files(directory: Path, min_size_gb: float) -> list:
|
||||||
|
min_bytes = min_size_gb * (1024 ** 3)
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
for root, dirs, files in os.walk(directory):
|
||||||
|
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
||||||
|
for fname in files:
|
||||||
|
if Path(fname).suffix.lower() in VIDEO_EXTENSIONS:
|
||||||
|
fpath = os.path.join(root, fname)
|
||||||
|
try:
|
||||||
|
fsize = os.path.getsize(fpath)
|
||||||
|
if fsize >= min_bytes:
|
||||||
|
results.append({
|
||||||
|
'path': fpath,
|
||||||
|
'name': fname,
|
||||||
|
'size_bytes': fsize,
|
||||||
|
'size_gb': round(fsize / (1024 ** 3), 3),
|
||||||
|
})
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
except PermissionError as exc:
|
||||||
|
raise PermissionError(f"Cannot access directory: {exc}") from exc
|
||||||
|
return results
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes — UI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html', media_root=str(MEDIA_ROOT))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes — API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.route('/api/config')
|
||||||
|
def api_config():
|
||||||
|
"""Expose server-side config the frontend needs (e.g. media root)."""
|
||||||
|
return jsonify({'media_root': str(MEDIA_ROOT)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/browse')
|
||||||
|
def browse_directory():
|
||||||
|
raw = request.args.get('path', str(MEDIA_ROOT))
|
||||||
|
try:
|
||||||
|
path = safe_path(raw)
|
||||||
|
except PermissionError as exc:
|
||||||
|
return jsonify({'error': str(exc)}), 403
|
||||||
|
|
||||||
|
if not path.exists():
|
||||||
|
return jsonify({'error': 'Path does not exist'}), 404
|
||||||
|
if not path.is_dir():
|
||||||
|
return jsonify({'error': 'Not a directory'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
entries = []
|
||||||
|
for entry in sorted(path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||||
|
if entry.name.startswith('.'):
|
||||||
|
continue
|
||||||
|
entries.append({'name': entry.name, 'path': str(entry), 'is_dir': entry.is_dir()})
|
||||||
|
|
||||||
|
parent = str(path.parent) if path != MEDIA_ROOT else None
|
||||||
|
return jsonify({
|
||||||
|
'current': str(path),
|
||||||
|
'parent': parent,
|
||||||
|
'entries': entries,
|
||||||
|
'media_root': str(MEDIA_ROOT),
|
||||||
|
})
|
||||||
|
except PermissionError:
|
||||||
|
return jsonify({'error': 'Permission denied'}), 403
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/scan', methods=['POST'])
|
||||||
|
def scan_directory():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
raw_dir = data.get('directory', '')
|
||||||
|
min_size_gb = float(data.get('min_size_gb', 1.0))
|
||||||
|
|
||||||
|
if not raw_dir:
|
||||||
|
return jsonify({'error': 'No directory provided'}), 400
|
||||||
|
try:
|
||||||
|
directory = safe_path(raw_dir)
|
||||||
|
except PermissionError as exc:
|
||||||
|
return jsonify({'error': str(exc)}), 403
|
||||||
|
if not directory.is_dir():
|
||||||
|
return jsonify({'error': 'Invalid directory'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
files = list_video_files(directory, min_size_gb)
|
||||||
|
except PermissionError as exc:
|
||||||
|
return jsonify({'error': str(exc)}), 403
|
||||||
|
|
||||||
|
enriched = []
|
||||||
|
for f in files:
|
||||||
|
info = get_video_info(f['path'])
|
||||||
|
if info:
|
||||||
|
f.update(info)
|
||||||
|
else:
|
||||||
|
f['bit_rate_mbps'] = round((f['size_bytes'] * 8) / (90 * 60 * 1_000_000), 2)
|
||||||
|
f['target_bit_rate_mbps'] = round(f['bit_rate_mbps'] / 3, 2)
|
||||||
|
f['bit_rate_bps'] = int(f['bit_rate_mbps'] * 1_000_000)
|
||||||
|
f['target_bit_rate_bps'] = int(f['target_bit_rate_mbps'] * 1_000_000)
|
||||||
|
f['duration'] = 0
|
||||||
|
f['codec'] = 'unknown'
|
||||||
|
f['width'] = 0
|
||||||
|
f['height'] = 0
|
||||||
|
enriched.append(f)
|
||||||
|
|
||||||
|
enriched.sort(key=lambda x: x['size_bytes'], reverse=True)
|
||||||
|
return jsonify({'files': enriched, 'count': len(enriched)})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/compress/start', methods=['POST'])
|
||||||
|
def start_compression():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
files = data.get('files', [])
|
||||||
|
suffix = data.get('suffix', '_new')
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
return jsonify({'error': 'No files provided'}), 400
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
safe_path(f.get('path', ''))
|
||||||
|
except PermissionError as exc:
|
||||||
|
return jsonify({'error': str(exc)}), 403
|
||||||
|
|
||||||
|
job_id = f"job_{int(time.time() * 1000)}"
|
||||||
|
job = {
|
||||||
|
'id': job_id,
|
||||||
|
'files': files,
|
||||||
|
'suffix': suffix,
|
||||||
|
'status': 'running',
|
||||||
|
'current_index': 0,
|
||||||
|
'total': len(files),
|
||||||
|
'events': [],
|
||||||
|
'process': None,
|
||||||
|
'cancelled': False,
|
||||||
|
'lock': threading.Lock(),
|
||||||
|
}
|
||||||
|
with job_lock:
|
||||||
|
active_jobs[job_id] = job
|
||||||
|
|
||||||
|
threading.Thread(target=run_compression_job, args=(job_id,), daemon=True).start()
|
||||||
|
return jsonify({'job_id': job_id})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/compress/progress/<job_id>')
|
||||||
|
def compression_progress(job_id):
|
||||||
|
"""
|
||||||
|
SSE stream. Works under Gunicorn+gevent: time.sleep() yields the
|
||||||
|
greenlet instead of blocking a real OS thread.
|
||||||
|
"""
|
||||||
|
def event_stream():
|
||||||
|
last_idx = 0
|
||||||
|
while True:
|
||||||
|
with job_lock:
|
||||||
|
job = active_jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
yield f"data: {json.dumps({'type': 'error', 'message': 'Job not found'})}\n\n"
|
||||||
|
return
|
||||||
|
|
||||||
|
with job['lock']:
|
||||||
|
new_events = job['events'][last_idx:]
|
||||||
|
last_idx += len(new_events)
|
||||||
|
status = job['status']
|
||||||
|
|
||||||
|
for event in new_events:
|
||||||
|
yield f"data: {json.dumps(event)}\n\n"
|
||||||
|
|
||||||
|
if status in ('done', 'cancelled', 'error') and not new_events:
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(0.25)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
stream_with_context(event_stream()),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/compress/cancel/<job_id>', methods=['POST'])
|
||||||
|
def cancel_compression(job_id):
|
||||||
|
with job_lock:
|
||||||
|
job = active_jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({'error': 'Job not found'}), 404
|
||||||
|
|
||||||
|
with job['lock']:
|
||||||
|
job['cancelled'] = True
|
||||||
|
proc = job.get('process')
|
||||||
|
|
||||||
|
if proc and proc.poll() is None:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
time.sleep(1)
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({'status': 'cancellation requested'})
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Compression worker
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def push_event(job: dict, event: dict) -> None:
|
||||||
|
with job['lock']:
|
||||||
|
job['events'].append(event)
|
||||||
|
|
||||||
|
|
||||||
|
def run_compression_job(job_id: str) -> None:
|
||||||
|
with job_lock:
|
||||||
|
job = active_jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return
|
||||||
|
|
||||||
|
files = job['files']
|
||||||
|
suffix = job['suffix']
|
||||||
|
total = job['total']
|
||||||
|
|
||||||
|
push_event(job, {'type': 'start', 'total': total,
|
||||||
|
'message': f'Starting compression of {total} file(s)'})
|
||||||
|
|
||||||
|
for idx, file_info in enumerate(files):
|
||||||
|
with job['lock']:
|
||||||
|
cancelled = job['cancelled']
|
||||||
|
if cancelled:
|
||||||
|
push_event(job, {'type': 'cancelled', 'message': 'Compression cancelled by user'})
|
||||||
|
with job['lock']:
|
||||||
|
job['status'] = 'cancelled'
|
||||||
|
return
|
||||||
|
|
||||||
|
src_path = file_info['path']
|
||||||
|
target_bitrate = file_info.get('target_bit_rate_bps', 1_000_000)
|
||||||
|
p = Path(src_path)
|
||||||
|
out_path = str(p.parent / (p.stem + suffix + p.suffix))
|
||||||
|
|
||||||
|
push_event(job, {
|
||||||
|
'type': 'file_start', 'index': idx, 'total': total,
|
||||||
|
'filename': p.name, 'output': out_path,
|
||||||
|
'message': f'Compressing ({idx + 1}/{total}): {p.name}',
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
probe = subprocess.run(
|
||||||
|
['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
||||||
|
'-of', 'default=noprint_wrappers=1:nokey=1', src_path],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
)
|
||||||
|
duration_secs = float(probe.stdout.strip()) if probe.stdout.strip() else 0
|
||||||
|
except Exception:
|
||||||
|
duration_secs = 0
|
||||||
|
|
||||||
|
video_k = max(int(target_bitrate / 1000), 200)
|
||||||
|
cmd = [
|
||||||
|
'ffmpeg', '-y', '-i', src_path,
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-b:v', f'{video_k}k',
|
||||||
|
'-maxrate', f'{int(video_k * 1.5)}k',
|
||||||
|
'-bufsize', f'{video_k * 2}k',
|
||||||
|
'-c:a', 'aac', '-b:a', '128k',
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
'-progress', 'pipe:1', '-nostats',
|
||||||
|
out_path,
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
text=True, bufsize=1)
|
||||||
|
with job['lock']:
|
||||||
|
job['process'] = proc
|
||||||
|
|
||||||
|
for line in proc.stdout:
|
||||||
|
with job['lock']:
|
||||||
|
cancelled = job['cancelled']
|
||||||
|
if cancelled:
|
||||||
|
proc.terminate()
|
||||||
|
break
|
||||||
|
|
||||||
|
line = line.strip()
|
||||||
|
if '=' not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition('=')
|
||||||
|
key, value = key.strip(), value.strip()
|
||||||
|
|
||||||
|
if key == 'out_time_ms' and duration_secs > 0:
|
||||||
|
try:
|
||||||
|
elapsed = int(value) / 1_000_000
|
||||||
|
pct = min(100.0, (elapsed / duration_secs) * 100)
|
||||||
|
push_event(job, {
|
||||||
|
'type': 'progress', 'index': idx,
|
||||||
|
'percent': round(pct, 1),
|
||||||
|
'elapsed_secs': round(elapsed, 1),
|
||||||
|
'duration_secs': round(duration_secs, 1),
|
||||||
|
})
|
||||||
|
except (ValueError, ZeroDivisionError):
|
||||||
|
pass
|
||||||
|
elif key == 'progress' and value == 'end':
|
||||||
|
push_event(job, {
|
||||||
|
'type': 'progress', 'index': idx,
|
||||||
|
'percent': 100.0,
|
||||||
|
'elapsed_secs': duration_secs,
|
||||||
|
'duration_secs': duration_secs,
|
||||||
|
})
|
||||||
|
|
||||||
|
proc.wait()
|
||||||
|
with job['lock']:
|
||||||
|
cancelled = job['cancelled']
|
||||||
|
|
||||||
|
if cancelled:
|
||||||
|
try:
|
||||||
|
if os.path.exists(out_path):
|
||||||
|
os.remove(out_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
push_event(job, {'type': 'cancelled', 'message': 'Compression cancelled by user'})
|
||||||
|
with job['lock']:
|
||||||
|
job['status'] = 'cancelled'
|
||||||
|
return
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
try:
|
||||||
|
tail = proc.stderr.read()[-500:]
|
||||||
|
except Exception:
|
||||||
|
tail = ''
|
||||||
|
push_event(job, {
|
||||||
|
'type': 'file_error', 'index': idx, 'filename': p.name,
|
||||||
|
'message': f'ffmpeg exited with code {proc.returncode}',
|
||||||
|
'detail': tail,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
out_sz = os.path.getsize(out_path)
|
||||||
|
out_gb = round(out_sz / (1024 ** 3), 3)
|
||||||
|
orig_sz = file_info.get('size_bytes', 0)
|
||||||
|
reduction = round((1 - out_sz / orig_sz) * 100, 1) if orig_sz else 0
|
||||||
|
except OSError:
|
||||||
|
out_gb = 0
|
||||||
|
reduction = 0
|
||||||
|
|
||||||
|
push_event(job, {
|
||||||
|
'type': 'file_done', 'index': idx,
|
||||||
|
'filename': p.name, 'output': out_path,
|
||||||
|
'output_size_gb': out_gb, 'reduction_pct': reduction,
|
||||||
|
'message': f'Completed: {p.name} → saved {reduction}%',
|
||||||
|
})
|
||||||
|
|
||||||
|
with job['lock']:
|
||||||
|
job['current_index'] = idx + 1
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
push_event(job, {
|
||||||
|
'type': 'file_error', 'index': idx,
|
||||||
|
'filename': p.name, 'message': f'Exception: {exc}',
|
||||||
|
})
|
||||||
|
|
||||||
|
push_event(job, {'type': 'done', 'message': f'All {total} file(s) processed.'})
|
||||||
|
with job['lock']:
|
||||||
|
job['status'] = 'done'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dev-server entry point (production: gunicorn -c gunicorn.conf.py wsgi:app)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 5000
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" VideoPress — dev server http://localhost:{port}")
|
||||||
|
print(f" MEDIA_ROOT : {MEDIA_ROOT}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
app.run(host='0.0.0.0', port=port, debug=False, threaded=True)
|
||||||
85
docker-compose.yml
Normal file
85
docker-compose.yml
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
# =============================================================================
|
||||||
|
# VideoPress — docker-compose.yml
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Quick start:
|
||||||
|
# 1. Edit MEDIA_HOST_PATH below (or set it as a shell variable before running)
|
||||||
|
# 2. docker compose up -d
|
||||||
|
# 3. Open http://localhost:8080
|
||||||
|
#
|
||||||
|
# All configuration lives in the 'environment' section — no .env file needed
|
||||||
|
# for basic usage, though a .env file is supported (see comments below).
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
videopress:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
|
# ── Alternatively, use a pre-built image: ───────────────────────────────
|
||||||
|
# image: videopress:latest
|
||||||
|
|
||||||
|
container_name: videopress
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ── Port mapping ─────────────────────────────────────────────────────────
|
||||||
|
# Format: "HOST_PORT:CONTAINER_PORT"
|
||||||
|
# The UI and REST API are served on the same port — no separate API port
|
||||||
|
# is required. Change the host port (left side) as needed.
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
# ── Volume mapping ────────────────────────────────────────────────────────
|
||||||
|
# Map the directory on your HOST that contains the video files into the
|
||||||
|
# container at /media (MEDIA_ROOT).
|
||||||
|
#
|
||||||
|
# *** Change /path/to/your/videos to the real path on your host. ***
|
||||||
|
#
|
||||||
|
# You can also set MEDIA_HOST_PATH as an environment variable before
|
||||||
|
# running docker compose:
|
||||||
|
# export MEDIA_HOST_PATH=/mnt/nas/videos && docker compose up -d
|
||||||
|
volumes:
|
||||||
|
- ${MEDIA_HOST_PATH:-/path/to/your/videos}:/media
|
||||||
|
|
||||||
|
# ── Environment variables ─────────────────────────────────────────────────
|
||||||
|
environment:
|
||||||
|
# Path *inside the container* where videos are accessible.
|
||||||
|
# Must match the right-hand side of the volume mount above.
|
||||||
|
MEDIA_ROOT: /media
|
||||||
|
|
||||||
|
# TCP port Gunicorn listens on (must match EXPOSE in Dockerfile and
|
||||||
|
# the right-hand side of the ports mapping above).
|
||||||
|
PORT: 8080
|
||||||
|
|
||||||
|
# Gunicorn log level: debug | info | warning | error | critical
|
||||||
|
LOG_LEVEL: info
|
||||||
|
|
||||||
|
# ── Resource limits (optional — uncomment to enable) ─────────────────────
|
||||||
|
# Compressing large video files is CPU-intensive. Limits prevent the
|
||||||
|
# container from starving other workloads on the host.
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# limits:
|
||||||
|
# cpus: '4'
|
||||||
|
# memory: 2G
|
||||||
|
# reservations:
|
||||||
|
# cpus: '1'
|
||||||
|
# memory: 512M
|
||||||
|
|
||||||
|
# ── Health check ──────────────────────────────────────────────────────────
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python3", "-c",
|
||||||
|
"import urllib.request; urllib.request.urlopen('http://localhost:8080/')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# ── Logging ───────────────────────────────────────────────────────────────
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
39
gunicorn.conf.py
Normal file
39
gunicorn.conf.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""
|
||||||
|
gunicorn.conf.py — Gunicorn configuration for VideoPress.
|
||||||
|
|
||||||
|
Key choices:
|
||||||
|
worker_class = gevent
|
||||||
|
Gevent workers are required for Server-Sent Events (SSE). A standard
|
||||||
|
sync worker would block on the SSE generator and starve other requests.
|
||||||
|
Gevent patches blocking I/O so each SSE stream is handled as a
|
||||||
|
lightweight greenlet, not a full OS thread.
|
||||||
|
|
||||||
|
workers = 1
|
||||||
|
FFmpeg compression jobs are stored in an in-process dict (active_jobs).
|
||||||
|
Multiple worker *processes* would not share that state. Keep workers=1
|
||||||
|
and let gevent's concurrency handle multiple simultaneous HTTP clients.
|
||||||
|
If you need multi-process resilience, replace the in-process job store
|
||||||
|
with Redis or a database.
|
||||||
|
|
||||||
|
timeout = 0
|
||||||
|
SSE streams are long-lived; the default 30-second worker timeout would
|
||||||
|
kill them. Setting timeout=0 disables the timeout entirely for gevent
|
||||||
|
workers (gevent uses its own internal greenlet scheduling).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
bind = f"0.0.0.0:{os.environ.get('PORT', '8080')}"
|
||||||
|
workers = 1 # see note above — must stay at 1
|
||||||
|
worker_class = 'gevent'
|
||||||
|
worker_connections = 100 # max simultaneous greenlets per worker
|
||||||
|
timeout = 0 # disable worker timeout for SSE streams
|
||||||
|
keepalive = 5
|
||||||
|
loglevel = os.environ.get('LOG_LEVEL', 'info')
|
||||||
|
accesslog = '-' # stdout
|
||||||
|
errorlog = '-' # stderr
|
||||||
|
capture_output = True
|
||||||
|
|
||||||
|
# Forward the real client IP when behind a reverse proxy (nginx / Traefik)
|
||||||
|
forwarded_allow_ips = '*'
|
||||||
|
proxy_allow_ips = '*'
|
||||||
260
index.html
Normal file
260
index.html
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Server-side video compression tool using FFmpeg. Browse, select, and compress large video files." />
|
||||||
|
<title>VideoPress — FFmpeg Compressor</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/main.css" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<!-- Fallback font stack if Google Fonts unavailable -->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Skip navigation for accessibility -->
|
||||||
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||||
|
|
||||||
|
<header class="app-header" role="banner">
|
||||||
|
<div class="header-inner">
|
||||||
|
<div class="logo" aria-label="VideoPress FFmpeg Compressor">
|
||||||
|
<span class="logo-icon" aria-hidden="true">▶</span>
|
||||||
|
<span class="logo-text">Video<strong>Press</strong></span>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button
|
||||||
|
id="theme-toggle"
|
||||||
|
class="btn-icon"
|
||||||
|
aria-label="Toggle dark/light mode"
|
||||||
|
title="Toggle dark/light mode"
|
||||||
|
>
|
||||||
|
<span class="theme-icon" aria-hidden="true" id="theme-icon">◑</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main-content" class="app-main" role="main">
|
||||||
|
|
||||||
|
<!-- STEP 1: Directory & Filter Configuration -->
|
||||||
|
<section class="card" id="section-config" aria-labelledby="config-heading">
|
||||||
|
<h2 id="config-heading" class="card-title">
|
||||||
|
<span class="step-badge" aria-hidden="true">01</span>
|
||||||
|
Configure Source
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="config-grid">
|
||||||
|
<!-- Directory Browser -->
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="dir-input" class="field-label">Server Directory</label>
|
||||||
|
<div class="dir-input-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="dir-input"
|
||||||
|
class="text-input"
|
||||||
|
placeholder="/path/to/videos"
|
||||||
|
value="/"
|
||||||
|
aria-describedby="dir-hint"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-secondary" id="browse-btn" aria-label="Browse server directories">
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="dir-hint" class="field-hint">Enter or browse to a directory on the server where videos are stored.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Minimum File Size -->
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="min-size-input" class="field-label">
|
||||||
|
Minimum File Size
|
||||||
|
<span class="field-unit">(GB)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="min-size-input"
|
||||||
|
class="text-input"
|
||||||
|
min="0.1"
|
||||||
|
max="1000"
|
||||||
|
step="0.1"
|
||||||
|
value="1.0"
|
||||||
|
aria-describedby="size-hint"
|
||||||
|
/>
|
||||||
|
<p id="size-hint" class="field-hint">Only files larger than this threshold will be listed.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Suffix -->
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="suffix-input" class="field-label">
|
||||||
|
Output Filename Suffix
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="suffix-input"
|
||||||
|
class="text-input"
|
||||||
|
value="_new"
|
||||||
|
placeholder="_new"
|
||||||
|
aria-describedby="suffix-hint"
|
||||||
|
maxlength="64"
|
||||||
|
/>
|
||||||
|
<p id="suffix-hint" class="field-hint">
|
||||||
|
Appended before the file extension. E.g. <code>movie_new.mp4</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<button class="btn btn-primary btn-lg" id="scan-btn" aria-describedby="scan-status">
|
||||||
|
<span class="btn-icon-prefix" aria-hidden="true">⊙</span>
|
||||||
|
Scan for Files
|
||||||
|
</button>
|
||||||
|
<span id="scan-status" class="status-text" aria-live="polite" aria-atomic="true"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Directory Browser Modal -->
|
||||||
|
<div
|
||||||
|
id="browser-modal"
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="browser-modal-title"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<div class="modal-panel">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="browser-modal-title" class="modal-title">Browse Server Directory</h3>
|
||||||
|
<button class="btn-icon" id="close-browser" aria-label="Close directory browser">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="browser-path-bar">
|
||||||
|
<span class="browser-path-label" aria-label="Current path:">
|
||||||
|
<span id="browser-current-path" aria-live="polite">/</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="browser-list" class="browser-list" role="listbox" aria-label="Directory contents" tabindex="0">
|
||||||
|
<p class="browser-loading">Loading…</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="browser-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="browser-select">Select This Directory</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- STEP 2: File List -->
|
||||||
|
<section class="card" id="section-files" aria-labelledby="files-heading" hidden>
|
||||||
|
<h2 id="files-heading" class="card-title">
|
||||||
|
<span class="step-badge" aria-hidden="true">02</span>
|
||||||
|
Select Files to Compress
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="files-toolbar" role="toolbar" aria-label="File selection controls">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<button class="btn btn-sm btn-outline" id="select-all-btn" aria-label="Mark all files for compression">
|
||||||
|
☑ Select All
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline" id="deselect-all-btn" aria-label="Unmark all files">
|
||||||
|
☐ Deselect All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<span id="selection-summary" class="selection-summary" aria-live="polite" aria-atomic="true">
|
||||||
|
0 of 0 selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrapper" role="region" aria-label="Video files list" tabindex="0">
|
||||||
|
<table class="file-table" id="file-table" aria-describedby="files-heading">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="col-check">
|
||||||
|
<span class="sr-only">Select</span>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="col-name">File Name</th>
|
||||||
|
<th scope="col" class="col-size">Size (GB)</th>
|
||||||
|
<th scope="col" class="col-bitrate">Current Bitrate</th>
|
||||||
|
<th scope="col" class="col-target">Target Bitrate</th>
|
||||||
|
<th scope="col" class="col-codec">Codec</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="file-tbody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<button class="btn btn-primary btn-lg" id="compress-btn" disabled>
|
||||||
|
<span class="btn-icon-prefix" aria-hidden="true">⚡</span>
|
||||||
|
Compress Selected Files
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- STEP 3: Compression Progress -->
|
||||||
|
<section class="card" id="section-progress" aria-labelledby="progress-heading" hidden>
|
||||||
|
<h2 id="progress-heading" class="card-title">
|
||||||
|
<span class="step-badge" aria-hidden="true">03</span>
|
||||||
|
Compression Progress
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="progress-overview" role="region" aria-label="Overall progress">
|
||||||
|
<div class="overview-stats">
|
||||||
|
<div class="stat-chip">
|
||||||
|
<span class="stat-label">Total Files</span>
|
||||||
|
<span class="stat-value" id="prog-total">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-chip">
|
||||||
|
<span class="stat-label">Completed</span>
|
||||||
|
<span class="stat-value" id="prog-done">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-chip">
|
||||||
|
<span class="stat-label">Status</span>
|
||||||
|
<span class="stat-value" id="prog-status" aria-live="polite">Waiting</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overall-bar-wrap" aria-label="Overall compression progress">
|
||||||
|
<div class="overall-bar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" id="overall-bar">
|
||||||
|
<div class="overall-bar-fill" id="overall-bar-fill" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="overall-pct" id="overall-pct" aria-hidden="true">0%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="file-progress-list" class="file-progress-list" aria-label="Individual file progress" role="list">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer">
|
||||||
|
<button class="btn btn-danger" id="cancel-btn" aria-label="Cancel all compression operations">
|
||||||
|
✕ Cancel Compression
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Results summary (shown after done/cancelled) -->
|
||||||
|
<section class="card" id="section-results" aria-labelledby="results-heading" hidden>
|
||||||
|
<h2 id="results-heading" class="card-title">
|
||||||
|
<span class="step-badge" aria-hidden="true">04</span>
|
||||||
|
Results
|
||||||
|
</h2>
|
||||||
|
<div id="results-content" class="results-content"></div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<button class="btn btn-secondary" id="restart-btn">
|
||||||
|
↺ Start New Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer" role="contentinfo">
|
||||||
|
<p>VideoPress uses <strong>FFmpeg</strong> for video compression. Files are processed on the server.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Live region for screen reader announcements -->
|
||||||
|
<div id="sr-announce" class="sr-only" aria-live="assertive" aria-atomic="true"></div>
|
||||||
|
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
requirements.txt
Normal file
12
requirements.txt
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# VideoPress — Python dependencies
|
||||||
|
# Install with: pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Web framework
|
||||||
|
Flask==3.1.0
|
||||||
|
|
||||||
|
# WSGI server (production)
|
||||||
|
gunicorn==23.0.0
|
||||||
|
|
||||||
|
# Async worker for SSE / long-lived streaming responses
|
||||||
|
gevent==24.11.1
|
||||||
|
greenlet==3.1.1
|
||||||
60
start.sh
Normal file
60
start.sh
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# VideoPress — startup script
|
||||||
|
# Usage: ./start.sh [--prod] [PORT]
|
||||||
|
# =============================================================================
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
MODE="dev"
|
||||||
|
PORT=""
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--prod) MODE="prod" ;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--prod] [PORT]"
|
||||||
|
echo " --prod Run via Gunicorn+gevent (production)"
|
||||||
|
echo " PORT Port number (default: 5000 dev / 8080 prod)"
|
||||||
|
exit 0 ;;
|
||||||
|
[0-9]*) PORT="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -z "$PORT" ]] && PORT=$( [[ "$MODE" == "prod" ]] && echo "8080" || echo "5000" )
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo " VideoPress | mode: $MODE | port: $PORT"
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
check() { command -v "$1" &>/dev/null || { echo "ERROR: $1 not found. $2"; exit 1; }; }
|
||||||
|
check python3 "Install Python 3.8+"
|
||||||
|
check ffmpeg "sudo apt install ffmpeg"
|
||||||
|
check ffprobe "Bundled with ffmpeg"
|
||||||
|
python3 -c "import flask" 2>/dev/null || { echo "ERROR: Flask missing — pip install -r requirements.txt"; exit 1; }
|
||||||
|
|
||||||
|
echo " ✓ Python $(python3 --version)"
|
||||||
|
echo " ✓ Flask $(python3 -c 'import flask; print(flask.__version__)')"
|
||||||
|
echo " ✓ FFmpeg available"
|
||||||
|
echo " MEDIA_ROOT: ${MEDIA_ROOT:-/media (default)}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "prod" ]]; then
|
||||||
|
python3 -c "import gunicorn" 2>/dev/null || { echo "ERROR: gunicorn missing — pip install -r requirements.txt"; exit 1; }
|
||||||
|
python3 -c "import gevent" 2>/dev/null || { echo "ERROR: gevent missing — pip install -r requirements.txt"; exit 1; }
|
||||||
|
echo " Starting Gunicorn on http://0.0.0.0:${PORT}"
|
||||||
|
echo " Press Ctrl+C to stop."
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
PORT="$PORT" exec gunicorn -c gunicorn.conf.py wsgi:app
|
||||||
|
else
|
||||||
|
echo " WARNING: Dev server only — use --prod or Docker for production."
|
||||||
|
echo " Starting Flask on http://localhost:${PORT}"
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
exec python3 app.py "$PORT"
|
||||||
|
fi
|
||||||
18
wsgi.py
Normal file
18
wsgi.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""
|
||||||
|
wsgi.py — Gunicorn entry point for VideoPress.
|
||||||
|
|
||||||
|
Start with:
|
||||||
|
gunicorn -c gunicorn.conf.py wsgi:app
|
||||||
|
|
||||||
|
Or directly:
|
||||||
|
gunicorn \
|
||||||
|
--worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker \
|
||||||
|
--workers 1 \
|
||||||
|
--bind 0.0.0.0:8080 \
|
||||||
|
wsgi:app
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app import app # noqa: F401 — 'app' is the Flask application object
|
||||||
|
|
||||||
|
# Gunicorn imports this module and looks for a callable named 'app'.
|
||||||
|
# Nothing else is needed here.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue