| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823 |
- #!/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/<Series>/Season NN/SNNENN - <Episode>.mkv
- movie → Movies/<Title> (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":
- # Jellyfin identifies music from embedded tags, not the folder path — it
- # only requires that each album live in its own folder. So we drop the
- # artist folder and use Music/<Album>/. Tracks with no album go into a
- # per-artist "<Artist> - Singles" folder, keeping the Music root clean
- # and giving loose tracks an album-like grouping in the library.
- album = sanitize(job.get("album") or "")
- if album:
- out_dir = MEDIA_DIR / "Music" / album
- else:
- artist = sanitize(job.get("artist") or job.get("author") or "Unknown Artist")
- out_dir = MEDIA_DIR / "Music" / f"{artist} - Singles"
- 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', '')}</title>
- <showtitle>{job.get('series', '')}</showtitle>
- <season>{job.get('season', 1)}</season>
- <episode>{job.get('episode', 1)}</episode>
- <plot>{job.get('description', '')}</plot>
- <aired>{job.get('upload_date', '')}</aired>
- <director>{job.get('author', '')}</director>
- <uniqueid type="youtube">{job.get('url', '')}</uniqueid>
- </episodedetails>"""
- elif mtype == "movie":
- nfo = f"""<?xml version="1.0" encoding="UTF-8"?>
- <movie>
- <title>{job.get('title', '')}</title>
- <year>{job.get('year', '')}</year>
- <plot>{job.get('description', '')}</plot>
- <director>{job.get('author', '')}</director>
- <uniqueid type="youtube">{job.get('url', '')}</uniqueid>
- </movie>"""
- 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/<section>/<path:folder>")
- def browse_folder(section: str, folder: str):
- """Subdirectories one level deep inside MEDIA_DIR/<section>/<folder>."""
- 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])
- # Build the response snapshot BEFORE starting the worker. A very fast job
- # (cached file, instant error) could otherwise run to completion between the
- # thread start and the snapshot, returning a half-written or already-done
- # state that races the client's own polling. Snapshotting first guarantees
- # the response is a clean, consistent "queued" view; the subsequent progress
- # and completion are delivered through normal polling.
- response = {"job_id": job_id, **jobs_snapshot()}
- Thread(target=run_download, args=(job_id,), daemon=True).start()
- log.info(f"Job {job_id} queued: {url}")
- return jsonify(response)
- @app.route("/api/jobs")
- def list_jobs():
- return jsonify(jobs_snapshot())
- @app.route("/api/jobs/<job_id>")
- 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/<job_id>/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/<job_id>", 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)
|