app.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809
  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. artist = sanitize(job.get("artist") or job.get("author") or "Unknown Artist")
  235. album = sanitize(job.get("album") or "")
  236. out_dir = MEDIA_DIR / "Music" / artist
  237. if album:
  238. out_dir = out_dir / album
  239. else:
  240. raise ValueError(f"Unknown media_type: {mtype!r}")
  241. try:
  242. out_dir.resolve().relative_to(MEDIA_DIR.resolve())
  243. except ValueError:
  244. raise ValueError(f"Resolved output path escapes media root: {out_dir}")
  245. out_dir.mkdir(parents=True, exist_ok=True)
  246. log.debug(f"resolve_output_dir: resolved → {out_dir}")
  247. return out_dir
  248. def output_extension(job: dict) -> str:
  249. """Final container/file extension for a job."""
  250. if job["media_type"] == "music":
  251. fmt = (job.get("format") or "m4a").lower()
  252. return fmt if fmt in ALLOWED_AUDIO_FORMATS else "m4a"
  253. return "mkv"
  254. def build_output_template(job: dict) -> Path:
  255. """Full yt-dlp outtmpl path, including the %(ext)s placeholder."""
  256. out_dir = resolve_output_dir(job)
  257. mtype = job["media_type"]
  258. title = sanitize(job.get("title") or "untitled")
  259. if mtype == "movie":
  260. year = job.get("year", "")
  261. base = f"{title} ({year})" if year else title
  262. filename = f"{base}.%(ext)s"
  263. elif mtype == "series":
  264. season = int(job.get("season") or 1)
  265. episode = int(job.get("episode") or 1)
  266. ep = sanitize(job.get("episode_title") or title)
  267. filename = f"S{season:02d}E{episode:02d} - {ep}.%(ext)s"
  268. elif mtype == "music":
  269. track = job.get("track")
  270. try:
  271. prefix = f"{int(track):02d} - " if track else ""
  272. except (ValueError, TypeError):
  273. prefix = ""
  274. filename = f"{prefix}{title}.%(ext)s"
  275. else:
  276. raise ValueError(f"Unknown media_type: {mtype!r}")
  277. return out_dir / filename
  278. # ══════════════════════════════════════════════════════════════════════════════
  279. # yt-dlp option building
  280. # ══════════════════════════════════════════════════════════════════════════════
  281. def build_ffmpeg_metadata_args(job: dict) -> list[str]:
  282. """ffmpeg -metadata flags embedded into the output container."""
  283. meta = {
  284. "title": job.get("title", ""),
  285. "artist": job.get("artist") or job.get("author", ""),
  286. "date": job.get("upload_date", ""),
  287. "comment": job.get("url", ""),
  288. "description": (job.get("description") or "")[:500],
  289. }
  290. if job["media_type"] == "series":
  291. meta["show"] = job.get("series", "")
  292. meta["season_number"] = str(job.get("season") or 1)
  293. meta["episode_id"] = str(job.get("episode") or 1)
  294. meta["episode_sort"] = str(job.get("episode") or 1)
  295. elif job["media_type"] == "music":
  296. if job.get("album"):
  297. meta["album"] = job["album"]
  298. if job.get("track"):
  299. meta["track"] = str(job["track"])
  300. if job.get("genre"):
  301. meta["genre"] = job["genre"]
  302. if job.get("year"):
  303. meta["date"] = str(job["year"])
  304. meta["album_artist"] = job.get("artist") or job.get("author", "")
  305. args: list[str] = []
  306. for k, v in meta.items():
  307. if v:
  308. args += ["-metadata", f"{k}={v}"]
  309. return args
  310. def build_ydl_opts(job: dict, output_template: Path, progress_hook) -> dict:
  311. """yt-dlp options. Music = audio-only; series/movie = H.264/AAC → MKV."""
  312. common = {
  313. "outtmpl": str(output_template),
  314. "writeinfojson": False,
  315. "postprocessor_args": {"ffmpeg": build_ffmpeg_metadata_args(job)},
  316. "progress_hooks": [progress_hook],
  317. "quiet": True,
  318. "no_warnings": True,
  319. "ignoreerrors": False,
  320. }
  321. if job["media_type"] == "music":
  322. fmt = (job.get("format") or "m4a").lower()
  323. if fmt not in ALLOWED_AUDIO_FORMATS:
  324. fmt = "m4a"
  325. # Opus lives in webm on YouTube; everything else starts from m4a/AAC.
  326. src = "bestaudio[ext=webm]/bestaudio/best" if fmt == "opus" \
  327. else "bestaudio[ext=m4a]/bestaudio/best"
  328. # Lossless/uncompressed containers don't take embedded cover art well.
  329. embed_thumb = fmt not in ("flac", "wav")
  330. post = [
  331. {"key": "FFmpegExtractAudio", "preferredcodec": fmt, "preferredquality": "0"},
  332. {"key": "FFmpegMetadata", "add_metadata": True},
  333. ]
  334. if embed_thumb:
  335. post.append({"key": "EmbedThumbnail", "already_have_thumbnail": False})
  336. return {
  337. **common,
  338. "format": src,
  339. "writethumbnail": embed_thumb,
  340. "embedthumbnail": embed_thumb,
  341. "postprocessors": post,
  342. }
  343. # Video (series / movie): prefer H.264 + AAC for Jellyfin direct-play,
  344. # falling back to best available if only VP9/AV1 is offered.
  345. return {
  346. **common,
  347. "format": (
  348. "bestvideo[vcodec^=avc1]+bestaudio[acodec^=mp4a]/"
  349. "bestvideo[vcodec^=avc1]+bestaudio/"
  350. "bestvideo+bestaudio/best"
  351. ),
  352. "merge_output_format": "mkv",
  353. "writethumbnail": True,
  354. "embedthumbnail": True,
  355. "postprocessors": [
  356. {"key": "FFmpegVideoConvertor", "preferedformat": "mkv"},
  357. {"key": "FFmpegMetadata", "add_metadata": True, "add_chapters": True},
  358. {"key": "EmbedThumbnail", "already_have_thumbnail": False},
  359. ],
  360. }
  361. def probe_url(url: str) -> dict:
  362. """Fetch metadata without downloading."""
  363. with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True, "skip_download": True}) as ydl:
  364. return ydl.extract_info(url, download=False)
  365. # ══════════════════════════════════════════════════════════════════════════════
  366. # NFO sidecar
  367. # ══════════════════════════════════════════════════════════════════════════════
  368. def write_nfo(job: dict, output_template: Path) -> None:
  369. """Write a Jellyfin NFO sidecar next to the media file.
  370. Series and movies get NFOs; music relies on embedded tags and is skipped.
  371. """
  372. nfo_path = Path(str(output_template).replace(".%(ext)s", ".nfo"))
  373. mtype = job["media_type"]
  374. if mtype == "series":
  375. nfo = f"""<?xml version="1.0" encoding="UTF-8"?>
  376. <episodedetails>
  377. <title>{job.get('episode_title') or job.get('title', '')}</title>
  378. <showtitle>{job.get('series', '')}</showtitle>
  379. <season>{job.get('season', 1)}</season>
  380. <episode>{job.get('episode', 1)}</episode>
  381. <plot>{job.get('description', '')}</plot>
  382. <aired>{job.get('upload_date', '')}</aired>
  383. <director>{job.get('author', '')}</director>
  384. <uniqueid type="youtube">{job.get('url', '')}</uniqueid>
  385. </episodedetails>"""
  386. elif mtype == "movie":
  387. nfo = f"""<?xml version="1.0" encoding="UTF-8"?>
  388. <movie>
  389. <title>{job.get('title', '')}</title>
  390. <year>{job.get('year', '')}</year>
  391. <plot>{job.get('description', '')}</plot>
  392. <director>{job.get('author', '')}</director>
  393. <uniqueid type="youtube">{job.get('url', '')}</uniqueid>
  394. </movie>"""
  395. else:
  396. return
  397. nfo_path.write_text(nfo, encoding="utf-8")
  398. log.info(f"NFO written: {nfo_path}")
  399. # ══════════════════════════════════════════════════════════════════════════════
  400. # Download worker
  401. # ══════════════════════════════════════════════════════════════════════════════
  402. class _Cancelled(Exception):
  403. """Raised inside the yt-dlp hook when a job is deleted mid-download."""
  404. def run_download(job_id: str) -> None:
  405. job = jobs[job_id]
  406. alog = archive_logger(job_id, job.get("url", ""))
  407. job.update(status="running", started_at=datetime.utcnow().isoformat(), progress=0, log=[])
  408. save_job(job)
  409. alog.info("=" * 60)
  410. alog.info("Archive job started")
  411. alog.info(f" Job ID : {job_id}")
  412. alog.info(f" URL : {job.get('url')}")
  413. alog.info(f" Media type : {job.get('media_type')}")
  414. alog.info(f" Title : {job.get('title') or '(pending probe)'}")
  415. if job.get("media_type") == "series":
  416. alog.info(f" Series : {job.get('series') or job.get('folder')}")
  417. alog.info(f" Episode : S{int(job.get('season') or 1):02d}E{int(job.get('episode') or 1):02d}")
  418. elif job.get("media_type") == "movie":
  419. alog.info(f" Year : {job.get('year') or '(unknown)'}")
  420. elif job.get("media_type") == "music":
  421. alog.info(f" Artist : {job.get('artist') or job.get('author')}")
  422. alog.info(f" Album : {job.get('album') or '(none)'}")
  423. alog.info(f" Format : {job.get('format') or 'm4a'}")
  424. alog.info("=" * 60)
  425. log.info(f"Job {job_id} started → {job['url']}")
  426. def hook(d):
  427. if job_id not in jobs:
  428. raise _Cancelled(f"Job {job_id} removed by user")
  429. if d["status"] == "downloading":
  430. pct = d.get("_percent_str", "0%").strip().replace("%", "")
  431. try:
  432. job["progress"] = float(pct)
  433. except ValueError:
  434. pass
  435. job["speed"] = d.get("_speed_str", "")
  436. job["eta"] = d.get("_eta_str", "")
  437. save_job(job)
  438. elif d["status"] == "finished":
  439. job["progress"] = 99
  440. fname = d.get("filename", "")
  441. job["log"].append(f"Downloaded: {fname}")
  442. alog.info(f"yt-dlp finished downloading: {fname}")
  443. save_job(job)
  444. try:
  445. # ── Pre-download mount check ──────────────────────────────────────────
  446. mlog = mount_logger(f"pre_archive_{job_id}")
  447. mlog.info(f"Pre-download mount check for job {job_id} — {MEDIA_DIR}")
  448. if not MEDIA_DIR.exists():
  449. mlog.error(f" FAIL — {MEDIA_DIR} does not exist")
  450. alog.error(f"Mount check failed: {MEDIA_DIR} does not exist")
  451. raise RuntimeError(
  452. f"Media root {MEDIA_DIR} does not exist. Check that the Proxmox "
  453. "bind-mount (mp0) is attached and the container is running."
  454. )
  455. if not os.access(MEDIA_DIR, os.W_OK):
  456. mlog.error(f" FAIL — {MEDIA_DIR} is not writable")
  457. alog.error(f"Mount check failed: {MEDIA_DIR} is not writable")
  458. raise RuntimeError(f"Media root {MEDIA_DIR} is not writable. Check container mount permissions.")
  459. mlog.info(f" OK — {MEDIA_DIR} is mounted and writable")
  460. # ── Resolve output path ───────────────────────────────────────────────
  461. alog.info("Resolving output directory…")
  462. output_template = build_output_template(job)
  463. job["output_path"] = str(output_template).replace(".%(ext)s", f".{output_extension(job)}")
  464. alog.info(f" Output : {job['output_path']}")
  465. save_job(job)
  466. # ── Download ──────────────────────────────────────────────────────────
  467. alog.info("Starting yt-dlp download…")
  468. with yt_dlp.YoutubeDL(build_ydl_opts(job, output_template, hook)) as ydl:
  469. info = ydl.extract_info(job["url"])
  470. if not job.get("author"):
  471. job["author"] = info.get("uploader") or info.get("channel") or "Unknown"
  472. if not job.get("upload_date"):
  473. raw = info.get("upload_date", "")
  474. if raw:
  475. job["upload_date"] = f"{raw[:4]}-{raw[4:6]}-{raw[6:]}"
  476. job["description"] = (info.get("description") or "")[:500]
  477. alog.info(f" Channel : {job.get('author')}")
  478. alog.info(f" Upload date: {job.get('upload_date')}")
  479. write_nfo(job, output_template)
  480. job.update(status="done", progress=100, finished_at=datetime.utcnow().isoformat())
  481. alog.info("-" * 60)
  482. alog.info("Archive COMPLETE")
  483. alog.info(f" Output : {job['output_path']}")
  484. alog.info(f" Finished at : {job['finished_at']}")
  485. alog.info("-" * 60)
  486. log.info(f"Job {job_id} complete → {job['output_path']}")
  487. except _Cancelled:
  488. alog.warning(f"Job {job_id} cancelled by user — download aborted")
  489. log.info(f"Job {job_id} cancelled by user")
  490. return
  491. except Exception as e:
  492. # A job that vanished mid-download is a cancellation, not an error.
  493. if job_id not in jobs:
  494. alog.warning(f"Job {job_id} removed during download — treating as cancellation")
  495. log.info(f"Job {job_id} removed during download")
  496. return
  497. job.update(status="error", error=str(e))
  498. alog.error(f"Archive FAILED: {e}")
  499. log.error(f"Job {job_id} failed: {e}")
  500. save_job(job)
  501. # ══════════════════════════════════════════════════════════════════════════════
  502. # Routes
  503. # ══════════════════════════════════════════════════════════════════════════════
  504. @app.route("/")
  505. def index():
  506. return render_template("index.html")
  507. @app.route("/api/mounts")
  508. def mounts():
  509. """Mount status for the UI banner."""
  510. return jsonify(get_mount_info())
  511. @app.route("/api/browse")
  512. def browse():
  513. """First-level subdirectories inside each managed media section."""
  514. result = {}
  515. for section in MEDIA_SECTIONS:
  516. p = MEDIA_DIR / section
  517. result[section] = sorted(
  518. e.name for e in p.iterdir() if e.is_dir() and not e.name.startswith(".")
  519. ) if p.is_dir() else []
  520. return jsonify(result)
  521. @app.route("/api/browse/<section>/<path:folder>")
  522. def browse_folder(section: str, folder: str):
  523. """Subdirectories one level deep inside MEDIA_DIR/<section>/<folder>."""
  524. if section not in MEDIA_SECTIONS:
  525. return jsonify({"error": "Unknown section"}), 400
  526. target = MEDIA_DIR / section / folder
  527. try:
  528. target.resolve().relative_to(MEDIA_DIR.resolve())
  529. except ValueError:
  530. return jsonify({"error": "Invalid path"}), 400
  531. if not target.is_dir():
  532. return jsonify([])
  533. return jsonify(sorted(
  534. e.name for e in target.iterdir() if e.is_dir() and not e.name.startswith(".")
  535. ))
  536. @app.route("/api/probe", methods=["POST"])
  537. def probe():
  538. """Fetch video metadata without downloading."""
  539. url = clean_url((request.json or {}).get("url", ""))
  540. if not url:
  541. return jsonify({"error": "No URL provided"}), 400
  542. try:
  543. info = probe_url(url)
  544. upload_date = info.get("upload_date", "")
  545. if upload_date:
  546. upload_date = f"{upload_date[:4]}-{upload_date[4:6]}-{upload_date[6:]}"
  547. return jsonify({
  548. "title": info.get("title", ""),
  549. "author": info.get("uploader") or info.get("channel") or "",
  550. "upload_date": upload_date,
  551. "duration": info.get("duration_string") or str(info.get("duration", "")),
  552. "thumbnail": info.get("thumbnail", ""),
  553. "description": (info.get("description") or "")[:300],
  554. "year": upload_date[:4] if upload_date else "",
  555. })
  556. except Exception as e:
  557. return jsonify({"error": str(e)}), 500
  558. @app.route("/api/archive", methods=["POST"])
  559. def archive():
  560. """Validate inputs and enqueue a download job."""
  561. data = request.json or {}
  562. url = clean_url(data.get("url", ""))
  563. if not url:
  564. return jsonify({"error": "URL is required"}), 400
  565. # Fail fast if the mount is missing, before creating a doomed job.
  566. if not MEDIA_DIR.exists():
  567. return jsonify({
  568. "error": f"Media root '{MEDIA_DIR}' is not accessible. Ensure the "
  569. "Proxmox bind-mount (mp0) is configured and the container is started."
  570. }), 503
  571. mtype = data.get("media_type", "series")
  572. if mtype not in ("series", "movie", "music"):
  573. return jsonify({"error": f"media_type must be 'series', 'movie', or 'music' (got: {mtype!r})"}), 400
  574. if mtype == "music":
  575. fmt = (data.get("format") or "m4a").lower()
  576. if fmt not in ALLOWED_AUDIO_FORMATS:
  577. return jsonify({"error": f"format must be one of {'/'.join(ALLOWED_AUDIO_FORMATS)} (got: {fmt!r})"}), 400
  578. job_id = str(uuid.uuid4())[:8]
  579. jobs[job_id] = {
  580. "id": job_id,
  581. "url": url,
  582. "media_type": mtype,
  583. "title": data.get("title", ""),
  584. "author": data.get("author", ""),
  585. "upload_date": data.get("upload_date", ""),
  586. "description": data.get("description", ""),
  587. "year": data.get("year", ""),
  588. # series
  589. "series": data.get("series", ""),
  590. "season": int(data.get("season") or 1),
  591. "episode": int(data.get("episode") or 1),
  592. "episode_title": data.get("episode_title", ""),
  593. "folder": data.get("folder", ""),
  594. # music
  595. "artist": data.get("artist", ""),
  596. "album": data.get("album", ""),
  597. "track": data.get("track", ""),
  598. "genre": data.get("genre", ""),
  599. "format": (data.get("format") or "m4a").lower(),
  600. # status
  601. "output_path": "",
  602. "status": "queued",
  603. "progress": 0,
  604. "created_at": datetime.utcnow().isoformat(),
  605. }
  606. save_job(jobs[job_id])
  607. Thread(target=run_download, args=(job_id,), daemon=True).start()
  608. log.info(f"Job {job_id} queued: {url}")
  609. return jsonify({"job_id": job_id, **jobs_snapshot()})
  610. @app.route("/api/jobs")
  611. def list_jobs():
  612. return jsonify(jobs_snapshot())
  613. @app.route("/api/jobs/<job_id>")
  614. def get_job(job_id):
  615. job = jobs.get(job_id)
  616. if not job:
  617. return jsonify({"error": "Job not found"}), 404
  618. return jsonify(job)
  619. @app.route("/api/jobs/<job_id>/retry", methods=["POST"])
  620. def retry_job(job_id):
  621. job = jobs.get(job_id)
  622. if not job:
  623. return jsonify({"error": "Job not found"}), 404
  624. job.update(status="queued", progress=0, error="")
  625. save_job(job)
  626. Thread(target=run_download, args=(job_id,), daemon=True).start()
  627. return jsonify({"ok": True, **jobs_snapshot()})
  628. @app.route("/api/jobs/<job_id>", methods=["DELETE"])
  629. def delete_job(job_id):
  630. if jobs.pop(job_id, None) is not None:
  631. bump_version()
  632. p = JOB_DIR / f"{job_id}.json"
  633. if p.exists():
  634. p.unlink()
  635. return jsonify({"ok": True, **jobs_snapshot()})
  636. @app.route("/api/jobs", methods=["DELETE"])
  637. def clear_finished_jobs():
  638. """Remove all done/error jobs. Active jobs are left untouched."""
  639. removed = []
  640. for job_id in [jid for jid, j in jobs.items() if j.get("status") in ("done", "error")]:
  641. jobs.pop(job_id, None)
  642. p = JOB_DIR / f"{job_id}.json"
  643. if p.exists():
  644. p.unlink()
  645. removed.append(job_id)
  646. if removed:
  647. bump_version()
  648. return jsonify({"removed": removed, "count": len(removed), **jobs_snapshot()})
  649. if __name__ == "__main__":
  650. app.run(host="0.0.0.0", port=PORT, debug=False)