""" 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/ → SSE progress stream POST /api/compress/cancel/ → 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/') 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/') 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/', 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'})