Updates galore. Improved folder structure, componentized, and notifications upon completion.
This commit is contained in:
parent
b48784e2ad
commit
7e0502ca40
33 changed files with 3565 additions and 728 deletions
470
app/routes.py
Normal file
470
app/routes.py
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
"""
|
||||
app/routes.py
|
||||
=============
|
||||
All Flask route handlers. Registered on the app object via register_routes()
|
||||
which is called by the application factory in app/__init__.py.
|
||||
|
||||
Routes
|
||||
------
|
||||
GET / → index page
|
||||
GET /api/config → server configuration (media_root)
|
||||
GET /api/browse?path=… → directory listing
|
||||
POST /api/scan → scan for video files
|
||||
POST /api/compress/start → start a compression job
|
||||
GET /api/compress/progress/<id> → SSE progress stream
|
||||
POST /api/compress/cancel/<id> → cancel a running job
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
|
||||
|
||||
from .config import MEDIA_ROOT, safe_path
|
||||
from .db import get_all_settings, save_setting, delete_setting
|
||||
from .media import get_video_info, list_video_files
|
||||
from .jobs import active_jobs, job_lock, run_compression_job
|
||||
from .notify import get_smtp_config, send_completion_email
|
||||
|
||||
|
||||
def fmttime(seconds: float) -> str:
|
||||
"""Format *seconds* as M:SS or H:MM:SS."""
|
||||
s = int(seconds)
|
||||
h = s // 3600
|
||||
m = (s % 3600) // 60
|
||||
sec = s % 60
|
||||
if h:
|
||||
return f"{h}:{m:02d}:{sec:02d}"
|
||||
return f"{m}:{sec:02d}"
|
||||
|
||||
|
||||
def register_routes(app: Flask) -> None:
|
||||
"""Attach all routes to *app*."""
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html', media_root=str(MEDIA_ROOT))
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/api/config')
|
||||
def api_config():
|
||||
"""Return server-side settings the frontend needs at startup."""
|
||||
return jsonify({'media_root': str(MEDIA_ROOT)})
|
||||
|
||||
# ── SMTP settings ─────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/api/settings/smtp', methods=['GET'])
|
||||
def smtp_settings_get():
|
||||
"""
|
||||
Return current SMTP settings (password is never sent, only a flag
|
||||
indicating whether one is stored).
|
||||
"""
|
||||
return jsonify(get_smtp_config())
|
||||
|
||||
@app.route('/api/settings/smtp', methods=['POST'])
|
||||
def smtp_settings_save():
|
||||
"""
|
||||
Save SMTP settings to SQLite. Only fields present in the request
|
||||
body are updated; omitting 'password' leaves the stored password
|
||||
unchanged (useful when the user edits other fields but doesn't want
|
||||
to re-enter the password).
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
|
||||
# Fields whose DB key matches smtp_{field} exactly
|
||||
for field in ('host', 'port', 'security'):
|
||||
if field in data:
|
||||
value = str(data[field]).strip()
|
||||
if not value:
|
||||
return jsonify({'error': f"'{field}' cannot be empty"}), 400
|
||||
save_setting(f'smtp_{field}', value)
|
||||
|
||||
# from_addr is stored as 'smtp_from' (not 'smtp_from_addr')
|
||||
if 'from_addr' in data:
|
||||
value = str(data['from_addr']).strip()
|
||||
if not value:
|
||||
return jsonify({'error': "'from_addr' cannot be empty"}), 400
|
||||
save_setting('smtp_from', value)
|
||||
|
||||
# Optional fields
|
||||
if 'user' in data:
|
||||
val = str(data['user']).strip()
|
||||
if val:
|
||||
save_setting('smtp_user', val)
|
||||
else:
|
||||
delete_setting('smtp_user')
|
||||
|
||||
# Password: only update if a non-empty value is explicitly sent
|
||||
if 'password' in data and str(data['password']).strip():
|
||||
save_setting('smtp_password', str(data['password']).strip())
|
||||
|
||||
return jsonify({'ok': True, 'config': get_smtp_config()})
|
||||
|
||||
@app.route('/api/settings/smtp/test', methods=['POST'])
|
||||
def smtp_settings_test():
|
||||
"""
|
||||
Send a test email using the currently saved SMTP settings.
|
||||
Always returns HTTP 200 — SMTP failures are reported in the
|
||||
JSON body as {ok: false, message: "..."} so the browser can
|
||||
display the exact error without interference from proxies or
|
||||
the browser's own error handling for 5xx responses.
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
test_to = data.get('to', '').strip()
|
||||
|
||||
if not test_to or '@' not in test_to:
|
||||
return jsonify({'ok': False, 'message': 'Please enter a valid recipient address.'}), 400
|
||||
|
||||
ok, err = send_completion_email(
|
||||
to_address = test_to,
|
||||
results = [{
|
||||
'status': 'done',
|
||||
'filename': 'test_video.mp4',
|
||||
'output_size_gb': 1.2,
|
||||
'reduction_pct': 33,
|
||||
}],
|
||||
cancelled = False,
|
||||
)
|
||||
|
||||
if ok:
|
||||
return jsonify({'ok': True, 'message': f'Test email sent to {test_to}.'})
|
||||
|
||||
# Always 200 — the caller checks data.ok, not the HTTP status
|
||||
return jsonify({'ok': False, 'message': err})
|
||||
|
||||
# ── Directory browser ─────────────────────────────────────────────────
|
||||
|
||||
@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 = [
|
||||
{'name': e.name, 'path': str(e), 'is_dir': e.is_dir()}
|
||||
for e in sorted(
|
||||
path.iterdir(),
|
||||
key=lambda e: (not e.is_dir(), e.name.lower()),
|
||||
)
|
||||
if not e.name.startswith('.')
|
||||
]
|
||||
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
|
||||
|
||||
# ── File scanner ──────────────────────────────────────────────────────
|
||||
|
||||
@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:
|
||||
# Rough fallback: assume a 90-minute feature film
|
||||
bps = int((f['size_bytes'] * 8) / (90 * 60))
|
||||
f.update({
|
||||
'bit_rate_bps': bps,
|
||||
'bit_rate_mbps': round(bps / 1_000_000, 2),
|
||||
'target_bit_rate_bps': max(bps // 3, 200_000),
|
||||
'target_bit_rate_mbps': round(max(bps // 3, 200_000) / 1_000_000, 2),
|
||||
'duration': 0,
|
||||
'codec': 'unknown',
|
||||
'width': 0,
|
||||
'height': 0,
|
||||
})
|
||||
enriched.append(f)
|
||||
|
||||
enriched.sort(key=lambda x: x['size_bytes'], reverse=True)
|
||||
return jsonify({'files': enriched, 'count': len(enriched)})
|
||||
|
||||
# ── Compression — status snapshot (for reconnect/reload) ─────────────
|
||||
|
||||
@app.route('/api/compress/status/<job_id>')
|
||||
def compression_status(job_id):
|
||||
"""
|
||||
Return a complete point-in-time snapshot of a job's state.
|
||||
|
||||
This is used when the browser reconnects after losing the SSE stream
|
||||
(page reload, tab backgrounded, network blip). The frontend replays
|
||||
this snapshot to rebuild the full progress UI, then re-attaches the
|
||||
live SSE stream from where it left off.
|
||||
|
||||
Response shape
|
||||
--------------
|
||||
{
|
||||
job_id, status, total, current_index,
|
||||
files: [ {path, name, ...original file info} ],
|
||||
file_states: [ # one entry per file, index-aligned
|
||||
{
|
||||
status: 'waiting' | 'running' | 'done' | 'error',
|
||||
percent: 0-100,
|
||||
detail: str, # time elapsed / output size / error msg
|
||||
filename, output, reduction_pct, output_size_gb (done only)
|
||||
message (error only)
|
||||
}
|
||||
],
|
||||
done_count: int,
|
||||
event_count: int # total events stored; SSE stream resumes from here
|
||||
}
|
||||
"""
|
||||
with job_lock:
|
||||
job = active_jobs.get(job_id)
|
||||
if not job:
|
||||
return jsonify({'error': 'Job not found'}), 404
|
||||
|
||||
with job['lock']:
|
||||
events = list(job['events'])
|
||||
status = job['status']
|
||||
total = job['total']
|
||||
current_index = job['current_index']
|
||||
files = job['files']
|
||||
|
||||
# Replay the event log to reconstruct per-file state
|
||||
file_states = [
|
||||
{'status': 'waiting', 'percent': 0, 'detail': '', 'filename': f.get('name', '')}
|
||||
for f in files
|
||||
]
|
||||
done_count = 0
|
||||
|
||||
for evt in events:
|
||||
t = evt.get('type')
|
||||
idx = evt.get('index')
|
||||
|
||||
if t == 'file_start' and idx is not None:
|
||||
file_states[idx].update({
|
||||
'status': 'running',
|
||||
'percent': 0,
|
||||
'detail': '',
|
||||
'filename': evt.get('filename', file_states[idx]['filename']),
|
||||
'output': evt.get('output', ''),
|
||||
'encoder': evt.get('encoder', ''),
|
||||
})
|
||||
|
||||
elif t == 'progress' and idx is not None:
|
||||
file_states[idx].update({
|
||||
'status': 'running',
|
||||
'percent': evt.get('percent', 0),
|
||||
'detail': (
|
||||
f"{fmttime(evt.get('elapsed_secs',0))} / "
|
||||
f"{fmttime(evt.get('duration_secs',0))}"
|
||||
if evt.get('duration_secs', 0) > 0 else ''
|
||||
),
|
||||
})
|
||||
|
||||
elif t == 'file_done' and idx is not None:
|
||||
done_count += 1
|
||||
file_states[idx].update({
|
||||
'status': 'done',
|
||||
'percent': 100,
|
||||
'detail': (f"{evt.get('output_size_gb','?')} GB "
|
||||
f"saved {evt.get('reduction_pct','?')}%"),
|
||||
'filename': evt.get('filename', ''),
|
||||
'output': evt.get('output', ''),
|
||||
'reduction_pct': evt.get('reduction_pct', 0),
|
||||
'output_size_gb': evt.get('output_size_gb', 0),
|
||||
})
|
||||
|
||||
elif t == 'file_error' and idx is not None:
|
||||
file_states[idx].update({
|
||||
'status': 'error',
|
||||
'percent': 0,
|
||||
'detail': evt.get('message', 'Unknown error'),
|
||||
'message': evt.get('message', ''),
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'job_id': job_id,
|
||||
'status': status,
|
||||
'total': total,
|
||||
'current_index': current_index,
|
||||
'done_count': done_count,
|
||||
'event_count': len(events),
|
||||
'files': files,
|
||||
'file_states': file_states,
|
||||
})
|
||||
|
||||
# ── Compression — list active jobs (for page-load auto-reconnect) ─────
|
||||
|
||||
@app.route('/api/compress/active')
|
||||
def list_active_jobs():
|
||||
"""
|
||||
Return a list of jobs that are currently running or recently finished.
|
||||
The frontend calls this on page load to detect whether a job is in
|
||||
progress and should be reconnected to.
|
||||
"""
|
||||
with job_lock:
|
||||
jobs = list(active_jobs.values())
|
||||
|
||||
result = []
|
||||
for job in jobs:
|
||||
with job['lock']:
|
||||
result.append({
|
||||
'job_id': job['id'],
|
||||
'status': job['status'],
|
||||
'total': job['total'],
|
||||
'current_index': job['current_index'],
|
||||
})
|
||||
|
||||
# Most recent first
|
||||
result.sort(key=lambda j: j['job_id'], reverse=True)
|
||||
return jsonify({'jobs': result})
|
||||
|
||||
# ── Compression — start ───────────────────────────────────────────────
|
||||
|
||||
@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')
|
||||
notify_email = data.get('notify_email', '').strip()
|
||||
|
||||
if not files:
|
||||
return jsonify({'error': 'No files provided'}), 400
|
||||
|
||||
if notify_email and (len(notify_email) > 254 or '@' not in notify_email):
|
||||
return jsonify({'error': 'Invalid notification email address'}), 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,
|
||||
'notify_email': notify_email,
|
||||
'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})
|
||||
|
||||
# ── Compression — SSE progress stream ─────────────────────────────────
|
||||
|
||||
@app.route('/api/compress/progress/<job_id>')
|
||||
def compression_progress(job_id):
|
||||
"""
|
||||
Server-Sent Events stream for real-time job progress.
|
||||
|
||||
Query param: ?from=N — start streaming from event index N (default 0).
|
||||
On reconnect the client passes the last event index it saw so it only
|
||||
receives new events, not a full replay of the history.
|
||||
|
||||
Compatible with Gunicorn + gevent: time.sleep() yields the greenlet
|
||||
rather than blocking a real OS thread.
|
||||
"""
|
||||
try:
|
||||
start_from = int(request.args.get('from', 0))
|
||||
except (TypeError, ValueError):
|
||||
start_from = 0
|
||||
|
||||
def event_stream():
|
||||
last_idx = start_from
|
||||
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',
|
||||
},
|
||||
)
|
||||
|
||||
# ── Compression — cancel ──────────────────────────────────────────────
|
||||
|
||||
@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'})
|
||||
Loading…
Add table
Add a link
Reference in a new issue