#!/usr/bin/env python3 """YAAR - YouTube Auto-Archiver and Retagger Flask web UI that archives YouTube content into a Jellyfin-compatible layout. Media root defaults to /nas/jellyfin (a Proxmox mp0 bind-mount). Expected top-level subdirectories: Movies/ Shows/ Music/ A job is archived as one of three media types: series → Shows//Season NN/SNNENN - .mkv movie → Movies/ (Year).mkv (flat, no per-movie folder) music → Music/<Artist>/<Album>/<Track>.<ext> (album optional) Logging is split into discrete files under <LOG_DIR>: yaar.log unified tail of everything log_boot/<ts>.log one file per service start log_mount/<ts>.log one file per mount check (UI + pre-download) log_archive/<id>.log one file per archive job, named by video id """ import os import re import json import uuid import logging from datetime import datetime from pathlib import Path from threading import Thread from urllib.parse import urlparse, urlunparse, parse_qs, urlencode import yt_dlp from flask import Flask, render_template, request, jsonify # ══════════════════════════════════════════════════════════════════════════════ # Configuration # ══════════════════════════════════════════════════════════════════════════════ BASE_DIR = Path(os.environ.get("YAAR_BASE", "/opt/yaar")) MEDIA_DIR = Path(os.environ.get("YAAR_MEDIA", "/nas/jellyfin")) PORT = int(os.environ.get("YAAR_PORT", 7474)) LOG_DIR = BASE_DIR / "logs" JOB_DIR = BASE_DIR / "jobs" BOOT_LOG_DIR = LOG_DIR / "log_boot" MOUNT_LOG_DIR = LOG_DIR / "log_mount" ARCHIVE_LOG_DIR = LOG_DIR / "log_archive" for _d in (BASE_DIR, LOG_DIR, JOB_DIR, BOOT_LOG_DIR, MOUNT_LOG_DIR, ARCHIVE_LOG_DIR): _d.mkdir(parents=True, exist_ok=True) # Media sections we manage. Single source of truth used by browse + mount check. MEDIA_SECTIONS = ("Movies", "Shows", "Music") # Audio formats accepted by /api/archive for music jobs. Each maps directly to # yt-dlp's FFmpegExtractAudio `preferredcodec`. ALLOWED_AUDIO_FORMATS = ("m4a", "opus", "mp3", "flac", "aac", "wav") # Audio formats whose output extension differs from / needs normalising. # (All current formats use their own name as the extension.) # ══════════════════════════════════════════════════════════════════════════════ # Logging # ══════════════════════════════════════════════════════════════════════════════ _LOG_FMT = "%(asctime)s [%(levelname)s] %(message)s" _LOG_DATE = "%Y-%m-%d %H:%M:%S" _FORMATTER = logging.Formatter(_LOG_FMT, datefmt=_LOG_DATE) def _file_handler(path: Path) -> logging.FileHandler: h = logging.FileHandler(path, encoding="utf-8") h.setFormatter(_FORMATTER) return h # Root logger. Everything propagates up to here, so yaar.log + console always # carry the full picture regardless of which discrete logger emitted the line. log = logging.getLogger("yaar") log.setLevel(logging.DEBUG) log.addHandler(_file_handler(LOG_DIR / "yaar.log")) _console = logging.StreamHandler() _console.setFormatter(_FORMATTER) log.addHandler(_console) def _ts() -> str: """Filesystem-safe UTC timestamp, e.g. 2025-01-15_09-32-11.""" return datetime.utcnow().strftime("%Y-%m-%d_%H-%M-%S") def _fresh_logger(name: str, path: Path) -> logging.Logger: """Return a uniquely-named child logger with exactly one file handler. Child loggers propagate to the root 'yaar' logger, so their records also land in yaar.log and on the console. The name is always unique (caller includes a uuid/job id), which guarantees we never stack a second handler onto a previously-created logger and emit duplicate lines. """ lg = logging.getLogger(f"yaar.{name}") if not lg.handlers: # idempotent: never double-attach lg.addHandler(_file_handler(path)) lg.setLevel(logging.DEBUG) lg.propagate = True return lg def boot_logger() -> logging.Logger: return _fresh_logger(f"boot.{_ts()}", BOOT_LOG_DIR / f"{_ts()}.log") def mount_logger(context: str = "") -> logging.Logger: tag = f"_{context}" if context else "" uid = uuid.uuid4().hex[:6] path = MOUNT_LOG_DIR / f"{_ts()}{tag}.log" return _fresh_logger(f"mount.{_ts()}.{uid}", path) def archive_logger(job_id: str, url: str) -> logging.Logger: """One file per job, named '<video_id>__<job_id>.log'. Keyed on job_id so retries reuse the same logger object (and append to the same file) without stacking duplicate handlers. """ path = ARCHIVE_LOG_DIR / f"{video_id(url)}__{job_id}.log" return _fresh_logger(f"archive.{job_id}", path) # ══════════════════════════════════════════════════════════════════════════════ # URL + filename helpers # ══════════════════════════════════════════════════════════════════════════════ def sanitize(name: str) -> str: """Strip characters invalid in Linux/Windows filenames.""" return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", name or "").strip() def clean_url(url: str) -> str: """Strip tracking/playlist params, keeping only what yt-dlp needs. youtube.com/watch → keep only ?v=<id> youtu.be / shorts → drop the entire query string (id is in the path) """ parsed = urlparse((url or "").strip()) host = parsed.netloc.lower().removeprefix("www.") if host == "youtube.com" and parsed.path.startswith("/watch"): v = parse_qs(parsed.query).get("v", [""])[0] query = urlencode({"v": v}) if v else "" return urlunparse(parsed._replace(query=query, fragment="")) return urlunparse(parsed._replace(query="", fragment="")) def video_id(url: str) -> str: """Extract a YouTube video id for use as a log filename. Falls back to a sanitised, truncated form of the URL when no id is found. """ try: p = urlparse(url or "") host = p.netloc.lower().removeprefix("www.") if host == "youtu.be": vid = p.path.lstrip("/").split("/")[0] if vid: return vid if host == "youtube.com": v = parse_qs(p.query).get("v", [""])[0] if v: return v parts = [x for x in p.path.split("/") if x] if len(parts) >= 2 and parts[0] in ("shorts", "live", "embed"): return parts[1] except Exception: pass return re.sub(r"[^A-Za-z0-9_-]", "_", url or "unknown")[:60] or "unknown" # ══════════════════════════════════════════════════════════════════════════════ # Boot # ══════════════════════════════════════════════════════════════════════════════ app = Flask(__name__) # In-memory job registry, persisted to JOB_DIR as one JSON file per job. jobs: dict[str, dict] = {} # Polling support. `action_version` increments on every state mutation so the # frontend can drop stale, out-of-order poll responses. `server_instance` lets # the frontend detect a restart (version resets to 0) and re-sync instead of # freezing because the counter went backwards. action_version = 0 server_instance = uuid.uuid4().hex[:12] def bump_version() -> int: global action_version action_version += 1 return action_version def jobs_snapshot() -> dict: """Standard response body for every endpoint that lists or mutates jobs.""" ordered = sorted(jobs.values(), key=lambda j: j.get("created_at", ""), reverse=True) return { "server_instance": server_instance, "action_version": action_version, "jobs": ordered[:50], } def save_job(job: dict) -> None: """Persist a job to disk, but only if it still exists in the registry. Guards against a deleted job being resurrected by a still-running worker thread that holds a closure reference to the orphaned dict. Bumps the action version so polling clients observe the change. """ job_id = job.get("id") if not job_id or job_id not in jobs: return bump_version() (JOB_DIR / f"{job_id}.json").write_text(json.dumps(job, default=str, indent=2)) def load_jobs() -> None: for p in sorted(JOB_DIR.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True): try: j = json.loads(p.read_text()) jobs[j["id"]] = j except Exception as e: log.warning(f"Could not load job {p}: {e}") load_jobs() _boot = boot_logger() _boot.info("=" * 60) _boot.info("YAAR service starting") _boot.info(f" BASE_DIR : {BASE_DIR}") _boot.info(f" MEDIA_DIR : {MEDIA_DIR}") _boot.info(f" LOG_DIR : {LOG_DIR}") _boot.info(f" JOB_DIR : {JOB_DIR}") _boot.info(f" Port : {PORT}") _boot.info(f" PID : {os.getpid()}") _boot.info(f"Restored {len(jobs)} job(s) from disk:") for _jid, _j in jobs.items(): _boot.info(f" [{_jid}] {_j.get('status', '?'):8s} {_j.get('url', '')}") _boot.info("=" * 60) # ══════════════════════════════════════════════════════════════════════════════ # Mount + path resolution # ══════════════════════════════════════════════════════════════════════════════ def get_mount_info() -> dict: """Report mount status and which known subdirectories exist. Writes a discrete entry to log_mount/ on every call. """ mlog = mount_logger("api") mlog.info(f"Mount check (API) — target: {MEDIA_DIR}") mounted = MEDIA_DIR.exists() writable = False subdirs: dict[str, dict] = {} if mounted: mlog.info(f" {MEDIA_DIR} exists") try: probe = MEDIA_DIR / ".yaar_write_test" probe.touch() probe.unlink() writable = True mlog.info(f" {MEDIA_DIR} is writable") except OSError as e: mlog.warning(f" {MEDIA_DIR} is NOT writable: {e}") for name in MEDIA_SECTIONS: p = MEDIA_DIR / name subdirs[name] = {"exists": p.exists(), "path": str(p)} mlog.info(f" subdir {name}/: {'present' if p.exists() else 'absent'}") else: mlog.warning(f" {MEDIA_DIR} does NOT exist — bind-mount (mp0) may be missing") mlog.info(f"Result: mounted={mounted} writable={writable}") return {"root": str(MEDIA_DIR), "mounted": mounted, "writable": writable, "subdirs": subdirs} def resolve_output_dir(job: dict) -> Path: """Return (and create) the directory a job's files should land in. Always rooted at MEDIA_DIR; raises ValueError on an unknown media type or if the resolved path would escape the media root. """ mtype = job["media_type"] log.debug(f"resolve_output_dir: media_type={mtype!r} job_id={job.get('id')}") if mtype == "movie": out_dir = MEDIA_DIR / "Movies" elif mtype == "series": folder = sanitize(job.get("folder") or job.get("series") or "Unknown Series") season = int(job.get("season") or 1) out_dir = MEDIA_DIR / "Shows" / folder / f"Season {season:02d}" elif mtype == "music": artist = sanitize(job.get("artist") or job.get("author") or "Unknown Artist") album = sanitize(job.get("album") or "") out_dir = MEDIA_DIR / "Music" / artist if album: out_dir = out_dir / album else: raise ValueError(f"Unknown media_type: {mtype!r}") try: out_dir.resolve().relative_to(MEDIA_DIR.resolve()) except ValueError: raise ValueError(f"Resolved output path escapes media root: {out_dir}") out_dir.mkdir(parents=True, exist_ok=True) log.debug(f"resolve_output_dir: resolved → {out_dir}") return out_dir def output_extension(job: dict) -> str: """Final container/file extension for a job.""" if job["media_type"] == "music": fmt = (job.get("format") or "m4a").lower() return fmt if fmt in ALLOWED_AUDIO_FORMATS else "m4a" return "mkv" def build_output_template(job: dict) -> Path: """Full yt-dlp outtmpl path, including the %(ext)s placeholder.""" out_dir = resolve_output_dir(job) mtype = job["media_type"] title = sanitize(job.get("title") or "untitled") if mtype == "movie": year = job.get("year", "") base = f"{title} ({year})" if year else title filename = f"{base}.%(ext)s" elif mtype == "series": season = int(job.get("season") or 1) episode = int(job.get("episode") or 1) ep = sanitize(job.get("episode_title") or title) filename = f"S{season:02d}E{episode:02d} - {ep}.%(ext)s" elif mtype == "music": track = job.get("track") try: prefix = f"{int(track):02d} - " if track else "" except (ValueError, TypeError): prefix = "" filename = f"{prefix}{title}.%(ext)s" else: raise ValueError(f"Unknown media_type: {mtype!r}") return out_dir / filename # ══════════════════════════════════════════════════════════════════════════════ # yt-dlp option building # ══════════════════════════════════════════════════════════════════════════════ def build_ffmpeg_metadata_args(job: dict) -> list[str]: """ffmpeg -metadata flags embedded into the output container.""" meta = { "title": job.get("title", ""), "artist": job.get("artist") or job.get("author", ""), "date": job.get("upload_date", ""), "comment": job.get("url", ""), "description": (job.get("description") or "")[:500], } if job["media_type"] == "series": meta["show"] = job.get("series", "") meta["season_number"] = str(job.get("season") or 1) meta["episode_id"] = str(job.get("episode") or 1) meta["episode_sort"] = str(job.get("episode") or 1) elif job["media_type"] == "music": if job.get("album"): meta["album"] = job["album"] if job.get("track"): meta["track"] = str(job["track"]) if job.get("genre"): meta["genre"] = job["genre"] if job.get("year"): meta["date"] = str(job["year"]) meta["album_artist"] = job.get("artist") or job.get("author", "") args: list[str] = [] for k, v in meta.items(): if v: args += ["-metadata", f"{k}={v}"] return args def build_ydl_opts(job: dict, output_template: Path, progress_hook) -> dict: """yt-dlp options. Music = audio-only; series/movie = H.264/AAC → MKV.""" common = { "outtmpl": str(output_template), "writeinfojson": False, "postprocessor_args": {"ffmpeg": build_ffmpeg_metadata_args(job)}, "progress_hooks": [progress_hook], "quiet": True, "no_warnings": True, "ignoreerrors": False, } if job["media_type"] == "music": fmt = (job.get("format") or "m4a").lower() if fmt not in ALLOWED_AUDIO_FORMATS: fmt = "m4a" # Opus lives in webm on YouTube; everything else starts from m4a/AAC. src = "bestaudio[ext=webm]/bestaudio/best" if fmt == "opus" \ else "bestaudio[ext=m4a]/bestaudio/best" # Lossless/uncompressed containers don't take embedded cover art well. embed_thumb = fmt not in ("flac", "wav") post = [ {"key": "FFmpegExtractAudio", "preferredcodec": fmt, "preferredquality": "0"}, {"key": "FFmpegMetadata", "add_metadata": True}, ] if embed_thumb: post.append({"key": "EmbedThumbnail", "already_have_thumbnail": False}) return { **common, "format": src, "writethumbnail": embed_thumb, "embedthumbnail": embed_thumb, "postprocessors": post, } # Video (series / movie): prefer H.264 + AAC for Jellyfin direct-play, # falling back to best available if only VP9/AV1 is offered. return { **common, "format": ( "bestvideo[vcodec^=avc1]+bestaudio[acodec^=mp4a]/" "bestvideo[vcodec^=avc1]+bestaudio/" "bestvideo+bestaudio/best" ), "merge_output_format": "mkv", "writethumbnail": True, "embedthumbnail": True, "postprocessors": [ {"key": "FFmpegVideoConvertor", "preferedformat": "mkv"}, {"key": "FFmpegMetadata", "add_metadata": True, "add_chapters": True}, {"key": "EmbedThumbnail", "already_have_thumbnail": False}, ], } def probe_url(url: str) -> dict: """Fetch metadata without downloading.""" with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True, "skip_download": True}) as ydl: return ydl.extract_info(url, download=False) # ══════════════════════════════════════════════════════════════════════════════ # NFO sidecar # ══════════════════════════════════════════════════════════════════════════════ def write_nfo(job: dict, output_template: Path) -> None: """Write a Jellyfin NFO sidecar next to the media file. Series and movies get NFOs; music relies on embedded tags and is skipped. """ nfo_path = Path(str(output_template).replace(".%(ext)s", ".nfo")) mtype = job["media_type"] if mtype == "series": nfo = f"""<?xml version="1.0" encoding="UTF-8"?> <episodedetails> <title>{job.get('episode_title') or job.get('title', '')} {job.get('series', '')} {job.get('season', 1)} {job.get('episode', 1)} {job.get('description', '')} {job.get('upload_date', '')} {job.get('author', '')} {job.get('url', '')} """ elif mtype == "movie": nfo = f""" {job.get('title', '')} {job.get('year', '')} {job.get('description', '')} {job.get('author', '')} {job.get('url', '')} """ else: return nfo_path.write_text(nfo, encoding="utf-8") log.info(f"NFO written: {nfo_path}") # ══════════════════════════════════════════════════════════════════════════════ # Download worker # ══════════════════════════════════════════════════════════════════════════════ class _Cancelled(Exception): """Raised inside the yt-dlp hook when a job is deleted mid-download.""" def run_download(job_id: str) -> None: job = jobs[job_id] alog = archive_logger(job_id, job.get("url", "")) job.update(status="running", started_at=datetime.utcnow().isoformat(), progress=0, log=[]) save_job(job) alog.info("=" * 60) alog.info("Archive job started") alog.info(f" Job ID : {job_id}") alog.info(f" URL : {job.get('url')}") alog.info(f" Media type : {job.get('media_type')}") alog.info(f" Title : {job.get('title') or '(pending probe)'}") if job.get("media_type") == "series": alog.info(f" Series : {job.get('series') or job.get('folder')}") alog.info(f" Episode : S{int(job.get('season') or 1):02d}E{int(job.get('episode') or 1):02d}") elif job.get("media_type") == "movie": alog.info(f" Year : {job.get('year') or '(unknown)'}") elif job.get("media_type") == "music": alog.info(f" Artist : {job.get('artist') or job.get('author')}") alog.info(f" Album : {job.get('album') or '(none)'}") alog.info(f" Format : {job.get('format') or 'm4a'}") alog.info("=" * 60) log.info(f"Job {job_id} started → {job['url']}") def hook(d): if job_id not in jobs: raise _Cancelled(f"Job {job_id} removed by user") if d["status"] == "downloading": pct = d.get("_percent_str", "0%").strip().replace("%", "") try: job["progress"] = float(pct) except ValueError: pass job["speed"] = d.get("_speed_str", "") job["eta"] = d.get("_eta_str", "") save_job(job) elif d["status"] == "finished": job["progress"] = 99 fname = d.get("filename", "") job["log"].append(f"Downloaded: {fname}") alog.info(f"yt-dlp finished downloading: {fname}") save_job(job) try: # ── Pre-download mount check ────────────────────────────────────────── mlog = mount_logger(f"pre_archive_{job_id}") mlog.info(f"Pre-download mount check for job {job_id} — {MEDIA_DIR}") if not MEDIA_DIR.exists(): mlog.error(f" FAIL — {MEDIA_DIR} does not exist") alog.error(f"Mount check failed: {MEDIA_DIR} does not exist") raise RuntimeError( f"Media root {MEDIA_DIR} does not exist. Check that the Proxmox " "bind-mount (mp0) is attached and the container is running." ) if not os.access(MEDIA_DIR, os.W_OK): mlog.error(f" FAIL — {MEDIA_DIR} is not writable") alog.error(f"Mount check failed: {MEDIA_DIR} is not writable") raise RuntimeError(f"Media root {MEDIA_DIR} is not writable. Check container mount permissions.") mlog.info(f" OK — {MEDIA_DIR} is mounted and writable") # ── Resolve output path ─────────────────────────────────────────────── alog.info("Resolving output directory…") output_template = build_output_template(job) job["output_path"] = str(output_template).replace(".%(ext)s", f".{output_extension(job)}") alog.info(f" Output : {job['output_path']}") save_job(job) # ── Download ────────────────────────────────────────────────────────── alog.info("Starting yt-dlp download…") with yt_dlp.YoutubeDL(build_ydl_opts(job, output_template, hook)) as ydl: info = ydl.extract_info(job["url"]) if not job.get("author"): job["author"] = info.get("uploader") or info.get("channel") or "Unknown" if not job.get("upload_date"): raw = info.get("upload_date", "") if raw: job["upload_date"] = f"{raw[:4]}-{raw[4:6]}-{raw[6:]}" job["description"] = (info.get("description") or "")[:500] alog.info(f" Channel : {job.get('author')}") alog.info(f" Upload date: {job.get('upload_date')}") write_nfo(job, output_template) job.update(status="done", progress=100, finished_at=datetime.utcnow().isoformat()) alog.info("-" * 60) alog.info("Archive COMPLETE") alog.info(f" Output : {job['output_path']}") alog.info(f" Finished at : {job['finished_at']}") alog.info("-" * 60) log.info(f"Job {job_id} complete → {job['output_path']}") except _Cancelled: alog.warning(f"Job {job_id} cancelled by user — download aborted") log.info(f"Job {job_id} cancelled by user") return except Exception as e: # A job that vanished mid-download is a cancellation, not an error. if job_id not in jobs: alog.warning(f"Job {job_id} removed during download — treating as cancellation") log.info(f"Job {job_id} removed during download") return job.update(status="error", error=str(e)) alog.error(f"Archive FAILED: {e}") log.error(f"Job {job_id} failed: {e}") save_job(job) # ══════════════════════════════════════════════════════════════════════════════ # Routes # ══════════════════════════════════════════════════════════════════════════════ @app.route("/") def index(): return render_template("index.html") @app.route("/api/mounts") def mounts(): """Mount status for the UI banner.""" return jsonify(get_mount_info()) @app.route("/api/browse") def browse(): """First-level subdirectories inside each managed media section.""" result = {} for section in MEDIA_SECTIONS: p = MEDIA_DIR / section result[section] = sorted( e.name for e in p.iterdir() if e.is_dir() and not e.name.startswith(".") ) if p.is_dir() else [] return jsonify(result) @app.route("/api/browse/
/") def browse_folder(section: str, folder: str): """Subdirectories one level deep inside MEDIA_DIR/
/.""" if section not in MEDIA_SECTIONS: return jsonify({"error": "Unknown section"}), 400 target = MEDIA_DIR / section / folder try: target.resolve().relative_to(MEDIA_DIR.resolve()) except ValueError: return jsonify({"error": "Invalid path"}), 400 if not target.is_dir(): return jsonify([]) return jsonify(sorted( e.name for e in target.iterdir() if e.is_dir() and not e.name.startswith(".") )) @app.route("/api/probe", methods=["POST"]) def probe(): """Fetch video metadata without downloading.""" url = clean_url((request.json or {}).get("url", "")) if not url: return jsonify({"error": "No URL provided"}), 400 try: info = probe_url(url) upload_date = info.get("upload_date", "") if upload_date: upload_date = f"{upload_date[:4]}-{upload_date[4:6]}-{upload_date[6:]}" return jsonify({ "title": info.get("title", ""), "author": info.get("uploader") or info.get("channel") or "", "upload_date": upload_date, "duration": info.get("duration_string") or str(info.get("duration", "")), "thumbnail": info.get("thumbnail", ""), "description": (info.get("description") or "")[:300], "year": upload_date[:4] if upload_date else "", }) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/archive", methods=["POST"]) def archive(): """Validate inputs and enqueue a download job.""" data = request.json or {} url = clean_url(data.get("url", "")) if not url: return jsonify({"error": "URL is required"}), 400 # Fail fast if the mount is missing, before creating a doomed job. if not MEDIA_DIR.exists(): return jsonify({ "error": f"Media root '{MEDIA_DIR}' is not accessible. Ensure the " "Proxmox bind-mount (mp0) is configured and the container is started." }), 503 mtype = data.get("media_type", "series") if mtype not in ("series", "movie", "music"): return jsonify({"error": f"media_type must be 'series', 'movie', or 'music' (got: {mtype!r})"}), 400 if mtype == "music": fmt = (data.get("format") or "m4a").lower() if fmt not in ALLOWED_AUDIO_FORMATS: return jsonify({"error": f"format must be one of {'/'.join(ALLOWED_AUDIO_FORMATS)} (got: {fmt!r})"}), 400 job_id = str(uuid.uuid4())[:8] jobs[job_id] = { "id": job_id, "url": url, "media_type": mtype, "title": data.get("title", ""), "author": data.get("author", ""), "upload_date": data.get("upload_date", ""), "description": data.get("description", ""), "year": data.get("year", ""), # series "series": data.get("series", ""), "season": int(data.get("season") or 1), "episode": int(data.get("episode") or 1), "episode_title": data.get("episode_title", ""), "folder": data.get("folder", ""), # music "artist": data.get("artist", ""), "album": data.get("album", ""), "track": data.get("track", ""), "genre": data.get("genre", ""), "format": (data.get("format") or "m4a").lower(), # status "output_path": "", "status": "queued", "progress": 0, "created_at": datetime.utcnow().isoformat(), } save_job(jobs[job_id]) Thread(target=run_download, args=(job_id,), daemon=True).start() log.info(f"Job {job_id} queued: {url}") return jsonify({"job_id": job_id, **jobs_snapshot()}) @app.route("/api/jobs") def list_jobs(): return jsonify(jobs_snapshot()) @app.route("/api/jobs/") def get_job(job_id): job = jobs.get(job_id) if not job: return jsonify({"error": "Job not found"}), 404 return jsonify(job) @app.route("/api/jobs//retry", methods=["POST"]) def retry_job(job_id): job = jobs.get(job_id) if not job: return jsonify({"error": "Job not found"}), 404 job.update(status="queued", progress=0, error="") save_job(job) Thread(target=run_download, args=(job_id,), daemon=True).start() return jsonify({"ok": True, **jobs_snapshot()}) @app.route("/api/jobs/", methods=["DELETE"]) def delete_job(job_id): if jobs.pop(job_id, None) is not None: bump_version() p = JOB_DIR / f"{job_id}.json" if p.exists(): p.unlink() return jsonify({"ok": True, **jobs_snapshot()}) @app.route("/api/jobs", methods=["DELETE"]) def clear_finished_jobs(): """Remove all done/error jobs. Active jobs are left untouched.""" removed = [] for job_id in [jid for jid, j in jobs.items() if j.get("status") in ("done", "error")]: jobs.pop(job_id, None) p = JOB_DIR / f"{job_id}.json" if p.exists(): p.unlink() removed.append(job_id) if removed: bump_version() return jsonify({"removed": removed, "count": len(removed), **jobs_snapshot()}) if __name__ == "__main__": app.run(host="0.0.0.0", port=PORT, debug=False)