From 7e0502ca403b78efb0fe3659f2904310422e5b10 Mon Sep 17 00:00:00 2001 From: Brian McGonagill Date: Tue, 17 Mar 2026 14:01:35 -0500 Subject: [PATCH] Updates galore. Improved folder structure, componentized, and notifications upon completion. --- Dockerfile | 10 +- __pycache__/app.cpython-313.pyc | Bin 0 -> 22992 bytes app.py => app.py.archive | 0 app/__init__.py | 37 ++ app/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 1212 bytes app/__pycache__/config.cpython-313.pyc | Bin 0 -> 2231 bytes app/__pycache__/jobs.cpython-313.pyc | Bin 0 -> 13317 bytes app/__pycache__/media.cpython-313.pyc | Bin 0 -> 5241 bytes app/__pycache__/notify.cpython-313.pyc | Bin 0 -> 11178 bytes app/__pycache__/routes.cpython-313.pyc | Bin 0 -> 11170 bytes app/config.py | 51 ++ app/db.py | 142 +++++ app/jobs.py | 349 +++++++++++ app/media.py | 140 +++++ app/notify.py | 329 ++++++++++ app/routes.py | 470 +++++++++++++++ docker-compose.yml | 47 +- run.py | 22 + start.sh | 4 +- static/css/main.css | 276 ++++++++- static/js/app.js | 729 +---------------------- static/js/modules/browser.js | 111 ++++ static/js/modules/compress.js | 195 ++++++ static/js/modules/progress.js | 172 ++++++ static/js/modules/scan.js | 194 ++++++ static/js/modules/session.js | 59 ++ static/js/modules/settings.js | 260 ++++++++ static/js/modules/state.js | 119 ++++ static/js/modules/stream.js | 276 +++++++++ static/js/modules/theme.js | 46 ++ static/js/modules/utils.js | 45 ++ templates/index.html | 193 +++++- wsgi.py | 17 +- 33 files changed, 3565 insertions(+), 728 deletions(-) create mode 100644 __pycache__/app.cpython-313.pyc rename app.py => app.py.archive (100%) create mode 100644 app/__init__.py create mode 100644 app/__pycache__/__init__.cpython-313.pyc create mode 100644 app/__pycache__/config.cpython-313.pyc create mode 100644 app/__pycache__/jobs.cpython-313.pyc create mode 100644 app/__pycache__/media.cpython-313.pyc create mode 100644 app/__pycache__/notify.cpython-313.pyc create mode 100644 app/__pycache__/routes.cpython-313.pyc create mode 100644 app/config.py create mode 100644 app/db.py create mode 100644 app/jobs.py create mode 100644 app/media.py create mode 100644 app/notify.py create mode 100644 app/routes.py create mode 100644 run.py mode change 100644 => 100755 start.sh create mode 100644 static/js/modules/browser.js create mode 100644 static/js/modules/compress.js create mode 100644 static/js/modules/progress.js create mode 100644 static/js/modules/scan.js create mode 100644 static/js/modules/session.js create mode 100644 static/js/modules/settings.js create mode 100644 static/js/modules/state.js create mode 100644 static/js/modules/stream.js create mode 100644 static/js/modules/theme.js create mode 100644 static/js/modules/utils.js diff --git a/Dockerfile b/Dockerfile index abd1a99..26119cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,7 +52,8 @@ RUN groupadd --gid 1000 appuser \ # ── Application code ───────────────────────────────────────────────────────── WORKDIR /app -COPY app.py wsgi.py gunicorn.conf.py requirements.txt ./ +COPY app/ app/ +COPY wsgi.py run.py gunicorn.conf.py requirements.txt ./ COPY templates/ templates/ COPY static/ static/ @@ -61,6 +62,10 @@ COPY static/ static/ # All file-system access by the application is restricted to this path. RUN mkdir -p /media && chown appuser:appuser /media +# ── Data directory for the SQLite settings database ────────────────────────── +# Mounted as a named volume in docker-compose so settings survive restarts. +RUN mkdir -p /data && chown appuser:appuser /data + # ── File ownership ──────────────────────────────────────────────────────────── RUN chown -R appuser:appuser /app @@ -73,6 +78,7 @@ USER appuser # PORT — TCP port Gunicorn listens on (exposed below). # LOG_LEVEL — Gunicorn log verbosity (debug | info | warning | error). ENV MEDIA_ROOT=/media \ + DB_PATH=/data/videopress.db \ PORT=8080 \ LOG_LEVEL=info \ PYTHONUNBUFFERED=1 \ @@ -89,4 +95,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ || exit 1 # ── Start Gunicorn ──────────────────────────────────────────────────────────── -CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:app"] +CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:application"] diff --git a/__pycache__/app.cpython-313.pyc b/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9b50a36075371fa874cfaefe2a5bb1f61789f6be GIT binary patch literal 22992 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 Flask: + """ + Create and return a configured Flask application instance. + """ + flask_app = Flask( + __name__, + template_folder=str(BASE_DIR / 'templates'), + static_folder=str(BASE_DIR / 'static'), + ) + + # Initialise the SQLite settings database + init_db() + + register_routes(flask_app) + + return flask_app diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..22033f0c810d7a0b9887adb601be4d9a14b6844b GIT binary patch literal 1212 zcmZuw&ubGw6rSB|HpytQ)mrKgWJC`M*aZA>5VfGTYFkurTM#NN>+U2SyO~+uOd{1w z{{$}{1P@-l`-docS}F)0MDUYDFJp^4`q%Ufz4(m+i4JO3+Tccq6(MLO$DI zWbTf$J&VqB5)dGP6Szyxq6;n~K_&2(DvKU?i&dx=adpXGtU(QZ|ABkn4QfZpBM<7m zdaLo|8;!E86NW+x6Nc?kLt;h$ADEWZft>3O3r5uIQ-8>5MhXSQk{^ z0-5M%3Ji;ZM}~)ZV;^l9>$59+9-1vmZw{o06-d^6ta}1mXt6j-Q~ei=DH(GX8KzAH zW{|b%@)~CeAI@kFLk^5f0~;(;LK@bDfh_7Iifd*NsG~k_)30`QEhn!QGktYzwNmd)9{FU8YMpnpa(()o7i_z=F%2^~6 zRyNBb7Ax5my#erq{mZ40xKLazlDW+LS&D?|A|0=dh*?*p_5wK8(<&We5#WdtL(YsU zcAJ5E#ACxiDMOp1Wg^WPrXd@ui!h-vjMq^vtusukold$!CIvx#>=FR0q#G319zlXX ztIJf*9xGTEvd0o(At=3)(~@|soJIo~uVHIefo+;M%xIL{FXup-4~#Cuu1XUQ`B?e1 z6Sk^gdCco!C{Zk7m{+s`u-rin4-`aWSHs#8$MSQXHPz>xhae)^nd3vAs;rMAcZMS2 zD$H&~nb z;;Pi@sfYtqZAGeHa;o6gLl3Bz3aL`Bv3v;HhEq?y8HGw!PyJ^dCoXVcw7c{CAK(A~ z-|Tiew`N)>pUBw4}m9$(Duoq`Kfg=TEkFq8onp)}k*O)PICPsd_#i z%h*P>taC#M! zWx`gm4QCk6*+#)6y{bBFn%F5=L{FOpFY9K3aGcRCoF>?Y^Nbn>vG6k0F|n2@v#lH$ zhl7j?2_9EfjHkv%$A?pinVIVtTEey+Y*0orjt%yNO8~~CnVOq9eHAmJ8?hBe9fG?J zlGO{Q6UVU}F{mB|ebY17$1i`9x}2Dq0)0th8Q`&Q7>saULqjcW*$&Otm69Cba(QzmipGb$80gH zOIp6Y-8pG@RsQ%)d<7x>B0_WW>v%ioo8M(@qP?zvukUHF(Y>>5yp7Pjz~i-~Z%)!s zQr`0kX8yCgw*`^rqzF2K=KXbv0Mfw{Zqy;op$k5bUprADQWjm5-$hv|+Ejd|CJ499 zWr9yacu!)-2CiT|4QmCcK*1M}JUZtDYK6kXF||14s-d)jD+w9$0v%%P6$MH z!SNt@gfvZi1)TRNhnZk(chMK^jkYjRLvCOPp4&2ZD?{?)esM!DA#!EzFecLCs@&0; z!&fMfz-M#VA^E7EiQj~))FTBWu#*BIR}nE6BQC@R+uZkKOLmUL(r~X>u~ES`J1?%9 zm`9nE$C=pwE=9M6>sgW#s+jl%MB(K>z^8<&?Q+w=R`|f0{LSEII8qKrD&gZ_DK8Y% z9{x6d_lwfitx(7PfxDlVu09QfDuF}mS2rX5&u`RB!c6x_d!B(SX=Ue_M- zM~fR>eHE>LL+J;Rrh$KXEBN>2!QnylWUzB2EJr)sKq_U}nN*4iBDu}n&fFx98xXKe zny#foNu{#XBydP@*lo`13_|0!&JJIi7``%=8XZr#%E)lCHVW+A)ULcpoLYhcFv@;v zSgc9N2a{vdqf^7VfFMyBGHVhF9i!;iF+Am^=9fhf&l^0upAxY1Dt&)Gc z6G`eHsO=eg=XpB{g&~~QrWPq{k&1SFQ|l>fJ*C#C+QCf?mo;2co;Qm_XCB1Ajz1Xs zdT9OBPf8^`SkdB}+DKU&sc54mwHie29rucNiuZ2cxxJQq_}SyWUx$7fs(di95ue=X gnW}`QL1*Z_Qm`7_C+)j`wu+!zJNLKf*b#8@KPX8mbpQYW literal 0 HcmV?d00001 diff --git a/app/__pycache__/jobs.cpython-313.pyc b/app/__pycache__/jobs.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..991aa163699e5f8841ce785fccf0a7bf9cfe2bed GIT binary patch literal 13317 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 GQTaarT|T7% literal 0 HcmV?d00001 diff --git a/app/__pycache__/notify.cpython-313.pyc b/app/__pycache__/notify.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9b377732c0705351ef50c653d838fe4db4559bd7 GIT binary patch literal 11178 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 literal 0 HcmV?d00001 diff --git a/app/__pycache__/routes.cpython-313.pyc b/app/__pycache__/routes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8fb84cf711abe75ec082358008aaacc8dfa0e4f9 GIT binary patch literal 11170 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 MJJa$9_RCiNKSVBiwEzGB literal 0 HcmV?d00001 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..d130e30 --- /dev/null +++ b/app/config.py @@ -0,0 +1,51 @@ +""" +app/config.py +============= +Central configuration and the path-jail helper used by every other module. + +All tuneable values can be overridden via environment variables: + + MEDIA_ROOT Root directory the application may read/write (default: /media) + DB_PATH Path to the SQLite database file (default: /videopress.db) + PORT TCP port Gunicorn listens on (default: 8080) + LOG_LEVEL Gunicorn log verbosity (default: info) +""" + +import os +from pathlib import Path + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +PACKAGE_DIR = Path(__file__).resolve().parent # …/app/ +BASE_DIR = PACKAGE_DIR.parent # …/videocompressor/ + +# Every file-system operation in the application is restricted to MEDIA_ROOT. +MEDIA_ROOT = Path(os.environ.get('MEDIA_ROOT', '/media')).resolve() + +# --------------------------------------------------------------------------- +# Path-jail helper +# --------------------------------------------------------------------------- + +def safe_path(raw: str) -> Path: + """ + Resolve *raw* to an absolute path and assert it is inside MEDIA_ROOT. + + Returns the resolved Path on success. + Raises PermissionError if the path would escape MEDIA_ROOT (including + symlink traversal and ../../ attacks). + """ + 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: '{resolved}' is outside the allowed " + f"media root ({MEDIA_ROOT})." + ) + return resolved diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..b916a55 --- /dev/null +++ b/app/db.py @@ -0,0 +1,142 @@ +""" +app/db.py +========= +Lightweight SQLite-backed key/value settings store. + +The database file is created automatically on first use beside the +application package, or at the path set by the DB_PATH environment +variable (useful for Docker volume persistence). + +Public API +---------- + init_db() — create the table if it doesn't exist (call at startup) + get_setting(key) — return the stored string value, or None + save_setting(key, val) — upsert a key/value pair + get_all_settings() — return all rows as {key: value} + delete_setting(key) — remove a key (used to clear optional fields) +""" + +import os +import sqlite3 +import threading +from pathlib import Path + +from .config import BASE_DIR + +# --------------------------------------------------------------------------- +# Database location +# --------------------------------------------------------------------------- + +# Default: videocompressor/videopress.db — sits beside the app/ package. +# Override with the DB_PATH env var (e.g. to a Docker-mounted volume path). +DB_PATH = Path(os.environ.get('DB_PATH', str(BASE_DIR / 'videopress.db'))) + +# SQLite connections are not thread-safe across threads; use a per-thread +# connection via threading.local() so each worker greenlet/thread gets its own. +_local = threading.local() + +_INIT_LOCK = threading.Lock() +_initialised = False + + +def _connect() -> sqlite3.Connection: + """Return (and cache) a per-thread SQLite connection.""" + if not hasattr(_local, 'conn') or _local.conn is None: + _local.conn = sqlite3.connect(str(DB_PATH), check_same_thread=False) + _local.conn.row_factory = sqlite3.Row + # WAL mode allows concurrent reads alongside a single writer + _local.conn.execute('PRAGMA journal_mode=WAL') + _local.conn.execute('PRAGMA foreign_keys=ON') + return _local.conn + + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + +def init_db() -> None: + """ + Create the settings table if it does not already exist. + Also creates the parent directory of DB_PATH if needed. + Safe to call multiple times — idempotent. + """ + global _initialised + with _INIT_LOCK: + if _initialised: + return + + # Ensure the directory exists before SQLite tries to create the file. + # This handles the case where the Docker volume mount creates ./data + # as root before the container user can write to it. + db_dir = DB_PATH.parent + try: + db_dir.mkdir(parents=True, exist_ok=True) + except PermissionError: + raise PermissionError( + f"Cannot create database directory '{db_dir}'. " + f"If running in Docker, create the directory on the host first " + f"and ensure it is writable by UID 1000:\n" + f" mkdir -p {db_dir} && chown 1000:1000 {db_dir}" + ) + + # Test that we can actually write to the directory before SQLite tries + test_file = db_dir / '.write_test' + try: + test_file.touch() + test_file.unlink() + except PermissionError: + raise PermissionError( + f"Database directory '{db_dir}' is not writable by the current user. " + f"If running in Docker, fix permissions on the host:\n" + f" chown 1000:1000 {db_dir}" + ) + + conn = _connect() + conn.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + conn.commit() + _initialised = True + + +# --------------------------------------------------------------------------- +# CRUD helpers +# --------------------------------------------------------------------------- + +def get_setting(key: str) -> str | None: + """Return the stored value for *key*, or None if not set.""" + init_db() + row = _connect().execute( + 'SELECT value FROM settings WHERE key = ?', (key,) + ).fetchone() + return row['value'] if row else None + + +def save_setting(key: str, value: str) -> None: + """Insert or update *key* with *value*.""" + init_db() + conn = _connect() + conn.execute( + 'INSERT INTO settings (key, value) VALUES (?, ?)' + ' ON CONFLICT(key) DO UPDATE SET value = excluded.value', + (key, value), + ) + conn.commit() + + +def delete_setting(key: str) -> None: + """Remove *key* from the store (silently succeeds if absent).""" + init_db() + conn = _connect() + conn.execute('DELETE FROM settings WHERE key = ?', (key,)) + conn.commit() + + +def get_all_settings() -> dict[str, str]: + """Return all stored settings as a plain dict.""" + init_db() + rows = _connect().execute('SELECT key, value FROM settings').fetchall() + return {row['key']: row['value'] for row in rows} diff --git a/app/jobs.py b/app/jobs.py new file mode 100644 index 0000000..f707bd8 --- /dev/null +++ b/app/jobs.py @@ -0,0 +1,349 @@ +""" +app/jobs.py +=========== +In-process job store and the ffmpeg compression worker thread. + +Design note: job state is kept in a plain dict protected by a threading.Lock. +This is intentional — VideoPress uses a single Gunicorn worker process +(required for SSE streaming with gevent), so cross-process state sharing is +not needed. If you ever move to multiple workers, replace `active_jobs` with +a Redis-backed store and remove the threading.Lock. + +Public API +---------- + active_jobs : dict {job_id -> job_dict} + job_lock : Lock protects mutations to active_jobs + push_event() : append an SSE event to a job's event queue + run_compression_job(): worker — called in a daemon thread +""" + +import os +import subprocess +import threading +import time +from pathlib import Path + +from .notify import send_completion_email + +# --------------------------------------------------------------------------- +# Job store +# --------------------------------------------------------------------------- + +active_jobs: dict = {} +job_lock = threading.Lock() + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def push_event(job: dict, event: dict) -> None: + """Append *event* to job['events'] under the job's own lock.""" + with job['lock']: + job['events'].append(event) + + +def _choose_encoder(codec: str) -> tuple[str, bool]: + """ + Return (ffmpeg_encoder_name, is_hevc) for the given source codec string. + + HEVC / H.265 sources are re-encoded with libx265 to preserve efficiency. + Everything else uses libx264 (universally supported, always available). + """ + normalised = codec.lower() + is_hevc = normalised in ('hevc', 'h265', 'x265') + encoder = 'libx265' if is_hevc else 'libx264' + return encoder, is_hevc + + +def _build_ffmpeg_cmd( + src: str, + out: str, + video_k: int, + is_hevc: bool, + encoder: str, +) -> list[str]: + """ + Build the ffmpeg command list for one file. + + libx264 accepts -maxrate / -bufsize directly. + libx265 requires those same constraints via -x265-params because its + CLI option names differ from the generic ffmpeg flags. + Both use AAC audio at 128 kbps. + -movflags +faststart is only meaningful for MP4 containers but is + silently ignored for MKV / MOV / etc., so it is always included. + """ + if is_hevc: + vbv_maxrate = int(video_k * 1.5) + vbv_bufsize = video_k * 2 + encoder_opts = [ + '-c:v', encoder, + '-b:v', f'{video_k}k', + '-x265-params', f'vbv-maxrate={vbv_maxrate}:vbv-bufsize={vbv_bufsize}', + ] + else: + encoder_opts = [ + '-c:v', encoder, + '-b:v', f'{video_k}k', + '-maxrate', f'{int(video_k * 1.5)}k', + '-bufsize', f'{video_k * 2}k', + ] + + return [ + 'ffmpeg', '-y', '-i', src, + *encoder_opts, + '-c:a', 'aac', '-b:a', '128k', + '-movflags', '+faststart', + '-progress', 'pipe:1', '-nostats', + out, + ] + + +def _get_duration(filepath: str) -> float: + """Return the duration of *filepath* in seconds, or 0.0 on failure.""" + try: + probe = subprocess.run( + ['ffprobe', '-v', 'error', + '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', + filepath], + capture_output=True, text=True, timeout=30, + ) + return float(probe.stdout.strip()) if probe.stdout.strip() else 0.0 + except Exception: + return 0.0 + + +def _send_notification(job: dict, email_results: list[dict], cancelled: bool) -> None: + """Send email and push a 'notify' event regardless of outcome.""" + notify_email = job.get('notify_email', '') + if not notify_email: + return + ok, err = send_completion_email(notify_email, email_results, cancelled) + push_event(job, { + 'type': 'notify', + 'success': ok, + 'message': (f'Notification sent to {notify_email}.' if ok + else f'Could not send notification: {err}'), + }) + + +# --------------------------------------------------------------------------- +# Compression worker +# --------------------------------------------------------------------------- + +def run_compression_job(job_id: str) -> None: + """ + Worker function executed in a daemon thread for each compression job. + + Iterates over the file list, runs ffmpeg for each file, streams progress + events, and sends an email notification when finished (if requested). + """ + 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): + + # ── Cancellation check ──────────────────────────────────────────── + with job['lock']: + cancelled = job['cancelled'] + if cancelled: + _handle_cancel(job, idx) + return + + # ── Per-file setup ──────────────────────────────────────────────── + src_path = file_info['path'] + target_bitrate = file_info.get('target_bit_rate_bps', 1_000_000) + src_codec = file_info.get('codec', 'unknown') + p = Path(src_path) + out_path = str(p.parent / (p.stem + suffix + p.suffix)) + encoder, is_hevc = _choose_encoder(src_codec) + video_k = max(int(target_bitrate / 1000), 200) + + push_event(job, { + 'type': 'file_start', + 'index': idx, + 'total': total, + 'filename': p.name, + 'output': out_path, + 'encoder': encoder, + 'message': f'Compressing ({idx + 1}/{total}): {p.name} [{encoder}]', + }) + + duration_secs = _get_duration(src_path) + cmd = _build_ffmpeg_cmd(src_path, out_path, video_k, is_hevc, encoder) + + # ── Run ffmpeg ──────────────────────────────────────────────────── + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + with job['lock']: + job['process'] = proc + + _stream_progress(job, proc, idx, duration_secs) + proc.wait() + + with job['lock']: + cancelled = job['cancelled'] + + if cancelled: + _remove_partial(out_path) + _handle_cancel(job, idx) + return + + if proc.returncode != 0: + _push_file_error(job, idx, p.name, proc) + else: + _push_file_done(job, idx, p.name, out_path, file_info) + + 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}', + }) + + # ── All files processed ─────────────────────────────────────────────── + push_event(job, { + 'type': 'done', + 'message': f'All {total} file(s) processed.', + }) + with job['lock']: + job['status'] = 'done' + all_events = list(job['events']) + + completed = [{'status': 'done', **e} for e in all_events if e.get('type') == 'file_done'] + errored = [{'status': 'error', **e} for e in all_events if e.get('type') == 'file_error'] + _send_notification(job, completed + errored, cancelled=False) + + +# --------------------------------------------------------------------------- +# Private sub-helpers +# --------------------------------------------------------------------------- + +def _stream_progress( + job: dict, + proc: subprocess.Popen, + idx: int, + duration_secs: float, +) -> None: + """Read ffmpeg's -progress output and push progress events.""" + for line in proc.stdout: + with job['lock']: + if job['cancelled']: + proc.terminate() + return + + 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, + }) + + +def _remove_partial(path: str) -> None: + try: + if os.path.exists(path): + os.remove(path) + except OSError: + pass + + +def _handle_cancel(job: dict, idx: int) -> None: + """Push cancel event, set status, send notification for cancelled run.""" + push_event(job, {'type': 'cancelled', 'message': 'Compression cancelled by user'}) + with job['lock']: + job['status'] = 'cancelled' + all_events = list(job['events']) + + completed = [{'status': 'done', **e} for e in all_events if e.get('type') == 'file_done'] + errored = [{'status': 'error', **e} for e in all_events if e.get('type') == 'file_error'] + _send_notification(job, completed + errored, cancelled=True) + + +def _push_file_error( + job: dict, + idx: int, + filename: str, + proc: subprocess.Popen, +) -> None: + try: + tail = proc.stderr.read()[-500:] + except Exception: + tail = '' + push_event(job, { + 'type': 'file_error', + 'index': idx, + 'filename': filename, + 'message': f'ffmpeg exited with code {proc.returncode}', + 'detail': tail, + }) + + +def _push_file_done( + job: dict, + idx: int, + filename: str, + out_path: str, + file_info: dict, +) -> None: + 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': filename, + 'output': out_path, + 'output_size_gb': out_gb, + 'reduction_pct': reduction, + 'message': f'Completed: {filename} → saved {reduction}%', + }) diff --git a/app/media.py b/app/media.py new file mode 100644 index 0000000..83d1b3f --- /dev/null +++ b/app/media.py @@ -0,0 +1,140 @@ +""" +app/media.py +============ +File-system scanning and FFprobe metadata helpers. + +Public API +---------- + VIDEO_EXTENSIONS : frozenset of lowercase video file suffixes + get_video_info() : run ffprobe on a single file, return a metadata dict + list_video_files(): walk a directory tree and return files above a size floor +""" + +import json +import os +import subprocess +from pathlib import Path + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +VIDEO_EXTENSIONS: frozenset[str] = frozenset({ + '.mp4', '.mkv', '.mov', '.avi', '.wmv', '.flv', + '.webm', '.m4v', '.mpg', '.mpeg', '.ts', '.mts', + '.m2ts', '.vob', '.ogv', '.3gp', '.3g2', +}) + +# --------------------------------------------------------------------------- +# FFprobe helper +# --------------------------------------------------------------------------- + +def get_video_info(filepath: str) -> dict | None: + """ + Use ffprobe to get duration, total bitrate, codec, and dimensions. + + Returns a dict with the keys below, or None if ffprobe fails. + + Bitrate resolution order (handles HEVC/MKV where the stream-level + bit_rate field is absent): + 1. Stream-level bit_rate — present for H.264/MP4, often missing for HEVC + 2. Format-level bit_rate — reliable for all containers + 3. Derived from size / duration — final fallback + + Returned keys + ------------- + duration, bit_rate_bps, bit_rate_mbps, + target_bit_rate_bps, target_bit_rate_mbps, + size_bytes, size_gb, codec, width, height + """ + cmd = [ + 'ffprobe', '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', + 'format=duration,bit_rate,size:stream=codec_name,width,height,bit_rate', + '-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)) + size_bytes = int(fmt.get('size', 0)) + codec = stream.get('codec_name', 'unknown') + width = stream.get('width', 0) + height = stream.get('height', 0) + + stream_br = int(stream.get('bit_rate') or 0) + format_br = int(fmt.get('bit_rate') or 0) + if stream_br > 0: + bit_rate = stream_br + elif format_br > 0: + bit_rate = format_br + elif duration > 0: + bit_rate = int((size_bytes * 8) / duration) + else: + bit_rate = 0 + + # Target ≈ 1/3 of the total bitrate; reserve 128 kbps for audio. + 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 + + +# --------------------------------------------------------------------------- +# Directory scanner +# --------------------------------------------------------------------------- + +def list_video_files(directory: Path, min_size_gb: float) -> list[dict]: + """ + Recursively walk *directory* and return video files larger than + *min_size_gb* gigabytes. + + Each entry is a dict with: path, name, size_bytes, size_gb. + Raises PermissionError if the root directory is inaccessible. + """ + min_bytes = min_size_gb * (1024 ** 3) + results: list[dict] = [] + + 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 diff --git a/app/notify.py b/app/notify.py new file mode 100644 index 0000000..35d2b61 --- /dev/null +++ b/app/notify.py @@ -0,0 +1,329 @@ +""" +app/notify.py +============= +Email notification helper for compression job completion. + +Delivery uses SMTP settings stored in SQLite (via app.db). +If no SMTP settings have been configured, the send call returns an +informative error rather than silently failing. + +Public API +---------- + get_smtp_config() -> dict with all SMTP fields (safe for the UI) + send_completion_email(to, results, cancelled) -> (ok: bool, error: str) + +SMTP settings keys (stored in the 'settings' table) +---------------------------------------------------- + smtp_host — hostname or IP of the SMTP server + smtp_port — port number (str) + smtp_security — 'tls' (STARTTLS) | 'ssl' (SMTPS) | 'none' + smtp_user — login username (optional) + smtp_password — login password (optional, stored as-is) + smtp_from — From: address used in sent mail +""" + +import smtplib +import socket +import ssl +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import formatdate, make_msgid + +from .db import get_setting + +# --------------------------------------------------------------------------- +# SMTP config helper +# --------------------------------------------------------------------------- + +def get_smtp_config() -> dict: + """ + Read SMTP settings from the database and return them as a dict. + The password field is replaced with a placeholder so this dict is + safe to serialise and send to the browser. + + Returns + ------- + { + host, port, security, user, from_addr, + password_set: bool (True if a password is stored) + } + """ + return { + 'host': get_setting('smtp_host') or '', + 'port': get_setting('smtp_port') or '587', + 'security': get_setting('smtp_security') or 'tls', + 'user': get_setting('smtp_user') or '', + 'from_addr': get_setting('smtp_from') or '', + 'password_set': bool(get_setting('smtp_password')), + } + + +def _load_smtp_config() -> dict: + """Load full config including the raw password (server-side only).""" + return { + 'host': get_setting('smtp_host') or '', + 'port': int(get_setting('smtp_port') or 587), + 'security': get_setting('smtp_security') or 'tls', + 'user': get_setting('smtp_user') or '', + 'password': get_setting('smtp_password') or '', + 'from_addr': get_setting('smtp_from') or '', + } + + +# --------------------------------------------------------------------------- +# Send helper +# --------------------------------------------------------------------------- + +def send_completion_email( + to_address: str, + results: list[dict], + cancelled: bool, +) -> tuple[bool, str]: + """ + Send a job-completion notification to *to_address* using the SMTP + settings stored in SQLite. + + Returns (success, error_message). + """ + if not to_address or '@' not in to_address: + return False, 'Invalid recipient email address' + + cfg = _load_smtp_config() + + if not cfg['host']: + return False, ( + 'No SMTP server configured. ' + 'Please add your SMTP settings in the ⚙ Settings panel.' + ) + if not cfg['from_addr']: + return False, ( + 'No From address configured. ' + 'Please add your SMTP settings in the ⚙ Settings panel.' + ) + + # ── Build message ───────────────────────────────────────────────────── + done_files = [r for r in results if r.get('status') == 'done'] + error_files = [r for r in results if r.get('status') == 'error'] + total = len(results) + hostname = socket.getfqdn() + + if cancelled: + subject = (f'VideoPress: compression cancelled ' + f'({len(done_files)}/{total} completed) on {hostname}') + elif error_files: + subject = (f'VideoPress: compression complete with ' + f'{len(error_files)} error(s) on {hostname}') + else: + subject = (f'VideoPress: compression complete — ' + f'{total} file(s) processed on {hostname}') + + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = cfg['from_addr'] + msg['To'] = to_address + msg['Date'] = formatdate(localtime=True) + msg['Message-ID'] = make_msgid(domain=hostname) + msg.attach(MIMEText( + _build_plain(hostname, cancelled, done_files, error_files, total), + 'plain', 'utf-8', + )) + msg.attach(MIMEText( + _build_html(hostname, subject, cancelled, done_files, error_files, total), + 'html', 'utf-8', + )) + + # ── Connect and send ────────────────────────────────────────────────── + try: + security = cfg['security'].lower() + host = cfg['host'] + port = cfg['port'] + + if security == 'ssl': + # SMTPS — wrap in SSL from the start (port 465 typically) + context = ssl.create_default_context() + server = smtplib.SMTP_SSL(host, port, context=context, timeout=15) + else: + # Plain or STARTTLS (port 587 typically) + server = smtplib.SMTP(host, port, timeout=15) + server.ehlo() + if security == 'tls': + context = ssl.create_default_context() + server.starttls(context=context) + server.ehlo() + + with server: + if cfg['user'] and cfg['password']: + server.login(cfg['user'], cfg['password']) + server.sendmail(cfg['from_addr'], [to_address], msg.as_bytes()) + + return True, '' + + except smtplib.SMTPAuthenticationError: + return False, ( + 'Authentication failed — check your username and password. ' + 'For Gmail/Google Workspace, use an App Password rather than ' + 'your account password.' + ) + except smtplib.SMTPConnectError as exc: + return False, ( + f'Could not connect to {host}:{port}. ' + f'Check the host, port, and security setting. ({exc})' + ) + except smtplib.SMTPRecipientsRefused as exc: + refused = ', '.join(exc.recipients.keys()) + return False, f'Recipient address rejected by server: {refused}' + except smtplib.SMTPSenderRefused as exc: + return False, ( + f'From address "{cfg["from_addr"]}" was rejected by the server. ' + f'Ensure it matches your authenticated account. ({exc.smtp_error.decode(errors="replace")})' + ) + except smtplib.SMTPException as exc: + return False, f'SMTP error: {exc}' + except ssl.SSLError as exc: + return False, ( + f'SSL/TLS error connecting to {host}:{port} — ' + f'try changing the Security setting. ({exc})' + ) + except TimeoutError: + return False, ( + f'Connection to {host}:{port} timed out. ' + f'Check the host and port, and that the server is reachable.' + ) + except OSError as exc: + return False, ( + f'Network error connecting to {host}:{port} — {exc}. ' + f'Check the hostname and that the server is reachable.' + ) + except Exception as exc: + return False, f'Unexpected error: {exc}' + + +# --------------------------------------------------------------------------- +# Email body builders +# --------------------------------------------------------------------------- + +def _build_plain(hostname, cancelled, done_files, error_files, total) -> str: + lines = [ + 'VideoPress Compression Report', + f'Host : {hostname}', + f'Status : {"Cancelled" if cancelled else "Complete"}', + f'Files : {len(done_files)} succeeded, {len(error_files)} failed, {total} total', + '', + ] + if done_files: + lines.append('Completed files:') + for r in done_files: + lines.append( + f" ✓ {r.get('filename','?')} " + f"({r.get('output_size_gb','?')} GB, " + f"-{r.get('reduction_pct','?')}%)" + ) + lines.append('') + if error_files: + lines.append('Failed files:') + for r in error_files: + lines.append( + f" ✗ {r.get('filename','?')} " + f"— {r.get('message','unknown error')}" + ) + lines.append('') + lines += ['—', 'Sent by VideoPress FFmpeg Compressor'] + return '\n'.join(lines) + + +def _build_html(hostname, subject, cancelled, done_files, error_files, total) -> str: + status_colour = ( + '#166534' if not cancelled and not error_files + else '#92400e' if cancelled + else '#991b1b' + ) + status_label = ( + 'Cancelled' if cancelled + else 'Complete ✓' if not error_files + else 'Complete with errors' + ) + + def file_rows(files, icon, bg): + rows = '' + for r in files: + detail = ( + f"{r.get('output_size_gb','?')} GB  ·  " + f"-{r.get('reduction_pct','?')}%" + if r.get('status') == 'done' + else r.get('message', 'unknown error') + ) + rows += ( + f'' + f'{icon}' + f'' + f'{r.get("filename","?")}' + f'{detail}' + f'' + ) + return rows + + done_rows = file_rows(done_files, '✅', '#f0fdf4') + error_rows = file_rows(error_files, '❌', '#fef2f2') + + error_cell = ( + f'
Failed
' + f'
' + f'{len(error_files)}
' + ) if error_files else '' + + done_section = ( + f'

Completed

' + f'' + f'{done_rows}
' + ) if done_files else '' + + error_section = ( + f'

Errors

' + f'' + f'{error_rows}
' + ) if error_files else '' + + return f""" + +{subject} + +
+
+ + + VideoPress + +
+
+

Compression Run Report

+

Host: {hostname}

+
+
+
Status
+
{status_label}
+
+
+
Total
+
{total}
+
+
+
Succeeded
+
{len(done_files)}
+
+ {error_cell} +
+ {done_section} + {error_section} +
+

Sent by VideoPress FFmpeg Compressor

+
+
+ +""" diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..5b2f04f --- /dev/null +++ b/app/routes.py @@ -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/ → 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'}) diff --git a/docker-compose.yml b/docker-compose.yml index e4f9aba..4ff7f93 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,13 +13,20 @@ services: videopress: - # build: - # context: . - # dockerfile: Dockerfile + build: + context: . + dockerfile: Dockerfile # ── Alternatively, use a pre-built image: ─────────────────────────────── - image: bmcgonag/videopress:latest + # image: videopress:latest + container_name: videopress + + # Run as UID:GID 1000:1000 (matches the 'appuser' created in the Dockerfile). + # This ensures the container can write to bind-mounted host directories + # that are owned by UID 1000. + user: "1000:1000" + restart: unless-stopped # ── Port mapping ───────────────────────────────────────────────────────── @@ -38,8 +45,19 @@ services: # 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 + # + # IMPORTANT — before first run, create the data directory on the HOST + # and give it to UID 1000 (the container's non-root user) so SQLite can + # write the settings database: + # + # mkdir -p ./data + # chown 1000:1000 ./data + # + # If you skip this step Docker will create ./data as root and the + # container will fail to start with "unable to open database file". volumes: - ${MEDIA_HOST_PATH:-/path/to/your/videos}:/media + - ./data:/data # ── Environment variables ───────────────────────────────────────────────── environment: @@ -47,6 +65,10 @@ services: # Must match the right-hand side of the volume mount above. MEDIA_ROOT: /media + # SQLite database path inside the container. + # Must match the right-hand side of the ./data:/data volume mount. + DB_PATH: /data/videopress.db + # TCP port Gunicorn listens on (must match EXPOSE in Dockerfile and # the right-hand side of the ports mapping above). PORT: 8080 @@ -57,15 +79,14 @@ services: # ── Resource limits (optional — uncomment to enable) ───────────────────── # Compressing large video files is CPU-intensive. Limits prevent the # container from starving other workloads on the host. - # Feel free to comment out this whole section if youw ant it to run full blast. - deploy: - resources: - limits: - cpus: '4' - memory: 2G - reservations: - cpus: '1' - memory: 512M + # deploy: + # resources: + # limits: + # cpus: '4' + # memory: 2G + # reservations: + # cpus: '1' + # memory: 512M # ── Health check ────────────────────────────────────────────────────────── healthcheck: diff --git a/run.py b/run.py new file mode 100644 index 0000000..472dc76 --- /dev/null +++ b/run.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +""" +run.py — Development server entry point. + +Usage: + python3 run.py [PORT] + +Do NOT use this in production — use Gunicorn via wsgi.py instead. +""" + +import sys +from app import create_app +from app.config import MEDIA_ROOT + +if __name__ == '__main__': + 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" WARNING : dev server only — use Gunicorn for production") + print(f"{'='*60}\n") + create_app().run(host='0.0.0.0', port=port, debug=False, threaded=True) diff --git a/start.sh b/start.sh old mode 100644 new mode 100755 index a2ad87f..4605f82 --- a/start.sh +++ b/start.sh @@ -50,11 +50,11 @@ if [[ "$MODE" == "prod" ]]; then echo " Press Ctrl+C to stop." echo "============================================================" echo "" - PORT="$PORT" exec gunicorn -c gunicorn.conf.py wsgi:app + PORT="$PORT" exec gunicorn -c gunicorn.conf.py wsgi:application 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" + exec python3 run.py "$PORT" fi diff --git a/static/css/main.css b/static/css/main.css index c23a460..4e7480d 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1202,7 +1202,107 @@ body { color: rgba(245,245,242,0.65); } -/* ── Animations ─────────────────────────────────────────────── */ +/* ── Notification opt-in ────────────────────────────────────── */ +.notify-group { + display: flex; + flex-direction: column; + gap: var(--space-sm); + flex: 1; +} + +.notify-checkbox-row { + display: flex; + align-items: center; + gap: var(--space-sm); +} + +.notify-checkbox { + width: 18px; + height: 18px; + min-width: 18px; + min-height: 18px; + cursor: pointer; + accent-color: var(--accent); + flex-shrink: 0; +} + +.notify-label { + font-size: 0.9rem; + color: var(--text-primary); + cursor: pointer; + font-weight: 500; + line-height: 1.3; +} + +.notify-email-row { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding-left: 26px; /* indent under checkbox */ + animation: slide-down 180ms ease; +} + +.notify-email-row[hidden] { + display: none !important; +} + +@keyframes slide-down { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.notify-email-label { + margin-bottom: 0; +} + +.notify-email-input { + max-width: 340px; +} + +.notify-divider { + width: 1px; + background: var(--border-base); + align-self: stretch; + margin: 0 var(--space-sm); + flex-shrink: 0; +} + +/* Notification send status shown in progress footer */ +.notify-status { + font-size: 0.85rem; + display: inline-flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-md); + border-radius: var(--radius-pill); + font-weight: 600; +} + +.notify-status[hidden] { + display: none !important; +} + +.notify-status.ok { + background: rgba(22, 101, 52, 0.10); + color: var(--text-success); + border: 1px solid rgba(22, 101, 52, 0.25); +} + +.notify-status.fail { + background: rgba(185, 28, 28, 0.10); + color: var(--text-danger); + border: 1px solid rgba(185, 28, 28, 0.25); +} + +[data-theme="dark"] .notify-status.ok { + background: rgba(134, 239, 172, 0.10); + border-color: rgba(134, 239, 172, 0.25); +} + +[data-theme="dark"] .notify-status.fail { + background: rgba(252, 165, 165, 0.10); + border-color: rgba(252, 165, 165, 0.25); +} @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } @@ -1212,6 +1312,60 @@ body { animation: pulse 1.8s ease infinite; } +/* ── Stream-lost banner ─────────────────────────────────────── */ +.stream-lost-banner { + display: flex; + align-items: center; + gap: var(--space-md); + flex-wrap: wrap; + background: rgba(180, 100, 0, 0.10); + border: 1.5px solid rgba(180, 100, 0, 0.35); + border-radius: var(--radius-md); + padding: var(--space-md) var(--space-lg); + margin-bottom: var(--space-lg); + color: #7a4500; +} + +[data-theme="dark"] .stream-lost-banner { + background: rgba(251, 191, 36, 0.10); + border-color: rgba(251, 191, 36, 0.30); + color: #fbbf24; +} + +.stream-lost-banner[hidden] { + display: none !important; +} + +.banner-icon { + font-size: 1.2rem; + flex-shrink: 0; +} + +.banner-text { + flex: 1; + font-size: 0.88rem; + font-weight: 500; + line-height: 1.4; +} + +/* Reconnect button sits in the card title row */ +.card-title .reconnect-btn { + margin-left: auto; + font-size: 0.78rem; + padding: 5px 12px; + min-height: 32px; + animation: pulse-reconnect 1.8s ease infinite; +} + +.reconnect-btn[hidden] { + display: none !important; +} + +@keyframes pulse-reconnect { + 0%, 100% { border-color: var(--btn-outline-border); } + 50% { border-color: var(--accent); color: var(--accent); } +} + /* ── Responsive ─────────────────────────────────────────────── */ @media (max-width: 768px) { .app-main { @@ -1258,3 +1412,123 @@ body { [data-theme="dark"] .file-table th { color: var(--text-primary); } + +/* ── Settings modal ─────────────────────────────────────────── */ +.settings-panel { + max-width: 560px; + max-height: 90vh; + display: flex; + flex-direction: column; +} + +.settings-body { + flex: 1; + overflow-y: auto; + padding: var(--space-lg) var(--space-xl); +} + +.settings-body::-webkit-scrollbar { width: 6px; } +.settings-body::-webkit-scrollbar-track { background: var(--bg-card2); } +.settings-body::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; } + +.settings-intro { + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: var(--space-lg); + line-height: 1.5; +} + +.settings-grid { + display: flex; + flex-direction: column; + gap: var(--space-lg); +} + +.settings-row-2 { + display: grid; + grid-template-columns: 100px 1fr; + gap: var(--space-md); +} + +.settings-divider-above { + border-top: 1px solid var(--border-base); + padding-top: var(--space-lg); + margin-top: var(--space-sm); +} + +.settings-save-status { + font-size: 0.82rem; + text-align: center; + min-height: 1.4em; + padding: var(--space-xs) var(--space-xl) var(--space-md); + color: var(--text-muted); +} + +.settings-test-result { + min-height: 1.4em; +} + +/* Password row with toggle */ +.password-row { + display: flex; + gap: var(--space-sm); + align-items: center; +} + +.password-row .text-input { flex: 1; } + +.btn-icon-inline { + width: 40px; + height: 40px; + min-width: 40px; + min-height: 40px; + border: 1.5px solid var(--border-input); + border-radius: var(--radius-md); + background: var(--bg-input); + color: var(--text-muted); + font-size: 1rem; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background var(--transition-fast), border-color var(--transition-fast); + flex-shrink: 0; +} + +.btn-icon-inline:hover { + background: var(--bg-row-alt); + border-color: var(--border-strong); +} + +/* Select input to match text-input style */ +.select-input { + cursor: pointer; +} + +/* Inline button link (used in hint text) */ +.btn-link { + background: none; + border: none; + padding: 0; + color: var(--text-link); + font: inherit; + font-size: inherit; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.btn-link:hover { + color: var(--accent); +} + +/* Settings save status colours */ +.settings-save-status.ok { color: var(--text-success); } +.settings-save-status.fail { color: var(--text-danger); } + +/* SMTP not configured warning on the notify row */ +.smtp-warn { + color: var(--accent); + font-size: 0.78rem; + font-weight: 600; +} diff --git a/static/js/app.js b/static/js/app.js index d9602c2..377bcb8 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,705 +1,38 @@ /** - * VideoPress — Frontend Application - * Handles all UI interactions, API calls, and SSE progress streaming. - * WCAG 2.2 compliant, fully functional, no stubs. + * app.js — VideoPress entry point + * -------------------------------- + * Imports every feature module and calls its init function. + * No application logic lives here — this file is intentionally thin. + * + * Module layout + * ------------- + * utils.js Pure helpers: esc(), fmtTime(), pad() + * state.js Shared state object, DOM refs (els), announce() + * theme.js Dark / light mode toggle + * browser.js Server-side directory browser modal + * scan.js /api/scan, file selection table, select-all controls + * progress.js Progress bars, results card, stream-lost banner + * stream.js SSE stream, reconnect, snapshot restore (applySnapshot) + * compress.js Start / cancel / restart compression, notification opt-in + * session.js Page-load restore via /api/compress/active + * settings.js SMTP email settings modal */ 'use strict'; -// ─── State ────────────────────────────────────────────────────────────────── -const state = { - scannedFiles: [], // enriched file objects from API - selectedPaths: new Set(), // paths of selected files - currentJobId: null, - eventSource: null, - compressionResults: [], - browserPath: '/', -}; +import { initTheme } from './modules/theme.js'; +import { initBrowser } from './modules/browser.js'; +import { initScan } from './modules/scan.js'; +import { initStreamControls } from './modules/stream.js'; +import { initCompress } from './modules/compress.js'; +import { tryRestoreSession } from './modules/session.js'; +import { initSettings } from './modules/settings.js'; -// ─── 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 = '

Loading…

'; - 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 += ` - `; - } - - if (data.entries.length === 0 && !data.parent) { - html += '

No accessible directories found.

'; - } - - for (const entry of data.entries) { - if (!entry.is_dir) continue; - html += ` - `; - } - - if (html === '') { - html = '

No subdirectories found.

'; - } - - 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 = ``; - } -} - -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 = ' 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').toLowerCase(); - const pathDir = f.path.replace(f.name, ''); - - // Normalise codec label and pick a CSS modifier for the badge colour - const isHevc = ['hevc', 'h265', 'x265'].includes(codec); - const isH264 = ['h264', 'avc', 'x264'].includes(codec); - const codecLabel = isHevc ? 'H.265 / HEVC' - : isH264 ? 'H.264 / AVC' - : codec.toUpperCase(); - const codecMod = isHevc ? 'hevc' : isH264 ? 'h264' : ''; - - html += ` - - - - - - - - - ${sizeFmt} GB - - - ${escHtml(curBitrate)} - - - ${escHtml(tgtBitrate)} - - - ${escHtml(codecLabel)} - - `; - }); - - 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, - codec: f.codec || 'unknown', - })), - 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 = ' 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 = ' 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 += ` -
-
- ${escHtml(f.name)} - Waiting -
-
-
-
-
- -
-
-
`; - }); - - 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 += `

- Compression was cancelled. Any completed files are listed below. -

`; - } - - if (results.length === 0 && finalStatus === 'cancelled') { - html += '

No files were completed before cancellation.

'; - } - - results.forEach(r => { - if (r.status === 'done') { - html += ` -
- -
-
${escHtml(r.filename)}
-
→ ${escHtml(r.output || '')}
-
- -${r.reduction_pct}% -
`; - } else if (r.status === 'error') { - html += ` -
- -
-
${escHtml(r.filename)}
-
${escHtml(r.message)}
-
-
`; - } - }); - - if (html === '') { - html = '

No results to display.

'; - } - - 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 = ' 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, '''); -} - -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(); +initBrowser(); +initScan(); +initStreamControls(); +initCompress(); +initSettings(); + +tryRestoreSession(); diff --git a/static/js/modules/browser.js b/static/js/modules/browser.js new file mode 100644 index 0000000..01b4053 --- /dev/null +++ b/static/js/modules/browser.js @@ -0,0 +1,111 @@ +/** + * browser.js + * ---------- + * Server-side directory browser modal. + * + * Fetches directory listings from /api/browse and renders them inside the + * modal panel. The user navigates the server filesystem and selects a + * directory to populate the scan path input. + * + * Exports + * ------- + * initBrowser() — attach all event listeners; call once at startup + */ + +import { state, els, announce } from './state.js'; +import { esc } from './utils.js'; + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +async function loadBrowserPath(path) { + els.browserList.innerHTML = + '

Loading…

'; + 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 = ''; + + if (data.parent !== null) { + html += ` + `; + } + + for (const entry of data.entries) { + if (!entry.is_dir) continue; + html += ` + `; + } + + if (!html) html = '

No subdirectories found.

'; + els.browserList.innerHTML = html; + + els.browserList.querySelectorAll('.browser-item').forEach(btn => { + btn.addEventListener('click', () => loadBrowserPath(btn.dataset.path)); + btn.addEventListener('keydown', e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + loadBrowserPath(btn.dataset.path); + } + }); + }); + + } catch (err) { + els.browserList.innerHTML = + ``; + } +} + +function openBrowser() { + els.browserModal.hidden = false; + document.body.style.overflow = 'hidden'; + loadBrowserPath(els.dirInput.value || '/'); + els.closeBrowser.focus(); + announce('Directory browser opened'); +} + +function closeBrowser() { + els.browserModal.hidden = true; + document.body.style.overflow = ''; + els.browseBtn.focus(); + announce('Directory browser closed'); +} + +// ─── Public init ───────────────────────────────────────────────────────────── + +/** + * Attach all event listeners for the directory browser modal. + * Call once during app initialisation. + */ +export function initBrowser() { + els.browseBtn.addEventListener('click', openBrowser); + els.closeBrowser.addEventListener('click', closeBrowser); + els.browserCancel.addEventListener('click', closeBrowser); + + els.browserModal.addEventListener('click', e => { + if (e.target === els.browserModal) closeBrowser(); + }); + + els.browserSelect.addEventListener('click', () => { + els.dirInput.value = state.browserPath; + closeBrowser(); + announce(`Directory selected: ${state.browserPath}`); + }); + + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && !els.browserModal.hidden) closeBrowser(); + }); +} diff --git a/static/js/modules/compress.js b/static/js/modules/compress.js new file mode 100644 index 0000000..349f406 --- /dev/null +++ b/static/js/modules/compress.js @@ -0,0 +1,195 @@ +/** + * compress.js + * ----------- + * Compression job lifecycle: start, notification opt-in, cancel, and restart. + * + * Exports + * ------- + * initCompress() — attach all event listeners; call once at startup + */ + +import { state, els, announce } from './state.js'; +import { setupProgressSection, showResults } from './progress.js'; +import { startProgressStream } from './stream.js'; +import { smtpIsConfigured } from './settings.js'; + +// ─── Public init ───────────────────────────────────────────────────────────── + +/** + * Attach event listeners for: + * - Notification checkbox toggle + * - "Compress Selected Files" button + * - "Cancel Compression" button + * - "Start New Session" (restart) button + * + * Call once during app initialisation. + */ +export function initCompress() { + _initNotifyToggle(); + _initCompressButton(); + _initCancelButton(); + _initRestartButton(); +} + +// ─── Notification opt-in ───────────────────────────────────────────────────── + +function _initNotifyToggle() { + els.notifyChk.addEventListener('change', () => { + const show = els.notifyChk.checked; + els.notifyEmailRow.hidden = !show; + els.notifyEmail.setAttribute('aria-required', show ? 'true' : 'false'); + const warn = document.getElementById('smtp-not-configured-warn'); + if (show) { + els.notifyEmail.focus(); + if (warn) warn.hidden = smtpIsConfigured(); + } else { + els.notifyEmail.value = ''; + if (warn) warn.hidden = true; + } + }); +} + +// ─── Start compression ──────────────────────────────────────────────────────── + +function _initCompressButton() { + els.compressBtn.addEventListener('click', async () => { + const selectedFiles = state.scannedFiles.filter( + f => state.selectedPaths.has(f.path), + ); + if (!selectedFiles.length) return; + + const suffix = els.suffixInput.value.trim() || '_new'; + const notifyEmail = els.notifyChk.checked ? els.notifyEmail.value.trim() : ''; + + // Client-side email validation + if (els.notifyChk.checked) { + if (!notifyEmail) { + els.notifyEmail.setCustomValidity('Please enter your email address.'); + els.notifyEmail.reportValidity(); + return; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(notifyEmail)) { + els.notifyEmail.setCustomValidity('Please enter a valid email address.'); + els.notifyEmail.reportValidity(); + return; + } + els.notifyEmail.setCustomValidity(''); + } + + 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({ + files: selectedFiles.map(f => ({ + path: f.path, + size_bytes: f.size_bytes, + target_bit_rate_bps: f.target_bit_rate_bps || 1_000_000, + codec: f.codec || 'unknown', + })), + suffix, + notify_email: notifyEmail, + }), + }); + const data = await resp.json(); + + if (!resp.ok) { + alert(`Failed to start compression: ${data.error}`); + _resetCompressBtn(); + return; + } + + state.currentJobId = data.job_id; + state.seenEventCount = 0; + state.compressionResults = []; + sessionStorage.setItem('vp-job-id', data.job_id); + + setupProgressSection(selectedFiles); + els.sectionProgress.hidden = false; + els.sectionProgress.scrollIntoView({ behavior: 'smooth', block: 'start' }); + announce(`Compression started for ${selectedFiles.length} file(s).`); + startProgressStream(data.job_id, selectedFiles); + + } catch (err) { + alert(`Error: ${err.message}`); + _resetCompressBtn(); + } + }); +} + +function _resetCompressBtn() { + els.compressBtn.disabled = false; + els.compressBtn.innerHTML = + ' Compress Selected Files'; +} + +// ─── Cancel ─────────────────────────────────────────────────────────────────── + +function _initCancelButton() { + els.cancelBtn.addEventListener('click', async () => { + if (!state.currentJobId) return; + if (!confirm( + 'Cancel all compression operations? ' + + 'Any file currently being processed will be deleted.', + )) 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); + } + }); +} + +// ─── Restart (new session) ──────────────────────────────────────────────────── + +function _initRestartButton() { + els.restartBtn.addEventListener('click', () => { + // Clear state + state.scannedFiles = []; + state.selectedPaths.clear(); + state.currentJobId = null; + state.compressionResults = []; + state.seenEventCount = 0; + + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } + if (state.reconnectTimer) { + clearTimeout(state.reconnectTimer); + state.reconnectTimer = null; + } + sessionStorage.removeItem('vp-job-id'); + + // Reset UI + els.sectionFiles.hidden = true; + els.sectionProgress.hidden = true; + els.sectionResults.hidden = true; + els.fileTbody.innerHTML = ''; + els.fileProgressList.innerHTML = ''; + els.scanStatus.textContent = ''; + els.notifyChk.checked = false; + els.notifyEmailRow.hidden = true; + els.notifyEmail.value = ''; + els.notifyStatus.hidden = true; + els.notifyStatus.textContent = ''; + els.streamLostBanner.hidden = true; + els.reconnectBtn.hidden = true; + els.cancelBtn.disabled = false; + els.cancelBtn.textContent = '✕ Cancel Compression'; + _resetCompressBtn(); + + document.getElementById('section-config') + .scrollIntoView({ behavior: 'smooth' }); + els.dirInput.focus(); + announce('Session reset. Ready to scan again.'); + }); +} diff --git a/static/js/modules/progress.js b/static/js/modules/progress.js new file mode 100644 index 0000000..6ac4424 --- /dev/null +++ b/static/js/modules/progress.js @@ -0,0 +1,172 @@ +/** + * progress.js + * ----------- + * Progress section DOM management: per-file bars, overall bar, and + * the final results summary. + * + * These functions are called by both stream.js (live SSE updates) and + * session.js (snapshot restore on reconnect / page reload). + * + * Exports + * ------- + * setupProgressSection(files) + * setOverallProgress(pct) + * updateFileProgress(idx, pct, statusClass, statusText, detail, detailClass) + * showStreamLost() + * hideStreamLost() + * showResults(finalStatus) + */ + +import { state, els, announce } from './state.js'; +import { esc } from './utils.js'; + +// ─── Progress section setup ─────────────────────────────────────────────────── + +/** + * Render the initial per-file progress items and reset counters. + * Called when a new compression job starts or when the DOM needs to be + * rebuilt after a full page reload. + * @param {Array} files — file objects from the job (name, path, …) + */ +export function setupProgressSection(files) { + els.progTotal.textContent = files.length; + els.progDone.textContent = '0'; + els.progStatus.textContent = 'Running'; + setOverallProgress(0); + + let html = ''; + files.forEach((f, idx) => { + html += ` +
+
+ ${esc(f.name)} + Waiting +
+
+
+
+
+ +
+
+
`; + }); + els.fileProgressList.innerHTML = html; +} + +// ─── Bar helpers ───────────────────────────────────────────────────────────── + +/** + * Update the overall progress bar. + * @param {number} pct 0–100 + */ +export function setOverallProgress(pct) { + const p = Math.min(100, Math.round(pct)); + els.overallBarFill.style.width = `${p}%`; + els.overallBar.setAttribute('aria-valuenow', p); + els.overallPct.textContent = `${p}%`; +} + +/** + * Update a single file's progress bar, status badge, and detail text. + * + * @param {number} idx — file index (0-based) + * @param {number} pct — 0–100 + * @param {string} statusClass — 'waiting' | 'running' | 'done' | 'error' + * @param {string} statusText — visible badge text + * @param {string} [detail] — optional sub-text (elapsed time, size saved…) + * @param {string} [detailClass]— optional class applied to the detail element + */ +export function updateFileProgress(idx, pct, statusClass, statusText, detail, detailClass) { + const fill = document.getElementById(`fpfill-${idx}`); + const bar = document.getElementById(`fpbar-${idx}`); + const pctEl = document.getElementById(`fppct-${idx}`); + const status = document.getElementById(`fps-${idx}`); + const item = document.getElementById(`fpi-${idx}`); + const det = document.getElementById(`fpdetail-${idx}`); + if (!fill) return; + + const p = Math.min(100, 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}`; + fill.classList.toggle('active', statusClass === 'running'); + + if (detail !== undefined) { + det.textContent = detail; + det.className = `fp-detail ${detailClass || ''}`; + } +} + +// ─── Stream-lost banner ─────────────────────────────────────────────────────── + +/** Show the disconnection warning banner and reveal the Reconnect button. */ +export function showStreamLost() { + els.streamLostBanner.hidden = false; + els.reconnectBtn.hidden = false; + els.progStatus.textContent = 'Disconnected'; + announce('Live progress stream disconnected. Use Reconnect to resume.'); +} + +/** Hide the disconnection warning banner and Reconnect button. */ +export function hideStreamLost() { + els.streamLostBanner.hidden = true; + els.reconnectBtn.hidden = true; +} + +// ─── Results summary ───────────────────────────────────────────────────────── + +/** + * Render the Step 4 results card and scroll it into view. + * @param {'done'|'cancelled'} finalStatus + */ +export function showResults(finalStatus) { + const results = state.compressionResults; + let html = ''; + + if (finalStatus === 'cancelled') { + html += `

+ Compression was cancelled. Completed files are listed below.

`; + } + + if (!results.length && finalStatus === 'cancelled') { + html += '

No files were completed before cancellation.

'; + } + + results.forEach(r => { + if (r.status === 'done') { + html += ` +
+ +
+
${esc(r.filename)}
+
→ ${esc(r.output || '')}
+
+ -${r.reduction_pct}% +
`; + } else if (r.status === 'error') { + html += ` +
+ +
+
${esc(r.filename)}
+
+ ${esc(r.message)} +
+
+
`; + } + }); + + if (!html) html = '

No results to display.

'; + els.resultsContent.innerHTML = html; + els.sectionResults.hidden = false; + els.sectionResults.scrollIntoView({ behavior: 'smooth', block: 'start' }); +} diff --git a/static/js/modules/scan.js b/static/js/modules/scan.js new file mode 100644 index 0000000..3e96698 --- /dev/null +++ b/static/js/modules/scan.js @@ -0,0 +1,194 @@ +/** + * scan.js + * ------- + * Directory scan and file selection table. + * + * Handles the "Scan for Files" button, the /api/scan fetch, rendering the + * results table, and the select-all / deselect-all controls. + * + * Exports + * ------- + * initScan() — attach all event listeners; call once at startup + */ + +import { state, els, announce } from './state.js'; +import { esc } from './utils.js'; + +// ─── Status helper ──────────────────────────────────────────────────────────── + +function setScanStatus(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 ─────────────────────────────────────────────────────────────── + +function updateSelectionUI() { + els.selectionSummary.textContent = + `${state.selectedPaths.size} of ${state.scannedFiles.length} selected`; + els.compressBtn.disabled = state.selectedPaths.size === 0; +} + +/** + * Build and inject the file selection table from the scan results. + * Attaches checkbox change handlers for each row. + * @param {Array} files — enriched file objects from /api/scan + */ +export function renderFileTable(files) { + let html = ''; + + files.forEach((f, idx) => { + const codec = (f.codec || 'unknown').toLowerCase(); + const isHevc = ['hevc', 'h265', 'x265'].includes(codec); + const isH264 = ['h264', 'avc', 'x264'].includes(codec); + const codecLabel = isHevc ? 'H.265 / HEVC' + : isH264 ? 'H.264 / AVC' + : codec.toUpperCase(); + const codecMod = isHevc ? 'hevc' : isH264 ? 'h264' : ''; + const pathDir = f.path.replace(f.name, ''); + + html += ` + + + + + + + + ${f.size_gb.toFixed(3)} GB + + + ${esc(f.bit_rate_mbps ? f.bit_rate_mbps + ' Mbps' : 'Unknown')} + + + + + ${esc(f.target_bit_rate_mbps ? f.target_bit_rate_mbps + ' Mbps' : '—')} + + + + + ${esc(codecLabel)} + + + `; + }); + + els.fileTbody.innerHTML = html; + + els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => { + chk.addEventListener('change', () => { + const row = chk.closest('tr'); + if (chk.checked) { + state.selectedPaths.add(chk.dataset.path); + row.classList.add('selected'); + } else { + state.selectedPaths.delete(chk.dataset.path); + row.classList.remove('selected'); + } + updateSelectionUI(); + }); + }); + + updateSelectionUI(); +} + +// ─── Public init ───────────────────────────────────────────────────────────── + +/** + * Attach event listeners for the scan button and file selection controls. + * Call once during app initialisation. + */ +export function initScan() { + // ── Scan button ────────────────────────────────────────────────────────── + els.scanBtn.addEventListener('click', async () => { + const directory = els.dirInput.value.trim(); + const minSize = parseFloat(els.minSizeInput.value); + + if (!directory) { + setScanStatus('Please enter a directory path.', 'error'); + els.dirInput.focus(); + return; + } + if (isNaN(minSize) || minSize <= 0) { + setScanStatus('Please enter a valid minimum size > 0.', 'error'); + els.minSizeInput.focus(); + return; + } + + els.scanBtn.disabled = true; + els.scanBtn.textContent = '⟳ Scanning…'; + setScanStatus('Scanning directory, please wait…', 'info'); + announce('Scanning directory for video files.'); + 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) { + setScanStatus(`Error: ${data.error}`, 'error'); + announce(`Scan failed: ${data.error}`); + return; + } + + state.scannedFiles = data.files; + state.selectedPaths.clear(); + + if (!data.files.length) { + setScanStatus(`No video files larger than ${minSize} GB found.`, 'warn'); + announce('No video files found matching your criteria.'); + return; + } + + setScanStatus(`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) { + setScanStatus(`Network error: ${err.message}`, 'error'); + } finally { + els.scanBtn.disabled = false; + els.scanBtn.innerHTML = + ' Scan for Files'; + } + }); + + // ── Select all ─────────────────────────────────────────────────────────── + 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.`); + }); + + // ── Deselect all ───────────────────────────────────────────────────────── + els.deselectAllBtn.addEventListener('click', () => { + els.fileTbody.querySelectorAll('.file-checkbox').forEach(chk => { + chk.checked = false; + state.selectedPaths.delete(chk.dataset.path); + chk.closest('tr').classList.remove('selected'); + }); + updateSelectionUI(); + announce('All files deselected.'); + }); +} diff --git a/static/js/modules/session.js b/static/js/modules/session.js new file mode 100644 index 0000000..a8032f4 --- /dev/null +++ b/static/js/modules/session.js @@ -0,0 +1,59 @@ +/** + * session.js + * ---------- + * Page-load session restore. + * + * On every page load — including hard browser reloads (Ctrl+Shift+R) and + * opening the app in a new tab — asks the server whether a job is active, + * fetches its full snapshot, and reconnects the live SSE stream if needed. + * + * This does NOT depend on sessionStorage surviving the reload (though + * sessionStorage is still written as a fast secondary hint). + * + * Exports + * ------- + * tryRestoreSession() — call once at startup + */ + +import { announce } from './state.js'; +import { applySnapshot, startProgressStream } from './stream.js'; +import { showResults } from './progress.js'; + +/** + * Query the server for active/recent jobs and restore the UI if one is found. + * + * Strategy: + * 1. GET /api/compress/active — find the most recent running job (or any job) + * 2. GET /api/compress/status/ — fetch the full snapshot + * 3. applySnapshot() to rebuild all progress bars + * 4. If still running: re-attach the SSE stream + * 5. If done/cancelled: show the results card + */ +export async function tryRestoreSession() { + try { + const activeResp = await fetch('/api/compress/active'); + if (!activeResp.ok) return; + + const { jobs } = await activeResp.json(); + if (!jobs.length) return; + + // Prefer the most recent running job; fall back to any job + const candidate = jobs.find(j => j.status === 'running') || jobs[0]; + + const snapResp = await fetch(`/api/compress/status/${candidate.job_id}`); + if (!snapResp.ok) return; + + const snap = await snapResp.json(); + applySnapshot(snap); + announce('Active compression job restored.'); + + if (snap.status === 'running') { + startProgressStream(snap.job_id, snap.files); + } else if (snap.status === 'done' || snap.status === 'cancelled') { + showResults(snap.status); + sessionStorage.removeItem('vp-job-id'); + } + } catch { + // Server unreachable or no jobs — start fresh, no action needed + } +} diff --git a/static/js/modules/settings.js b/static/js/modules/settings.js new file mode 100644 index 0000000..86d4d98 --- /dev/null +++ b/static/js/modules/settings.js @@ -0,0 +1,260 @@ +/** + * settings.js + * ----------- + * SMTP email settings modal. + * + * Loads saved settings from the server on open, lets the user edit and + * save them, and sends a test email to verify the configuration works. + * + * Exports + * ------- + * initSettings() — wire up all listeners; call once at startup + * smtpIsConfigured() — returns true if the server has smtp_host saved + */ + +import { announce } from './state.js'; + +// ─── DOM refs (local to this module) ───────────────────────────────────────── + +const $ = id => document.getElementById(id); + +const modal = $('settings-modal'); +const openBtn = $('settings-btn'); +const openFromHint = $('open-settings-from-hint'); +const closeBtn = $('close-settings'); +const cancelBtn = $('settings-cancel'); +const saveBtn = $('settings-save'); +const saveStatus = $('settings-save-status'); + +const hostInput = $('smtp-host'); +const portInput = $('smtp-port'); +const securitySel = $('smtp-security'); +const fromInput = $('smtp-from'); +const userInput = $('smtp-user'); +const passwordInput = $('smtp-password'); +const passwordHint = $('smtp-password-hint'); +const togglePwBtn = $('toggle-password'); + +const testToInput = $('smtp-test-to'); +const testBtn = $('smtp-test-btn'); +const testResult = $('smtp-test-result'); + +// ─── Module-level state ─────────────────────────────────────────────────────── + +let _configured = false; // whether smtp_host is set on the server + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Returns true if the server has an SMTP host configured. + * Used by compress.js to warn the user before they start a job with + * notifications enabled but no SMTP server set up. + */ +export function smtpIsConfigured() { + return _configured; +} + +/** + * Attach all event listeners for the settings modal. + * Call once during app initialisation. + */ +export function initSettings() { + openBtn.addEventListener('click', openSettings); + if (openFromHint) openFromHint.addEventListener('click', openSettings); + const openFromWarn = document.getElementById('open-settings-from-warn'); + if (openFromWarn) openFromWarn.addEventListener('click', openSettings); + + closeBtn.addEventListener('click', closeSettings); + cancelBtn.addEventListener('click', closeSettings); + modal.addEventListener('click', e => { if (e.target === modal) closeSettings(); }); + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && !modal.hidden) closeSettings(); + }); + + saveBtn.addEventListener('click', saveSettings); + testBtn.addEventListener('click', sendTestEmail); + + // Password show/hide toggle + togglePwBtn.addEventListener('click', () => { + const isHidden = passwordInput.type === 'password'; + passwordInput.type = isHidden ? 'text' : 'password'; + togglePwBtn.setAttribute('aria-label', isHidden ? 'Hide password' : 'Show password'); + }); + + // Auto-fill port when security mode changes + securitySel.addEventListener('change', () => { + const presets = { tls: '587', ssl: '465', none: '25' }; + portInput.value = presets[securitySel.value] || portInput.value; + }); + + // Load current config silently at startup so smtpIsConfigured() works + _fetchConfig(false); +} + +// ─── Open / close ───────────────────────────────────────────────────────────── + +async function openSettings() { + modal.hidden = false; + document.body.style.overflow = 'hidden'; + clearStatus(); + await _fetchConfig(true); + closeBtn.focus(); + announce('SMTP settings panel opened'); +} + +function closeSettings() { + modal.hidden = true; + document.body.style.overflow = ''; + openBtn.focus(); + announce('SMTP settings panel closed'); +} + +// ─── Load settings from server ──────────────────────────────────────────────── + +async function _fetchConfig(populateForm) { + try { + const resp = await fetch('/api/settings/smtp'); + if (!resp.ok) return; + const cfg = await resp.json(); + + _configured = Boolean(cfg.host); + + if (!populateForm) return; + + hostInput.value = cfg.host || ''; + portInput.value = cfg.port || '587'; + fromInput.value = cfg.from_addr || ''; + userInput.value = cfg.user || ''; + passwordInput.value = ''; // never pre-fill passwords + + // Select the right security option + const opt = securitySel.querySelector(`option[value="${cfg.security || 'tls'}"]`); + if (opt) opt.selected = true; + + passwordHint.textContent = cfg.password_set + ? 'A password is saved. Enter a new value to replace it, or leave blank to keep it.' + : ''; + + } catch { + // Silently ignore — server may not be reachable during init + } +} + +// ─── Save ──────────────────────────────────────────────────────────────────── + +async function saveSettings() { + const host = hostInput.value.trim(); + const port = portInput.value.trim(); + const security = securitySel.value; + const from = fromInput.value.trim(); + const user = userInput.value.trim(); + const password = passwordInput.value; // not trimmed — passwords may have spaces + + if (!host) { + showStatus('SMTP server host is required.', 'fail'); + hostInput.focus(); + return; + } + if (!port || isNaN(Number(port))) { + showStatus('A valid port number is required.', 'fail'); + portInput.focus(); + return; + } + if (!from || !from.includes('@')) { + showStatus('A valid From address is required.', 'fail'); + fromInput.focus(); + return; + } + + saveBtn.disabled = true; + saveBtn.textContent = 'Saving…'; + clearStatus(); + + try { + const body = { host, port, security, from_addr: from, user }; + if (password) body.password = password; + + const resp = await fetch('/api/settings/smtp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await resp.json(); + + if (!resp.ok) { + showStatus(`Error: ${data.error}`, 'fail'); + return; + } + + _configured = Boolean(data.config?.host); + passwordInput.value = ''; + passwordHint.textContent = + 'Password saved. Enter a new value to replace it, or leave blank to keep it.'; + showStatus('Settings saved successfully.', 'ok'); + announce('SMTP settings saved.'); + + } catch (err) { + showStatus(`Network error: ${err.message}`, 'fail'); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Save Settings'; + } +} + +// ─── Test email ─────────────────────────────────────────────────────────────── + +async function sendTestEmail() { + const to = testToInput.value.trim(); + if (!to || !to.includes('@')) { + setTestResult('Please enter a valid recipient address.', 'fail'); + testToInput.focus(); + return; + } + + testBtn.disabled = true; + testBtn.textContent = 'Sending…'; + setTestResult('Sending test email…', ''); + + try { + const resp = await fetch('/api/settings/smtp/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ to }), + }); + const data = await resp.json(); + + if (data.ok) { + setTestResult(`✓ ${data.message}`, 'ok'); + announce(`Test email sent to ${to}.`); + } else { + setTestResult(`✗ ${data.message}`, 'fail'); + announce(`Test email failed: ${data.message}`); + } + } catch (err) { + setTestResult(`Network error: ${err.message}`, 'fail'); + } finally { + testBtn.disabled = false; + testBtn.textContent = 'Send Test'; + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function showStatus(msg, type) { + saveStatus.textContent = msg; + saveStatus.className = `settings-save-status ${type}`; +} + +function clearStatus() { + saveStatus.textContent = ''; + saveStatus.className = 'settings-save-status'; + setTestResult('', ''); +} + +function setTestResult(msg, type) { + testResult.textContent = msg; + testResult.style.color = + type === 'ok' ? 'var(--text-success)' + : type === 'fail' ? 'var(--text-danger)' + : 'var(--text-muted)'; +} diff --git a/static/js/modules/state.js b/static/js/modules/state.js new file mode 100644 index 0000000..7bfc4b3 --- /dev/null +++ b/static/js/modules/state.js @@ -0,0 +1,119 @@ +/** + * state.js + * -------- + * Single shared application state object and all DOM element references. + * + * Centralising these here means every module imports the same live object — + * mutations made in one module are immediately visible to all others without + * any event bus or pub/sub layer. + * + * Also exports announce(), which every module uses to push messages to the + * ARIA live region for screen-reader users. + */ + +// ─── Shared mutable state ──────────────────────────────────────────────────── +export const state = { + /** Files returned by the last /api/scan call. */ + scannedFiles: [], + + /** Set of file paths the user has checked for compression. */ + selectedPaths: new Set(), + + /** job_id of the currently active or most-recently-seen compression job. */ + currentJobId: null, + + /** Active EventSource for the SSE progress stream. */ + eventSource: null, + + /** Per-file result objects accumulated during a compression run. */ + compressionResults: [], + + /** Current path shown in the server-side directory browser modal. */ + browserPath: '/', + + /** + * Index of the last SSE event we have processed. + * Passed as ?from=N when reconnecting so the server skips events + * we already applied to the UI. + */ + seenEventCount: 0, + + /** Handle returned by setTimeout for the auto-reconnect retry. */ + reconnectTimer: null, +}; + +// ─── DOM element references ─────────────────────────────────────────────────── +const $ = id => document.getElementById(id); + +export const els = { + // Step 1 — Configure source + dirInput: $('dir-input'), + browseBtn: $('browse-btn'), + minSizeInput: $('min-size-input'), + suffixInput: $('suffix-input'), + scanBtn: $('scan-btn'), + scanStatus: $('scan-status'), + + // Directory browser modal + browserModal: $('browser-modal'), + browserList: $('browser-list'), + browserPath: $('browser-current-path'), + closeBrowser: $('close-browser'), + browserCancel: $('browser-cancel'), + browserSelect: $('browser-select'), + + // Step 2 — File selection + sectionFiles: $('section-files'), + selectAllBtn: $('select-all-btn'), + deselectAllBtn: $('deselect-all-btn'), + selectionSummary: $('selection-summary'), + fileTbody: $('file-tbody'), + compressBtn: $('compress-btn'), + + // Email notification opt-in + notifyChk: $('notify-chk'), + notifyEmailRow: $('notify-email-row'), + notifyEmail: $('notify-email'), + + // Step 3 — Compression progress + 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'), + notifyStatus: $('notify-status'), + reconnectBtn: $('reconnect-btn'), + reconnectBtnBanner: $('reconnect-btn-banner'), + streamLostBanner: $('stream-lost-banner'), + + // Step 4 — Results + sectionResults: $('section-results'), + resultsContent: $('results-content'), + restartBtn: $('restart-btn'), + + // Header + themeToggle: $('theme-toggle'), + themeIcon: $('theme-icon'), + settingsBtn: $('settings-btn'), + + // Accessibility live region + srAnnounce: $('sr-announce'), +}; + +// ─── Screen-reader announcements ───────────────────────────────────────────── + +/** + * Push a message to the ARIA assertive live region. + * Clears first so repeated identical messages are still announced. + * @param {string} msg + */ +export function announce(msg) { + els.srAnnounce.textContent = ''; + requestAnimationFrame(() => { + els.srAnnounce.textContent = msg; + }); +} diff --git a/static/js/modules/stream.js b/static/js/modules/stream.js new file mode 100644 index 0000000..bf76935 --- /dev/null +++ b/static/js/modules/stream.js @@ -0,0 +1,276 @@ +/** + * stream.js + * --------- + * SSE progress stream management and reconnect / snapshot-restore logic. + * + * Exports + * ------- + * startProgressStream(jobId, files) — open (or re-open) the SSE connection + * reconnectToJob(jobId) — fetch snapshot then re-open stream + * applySnapshot(snap) — paint a server snapshot onto the UI + * initStreamControls() — wire up Reconnect buttons; call once + */ + +import { state, els, announce } from './state.js'; +import { fmtTime } from './utils.js'; +import { + setupProgressSection, + setOverallProgress, + updateFileProgress, + showStreamLost, + hideStreamLost, + showResults, +} from './progress.js'; + +// ─── SSE stream ─────────────────────────────────────────────────────────────── + +/** + * Open a Server-Sent Events connection for *jobId*. + * + * Resumes from state.seenEventCount so no events are replayed or skipped + * after a reconnect. doneCount is seeded from already-known results so + * the overall progress bar is correct on the first incoming event. + * + * @param {string} jobId + * @param {Array} files — file objects (need .name for announcements) + */ +export function startProgressStream(jobId, files) { + // Cancel any pending auto-reconnect timer + if (state.reconnectTimer) { + clearTimeout(state.reconnectTimer); + state.reconnectTimer = null; + } + // Close any stale connection + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } + hideStreamLost(); + + state.eventSource = new EventSource( + `/api/compress/progress/${jobId}?from=${state.seenEventCount}`, + ); + + // Seed from results already recorded by applySnapshot (reconnect path) + let doneCount = state.compressionResults.filter( + r => r.status === 'done' || r.status === 'error', + ).length; + + state.eventSource.onmessage = evt => { + let data; + try { data = JSON.parse(evt.data); } catch { return; } + state.seenEventCount++; + + switch (data.type) { + + case 'start': + els.progStatus.textContent = 'Running'; + break; + + case 'file_start': + updateFileProgress(data.index, 0, 'running', 'Compressing…', '', ''); + document.getElementById(`fpi-${data.index}`) + ?.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; + const detail = (data.elapsed_secs > 0 && data.duration_secs > 0) + ? `${fmtTime(data.elapsed_secs)} / ${fmtTime(data.duration_secs)}` : ''; + updateFileProgress(data.index, pct, 'running', 'Compressing…', detail, ''); + setOverallProgress(((doneCount + pct / 100) / files.length) * 100); + 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); + // Guard against replay on reconnect + if (!state.compressionResults.find( + r => r.index === data.index && r.status === 'done', + )) { + 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'); + if (!state.compressionResults.find( + r => r.index === data.index && r.status === 'error', + )) { + state.compressionResults.push({ ...data, status: 'error' }); + } + announce(`Error: ${files[data.index]?.name}: ${data.message}`); + break; + } + + case 'notify': + els.notifyStatus.hidden = false; + els.notifyStatus.className = `notify-status ${data.success ? 'ok' : 'fail'}`; + els.notifyStatus.textContent = `✉ ${data.message}`; + announce(data.message); + break; + + case 'done': + state.eventSource.close(); + sessionStorage.removeItem('vp-job-id'); + els.progStatus.textContent = 'Complete'; + setOverallProgress(100); + els.cancelBtn.disabled = true; + announce('All compression operations complete.'); + showResults('done'); + break; + + case 'cancelled': + state.eventSource.close(); + sessionStorage.removeItem('vp-job-id'); + 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 = () => { + // CLOSED means the stream ended cleanly (done/cancelled) — ignore. + if (!state.eventSource || state.eventSource.readyState === EventSource.CLOSED) return; + state.eventSource.close(); + state.eventSource = null; + showStreamLost(); + // Auto-retry after 5 s + state.reconnectTimer = setTimeout(() => { + if (state.currentJobId) reconnectToJob(state.currentJobId); + }, 5_000); + }; +} + +// ─── Reconnect ──────────────────────────────────────────────────────────────── + +/** + * Fetch a fresh status snapshot from the server, rebuild the progress UI to + * reflect everything that happened while disconnected, then re-open the SSE + * stream starting from the last event already processed. + * + * @param {string} jobId + */ +export async function reconnectToJob(jobId) { + if (state.reconnectTimer) { + clearTimeout(state.reconnectTimer); + state.reconnectTimer = null; + } + hideStreamLost(); + els.progStatus.textContent = 'Reconnecting…'; + announce('Reconnecting to compression job…'); + + try { + const resp = await fetch(`/api/compress/status/${jobId}`); + if (!resp.ok) throw new Error('Job no longer available on server.'); + const snap = await resp.json(); + + applySnapshot(snap); + + if (snap.status === 'done' || snap.status === 'cancelled') { + showResults(snap.status); + sessionStorage.removeItem('vp-job-id'); + } else { + startProgressStream(jobId, snap.files); + announce('Reconnected. Progress restored.'); + } + } catch (err) { + els.progStatus.textContent = 'Reconnect failed'; + showStreamLost(); + els.streamLostBanner.querySelector('.banner-text').textContent = + `Could not reconnect: ${err.message}`; + announce(`Reconnect failed: ${err.message}`); + } +} + +// ─── Snapshot restore ──────────────────────────────────────────────────────── + +/** + * Paint a server-supplied status snapshot onto the progress UI. + * + * Called by: + * - reconnectToJob() after a mid-session SSE drop + * - tryRestoreSession() on every page load to recover an active job + * + * @param {object} snap — response from GET /api/compress/status/ + */ +export function applySnapshot(snap) { + // Rebuild the per-file DOM if the page was reloaded and lost it + if (!document.getElementById('fpi-0')) { + setupProgressSection(snap.files); + } + + state.currentJobId = snap.job_id; + state.seenEventCount = snap.event_count; + sessionStorage.setItem('vp-job-id', snap.job_id); + + els.sectionProgress.hidden = false; + els.progTotal.textContent = snap.total; + els.progDone.textContent = snap.done_count; + els.progStatus.textContent = + snap.status === 'running' ? 'Running' + : snap.status === 'done' ? 'Complete' + : snap.status === 'cancelled' ? 'Cancelled' + : snap.status; + + // Restore each file bar from the snapshot's computed file_states + snap.file_states.forEach((fs, idx) => { + const statusClass = { done: 'done', error: 'error', running: 'running' }[fs.status] || 'waiting'; + const statusText = { done: '✓ Done', error: '✗ Error', running: 'Compressing…' }[fs.status] || 'Waiting'; + const detailClass = { done: 'success', error: 'error' }[fs.status] || ''; + updateFileProgress(idx, fs.percent || 0, statusClass, statusText, fs.detail || '', detailClass); + }); + + // Restore overall bar + const runningPct = snap.file_states.find(f => f.status === 'running')?.percent || 0; + const overall = snap.total > 0 + ? ((snap.done_count + runningPct / 100) / snap.total) * 100 : 0; + setOverallProgress(Math.min(overall, 100)); + + // Seed compressionResults so showResults() has data if job is already done + state.compressionResults = snap.file_states + .filter(fs => fs.status === 'done' || fs.status === 'error') + .map((fs, idx) => ({ ...fs, index: idx })); + + if (snap.status === 'done') { + els.cancelBtn.disabled = true; + setOverallProgress(100); + } +} + +// ─── Button wiring ──────────────────────────────────────────────────────────── + +/** + * Attach click handlers to both Reconnect buttons (title-bar and banner). + * Call once during app initialisation. + */ +export function initStreamControls() { + els.reconnectBtn.addEventListener('click', () => { + if (state.currentJobId) reconnectToJob(state.currentJobId); + }); + els.reconnectBtnBanner.addEventListener('click', () => { + if (state.currentJobId) reconnectToJob(state.currentJobId); + }); +} diff --git a/static/js/modules/theme.js b/static/js/modules/theme.js new file mode 100644 index 0000000..ad5253e --- /dev/null +++ b/static/js/modules/theme.js @@ -0,0 +1,46 @@ +/** + * theme.js + * -------- + * Dark / light theme management. + * + * Reads the user's saved preference from localStorage and falls back to the + * OS-level prefers-color-scheme media query. Writes back on every change + * so the choice persists across page loads. + * + * Exports + * ------- + * initTheme() — call once at startup; reads saved pref and applies it + * applyTheme() — apply a specific theme string ('dark' | 'light') + */ + +import { els } from './state.js'; + +/** + * Apply *theme* to the document and persist the choice. + * @param {'dark'|'light'} theme + */ +export 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); +} + +/** + * Read the saved theme preference (or detect from OS) and apply it. + * Attaches the toggle button's click listener. + * Call once during app initialisation. + */ +export function initTheme() { + const saved = localStorage.getItem('vp-theme'); + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + applyTheme(saved || (prefersDark ? 'dark' : 'light')); + + els.themeToggle.addEventListener('click', () => { + const current = document.documentElement.getAttribute('data-theme'); + applyTheme(current === 'dark' ? 'light' : 'dark'); + }); +} diff --git a/static/js/modules/utils.js b/static/js/modules/utils.js new file mode 100644 index 0000000..d0e1f15 --- /dev/null +++ b/static/js/modules/utils.js @@ -0,0 +1,45 @@ +/** + * utils.js + * -------- + * Pure utility functions with no DOM or state dependencies. + * Safe to import anywhere without side-effects. + */ + +/** + * Escape a string for safe insertion into HTML. + * @param {*} str + * @returns {string} + */ +export function esc(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Format a duration in seconds as M:SS or H:MM:SS. + * @param {number} seconds + * @returns {string} + */ +export 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; + return h > 0 + ? `${h}:${pad(m)}:${pad(sec)}` + : `${m}:${pad(sec)}`; +} + +/** + * Zero-pad a number to at least 2 digits. + * @param {number} n + * @returns {string} + */ +export function pad(n) { + return String(n).padStart(2, '0'); +} diff --git a/templates/index.html b/templates/index.html index c7b6b03..a45d760 100644 --- a/templates/index.html +++ b/templates/index.html @@ -21,6 +21,14 @@ VideoPress
+ . +

+ +
+ + + + + + +
@@ -229,6 +305,7 @@ +
@@ -246,6 +323,120 @@
+ + +