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",
|
||||
"microformats",
|
||||
"mkvtoolsnix",
|
||||
"MMDD",
|
||||
"movflags",
|
||||
"mpegts",
|
||||
"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)
|
||||
- [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 `<episodedetails>` XML
|
||||
- Movie mode generates `<movie>` XML
|
||||
|
||||
---
|
||||
|
||||
### GET /api/archiver
|
||||
**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."},
|
||||
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.")
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -188,12 +188,18 @@
|
|||
</NuxtLink>
|
||||
<template v-if="'error' === item.status">
|
||||
<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>Retry download</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<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 v-else-if="isEmbedable(item.url)">
|
||||
<NuxtLink class="dropdown-item has-text-danger"
|
||||
|
|
@ -384,6 +390,11 @@
|
|||
</NuxtLink>
|
||||
</template>
|
||||
<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 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'
|
||||
}
|
||||
if ('error' === item.status) {
|
||||
if (item.filename) {
|
||||
return 'fa-solid fa-file-circle-xmark'
|
||||
}
|
||||
return 'fa-solid fa-circle-xmark'
|
||||
}
|
||||
if ('cancelled' === item.status) {
|
||||
|
|
@ -780,6 +788,11 @@ const setIconColor = (item: StoreItem) => {
|
|||
if ('cancelled' === item.status || "skip" === item.status) {
|
||||
return 'has-text-warning'
|
||||
}
|
||||
|
||||
if ('error' === item.status && item.filename) {
|
||||
return 'has-text-warning'
|
||||
}
|
||||
|
||||
return 'has-text-danger'
|
||||
}
|
||||
|
||||
|
|
@ -791,6 +804,9 @@ const setStatus = (item: StoreItem) => {
|
|||
return item.is_live ? 'Streamed' : 'Completed'
|
||||
}
|
||||
if ('error' === item.status) {
|
||||
if (item.filename) {
|
||||
return 'Partial Error'
|
||||
}
|
||||
return 'Error'
|
||||
}
|
||||
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' : ''
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -101,11 +101,9 @@
|
|||
<span v-if="getDurationLabel(entry.item)" class="queue-thumb__badge">
|
||||
{{ getDurationLabel(entry.item) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="entry.item.filename && entry.item.status === 'finished' || isEmbedable(entry.item.url)"
|
||||
class="queue-thumb__overlay">
|
||||
<span v-if="entry.item.filename || isEmbedable(entry.item.url)" class="queue-thumb__overlay">
|
||||
<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" />
|
||||
</span>
|
||||
</span>
|
||||
|
|
@ -171,8 +169,8 @@
|
|||
<span class="icon"><i class="fas fa-download" /></span>
|
||||
<span>Download</span>
|
||||
</a>
|
||||
<button v-if="entry.item.status != 'finished' || !entry.item.filename" class="button is-info is-light"
|
||||
type="button" @click="async () => await requeueItem(entry.item)">
|
||||
<button v-if="!entry.item.filename" class="button is-info is-light" type="button"
|
||||
@click="async () => await requeueItem(entry.item)">
|
||||
<span class="icon"><i class="fas fa-rotate-right" /></span>
|
||||
<span>Requeue</span>
|
||||
</button>
|
||||
|
|
@ -202,7 +200,7 @@
|
|||
<div class="modal-background" @click="closePlayer"></div>
|
||||
<div class="modal-content is-unbounded-model">
|
||||
<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>
|
||||
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
|
||||
</div>
|
||||
|
|
@ -214,6 +212,10 @@
|
|||
</div>
|
||||
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<Dialog />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -246,6 +248,7 @@ const toast = useNotification()
|
|||
const dlFields = useStorage<Record<string, any>>('dl_fields', {})
|
||||
const show_thumbnail = useStorage<boolean>('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'
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue