app.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
  1. #!/usr/bin/env python3
  2. """YAAR - YouTube Auto-Archiver and Retagger
  3. Flask web UI that archives YouTube content into a Jellyfin-compatible layout.
  4. Media root defaults to /nas/jellyfin (a Proxmox mp0 bind-mount). Expected
  5. top-level subdirectories: Movies/ Shows/ Music/
  6. A job is archived as one of three media types:
  7. series → Shows/<Series>/Season NN/SNNENN - <Episode>.mkv
  8. movie → Movies/<Title> (Year).mkv (flat, no per-movie folder)
  9. music → Music/<Artist>/<Album>/<Track>.<ext> (album optional)
  10. Logging is split into discrete files under <LOG_DIR>:
  11. yaar.log unified tail of everything
  12. log_boot/<ts>.log one file per service start
  13. log_mount/<ts>.log one file per mount check (UI + pre-download)
  14. log_archive/<id>.log one file per archive job, named by video id
  15. """
  16. import os
  17. import re
  18. import json
  19. import uuid
  20. import logging
  21. from datetime import datetime
  22. from pathlib import Path
  23. from threading import Thread
  24. from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
  25. import yt_dlp
  26. from flask import Flask, render_template, request, jsonify
  27. # ══════════════════════════════════════════════════════════════════════════════
  28. # Configuration
  29. # ══════════════════════════════════════════════════════════════════════════════
  30. BASE_DIR = Path(os.environ.get("YAAR_BASE", "/opt/yaar"))
  31. MEDIA_DIR = Path(os.environ.get("YAAR_MEDIA", "/nas/jellyfin"))
  32. PORT = int(os.environ.get("YAAR_PORT", 7474))
  33. LOG_DIR = BASE_DIR / "logs"
  34. JOB_DIR = BASE_DIR / "jobs"
  35. BOOT_LOG_DIR = LOG_DIR / "log_boot"
  36. MOUNT_LOG_DIR = LOG_DIR / "log_mount"
  37. ARCHIVE_LOG_DIR = LOG_DIR / "log_archive"
  38. for _d in (BASE_DIR, LOG_DIR, JOB_DIR, BOOT_LOG_DIR, MOUNT_LOG_DIR, ARCHIVE_LOG_DIR):
  39. _d.mkdir(parents=True, exist_ok=True)
  40. # Media sections we manage. Single source of truth used by browse + mount check.
  41. MEDIA_SECTIONS = ("Movies", "Shows", "Music")
  42. # Audio formats accepted by /api/archive for music jobs. Each maps directly to
  43. # yt-dlp's FFmpegExtractAudio `preferredcodec`.
  44. ALLOWED_AUDIO_FORMATS = ("m4a", "opus", "mp3", "flac", "aac", "wav")
  45. # Audio formats whose output extension differs from / needs normalising.
  46. # (All current formats use their own name as the extension.)
  47. # ══════════════════════════════════════════════════════════════════════════════
  48. # Logging
  49. # ══════════════════════════════════════════════════════════════════════════════
  50. _LOG_FMT = "%(asctime)s [%(levelname)s] %(message)s"
  51. _LOG_DATE = "%Y-%m-%d %H:%M:%S"
  52. _FORMATTER = logging.Formatter(_LOG_FMT, datefmt=_LOG_DATE)
  53. def _file_handler(path: Path) -> logging.FileHandler:
  54. h = logging.FileHandler(path, encoding="utf-8")
  55. h.setFormatter(_FORMATTER)
  56. return h
  57. # Root logger. Everything propagates up to here, so yaar.log + console always
  58. # carry the full picture regardless of which discrete logger emitted the line.
  59. log = logging.getLogger("yaar")
  60. log.setLevel(logging.DEBUG)
  61. log.addHandler(_file_handler(LOG_DIR / "yaar.log"))
  62. _console = logging.StreamHandler()
  63. _console.setFormatter(_FORMATTER)
  64. log.addHandler(_console)
  65. def _ts() -> str:
  66. """Filesystem-safe UTC timestamp, e.g. 2025-01-15_09-32-11."""
  67. return datetime.utcnow().strftime("%Y-%m-%d_%H-%M-%S")
  68. def _fresh_logger(name: str, path: Path) -> logging.Logger:
  69. """Return a uniquely-named child logger with exactly one file handler.
  70. Child loggers propagate to the root 'yaar' logger, so their records also
  71. land in yaar.log and on the console. The name is always unique (caller
  72. includes a uuid/job id), which guarantees we never stack a second handler
  73. onto a previously-created logger and emit duplicate lines.
  74. """
  75. lg = logging.getLogger(f"yaar.{name}")
  76. if not lg.handlers: # idempotent: never double-attach
  77. lg.addHandler(_file_handler(path))
  78. lg.setLevel(logging.DEBUG)
  79. lg.propagate = True
  80. return lg
  81. def boot_logger() -> logging.Logger:
  82. return _fresh_logger(f"boot.{_ts()}", BOOT_LOG_DIR / f"{_ts()}.log")
  83. def mount_logger(context: str = "") -> logging.Logger:
  84. tag = f"_{context}" if context else ""
  85. uid = uuid.uuid4().hex[:6]
  86. path = MOUNT_LOG_DIR / f"{_ts()}{tag}.log"
  87. return _fresh_logger(f"mount.{_ts()}.{uid}", path)
  88. def archive_logger(job_id: str, url: str) -> logging.Logger:
  89. """One file per job, named '<video_id>__<job_id>.log'.
  90. Keyed on job_id so retries reuse the same logger object (and append to the
  91. same file) without stacking duplicate handlers.
  92. """
  93. path = ARCHIVE_LOG_DIR / f"{video_id(url)}__{job_id}.log"
  94. return _fresh_logger(f"archive.{job_id}", path)
  95. # ══════════════════════════════════════════════════════════════════════════════
  96. # URL + filename helpers
  97. # ══════════════════════════════════════════════════════════════════════════════
  98. def sanitize(name: str) -> str:
  99. """Strip characters invalid in Linux/Windows filenames."""
  100. return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", name or "").strip()
  101. def clean_url(url: str) -> str:
  102. """Strip tracking/playlist params, keeping only what yt-dlp needs.
  103. youtube.com/watch → keep only ?v=<id>
  104. youtu.be / shorts → drop the entire query string (id is in the path)
  105. """
  106. parsed = urlparse((url or "").strip())
  107. host = parsed.netloc.lower().removeprefix("www.")
  108. if host == "youtube.com" and parsed.path.startswith("/watch"):
  109. v = parse_qs(parsed.query).get("v", [""])[0]
  110. query = urlencode({"v": v}) if v else ""
  111. return urlunparse(parsed._replace(query=query, fragment=""))
  112. return urlunparse(parsed._replace(query="", fragment=""))
  113. def video_id(url: str) -> str:
  114. """Extract a YouTube video id for use as a log filename.
  115. Falls back to a sanitised, truncated form of the URL when no id is found.
  116. """
  117. try:
  118. p = urlparse(url or "")
  119. host = p.netloc.lower().removeprefix("www.")
  120. if host == "youtu.be":
  121. vid = p.path.lstrip("/").split("/")[0]
  122. if vid:
  123. return vid
  124. if host == "youtube.com":
  125. v = parse_qs(p.query).get("v", [""])[0]
  126. if v:
  127. return v
  128. parts = [x for x in p.path.split("/") if x]
  129. if len(parts) >= 2 and parts[0] in ("shorts", "live", "embed"):
  130. return parts[1]
  131. except Exception:
  132. pass
  133. return re.sub(r"[^A-Za-z0-9_-]", "_", url or "unknown")[:60] or "unknown"
  134. # ══════════════════════════════════════════════════════════════════════════════
  135. # Boot
  136. # ══════════════════════════════════════════════════════════════════════════════
  137. app = Flask(__name__)
  138. # In-memory job registry, persisted to JOB_DIR as one JSON file per job.
  139. jobs: dict[str, dict] = {}
  140. # Polling support. `action_version` increments on every state mutation so the
  141. # frontend can drop stale, out-of-order poll responses. `server_instance` lets
  142. # the frontend detect a restart (version resets to 0) and re-sync instead of
  143. # freezing because the counter went backwards.
  144. action_version = 0
  145. server_instance = uuid.uuid4().hex[:12]
  146. def bump_version() -> int:
  147. global action_version
  148. action_version += 1
  149. return action_version
  150. def jobs_snapshot() -> dict:
  151. """Standard response body for every endpoint that lists or mutates jobs."""
  152. ordered = sorted(jobs.values(), key=lambda j: j.get("created_at", ""), reverse=True)
  153. return {
  154. "server_instance": server_instance,
  155. "action_version": action_version,
  156. "jobs": ordered[:50],
  157. }
  158. def save_job(job: dict) -> None:
  159. """Persist a job to disk, but only if it still exists in the registry.
  160. Guards against a deleted job being resurrected by a still-running worker
  161. thread that holds a closure reference to the orphaned dict. Bumps the
  162. action version so polling clients observe the change.
  163. """
  164. job_id = job.get("id")
  165. if not job_id or job_id not in jobs:
  166. return
  167. bump_version()
  168. (JOB_DIR / f"{job_id}.json").write_text(json.dumps(job, default=str, indent=2))
  169. def load_jobs() -> None:
  170. for p in sorted(JOB_DIR.glob("*.json"), key=lambda f: f.stat().st_mtime, reverse=True):
  171. try:
  172. j = json.loads(p.read_text())
  173. jobs[j["id"]] = j
  174. except Exception as e:
  175. log.warning(f"Could not load job {p}: {e}")
  176. load_jobs()
  177. _boot = boot_logger()
  178. _boot.info("=" * 60)
  179. _boot.info("YAAR service starting")
  180. _boot.info(f" BASE_DIR : {BASE_DIR}")
  181. _boot.info(f" MEDIA_DIR : {MEDIA_DIR}")
  182. _boot.info(f" LOG_DIR : {LOG_DIR}")
  183. _boot.info(f" JOB_DIR : {JOB_DIR}")
  184. _boot.info(f" Port : {PORT}")
  185. _boot.info(f" PID : {os.getpid()}")
  186. _boot.info(f"Restored {len(jobs)} job(s) from disk:")
  187. for _jid, _j in jobs.items():
  188. _boot.info(f" [{_jid}] {_j.get('status', '?'):8s} {_j.get('url', '')}")
  189. _boot.info("=" * 60)
  190. # ══════════════════════════════════════════════════════════════════════════════
  191. # Mount + path resolution
  192. # ══════════════════════════════════════════════════════════════════════════════
  193. def get_mount_info() -> dict:
  194. """Report mount status and which known subdirectories exist.
  195. Writes a discrete entry to log_mount/ on every call.
  196. """
  197. mlog = mount_logger("api")
  198. mlog.info(f"Mount check (API) — target: {MEDIA_DIR}")
  199. mounted = MEDIA_DIR.exists()
  200. writable = False
  201. subdirs: dict[str, dict] = {}
  202. if mounted:
  203. mlog.info(f" {MEDIA_DIR} exists")
  204. try:
  205. probe = MEDIA_DIR / ".yaar_write_test"
  206. probe.touch()
  207. probe.unlink()
  208. writable = True
  209. mlog.info(f" {MEDIA_DIR} is writable")
  210. except OSError as e:
  211. mlog.warning(f" {MEDIA_DIR} is NOT writable: {e}")
  212. for name in MEDIA_SECTIONS:
  213. p = MEDIA_DIR / name
  214. subdirs[name] = {"exists": p.exists(), "path": str(p)}
  215. mlog.info(f" subdir {name}/: {'present' if p.exists() else 'absent'}")
  216. else:
  217. mlog.warning(f" {MEDIA_DIR} does NOT exist — bind-mount (mp0) may be missing")
  218. mlog.info(f"Result: mounted={mounted} writable={writable}")
  219. return {"root": str(MEDIA_DIR), "mounted": mounted, "writable": writable, "subdirs": subdirs}
  220. def resolve_output_dir(job: dict) -> Path:
  221. """Return (and create) the directory a job's files should land in.
  222. Always rooted at MEDIA_DIR; raises ValueError on an unknown media type or
  223. if the resolved path would escape the media root.
  224. """
  225. mtype = job["media_type"]
  226. log.debug(f"resolve_output_dir: media_type={mtype!r} job_id={job.get('id')}")
  227. if mtype == "movie":
  228. out_dir = MEDIA_DIR / "Movies"
  229. elif mtype == "series":
  230. folder = sanitize(job.get("folder") or job.get("series") or "Unknown Series")
  231. season = int(job.get("season") or 1)
  232. out_dir = MEDIA_DIR / "Shows" / folder / f"Season {season:02d}"
  233. elif mtype == "music":
  234. # Jellyfin identifies music from embedded tags, not the folder path — it
  235. # only requires that each album live in its own folder. So we drop the
  236. # artist folder and use Music/<Album>/. Tracks with no album go into a
  237. # per-artist "<Artist> - Singles" folder, keeping the Music root clean
  238. # and giving loose tracks an album-like grouping in the library.
  239. album = sanitize(job.get("album") or "")
  240. if album:
  241. out_dir = MEDIA_DIR / "Music" / album
  242. else:
  243. artist = sanitize(job.get("artist") or job.get("author") or "Unknown Artist")
  244. out_dir = MEDIA_DIR / "Music" / f"{artist} - Singles"
  245. else:
  246. raise ValueError(f"Unknown media_type: {mtype!r}")
  247. try:
  248. out_dir.resolve().relative_to(MEDIA_DIR.resolve())
  249. except ValueError:
  250. raise ValueError(f"Resolved output path escapes media root: {out_dir}")
  251. out_dir.mkdir(parents=True, exist_ok=True)
  252. log.debug(f"resolve_output_dir: resolved → {out_dir}")
  253. return out_dir
  254. def output_extension(job: dict) -> str:
  255. """Final container/file extension for a job."""
  256. if job["media_type"] == "music":
  257. fmt = (job.get("format") or "m4a").lower()
  258. return fmt if fmt in ALLOWED_AUDIO_FORMATS else "m4a"
  259. return "mkv"
  260. def build_output_template(job: dict) -> Path:
  261. """Full yt-dlp outtmpl path, including the %(ext)s placeholder."""
  262. out_dir = resolve_output_dir(job)
  263. mtype = job["media_type"]
  264. title = sanitize(job.get("title") or "untitled")
  265. if mtype == "movie":
  266. year = job.get("year", "")
  267. base = f"{title} ({year})" if year else title
  268. filename = f"{base}.%(ext)s"
  269. elif mtype == "series":
  270. season = int(job.get("season") or 1)
  271. episode = int(job.get("episode") or 1)
  272. ep = sanitize(job.get("episode_title") or title)
  273. filename = f"S{season:02d}E{episode:02d} - {ep}.%(ext)s"
  274. elif mtype == "music":
  275. track = job.get("track")
  276. try:
  277. prefix = f"{int(track):02d} - " if track else ""
  278. except (ValueError, TypeError):
  279. prefix = ""
  280. filename = f"{prefix}{title}.%(ext)s"
  281. else:
  282. raise ValueError(f"Unknown media_type: {mtype!r}")
  283. return out_dir / filename
  284. # ══════════════════════════════════════════════════════════════════════════════
  285. # yt-dlp option building
  286. # ══════════════════════════════════════════════════════════════════════════════
  287. def build_ffmpeg_metadata_args(job: dict) -> list[str]:
  288. """ffmpeg -metadata flags embedded into the output container."""
  289. meta = {
  290. "title": job.get("title", ""),
  291. "artist": job.get("artist") or job.get("author", ""),
  292. "date": job.get("upload_date", ""),
  293. "comment": job.get("url", ""),
  294. "description": (job.get("description") or "")[:500],
  295. }
  296. if job["media_type"] == "series":
  297. meta["show"] = job.get("series", "")
  298. meta["season_number"] = str(job.get("season") or 1)
  299. meta["episode_id"] = str(job.get("episode") or 1)
  300. meta["episode_sort"] = str(job.get("episode") or 1)
  301. elif job["media_type"] == "music":
  302. if job.get("album"):
  303. meta["album"] = job["album"]
  304. if job.get("track"):
  305. meta["track"] = str(job["track"])
  306. if job.get("genre"):
  307. meta["genre"] = job["genre"]
  308. if job.get("year"):
  309. meta["date"] = str(job["year"])
  310. meta["album_artist"] = job.get("artist") or job.get("author", "")
  311. args: list[str] = []
  312. for k, v in meta.items():
  313. if v:
  314. args += ["-metadata", f"{k}={v}"]
  315. return args
  316. def build_ydl_opts(job: dict, output_template: Path, progress_hook) -> dict:
  317. """yt-dlp options. Music = audio-only; series/movie = H.264/AAC → MKV."""
  318. common = {
  319. "outtmpl": str(output_template),
  320. "writeinfojson": False,
  321. "postprocessor_args": {"ffmpeg": build_ffmpeg_metadata_args(job)},
  322. "progress_hooks": [progress_hook],
  323. "quiet": True,
  324. "no_warnings": True,
  325. "ignoreerrors": False,
  326. }
  327. if job["media_type"] == "music":
  328. fmt = (job.get("format") or "m4a").lower()
  329. if fmt not in ALLOWED_AUDIO_FORMATS:
  330. fmt = "m4a"
  331. # Opus lives in webm on YouTube; everything else starts from m4a/AAC.
  332. src = "bestaudio[ext=webm]/bestaudio/best" if fmt == "opus" \
  333. else "bestaudio[ext=m4a]/bestaudio/best"
  334. # Lossless/uncompressed containers don't take embedded cover art well.
  335. embed_thumb = fmt not in ("flac", "wav")
  336. post = [
  337. {"key": "FFmpegExtractAudio", "preferredcodec": fmt, "preferredquality": "0"},
  338. {"key": "FFmpegMetadata", "add_metadata": True},
  339. ]
  340. if embed_thumb:
  341. post.append({"key": "EmbedThumbnail", "already_have_thumbnail": False})
  342. return {
  343. **common,
  344. "format": src,
  345. "writethumbnail": embed_thumb,
  346. "embedthumbnail": embed_thumb,
  347. "postprocessors": post,
  348. }
  349. # Video (series / movie): prefer H.264 + AAC for Jellyfin direct-play,
  350. # falling back to best available if only VP9/AV1 is offered.
  351. return {
  352. **common,
  353. "format": (
  354. "bestvideo[vcodec^=avc1]+bestaudio[acodec^=mp4a]/"
  355. "bestvideo[vcodec^=avc1]+bestaudio/"
  356. "bestvideo+bestaudio/best"
  357. ),
  358. "merge_output_format": "mkv",
  359. "writethumbnail": True,
  360. "embedthumbnail": True,
  361. "postprocessors": [
  362. {"key": "FFmpegVideoConvertor", "preferedformat": "mkv"},
  363. {"key": "FFmpegMetadata", "add_metadata": True, "add_chapters": True},
  364. {"key": "EmbedThumbnail", "already_have_thumbnail": False},
  365. ],
  366. }
  367. def probe_url(url: str) -> dict:
  368. """Fetch metadata without downloading."""
  369. with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True, "skip_download": True}) as ydl:
  370. return ydl.extract_info(url, download=False)
  371. # ══════════════════════════════════════════════════════════════════════════════
  372. # NFO sidecar
  373. # ══════════════════════════════════════════════════════════════════════════════
  374. def write_nfo(job: dict, output_template: Path) -> None:
  375. """Write a Jellyfin NFO sidecar next to the media file.
  376. Series and movies get NFOs; music relies on embedded tags and is skipped.
  377. """
  378. nfo_path = Path(str(output_template).replace(".%(ext)s", ".nfo"))
  379. mtype = job["media_type"]
  380. if mtype == "series":
  381. nfo = f"""<?xml version="1.0" encoding="UTF-8"?>
  382. <episodedetails>
  383. <title>{job.get('episode_title') or job.get('title', '')}</title>
  384. <showtitle>{job.get('series', '')}</showtitle>
  385. <season>{job.get('season', 1)}</season>
  386. <episode>{job.get('episode', 1)}</episode>
  387. <plot>{job.get('description', '')}</plot>
  388. <aired>{job.get('upload_date', '')}</aired>
  389. <director>{job.get('author', '')}</director>
  390. <uniqueid type="youtube">{job.get('url', '')}</uniqueid>
  391. </episodedetails>"""
  392. elif mtype == "movie":
  393. nfo = f"""<?xml version="1.0" encoding="UTF-8"?>
  394. <movie>
  395. <title>{job.get('title', '')}</title>
  396. <year>{job.get('year', '')}</year>
  397. <plot>{job.get('description', '')}</plot>
  398. <director>{job.get('author', '')}</director>
  399. <uniqueid type="youtube">{job.get('url', '')}</uniqueid>
  400. </movie>"""
  401. else:
  402. return
  403. nfo_path.write_text(nfo, encoding="utf-8")
  404. log.info(f"NFO written: {nfo_path}")
  405. # ══════════════════════════════════════════════════════════════════════════════
  406. # Download worker
  407. # ══════════════════════════════════════════════════════════════════════════════
  408. class _Cancelled(Exception):
  409. """Raised inside the yt-dlp hook when a job is deleted mid-download."""
  410. def run_download(job_id: str) -> None:
  411. job = jobs[job_id]
  412. alog = archive_logger(job_id, job.get("url", ""))
  413. job.update(status="running", started_at=datetime.utcnow().isoformat(), progress=0, log=[])
  414. save_job(job)
  415. alog.info("=" * 60)
  416. alog.info("Archive job started")
  417. alog.info(f" Job ID : {job_id}")
  418. alog.info(f" URL : {job.get('url')}")
  419. alog.info(f" Media type : {job.get('media_type')}")
  420. alog.info(f" Title : {job.get('title') or '(pending probe)'}")
  421. if job.get("media_type") == "series":
  422. alog.info(f" Series : {job.get('series') or job.get('folder')}")
  423. alog.info(f" Episode : S{int(job.get('season') or 1):02d}E{int(job.get('episode') or 1):02d}")
  424. elif job.get("media_type") == "movie":
  425. alog.info(f" Year : {job.get('year') or '(unknown)'}")
  426. elif job.get("media_type") == "music":
  427. alog.info(f" Artist : {job.get('artist') or job.get('author')}")
  428. alog.info(f" Album : {job.get('album') or '(none)'}")
  429. alog.info(f" Format : {job.get('format') or 'm4a'}")
  430. alog.info("=" * 60)
  431. log.info(f"Job {job_id} started → {job['url']}")
  432. def hook(d):
  433. if job_id not in jobs:
  434. raise _Cancelled(f"Job {job_id} removed by user")
  435. if d["status"] == "downloading":
  436. pct = d.get("_percent_str", "0%").strip().replace("%", "")
  437. try:
  438. job["progress"] = float(pct)
  439. except ValueError:
  440. pass
  441. job["speed"] = d.get("_speed_str", "")
  442. job["eta"] = d.get("_eta_str", "")
  443. save_job(job)
  444. elif d["status"] == "finished":
  445. job["progress"] = 99
  446. fname = d.get("filename", "")
  447. job["log"].append(f"Downloaded: {fname}")
  448. alog.info(f"yt-dlp finished downloading: {fname}")
  449. save_job(job)
  450. try:
  451. # ── Pre-download mount check ──────────────────────────────────────────
  452. mlog = mount_logger(f"pre_archive_{job_id}")
  453. mlog.info(f"Pre-download mount check for job {job_id} — {MEDIA_DIR}")
  454. if not MEDIA_DIR.exists():
  455. mlog.error(f" FAIL — {MEDIA_DIR} does not exist")
  456. alog.error(f"Mount check failed: {MEDIA_DIR} does not exist")
  457. raise RuntimeError(
  458. f"Media root {MEDIA_DIR} does not exist. Check that the Proxmox "
  459. "bind-mount (mp0) is attached and the container is running."
  460. )
  461. if not os.access(MEDIA_DIR, os.W_OK):
  462. mlog.error(f" FAIL — {MEDIA_DIR} is not writable")
  463. alog.error(f"Mount check failed: {MEDIA_DIR} is not writable")
  464. raise RuntimeError(f"Media root {MEDIA_DIR} is not writable. Check container mount permissions.")
  465. mlog.info(f" OK — {MEDIA_DIR} is mounted and writable")
  466. # ── Resolve output path ───────────────────────────────────────────────
  467. alog.info("Resolving output directory…")
  468. output_template = build_output_template(job)
  469. job["output_path"] = str(output_template).replace(".%(ext)s", f".{output_extension(job)}")
  470. alog.info(f" Output : {job['output_path']}")
  471. save_job(job)
  472. # ── Download ──────────────────────────────────────────────────────────
  473. alog.info("Starting yt-dlp download…")
  474. with yt_dlp.YoutubeDL(build_ydl_opts(job, output_template, hook)) as ydl:
  475. info = ydl.extract_info(job["url"])
  476. if not job.get("author"):
  477. job["author"] = info.get("uploader") or info.get("channel") or "Unknown"
  478. if not job.get("upload_date"):
  479. raw = info.get("upload_date", "")
  480. if raw:
  481. job["upload_date"] = f"{raw[:4]}-{raw[4:6]}-{raw[6:]}"
  482. job["description"] = (info.get("description") or "")[:500]
  483. alog.info(f" Channel : {job.get('author')}")
  484. alog.info(f" Upload date: {job.get('upload_date')}")
  485. write_nfo(job, output_template)
  486. job.update(status="done", progress=100, finished_at=datetime.utcnow().isoformat())
  487. alog.info("-" * 60)
  488. alog.info("Archive COMPLETE")
  489. alog.info(f" Output : {job['output_path']}")
  490. alog.info(f" Finished at : {job['finished_at']}")
  491. alog.info("-" * 60)
  492. log.info(f"Job {job_id} complete → {job['output_path']}")
  493. except _Cancelled:
  494. alog.warning(f"Job {job_id} cancelled by user — download aborted")
  495. log.info(f"Job {job_id} cancelled by user")
  496. return
  497. except Exception as e:
  498. # A job that vanished mid-download is a cancellation, not an error.
  499. if job_id not in jobs:
  500. alog.warning(f"Job {job_id} removed during download — treating as cancellation")
  501. log.info(f"Job {job_id} removed during download")
  502. return
  503. job.update(status="error", error=str(e))
  504. alog.error(f"Archive FAILED: {e}")
  505. log.error(f"Job {job_id} failed: {e}")
  506. save_job(job)
  507. # ══════════════════════════════════════════════════════════════════════════════
  508. # Routes
  509. # ══════════════════════════════════════════════════════════════════════════════
  510. @app.route("/")
  511. def index():
  512. return render_template("index.html")
  513. @app.route("/api/mounts")
  514. def mounts():
  515. """Mount status for the UI banner."""
  516. return jsonify(get_mount_info())
  517. @app.route("/api/browse")
  518. def browse():
  519. """First-level subdirectories inside each managed media section."""
  520. result = {}
  521. for section in MEDIA_SECTIONS:
  522. p = MEDIA_DIR / section
  523. result[section] = sorted(
  524. e.name for e in p.iterdir() if e.is_dir() and not e.name.startswith(".")
  525. ) if p.is_dir() else []
  526. return jsonify(result)
  527. @app.route("/api/browse/<section>/<path:folder>")
  528. def browse_folder(section: str, folder: str):
  529. """Subdirectories one level deep inside MEDIA_DIR/<section>/<folder>."""
  530. if section not in MEDIA_SECTIONS:
  531. return jsonify({"error": "Unknown section"}), 400
  532. target = MEDIA_DIR / section / folder
  533. try:
  534. target.resolve().relative_to(MEDIA_DIR.resolve())
  535. except ValueError:
  536. return jsonify({"error": "Invalid path"}), 400
  537. if not target.is_dir():
  538. return jsonify([])
  539. return jsonify(sorted(
  540. e.name for e in target.iterdir() if e.is_dir() and not e.name.startswith(".")
  541. ))
  542. @app.route("/api/probe", methods=["POST"])
  543. def probe():
  544. """Fetch video metadata without downloading."""
  545. url = clean_url((request.json or {}).get("url", ""))
  546. if not url:
  547. return jsonify({"error": "No URL provided"}), 400
  548. try:
  549. info = probe_url(url)
  550. upload_date = info.get("upload_date", "")
  551. if upload_date:
  552. upload_date = f"{upload_date[:4]}-{upload_date[4:6]}-{upload_date[6:]}"
  553. return jsonify({
  554. "title": info.get("title", ""),
  555. "author": info.get("uploader") or info.get("channel") or "",
  556. "upload_date": upload_date,
  557. "duration": info.get("duration_string") or str(info.get("duration", "")),
  558. "thumbnail": info.get("thumbnail", ""),
  559. "description": (info.get("description") or "")[:300],
  560. "year": upload_date[:4] if upload_date else "",
  561. })
  562. except Exception as e:
  563. return jsonify({"error": str(e)}), 500
  564. @app.route("/api/archive", methods=["POST"])
  565. def archive():
  566. """Validate inputs and enqueue a download job."""
  567. data = request.json or {}
  568. url = clean_url(data.get("url", ""))
  569. if not url:
  570. return jsonify({"error": "URL is required"}), 400
  571. # Fail fast if the mount is missing, before creating a doomed job.
  572. if not MEDIA_DIR.exists():
  573. return jsonify({
  574. "error": f"Media root '{MEDIA_DIR}' is not accessible. Ensure the "
  575. "Proxmox bind-mount (mp0) is configured and the container is started."
  576. }), 503
  577. mtype = data.get("media_type", "series")
  578. if mtype not in ("series", "movie", "music"):
  579. return jsonify({"error": f"media_type must be 'series', 'movie', or 'music' (got: {mtype!r})"}), 400
  580. if mtype == "music":
  581. fmt = (data.get("format") or "m4a").lower()
  582. if fmt not in ALLOWED_AUDIO_FORMATS:
  583. return jsonify({"error": f"format must be one of {'/'.join(ALLOWED_AUDIO_FORMATS)} (got: {fmt!r})"}), 400
  584. job_id = str(uuid.uuid4())[:8]
  585. jobs[job_id] = {
  586. "id": job_id,
  587. "url": url,
  588. "media_type": mtype,
  589. "title": data.get("title", ""),
  590. "author": data.get("author", ""),
  591. "upload_date": data.get("upload_date", ""),
  592. "description": data.get("description", ""),
  593. "year": data.get("year", ""),
  594. # series
  595. "series": data.get("series", ""),
  596. "season": int(data.get("season") or 1),
  597. "episode": int(data.get("episode") or 1),
  598. "episode_title": data.get("episode_title", ""),
  599. "folder": data.get("folder", ""),
  600. # music
  601. "artist": data.get("artist", ""),
  602. "album": data.get("album", ""),
  603. "track": data.get("track", ""),
  604. "genre": data.get("genre", ""),
  605. "format": (data.get("format") or "m4a").lower(),
  606. # status
  607. "output_path": "",
  608. "status": "queued",
  609. "progress": 0,
  610. "created_at": datetime.utcnow().isoformat(),
  611. }
  612. save_job(jobs[job_id])
  613. # Build the response snapshot BEFORE starting the worker. A very fast job
  614. # (cached file, instant error) could otherwise run to completion between the
  615. # thread start and the snapshot, returning a half-written or already-done
  616. # state that races the client's own polling. Snapshotting first guarantees
  617. # the response is a clean, consistent "queued" view; the subsequent progress
  618. # and completion are delivered through normal polling.
  619. response = {"job_id": job_id, **jobs_snapshot()}
  620. Thread(target=run_download, args=(job_id,), daemon=True).start()
  621. log.info(f"Job {job_id} queued: {url}")
  622. return jsonify(response)
  623. @app.route("/api/jobs")
  624. def list_jobs():
  625. return jsonify(jobs_snapshot())
  626. @app.route("/api/jobs/<job_id>")
  627. def get_job(job_id):
  628. job = jobs.get(job_id)
  629. if not job:
  630. return jsonify({"error": "Job not found"}), 404
  631. return jsonify(job)
  632. @app.route("/api/jobs/<job_id>/retry", methods=["POST"])
  633. def retry_job(job_id):
  634. job = jobs.get(job_id)
  635. if not job:
  636. return jsonify({"error": "Job not found"}), 404
  637. job.update(status="queued", progress=0, error="")
  638. save_job(job)
  639. Thread(target=run_download, args=(job_id,), daemon=True).start()
  640. return jsonify({"ok": True, **jobs_snapshot()})
  641. @app.route("/api/jobs/<job_id>", methods=["DELETE"])
  642. def delete_job(job_id):
  643. if jobs.pop(job_id, None) is not None:
  644. bump_version()
  645. p = JOB_DIR / f"{job_id}.json"
  646. if p.exists():
  647. p.unlink()
  648. return jsonify({"ok": True, **jobs_snapshot()})
  649. @app.route("/api/jobs", methods=["DELETE"])
  650. def clear_finished_jobs():
  651. """Remove all done/error jobs. Active jobs are left untouched."""
  652. removed = []
  653. for job_id in [jid for jid, j in jobs.items() if j.get("status") in ("done", "error")]:
  654. jobs.pop(job_id, None)
  655. p = JOB_DIR / f"{job_id}.json"
  656. if p.exists():
  657. p.unlink()
  658. removed.append(job_id)
  659. if removed:
  660. bump_version()
  661. return jsonify({"removed": removed, "count": len(removed), **jobs_snapshot()})
  662. if __name__ == "__main__":
  663. app.run(host="0.0.0.0", port=PORT, debug=False)