diff --git a/.vscode/settings.json b/.vscode/settings.json index f6c84fea..e7d38b11 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -124,6 +124,7 @@ "Microformat", "microformats", "mkvtoolsnix", + "MMDD", "movflags", "mpegts", "msvideo", diff --git a/API.md b/API.md index c2169f3b..c9bcfb55 100644 --- a/API.md +++ b/API.md @@ -31,6 +31,7 @@ This document describes the available endpoints and their usage. All endpoints r - [POST /api/history/cancel](#post-apihistorycancel) - [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive) - [POST /api/history/{id}/archive](#post-apihistoryidarchive) + - [POST /api/history/{id}/nfo](#post-apihistoryidnfo) - [GET /api/archiver](#get-apiarchiver) - [POST /api/archiver](#post-apiarchiver) - [DELETE /api/archiver](#delete-apiarchiver) @@ -770,6 +771,58 @@ or an error: --- +### POST /api/history/{id}/nfo +**Purpose**: Generate an NFO metadata file for a completed download. + +**Path Parameter**: +- `id`: Item ID from the history. + +**Body** (optional): +```json +{ + "type": "tv", + "overwrite": false +} +``` + +**Body Parameters**: +- `type` (string, optional): NFO format type. Either `"tv"` or `"movie"`. Default: `"tv"`. +- `overwrite` (boolean, optional): Overwrite existing NFO file. Default: `false`. + +**Response** (success): +```json +{ + "message": "NFO file created", + "nfo_file": "/path/to/file.nfo" +} +``` + +**Response** (NFO already exists): +```json +{ + "error": "NFO file already exists." +} +``` + +**Error Responses**: +- `400 Bad Request` if: + - Item has no downloaded file + - `type` is not `"tv"` or `"movie"` + - Failed to collect NFO data (e.g., invalid/no date) +- `404 Not Found` if the item or media file doesn't exist +- `409 Conflict` if NFO file already exists and `overwrite` is `false` +- `500 Internal Server Error` if failed to extract metadata from URL + +**Notes**: +- Fetches fresh metadata from the source URL using yt-dlp +- Creates NFO file in the same directory as the media file with `.nfo` extension +- Requires a valid upload/release date in the metadata to create NFO +- The NFO format follows Kodi/Jellyfin/Emby compatible structure +- TV mode generates `` XML +- Movie mode generates `` XML + +--- + ### GET /api/archiver **Purpose**: Read entries from the download archive associated with a preset. diff --git a/app/routes/api/history.py b/app/routes/api/history.py index 056e9db3..e29d7fc8 100644 --- a/app/routes/api/history.py +++ b/app/routes/api/history.py @@ -637,3 +637,96 @@ async def item_archive_delete(request: Request, queue: DownloadQueue, notify: Ev data={"message": f"item '{item.info.title}' removed from archive."}, status=web.HTTPOk.status_code, ) + + +@route("POST", r"api/history/{id}/nfo", "history.item.nfo.generate") +async def item_nfo_generate(request: Request, queue: DownloadQueue) -> Response: + """ + Generate NFO file for an existing download. + + Args: + request: The request object + queue: DownloadQueue instance + + Request Body: + type: 'tv' or 'movie' (default: 'tv') + overwrite: bool (default: false) + + Returns: + Response with NFO file path on success + + """ + from app.features.ytdlp.extractor import fetch_info + from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP + + if not (id := request.match_info.get("id")): + return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) + + try: + item: Download | None = await queue.done.get_by_id(id) + if not item: + return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code) + except KeyError: + return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code) + + if not item.info.filename: + return web.json_response( + data={"error": "item has no downloaded file."}, + status=web.HTTPBadRequest.status_code, + ) + + filepath = item.info.get_file() + if not filepath or not filepath.exists(): + return web.json_response( + data={"error": f"file '{item.info.filename}' not found."}, + status=web.HTTPNotFound.status_code, + ) + + post = {} + if request.body_exists: + try: + post = await request.json() or {} + except Exception: + post = {} + + mode: str = str(post.get("type", "tv")).lower() + if mode not in ("tv", "movie"): + return web.json_response( + data={"error": "type must be 'tv' or 'movie'."}, + status=web.HTTPBadRequest.status_code, + ) + + try: + (info_dict, _logs) = await fetch_info( + config=item.info.get_ytdlp_opts().get_all(), + url=item.info.url, + no_archive=True, + follow_redirect=True, + ) + + if not info_dict: + return web.json_response( + data={"error": "failed to extract metadata from URL."}, + status=web.HTTPInternalServerError.status_code, + ) + + result = NFOMakerPP.generate_nfo( + info_dict=info_dict, + filepath=filepath, + mode=mode, + overwrite=bool(post.get("overwrite", False)), + logger=LOG, + ) + + return web.json_response( + data={"message": result["message"], "nfo_file": result.get("nfo_file")} + if result["success"] + else {"error": result["message"]}, + status=web.HTTPOk.status_code if result["success"] else web.HTTPBadRequest.status_code, + ) + except Exception as e: + LOG.exception(f"Failed to generate NFO for item '{id}': {e}") + return web.json_response( + data={"error": f"failed to generate NFO: {e!s}"}, + status=web.HTTPInternalServerError.status_code, + ) diff --git a/app/tests/test_nfo_maker.py b/app/tests/test_nfo_maker.py new file mode 100644 index 00000000..205d2f15 --- /dev/null +++ b/app/tests/test_nfo_maker.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP + +TEST_DIR = Path("var/tmp/tests_nfo") +TEST_DIR.mkdir(parents=True, exist_ok=True) + + +def _clean_file(path: Path) -> None: + try: + path.unlink() + except FileNotFoundError: + pass + + +def sample_info_tv() -> dict: + return { + "id": "abc123", + "title": "Test Show - S01E02", + "uploader": "uploader_name", + "upload_date": "20230102", + "season_number": 1, + "episode_number": 2, + "description": "First line.\n#tag\n00:01:23 Intro", + "extractor": "youtube", + "filename": str(TEST_DIR / "test_show_s01e02.mkv"), + } + + +def sample_info_movie() -> dict: + return { + "id": "mov001", + "title": "Test Movie", + "uploader": "studio", + "release_date": "20221225", + "duration": 7200, + "thumbnail": "http://example.com/thumb.jpg", + "webpage_url": "http://example.com/trailer", + "filename": str(TEST_DIR / "test_movie.mp4"), + } + + +def test_generate_nfo_tv_mode(tmp_path): + # arrange + TEST_DIR.mkdir(parents=True, exist_ok=True) + info = sample_info_tv() + media_file = Path(info["filename"]) + media_file.write_text("dummy") + nfo_path = media_file.with_suffix(".nfo") + _clean_file(nfo_path) + + # act + res = NFOMakerPP.generate_nfo(info_dict=info, filepath=media_file, mode="tv", overwrite=True, prefix=True) + + # assert + assert res["success"] is True + assert nfo_path.exists() + + content = nfo_path.read_text(encoding="utf-8") + assert "" in content + assert "Test Show - S01E02" in content + # description cleaned (no timestamps / hashtags) + assert "Intro" not in content + assert "#tag" not in content + + # also ensure PostProcessor.run wrapper produces the same result and syncs mtime + _clean_file(nfo_path) + pp = NFOMakerPP(None, mode="tv", prefix=True) + media_mtime = media_file.stat().st_mtime + _, info_out = pp.run(info.copy()) + assert nfo_path.exists() + nfo_mtime = nfo_path.stat().st_mtime + assert abs(nfo_mtime - media_mtime) < 2.0 + + # cleanup + media_file.unlink() + nfo_path.unlink() + + +def test_generate_nfo_movie_mode_and_run_wrapper(tmp_path): + # arrange + TEST_DIR.mkdir(parents=True, exist_ok=True) + info = sample_info_movie() + media_file = Path(info["filename"]) + media_file.write_text("dummy-movie") + nfo_path = media_file.with_suffix(".nfo") + _clean_file(nfo_path) + + # act: generate_nfo static + res = NFOMakerPP.generate_nfo(info_dict=info, filepath=media_file, mode="movie", overwrite=True, prefix=False) + + # assert static helper + assert res["success"] is True + assert nfo_path.exists() + content = nfo_path.read_text(encoding="utf-8") + assert "" in content + assert "Test Movie" in content + assert "7200" in content or "7200" in content + + # cleanup nfo and re-run via PP.run wrapper + nfo_path.unlink() + + # call through instance run() to ensure wrapper works (mtime sync path exercised) + pp = NFOMakerPP(None, mode="movie", prefix=False) + # ensure file exists + assert media_file.exists() + + # capture media file mtime before run + media_mtime = media_file.stat().st_mtime + + _, info_out = pp.run(info.copy()) + + # run() should not raise and should produce nfo file + assert nfo_path.exists() + + # verify mtime was synced (allow small rounding differences) + nfo_mtime = nfo_path.stat().st_mtime + assert abs(nfo_mtime - media_mtime) < 2.0 + + # cleanup + media_file.unlink() + nfo_path.unlink() diff --git a/app/yt_dlp_plugins/postprocessor/nfo_maker.py b/app/yt_dlp_plugins/postprocessor/nfo_maker.py index 85d75708..9a341294 100644 --- a/app/yt_dlp_plugins/postprocessor/nfo_maker.py +++ b/app/yt_dlp_plugins/postprocessor/nfo_maker.py @@ -124,57 +124,105 @@ class NFOMakerPP(PostProcessor): self.report_warning("No info provided to NFO Maker.") return [], {} - if self.mode not in self._MODE: - self.report_warning(f"Invalid mode '{self.mode}'.") - return [], info - - # prefer explicit final path if present, else fall back to filename base_path = info.get("filename") if not base_path: self.report_warning("No 'filename' provided, skipping NFO creation.") return [], info - base_path = Path(base_path) + result = NFOMakerPP.generate_nfo( + info_dict=info, + filepath=base_path, + mode=self.mode, + overwrite=False, + prefix=self.prefix, + logger=getattr(self, "_downloader", None) or None, + ) - nfo_file = base_path.with_suffix(".nfo") - if nfo_file.exists(): - self.report_warning(f"NFO file '{nfo_file!s}' already exists, skipping creation.") + if not result.get("success"): + self.report_warning(result.get("message", "NFO generation failed")) return [], info try: - nfo_data = self._collect_nfo_data(info) - except Exception as e: - if self._downloader: - self._downloader.report_error(f"NFO data collection failed: {e}") - return [], info - - if 1 > len(nfo_data): - self.report_warning("No metadata found to write to NFO file.") - return [], info - - # derive year from any date if missing - if ("year" not in nfo_data) and any(k in nfo_data for k in self._DATE_FIELDS): - try: - first_date: str = next((str(nfo_data[k]) for k in self._DATE_FIELDS if nfo_data.get(k)), "") - if first_date: - nfo_data["year"] = first_date.split("-", maxsplit=1)[0] - except Exception as e: - self.report_warning(f"Error extracting year from date: {e}") - - status: bool = self._write_episode_info(nfo_file, base_path, nfo_data) - if status and nfo_file.exists() and base_path.exists: - try: - mtime: float = base_path.stat().st_mtime - self.try_utime(str(nfo_file), mtime, mtime) - except Exception as e: - self.report_warning(f"Failed to sync NFO mtime: {e}") + nfo_path = Path(base_path).with_suffix(".nfo") + if nfo_path.exists() and Path(base_path).exists(): + try: + mtime: float = Path(base_path).stat().st_mtime + self.try_utime(str(nfo_path), mtime, mtime) + except Exception as e: + self.report_warning(f"Failed to sync NFO mtime: {e}") + except Exception: + pass return [], info - def _collect_nfo_data(self, info: dict) -> dict[str, Any]: + @staticmethod + def generate_nfo( + *, + info_dict: dict, + filepath: str | Path, + mode: str = "tv", + overwrite: bool = False, + prefix: bool = True, + logger: Any | None = None, + ) -> dict[str, Any]: + """ + Public helper to generate an NFO outside the PostProcessor cycle. + + Returns: + dict: {"success": bool, "message": str, "nfo_file": Optional[str]} + + """ + try: + if not info_dict: + return {"success": False, "message": "no metadata provided"} + + mode = str(mode).lower() + if mode not in NFOMakerPP._MODE: + return {"success": False, "message": f"invalid mode '{mode}'"} + + base_path = Path(filepath) + nfo_file = base_path.with_suffix(".nfo") + if nfo_file.exists() and not overwrite: + return {"success": False, "message": "NFO file already exists"} + + nfo_data = NFOMakerPP._collect_nfo_data(info_dict, mode, None) + if 1 > len(nfo_data): + return {"success": False, "message": "no metadata found to write to NFO file"} + + # derive year from any date if missing + if ("year" not in nfo_data) and any(k in nfo_data for k in NFOMakerPP._DATE_FIELDS): + try: + first_date: str = next((str(nfo_data[k]) for k in NFOMakerPP._DATE_FIELDS if nfo_data.get(k)), "") + if first_date: + nfo_data["year"] = first_date.split("-", maxsplit=1)[0] + except Exception: + pass + + ok = NFOMakerPP._write_file(logger, nfo_file, base_path, nfo_data, prefix, mode) + if ok and nfo_file.exists(): + return {"success": True, "message": "NFO file created", "nfo_file": str(nfo_file)} + + return {"success": False, "message": "failed to write NFO"} + + except Exception as e: + if logger and hasattr(logger, "exception"): + logger.exception(f"generate_nfo failed: {e}") + return {"success": False, "message": f"generate_nfo failed: {e!s}"} + + @staticmethod + def _collect_nfo_data(info: dict, mode: str, downloader: Any | None = None) -> dict[str, Any]: + """ + Collect replacement data for templates without depending on an instance. + + Arguments: + info: extracted info dict + mode: 'tv' or 'movie' + downloader: optional downloader/logger to report errors + + """ data: dict[str, Any] = {} - for nfo_name, spec in self._MODE[self.mode].get("mapping", {}).items(): + for nfo_name, spec in NFOMakerPP._MODE[mode].get("mapping", {}).items(): try: values = spec if isinstance(spec, tuple) else (spec,) resolved_key = None @@ -190,7 +238,7 @@ class NFOMakerPP(PostProcessor): if raw is None: continue resolved_key = field - resolved_val = self._coerce_value(raw, fmt) + resolved_val = NFOMakerPP._coerce_value(raw, fmt) if resolved_val not in (None, ""): break continue @@ -206,8 +254,8 @@ class NFOMakerPP(PostProcessor): continue # normalize dates if source key is a known date - if resolved_key in self._DATE_FIELDS: - resolved_val = self._normalize_date(resolved_val) + if resolved_key in NFOMakerPP._DATE_FIELDS: + resolved_val = NFOMakerPP._normalize_date(resolved_val) # collapse multiline descriptions if "description" == resolved_key and isinstance(resolved_val, str): @@ -217,44 +265,73 @@ class NFOMakerPP(PostProcessor): data[nfo_name] = resolved_val except Exception as e: - if self._downloader: - self._downloader.report_error(f"Error processing {nfo_name} -> {spec}: {e}") + if downloader: + try: + downloader.report_error(f"Error processing {nfo_name} -> {spec}: {e}") + except Exception: + pass return data - def _write_episode_info(self, nfo_file: Path, real_file: Path, data: dict) -> bool: + @staticmethod + def _write_file(reporter: Any | None, nfo_file: Path, real_file: Path, data: dict, prefix: bool, mode: str) -> bool: + """ + Write episode/movie NFO using independent parameters. + + reporter: object with optional methods `to_screen(msg)` and `report_warning(msg)`; if a logger is given uses .info/.warning + prefix: whether to prefix episode numbers + mode: 'tv' or 'movie' + + """ aired = str( data.get("aired") or data.get("premiered") or data.get("release_date") or data.get("upload_date") or "" ) - aired = self._normalize_date(aired) if aired else "" + aired = NFOMakerPP._normalize_date(aired) if aired else "" if not aired or 3 > len(aired.split("-")): - self.report_warning("Invalid aired/premiered date, skipping NFO creation.") + if reporter and hasattr(reporter, "report_warning"): + reporter.report_warning("Invalid aired/premiered date, skipping NFO creation.") + elif reporter and hasattr(reporter, "warning"): + reporter.warning("Invalid aired/premiered date, skipping NFO creation.") return False year, month, day = aired.split("-") if not (year and month and day): - self.report_warning("Invalid aired date parts, skipping NFO creation.") + if reporter and hasattr(reporter, "report_warning"): + reporter.report_warning("Invalid aired date parts, skipping NFO creation.") + elif reporter and hasattr(reporter, "warning"): + reporter.warning("Invalid aired date parts, skipping NFO creation.") return False - self.to_screen(f"Creating {self.mode} NFO file at {nfo_file!s}") + if reporter and hasattr(reporter, "to_screen"): + reporter.to_screen(f"Creating {mode} NFO file at {nfo_file!s}") + elif reporter and hasattr(reporter, "info"): + reporter.info(f"Creating {mode} NFO file at {nfo_file!s}") + nfo_file.parent.mkdir(parents=True, exist_ok=True) nfo_file.touch(exist_ok=True) dt = datetime(int(year), int(month), int(day), tzinfo=UTC) data = dict(data) # do not mutate original - data["unique_id"] = self._build_unique_id(dt, real_file) + data["unique_id"] = NFOMakerPP._build_unique_id(dt, real_file) - self._write(nfo_file=nfo_file, text=self._MODE[self.mode].get("template", ""), repl=data) + NFOMakerPP._write( + reporter, + nfo_file=nfo_file, + text=NFOMakerPP._MODE[mode].get("template", ""), + repl=data, + prefix=prefix, + mode=mode, + ) return True @staticmethod def _build_unique_id(dt: datetime, file: Path) -> str: # 1MMDD + 4-digit stable hash from lowercase stem - h = hashlib.sha256(file.stem.lower().encode("utf-8")).hexdigest() + h: str = hashlib.sha256(file.stem.lower().encode("utf-8")).hexdigest() ascii_stream = "".join(str(ord(c)) for c in h) - suffix = ascii_stream[:4] if 4 <= len(ascii_stream) else ascii_stream.ljust(4, "9") - return f"1{dt.strftime('%m')}{dt.strftime('%d')}{suffix}" + suffix: str = ascii_stream[:4] if 4 <= len(ascii_stream) else ascii_stream.ljust(4, "9") + return f"1{dt.strftime('%m%d')}{suffix}" @staticmethod def _normalize_date(val: Any) -> str: @@ -311,27 +388,26 @@ class NFOMakerPP(PostProcessor): return raw - def _write(self, nfo_file: Path, text: str, repl: dict[str, Any]) -> None: + @staticmethod + def _write(reporter: Any | None, nfo_file: Path, text: str, repl: dict[str, Any], prefix: bool, mode: str) -> None: """ - Write the NFO file, replacing placeholders in the template. + Write rendered template to disk; independent of PostProcessor instance. - Args: - nfo_file (Path): Path to the NFO file. - text (str): Template text with placeholders. - repl (dict[str, Any]): Replacement dictionary. + reporter: optional object with `to_screen`/`report_warning` or a logger with `info`/`warning`. + prefix: whether to prefix episodes + mode: 'tv' or 'movie' """ safe_repl: dict[str, Any] = {} for k, v in repl.items(): safe_repl[k] = NFOMakerPP._escape_text(v) - # replace placeholders rendered = text for key, value in safe_repl.items(): if value is None: continue - if self.prefix and key in ("episode",): + if prefix and key in ("episode",): try: value: str = f"1{value}" except Exception: @@ -339,17 +415,22 @@ class NFOMakerPP(PostProcessor): rendered = rendered.replace(f"{{{key}}}", str(value)) - # remove any unresolved placeholder lines - mapping = self._MODE[self.mode].get("mapping", {}) + mapping = NFOMakerPP._MODE[mode].get("mapping", {}) unresolved_keys: Iterable[str] = set({*mapping, *safe_repl.keys()}) pattern: re.Pattern[str] = re.compile(rf".*{{(?:{'|'.join(map(re.escape, unresolved_keys))})}}.*") rendered: str = "\n".join(line for line in rendered.splitlines() if not pattern.fullmatch(line)) try: nfo_file.write_text(rendered, encoding="utf-8") - self.to_screen(f"NFO file written successfully at {nfo_file!s}") + if reporter and hasattr(reporter, "to_screen"): + reporter.to_screen(f"NFO file written successfully at {nfo_file!s}") + elif reporter and hasattr(reporter, "info"): + reporter.info(f"NFO file written successfully at {nfo_file!s}") except Exception as e: - self.report_warning(f"Error writing NFO file: {e}") + if reporter and hasattr(reporter, "report_warning"): + reporter.report_warning(f"Error writing NFO file: {e}") + elif reporter and hasattr(reporter, "warning"): + reporter.warning(f"Error writing NFO file: {e}") @staticmethod def _escape_text(text: Any) -> Any: diff --git a/ui/app/components/Dropdown.vue b/ui/app/components/Dropdown.vue index 1abddf80..099f02fa 100644 --- a/ui/app/components/Dropdown.vue +++ b/ui/app/components/Dropdown.vue @@ -182,8 +182,10 @@ onBeforeUnmount(() => { /* Override Bulma's display: none */ position: static; /* Don't use absolute positioning inside fixed container */ - max-height: 300px; + max-height: 350px; overflow-y: auto; + overflow-x: hidden; + min-width: max-content; padding-top: 4px; } diff --git a/ui/app/components/History.vue b/ui/app/components/History.vue index 2a5af12c..93ab0f48 100644 --- a/ui/app/components/History.vue +++ b/ui/app/components/History.vue @@ -188,12 +188,18 @@ + + + Generate NFO + + + + + Generate NFO + + @@ -246,6 +248,7 @@ const toast = useNotification() const dlFields = useStorage>('dl_fields', {}) const show_thumbnail = useStorage('show_thumbnail', true) const isMobile = useMediaQuery({ maxWidth: 1024 }) +const box = useConfirm() const { app, paused, presets } = storeToRefs(configStore) const { queue, history } = storeToRefs(stateStore) @@ -422,7 +425,7 @@ const resolveThumbnail = (entry: DisplayEntry): string => { } const openPlayer = (item: StoreItem): void => { - if (item.filename && item.status === 'finished') { + if (item.filename) { videoItem.value = item return } @@ -508,6 +511,9 @@ const getStatusLabel = (item: StoreItem): string => { if (null === item.status) { return 'Queued' } + if ('error' === item.status && item.filename) { + return 'Partial Error' + } return statusOverrides[item.status] ?? ucFirst(item.status.replace(/_/g, ' ')) } @@ -526,6 +532,9 @@ const getStatusClass = (item: StoreItem): string => { if (null === item.status) { return 'has-text-grey' } + if ('error' === item.status && item.filename) { + return 'has-text-warning' + } return statusColorMap[item.status] ?? 'has-text-info' }