feat: add NFO generation endpoint and UI integration for downloads
This commit is contained in:
parent
a3fc7f6abb
commit
19d5d60de1
8 changed files with 477 additions and 77 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -124,6 +124,7 @@
|
||||||
"Microformat",
|
"Microformat",
|
||||||
"microformats",
|
"microformats",
|
||||||
"mkvtoolsnix",
|
"mkvtoolsnix",
|
||||||
|
"MMDD",
|
||||||
"movflags",
|
"movflags",
|
||||||
"mpegts",
|
"mpegts",
|
||||||
"msvideo",
|
"msvideo",
|
||||||
|
|
|
||||||
53
API.md
53
API.md
|
|
@ -31,6 +31,7 @@ This document describes the available endpoints and their usage. All endpoints r
|
||||||
- [POST /api/history/cancel](#post-apihistorycancel)
|
- [POST /api/history/cancel](#post-apihistorycancel)
|
||||||
- [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive)
|
- [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive)
|
||||||
- [POST /api/history/{id}/archive](#post-apihistoryidarchive)
|
- [POST /api/history/{id}/archive](#post-apihistoryidarchive)
|
||||||
|
- [POST /api/history/{id}/nfo](#post-apihistoryidnfo)
|
||||||
- [GET /api/archiver](#get-apiarchiver)
|
- [GET /api/archiver](#get-apiarchiver)
|
||||||
- [POST /api/archiver](#post-apiarchiver)
|
- [POST /api/archiver](#post-apiarchiver)
|
||||||
- [DELETE /api/archiver](#delete-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 `<episodedetails>` XML
|
||||||
|
- Movie mode generates `<movie>` XML
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### GET /api/archiver
|
### GET /api/archiver
|
||||||
**Purpose**: Read entries from the download archive associated with a preset.
|
**Purpose**: Read entries from the download archive associated with a preset.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."},
|
data={"message": f"item '{item.info.title}' removed from archive."},
|
||||||
status=web.HTTPOk.status_code,
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
127
app/tests/test_nfo_maker.py
Normal file
127
app/tests/test_nfo_maker.py
Normal file
|
|
@ -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 "<episodedetails>" in content
|
||||||
|
assert "<title>Test Show - S01E02</title>" 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 "<movie>" in content
|
||||||
|
assert "<title>Test Movie</title>" in content
|
||||||
|
assert "<runtime>7200</runtime>" in content or "<runtime>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()
|
||||||
|
|
@ -124,57 +124,105 @@ class NFOMakerPP(PostProcessor):
|
||||||
self.report_warning("No info provided to NFO Maker.")
|
self.report_warning("No info provided to NFO Maker.")
|
||||||
return [], {}
|
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")
|
base_path = info.get("filename")
|
||||||
if not base_path:
|
if not base_path:
|
||||||
self.report_warning("No 'filename' provided, skipping NFO creation.")
|
self.report_warning("No 'filename' provided, skipping NFO creation.")
|
||||||
return [], info
|
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 not result.get("success"):
|
||||||
if nfo_file.exists():
|
self.report_warning(result.get("message", "NFO generation failed"))
|
||||||
self.report_warning(f"NFO file '{nfo_file!s}' already exists, skipping creation.")
|
|
||||||
return [], info
|
return [], info
|
||||||
|
|
||||||
try:
|
try:
|
||||||
nfo_data = self._collect_nfo_data(info)
|
nfo_path = Path(base_path).with_suffix(".nfo")
|
||||||
except Exception as e:
|
if nfo_path.exists() and Path(base_path).exists():
|
||||||
if self._downloader:
|
try:
|
||||||
self._downloader.report_error(f"NFO data collection failed: {e}")
|
mtime: float = Path(base_path).stat().st_mtime
|
||||||
return [], info
|
self.try_utime(str(nfo_path), mtime, mtime)
|
||||||
|
except Exception as e:
|
||||||
if 1 > len(nfo_data):
|
self.report_warning(f"Failed to sync NFO mtime: {e}")
|
||||||
self.report_warning("No metadata found to write to NFO file.")
|
except Exception:
|
||||||
return [], info
|
pass
|
||||||
|
|
||||||
# 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}")
|
|
||||||
|
|
||||||
return [], info
|
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] = {}
|
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:
|
try:
|
||||||
values = spec if isinstance(spec, tuple) else (spec,)
|
values = spec if isinstance(spec, tuple) else (spec,)
|
||||||
resolved_key = None
|
resolved_key = None
|
||||||
|
|
@ -190,7 +238,7 @@ class NFOMakerPP(PostProcessor):
|
||||||
if raw is None:
|
if raw is None:
|
||||||
continue
|
continue
|
||||||
resolved_key = field
|
resolved_key = field
|
||||||
resolved_val = self._coerce_value(raw, fmt)
|
resolved_val = NFOMakerPP._coerce_value(raw, fmt)
|
||||||
if resolved_val not in (None, ""):
|
if resolved_val not in (None, ""):
|
||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
|
|
@ -206,8 +254,8 @@ class NFOMakerPP(PostProcessor):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# normalize dates if source key is a known date
|
# normalize dates if source key is a known date
|
||||||
if resolved_key in self._DATE_FIELDS:
|
if resolved_key in NFOMakerPP._DATE_FIELDS:
|
||||||
resolved_val = self._normalize_date(resolved_val)
|
resolved_val = NFOMakerPP._normalize_date(resolved_val)
|
||||||
|
|
||||||
# collapse multiline descriptions
|
# collapse multiline descriptions
|
||||||
if "description" == resolved_key and isinstance(resolved_val, str):
|
if "description" == resolved_key and isinstance(resolved_val, str):
|
||||||
|
|
@ -217,44 +265,73 @@ class NFOMakerPP(PostProcessor):
|
||||||
data[nfo_name] = resolved_val
|
data[nfo_name] = resolved_val
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if self._downloader:
|
if downloader:
|
||||||
self._downloader.report_error(f"Error processing {nfo_name} -> {spec}: {e}")
|
try:
|
||||||
|
downloader.report_error(f"Error processing {nfo_name} -> {spec}: {e}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return data
|
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(
|
aired = str(
|
||||||
data.get("aired") or data.get("premiered") or data.get("release_date") or data.get("upload_date") or ""
|
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("-")):
|
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
|
return False
|
||||||
|
|
||||||
year, month, day = aired.split("-")
|
year, month, day = aired.split("-")
|
||||||
if not (year and month and day):
|
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
|
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.parent.mkdir(parents=True, exist_ok=True)
|
||||||
nfo_file.touch(exist_ok=True)
|
nfo_file.touch(exist_ok=True)
|
||||||
|
|
||||||
dt = datetime(int(year), int(month), int(day), tzinfo=UTC)
|
dt = datetime(int(year), int(month), int(day), tzinfo=UTC)
|
||||||
data = dict(data) # do not mutate original
|
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
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_unique_id(dt: datetime, file: Path) -> str:
|
def _build_unique_id(dt: datetime, file: Path) -> str:
|
||||||
# 1MMDD + 4-digit stable hash from lowercase stem
|
# 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)
|
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")
|
suffix: str = ascii_stream[:4] if 4 <= len(ascii_stream) else ascii_stream.ljust(4, "9")
|
||||||
return f"1{dt.strftime('%m')}{dt.strftime('%d')}{suffix}"
|
return f"1{dt.strftime('%m%d')}{suffix}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_date(val: Any) -> str:
|
def _normalize_date(val: Any) -> str:
|
||||||
|
|
@ -311,27 +388,26 @@ class NFOMakerPP(PostProcessor):
|
||||||
|
|
||||||
return raw
|
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:
|
reporter: optional object with `to_screen`/`report_warning` or a logger with `info`/`warning`.
|
||||||
nfo_file (Path): Path to the NFO file.
|
prefix: whether to prefix episodes
|
||||||
text (str): Template text with placeholders.
|
mode: 'tv' or 'movie'
|
||||||
repl (dict[str, Any]): Replacement dictionary.
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
safe_repl: dict[str, Any] = {}
|
safe_repl: dict[str, Any] = {}
|
||||||
for k, v in repl.items():
|
for k, v in repl.items():
|
||||||
safe_repl[k] = NFOMakerPP._escape_text(v)
|
safe_repl[k] = NFOMakerPP._escape_text(v)
|
||||||
|
|
||||||
# replace placeholders
|
|
||||||
rendered = text
|
rendered = text
|
||||||
for key, value in safe_repl.items():
|
for key, value in safe_repl.items():
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if self.prefix and key in ("episode",):
|
if prefix and key in ("episode",):
|
||||||
try:
|
try:
|
||||||
value: str = f"1{value}"
|
value: str = f"1{value}"
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -339,17 +415,22 @@ class NFOMakerPP(PostProcessor):
|
||||||
|
|
||||||
rendered = rendered.replace(f"{{{key}}}", str(value))
|
rendered = rendered.replace(f"{{{key}}}", str(value))
|
||||||
|
|
||||||
# remove any unresolved placeholder lines
|
mapping = NFOMakerPP._MODE[mode].get("mapping", {})
|
||||||
mapping = self._MODE[self.mode].get("mapping", {})
|
|
||||||
unresolved_keys: Iterable[str] = set({*mapping, *safe_repl.keys()})
|
unresolved_keys: Iterable[str] = set({*mapping, *safe_repl.keys()})
|
||||||
pattern: re.Pattern[str] = re.compile(rf".*{{(?:{'|'.join(map(re.escape, unresolved_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))
|
rendered: str = "\n".join(line for line in rendered.splitlines() if not pattern.fullmatch(line))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
nfo_file.write_text(rendered, encoding="utf-8")
|
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:
|
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
|
@staticmethod
|
||||||
def _escape_text(text: Any) -> Any:
|
def _escape_text(text: Any) -> Any:
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,10 @@ onBeforeUnmount(() => {
|
||||||
/* Override Bulma's display: none */
|
/* Override Bulma's display: none */
|
||||||
position: static;
|
position: static;
|
||||||
/* Don't use absolute positioning inside fixed container */
|
/* Don't use absolute positioning inside fixed container */
|
||||||
max-height: 300px;
|
max-height: 350px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
min-width: max-content;
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,12 +188,18 @@
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<template v-if="'error' === item.status">
|
<template v-if="'error' === item.status">
|
||||||
<hr class="dropdown-divider" />
|
<hr class="dropdown-divider" />
|
||||||
<NuxtLink class="dropdown-item has-text-warning" @click="async () => await retryItem(item, true)">
|
<NuxtLink class="dropdown-item has-text-warning"
|
||||||
|
@click="async () => await retryItem(item, true)">
|
||||||
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
|
||||||
<span>Retry download</span>
|
<span>Retry download</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
<hr class="dropdown-divider" />
|
<hr class="dropdown-divider" />
|
||||||
|
<NuxtLink class="dropdown-item has-text-info" @click="generateNfo(item)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-file-code" /></span>
|
||||||
|
<span>Generate NFO</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="isEmbedable(item.url)">
|
<template v-else-if="isEmbedable(item.url)">
|
||||||
<NuxtLink class="dropdown-item has-text-danger"
|
<NuxtLink class="dropdown-item has-text-danger"
|
||||||
|
|
@ -384,6 +390,11 @@
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
<hr class="dropdown-divider" />
|
<hr class="dropdown-divider" />
|
||||||
|
<NuxtLink class="dropdown-item has-text-info" @click="generateNfo(item)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-file-code" /></span>
|
||||||
|
<span>Generate NFO</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="isEmbedable(item.url)">
|
<template v-else-if="isEmbedable(item.url)">
|
||||||
|
|
@ -750,9 +761,6 @@ const setIcon = (item: StoreItem) => {
|
||||||
return item.is_live ? 'fa-solid fa-globe' : 'fa-solid fa-circle-check'
|
return item.is_live ? 'fa-solid fa-globe' : 'fa-solid fa-circle-check'
|
||||||
}
|
}
|
||||||
if ('error' === item.status) {
|
if ('error' === item.status) {
|
||||||
if (item.filename) {
|
|
||||||
return 'fa-solid fa-file-circle-xmark'
|
|
||||||
}
|
|
||||||
return 'fa-solid fa-circle-xmark'
|
return 'fa-solid fa-circle-xmark'
|
||||||
}
|
}
|
||||||
if ('cancelled' === item.status) {
|
if ('cancelled' === item.status) {
|
||||||
|
|
@ -780,6 +788,11 @@ const setIconColor = (item: StoreItem) => {
|
||||||
if ('cancelled' === item.status || "skip" === item.status) {
|
if ('cancelled' === item.status || "skip" === item.status) {
|
||||||
return 'has-text-warning'
|
return 'has-text-warning'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('error' === item.status && item.filename) {
|
||||||
|
return 'has-text-warning'
|
||||||
|
}
|
||||||
|
|
||||||
return 'has-text-danger'
|
return 'has-text-danger'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -791,6 +804,9 @@ const setStatus = (item: StoreItem) => {
|
||||||
return item.is_live ? 'Streamed' : 'Completed'
|
return item.is_live ? 'Streamed' : 'Completed'
|
||||||
}
|
}
|
||||||
if ('error' === item.status) {
|
if ('error' === item.status) {
|
||||||
|
if (item.filename) {
|
||||||
|
return 'Partial Error'
|
||||||
|
}
|
||||||
return 'Error'
|
return 'Error'
|
||||||
}
|
}
|
||||||
if ('cancelled' === item.status) {
|
if ('cancelled' === item.status) {
|
||||||
|
|
@ -1019,4 +1035,22 @@ const is_queued = (item: StoreItem) => {
|
||||||
}
|
}
|
||||||
return item.live_in || item.extras?.live_in || item.extras?.release_in ? 'fa-spin fa-spin-10' : ''
|
return item.live_in || item.extras?.live_in || item.extras?.release_in ? 'fa-spin fa-spin-10' : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generateNfo = async (item: StoreItem) => {
|
||||||
|
try {
|
||||||
|
toast.info('Generating please wait...', { timeout: 2000 })
|
||||||
|
const response = await request(`/api/history/${item._id}/nfo`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ type: 'tv', overwrite: true }),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error(data.error || 'Failed to generate NFO')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success(data.message || 'NFO file generated')
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Error: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -101,11 +101,9 @@
|
||||||
<span v-if="getDurationLabel(entry.item)" class="queue-thumb__badge">
|
<span v-if="getDurationLabel(entry.item)" class="queue-thumb__badge">
|
||||||
{{ getDurationLabel(entry.item) }}
|
{{ getDurationLabel(entry.item) }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span v-if="entry.item.filename || isEmbedable(entry.item.url)" class="queue-thumb__overlay">
|
||||||
v-if="entry.item.filename && entry.item.status === 'finished' || isEmbedable(entry.item.url)"
|
|
||||||
class="queue-thumb__overlay">
|
|
||||||
<span class="icon circle-border"
|
<span class="icon circle-border"
|
||||||
:class="{ 'has-text-danger': isEmbedable(entry.item.url) && (!entry.item.filename || entry.item.status !== 'finished') }">
|
:class="{ 'has-text-danger': isEmbedable(entry.item.url) && !entry.item.filename }">
|
||||||
<i class="fas fa-play" />
|
<i class="fas fa-play" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -171,8 +169,8 @@
|
||||||
<span class="icon"><i class="fas fa-download" /></span>
|
<span class="icon"><i class="fas fa-download" /></span>
|
||||||
<span>Download</span>
|
<span>Download</span>
|
||||||
</a>
|
</a>
|
||||||
<button v-if="entry.item.status != 'finished' || !entry.item.filename" class="button is-info is-light"
|
<button v-if="!entry.item.filename" class="button is-info is-light" type="button"
|
||||||
type="button" @click="async () => await requeueItem(entry.item)">
|
@click="async () => await requeueItem(entry.item)">
|
||||||
<span class="icon"><i class="fas fa-rotate-right" /></span>
|
<span class="icon"><i class="fas fa-rotate-right" /></span>
|
||||||
<span>Requeue</span>
|
<span>Requeue</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -202,7 +200,7 @@
|
||||||
<div class="modal-background" @click="closePlayer"></div>
|
<div class="modal-background" @click="closePlayer"></div>
|
||||||
<div class="modal-content is-unbounded-model">
|
<div class="modal-content is-unbounded-model">
|
||||||
<VideoPlayer type="default" :isMuted="false" autoplay="true" :isControls="true" :item="videoItem"
|
<VideoPlayer type="default" :isMuted="false" autoplay="true" :isControls="true" :item="videoItem"
|
||||||
class="is-fullwidth" @closeModel="closePlayer" />
|
class="is-fullwidth" @closeModel="closePlayer" @error="async (error: string) => await box.alert(error)" />
|
||||||
</div>
|
</div>
|
||||||
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
|
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -214,6 +212,10 @@
|
||||||
</div>
|
</div>
|
||||||
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
|
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ClientOnly>
|
||||||
|
<Dialog />
|
||||||
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -246,6 +248,7 @@ const toast = useNotification()
|
||||||
const dlFields = useStorage<Record<string, any>>('dl_fields', {})
|
const dlFields = useStorage<Record<string, any>>('dl_fields', {})
|
||||||
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||||
|
const box = useConfirm()
|
||||||
|
|
||||||
const { app, paused, presets } = storeToRefs(configStore)
|
const { app, paused, presets } = storeToRefs(configStore)
|
||||||
const { queue, history } = storeToRefs(stateStore)
|
const { queue, history } = storeToRefs(stateStore)
|
||||||
|
|
@ -422,7 +425,7 @@ const resolveThumbnail = (entry: DisplayEntry): string => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const openPlayer = (item: StoreItem): void => {
|
const openPlayer = (item: StoreItem): void => {
|
||||||
if (item.filename && item.status === 'finished') {
|
if (item.filename) {
|
||||||
videoItem.value = item
|
videoItem.value = item
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -508,6 +511,9 @@ const getStatusLabel = (item: StoreItem): string => {
|
||||||
if (null === item.status) {
|
if (null === item.status) {
|
||||||
return 'Queued'
|
return 'Queued'
|
||||||
}
|
}
|
||||||
|
if ('error' === item.status && item.filename) {
|
||||||
|
return 'Partial Error'
|
||||||
|
}
|
||||||
return statusOverrides[item.status] ?? ucFirst(item.status.replace(/_/g, ' '))
|
return statusOverrides[item.status] ?? ucFirst(item.status.replace(/_/g, ' '))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -526,6 +532,9 @@ const getStatusClass = (item: StoreItem): string => {
|
||||||
if (null === item.status) {
|
if (null === item.status) {
|
||||||
return 'has-text-grey'
|
return 'has-text-grey'
|
||||||
}
|
}
|
||||||
|
if ('error' === item.status && item.filename) {
|
||||||
|
return 'has-text-warning'
|
||||||
|
}
|
||||||
return statusColorMap[item.status] ?? 'has-text-info'
|
return statusColorMap[item.status] ?? 'has-text-info'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue