[FEAT] create channel metadata and thumbnails. Closes #446
This commit is contained in:
parent
319f7d2f7a
commit
e085e680cc
9 changed files with 474 additions and 26 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -87,6 +87,7 @@
|
||||||
"jmespath",
|
"jmespath",
|
||||||
"jsonschema",
|
"jsonschema",
|
||||||
"kibibytes",
|
"kibibytes",
|
||||||
|
"Kodi",
|
||||||
"lastgroup",
|
"lastgroup",
|
||||||
"levelno",
|
"levelno",
|
||||||
"libcurl",
|
"libcurl",
|
||||||
|
|
|
||||||
53
API.md
53
API.md
|
|
@ -35,6 +35,7 @@ This document describes the available endpoints and their usage. All endpoints r
|
||||||
- [POST /api/tasks/inspect](#post-apitasksinspect)
|
- [POST /api/tasks/inspect](#post-apitasksinspect)
|
||||||
- [POST /api/tasks/{id}/mark](#post-apitasksidmark)
|
- [POST /api/tasks/{id}/mark](#post-apitasksidmark)
|
||||||
- [DELETE /api/tasks/{id}/mark](#delete-apitasksidmark)
|
- [DELETE /api/tasks/{id}/mark](#delete-apitasksidmark)
|
||||||
|
- [POST /api/tasks/{id}/metadata](#post-apitasksidmetadata)
|
||||||
- [GET /api/task\_definitions/](#get-apitask_definitions)
|
- [GET /api/task\_definitions/](#get-apitask_definitions)
|
||||||
- [GET /api/task\_definitions/{identifier}](#get-apitask_definitionsidentifier)
|
- [GET /api/task\_definitions/{identifier}](#get-apitask_definitionsidentifier)
|
||||||
- [POST /api/task\_definitions/](#post-apitask_definitions)
|
- [POST /api/task\_definitions/](#post-apitask_definitions)
|
||||||
|
|
@ -658,6 +659,58 @@ or
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### POST /api/tasks/{id}/metadata
|
||||||
|
**Purpose**: Generate metadata files for a scheduled task, including NFO files, thumbnails, and JSON metadata compatible with media centers.
|
||||||
|
|
||||||
|
**Path Parameter**:
|
||||||
|
- `id`: Task ID.
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "channel_or_playlist_id",
|
||||||
|
"id_type": "youtube|twitch|...",
|
||||||
|
"title": "Channel/Playlist Title",
|
||||||
|
"description": "Description text...",
|
||||||
|
"uploader": "Uploader name",
|
||||||
|
"tags": ["tag1", "tag2"],
|
||||||
|
"year": 2024,
|
||||||
|
"json_file": "path/to/Title [id].info.json",
|
||||||
|
"nfo_file": "path/to/tvshow.nfo",
|
||||||
|
"thumbnails": {
|
||||||
|
"poster": "path/to/poster.jpg",
|
||||||
|
"fanart": "path/to/fanart.jpg",
|
||||||
|
"thumb": "path/to/thumb.jpg",
|
||||||
|
"banner": "path/to/banner.jpg",
|
||||||
|
"icon": "path/to/icon.jpg",
|
||||||
|
"landscape": "path/to/landscape.jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
or
|
||||||
|
```json
|
||||||
|
{ "error": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files Generated**:
|
||||||
|
- `tvshow.nfo` - NFO file for media center compatibility (Kodi, Jellyfin, Emby, etc.)
|
||||||
|
- `Title [id].info.json` - yt-dlp metadata file.
|
||||||
|
- `poster.jpg`, `fanart.jpg`, `thumb.jpg`, `banner.jpg`, `icon.jpg`, `landscape.jpg` - Thumbnail images (if available from the source)
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
- This endpoint fetches metadata from the URL associated with the task
|
||||||
|
- Files are saved to the task's configured folder (or default download path)
|
||||||
|
- Existing files will be overwritten.
|
||||||
|
- Thumbnail images are only downloaded if available from the source.
|
||||||
|
- The NFO file follows the Kodi tvshow.nfo format
|
||||||
|
|
||||||
|
**Status Codes**:
|
||||||
|
- `200 OK` - Metadata generated successfully
|
||||||
|
- `400 Bad Request` - Missing task ID, invalid folder path, or failed to fetch metadata
|
||||||
|
- `404 Not Found` - Task does not exist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### GET /api/task_definitions/
|
### GET /api/task_definitions/
|
||||||
**Purpose**: Retrieve all task definitions.
|
**Purpose**: Retrieve all task definitions.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,27 @@ class Task:
|
||||||
return Encoder().encode(self.serialize())
|
return Encoder().encode(self.serialize())
|
||||||
|
|
||||||
def get(self, key: str, default: Any = None) -> Any:
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""
|
||||||
|
Get a value from the task by key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key (str): The key to get.
|
||||||
|
default (Any): The default value if the key is not found.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: The value of the key or the default value.
|
||||||
|
|
||||||
|
"""
|
||||||
return self.serialize().get(key, default)
|
return self.serialize().get(key, default)
|
||||||
|
|
||||||
def get_ytdlp_opts(self) -> YTDLPOpts:
|
def get_ytdlp_opts(self) -> YTDLPOpts:
|
||||||
|
"""
|
||||||
|
Get the yt-dlp options for the task.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
YTDLPOpts: The yt-dlp options.
|
||||||
|
|
||||||
|
"""
|
||||||
params: YTDLPOpts = YTDLPOpts.get_instance()
|
params: YTDLPOpts = YTDLPOpts.get_instance()
|
||||||
|
|
||||||
if self.preset:
|
if self.preset:
|
||||||
|
|
@ -60,6 +78,13 @@ class Task:
|
||||||
return params
|
return params
|
||||||
|
|
||||||
def mark(self) -> tuple[bool, str]:
|
def mark(self) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Mark the task's items as downloaded in the archive file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[bool, str]: A tuple indicating success and a message.
|
||||||
|
|
||||||
|
"""
|
||||||
ret = self._mark_logic()
|
ret = self._mark_logic()
|
||||||
if isinstance(ret, tuple):
|
if isinstance(ret, tuple):
|
||||||
return ret
|
return ret
|
||||||
|
|
@ -73,6 +98,13 @@ class Task:
|
||||||
return (True, f"Task '{self.name}' items marked as downloaded.")
|
return (True, f"Task '{self.name}' items marked as downloaded.")
|
||||||
|
|
||||||
def unmark(self) -> tuple[bool, str]:
|
def unmark(self) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Unmark the task's items from the archive file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[bool, str]: A tuple indicating success and a message.
|
||||||
|
|
||||||
|
"""
|
||||||
ret: tuple[bool, str] | set[tuple[Path, set[str]]] = self._mark_logic()
|
ret: tuple[bool, str] | set[tuple[Path, set[str]]] = self._mark_logic()
|
||||||
if isinstance(ret, tuple):
|
if isinstance(ret, tuple):
|
||||||
return ret
|
return ret
|
||||||
|
|
@ -85,6 +117,33 @@ class Task:
|
||||||
|
|
||||||
return (True, f"Removed '{self.name}' items from archive file.")
|
return (True, f"Removed '{self.name}' items from archive file.")
|
||||||
|
|
||||||
|
def fetch_metadata(self, full: bool = False) -> tuple[dict[str, Any] | None, bool, str]:
|
||||||
|
"""
|
||||||
|
Fetch metadata for the task's URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
full (bool): Whether to fetch full metadata including all entries for playlists.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[dict[str, Any]|None, bool, str]: A tuple containing the metadata (or None on failure), a boolean
|
||||||
|
indicating if the operation was successful, and a message.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not self.url:
|
||||||
|
return ({}, False, "No URL found in task parameters.")
|
||||||
|
|
||||||
|
params = self.get_ytdlp_opts()
|
||||||
|
if not full:
|
||||||
|
params.add_cli("-I0", from_user=False)
|
||||||
|
|
||||||
|
params = params.get_all()
|
||||||
|
|
||||||
|
ie_info: dict | None = extract_info(params, self.url, no_archive=True, follow_redirect=False, cache=True)
|
||||||
|
if not ie_info or not isinstance(ie_info, dict):
|
||||||
|
return ({}, False, "Failed to extract information from URL.")
|
||||||
|
|
||||||
|
return (ie_info, True, "")
|
||||||
|
|
||||||
def _mark_logic(self) -> tuple[bool, str] | set[tuple[Path, set[str]]]:
|
def _mark_logic(self) -> tuple[bool, str] | set[tuple[Path, set[str]]]:
|
||||||
if not self.url:
|
if not self.url:
|
||||||
return (False, "No URL found in task parameters.")
|
return (False, "No URL found in task parameters.")
|
||||||
|
|
|
||||||
|
|
@ -1572,3 +1572,60 @@ def archive_delete(file: str | Path, ids: list[str]) -> bool:
|
||||||
from app.library.Archiver import Archiver
|
from app.library.Archiver import Archiver
|
||||||
|
|
||||||
return Archiver.get_instance().delete(file, ids)
|
return Archiver.get_instance().delete(file, ids)
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_images(thumbnails: list[dict]) -> dict:
|
||||||
|
"""
|
||||||
|
Extract channel images from a list of thumbnail dictionaries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
thumbnails (list[dict]): List of thumbnail dictionaries with keys 'url', 'width', 'height', and 'id'.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: A dictionary with keys 'icon', 'landscape', 'poster', 'thumb', 'fanart', and 'banner' mapped to their respective URLs.
|
||||||
|
|
||||||
|
"""
|
||||||
|
artwork = {}
|
||||||
|
|
||||||
|
for t in thumbnails:
|
||||||
|
url = t.get("url")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
width = t.get("width")
|
||||||
|
height = t.get("height")
|
||||||
|
tid = t.get("id", "")
|
||||||
|
|
||||||
|
if not width or not height:
|
||||||
|
if "avatar_uncropped" in tid:
|
||||||
|
artwork["icon"] = url
|
||||||
|
|
||||||
|
elif "banner_uncropped" in tid:
|
||||||
|
artwork["landscape"] = url
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
ratio = width / height
|
||||||
|
|
||||||
|
if 0.55 <= ratio <= 0.75: # portrait → poster
|
||||||
|
artwork["poster"] = url
|
||||||
|
elif 0.9 <= ratio <= 1.1: # square → thumb
|
||||||
|
artwork.setdefault("thumb", url)
|
||||||
|
elif ratio >= 5: # very wide
|
||||||
|
if width >= 1920:
|
||||||
|
artwork["fanart"] = url
|
||||||
|
else:
|
||||||
|
artwork["banner"] = url
|
||||||
|
elif 1.6 <= ratio <= 1.8: # landscape
|
||||||
|
artwork["landscape"] = url
|
||||||
|
|
||||||
|
if "fanart" not in artwork and "banner" in artwork:
|
||||||
|
artwork["fanart"] = artwork["banner"]
|
||||||
|
|
||||||
|
if "banner" not in artwork and "fanart" in artwork:
|
||||||
|
artwork["banner"] = artwork["fanart"]
|
||||||
|
|
||||||
|
if "poster" not in artwork and "thumb" in artwork:
|
||||||
|
artwork["poster"] = artwork["thumb"] # optional fallback
|
||||||
|
|
||||||
|
return artwork
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ class NFOMakerPP(PostProcessor):
|
||||||
|
|
||||||
# collapse multiline descriptions
|
# collapse multiline descriptions
|
||||||
if "description" == resolved_key and isinstance(resolved_val, str):
|
if "description" == resolved_key and isinstance(resolved_val, str):
|
||||||
resolved_val = self._clean_description(resolved_val)
|
resolved_val = NFOMakerPP._clean_description(resolved_val)
|
||||||
|
|
||||||
if resolved_val not in (None, ""):
|
if resolved_val not in (None, ""):
|
||||||
data[nfo_name] = resolved_val
|
data[nfo_name] = resolved_val
|
||||||
|
|
@ -321,15 +321,9 @@ class NFOMakerPP(PostProcessor):
|
||||||
repl (dict[str, Any]): Replacement dictionary.
|
repl (dict[str, Any]): Replacement dictionary.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from xml.sax.saxutils import escape
|
|
||||||
|
|
||||||
# escape XML on a copy
|
|
||||||
safe_repl: dict[str, Any] = {}
|
safe_repl: dict[str, Any] = {}
|
||||||
for k, v in repl.items():
|
for k, v in repl.items():
|
||||||
if isinstance(v, str):
|
safe_repl[k] = NFOMakerPP._escape_text(v)
|
||||||
safe_repl[k] = escape(v)
|
|
||||||
else:
|
|
||||||
safe_repl[k] = v
|
|
||||||
|
|
||||||
# replace placeholders
|
# replace placeholders
|
||||||
rendered = text
|
rendered = text
|
||||||
|
|
@ -357,7 +351,24 @@ class NFOMakerPP(PostProcessor):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.to_screen(f"Error writing NFO file: {e}")
|
self.to_screen(f"Error writing NFO file: {e}")
|
||||||
|
|
||||||
def _clean_description(self, text: str) -> str:
|
@staticmethod
|
||||||
|
def _escape_text(text: Any) -> Any:
|
||||||
|
"""
|
||||||
|
Escape text for XML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text (str): Text to escape.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Any: Escaped text if input is str, else original value.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
|
return escape(text) if isinstance(text, str) else text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_description(text: str) -> str:
|
||||||
"""
|
"""
|
||||||
Strip links, chapters/timestamps, pure hashtags/mentions, and promo lines.
|
Strip links, chapters/timestamps, pure hashtags/mentions, and promo lines.
|
||||||
Return a compact single-line summary suitable for NFO <plot>.
|
Return a compact single-line summary suitable for NFO <plot>.
|
||||||
|
|
@ -374,20 +385,20 @@ class NFOMakerPP(PostProcessor):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# remove markdown links, keep labels
|
# remove markdown links, keep labels
|
||||||
ln = self._MD_LINK.sub(r"\1", ln)
|
ln = NFOMakerPP._MD_LINK.sub(r"\1", ln)
|
||||||
|
|
||||||
# drop lines that are clearly noise
|
# drop lines that are clearly noise
|
||||||
if self._TIME_LINE_PAT.match(ln):
|
if NFOMakerPP._TIME_LINE_PAT.match(ln):
|
||||||
continue
|
continue
|
||||||
if self._HASHTAGS_LINE.match(ln):
|
if NFOMakerPP._HASHTAGS_LINE.match(ln):
|
||||||
continue
|
continue
|
||||||
if self._MENTION_LINE.match(ln):
|
if NFOMakerPP._MENTION_LINE.match(ln):
|
||||||
continue
|
continue
|
||||||
if self._PROMO_LINE_PAT.search(ln):
|
if NFOMakerPP._PROMO_LINE_PAT.search(ln):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# strip raw/bare urls and domains
|
# strip raw/bare urls and domains
|
||||||
ln = self._URL_PAT.sub("", ln)
|
ln = NFOMakerPP._URL_PAT.sub("", ln)
|
||||||
|
|
||||||
# collapse leftover multiple spaces and stray separators
|
# collapse leftover multiple spaces and stray separators
|
||||||
ln = re.sub(r"\s{2,}", " ", ln)
|
ln = re.sub(r"\s{2,}", " ", ln)
|
||||||
|
|
@ -402,7 +413,7 @@ class NFOMakerPP(PostProcessor):
|
||||||
|
|
||||||
# optional minimum signal: if too short, fall back to original first sentence without links
|
# optional minimum signal: if too short, fall back to original first sentence without links
|
||||||
if 8 > len(summary.split()):
|
if 8 > len(summary.split()):
|
||||||
fallback = self._URL_PAT.sub("", text)
|
fallback = NFOMakerPP._URL_PAT.sub("", text)
|
||||||
fallback = re.sub(r"\s{2,}", " ", fallback).strip()
|
fallback = re.sub(r"\s{2,}", " ", fallback).strip()
|
||||||
if 8 <= len(fallback.split()):
|
if 8 <= len(fallback.split()):
|
||||||
summary = fallback
|
summary = fallback
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp.web import Request, Response
|
from aiohttp.web import Request, Response
|
||||||
|
|
||||||
|
from app.library.ag_utils import ag
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
from app.library.encoder import Encoder
|
from app.library.encoder import Encoder
|
||||||
from app.library.router import route
|
from app.library.router import route
|
||||||
from app.library.Tasks import Task, TaskFailure, TaskResult, Tasks
|
from app.library.Tasks import Task, TaskFailure, TaskResult, Tasks
|
||||||
from app.library.Utils import init_class, validate_url, validate_uuid
|
from app.library.Utils import get_channel_images, get_file, init_class, validate_url, validate_uuid
|
||||||
|
from app.postprocessors.nfo_maker import NFOMakerPP
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger(__name__)
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -195,3 +198,144 @@ async def task_unmark(request: Request, encoder: Encoder) -> Response:
|
||||||
return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
@route("POST", "api/tasks/{id}/metadata", "tasks_metadata")
|
||||||
|
async def task_metadata(request: Request, config: Config, encoder: Encoder) -> Response:
|
||||||
|
"""
|
||||||
|
Generate metadata for the task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request (Request): The request object.
|
||||||
|
config (Config): The config instance.
|
||||||
|
encoder (Encoder): The encoder instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: The response object
|
||||||
|
|
||||||
|
"""
|
||||||
|
task_id: str = request.match_info.get("id", None)
|
||||||
|
|
||||||
|
if not task_id:
|
||||||
|
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
tasks: Tasks = Tasks.get_instance()
|
||||||
|
try:
|
||||||
|
task: Task | None = tasks.get(task_id)
|
||||||
|
if not task:
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
|
||||||
|
)
|
||||||
|
|
||||||
|
(save_path, _) = get_file(config.download_path, task.folder)
|
||||||
|
if not str(save_path or "").startswith(str(config.download_path)):
|
||||||
|
return web.json_response(data={"error": "Invalid task folder."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
if not save_path.exists():
|
||||||
|
save_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
metadata, status, message = task.fetch_metadata(full=False)
|
||||||
|
if not status:
|
||||||
|
return web.json_response(data={"error": message}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"id": ag(metadata, ["id", "channel_id"]),
|
||||||
|
"id_type": metadata.get("extractor", "").split(":")[0].lower() if metadata.get("extractor") else None,
|
||||||
|
"title": ag(metadata, ["title", "fulltitle"]) or None,
|
||||||
|
"description": metadata.get("description", ""),
|
||||||
|
"uploader": metadata.get("uploader", ""),
|
||||||
|
"tags": metadata.get("tags", []),
|
||||||
|
"year": metadata.get("release_year"),
|
||||||
|
"thumbnails": get_channel_images(metadata.get("thumbnails", {})),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not info.get("title"):
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": "Failed to get title from metadata."}, status=web.HTTPBadRequest.status_code
|
||||||
|
)
|
||||||
|
|
||||||
|
from yt_dlp.utils import sanitize_filename
|
||||||
|
|
||||||
|
title = sanitize_filename(info.get("title"))
|
||||||
|
filename = save_path / f"{title} [{info.get('id')}].info.json"
|
||||||
|
filename.write_text(encoder.encode(metadata), encoding="utf-8")
|
||||||
|
info["json_file"] = str(filename.relative_to(config.download_path))
|
||||||
|
|
||||||
|
filename = save_path / "tvshow.nfo"
|
||||||
|
info["nfo_file"] = f"{save_path}/{filename}"
|
||||||
|
|
||||||
|
xml_content = "<tvshow>\n"
|
||||||
|
xml_content += f" <title>{NFOMakerPP._escape_text(info.get('title'))}</title>\n"
|
||||||
|
if info.get("description"):
|
||||||
|
xml_content += (
|
||||||
|
f" <plot>{NFOMakerPP._escape_text(NFOMakerPP._clean_description(info.get('description')))}</plot>\n"
|
||||||
|
)
|
||||||
|
if info.get("id"):
|
||||||
|
xml_content += f" <id>{NFOMakerPP._escape_text(info.get('id'))}</id>\n"
|
||||||
|
if info.get("id_type") and info.get("id"):
|
||||||
|
xml_content += f' <uniqueid type="{NFOMakerPP._escape_text(info.get("id_type"))}" default="true">{NFOMakerPP._escape_text(info.get("id"))}</uniqueid>\n'
|
||||||
|
if info.get("uploader"):
|
||||||
|
xml_content += f" <studio>{NFOMakerPP._escape_text(info.get('uploader'))}</studio>\n"
|
||||||
|
if info.get("tags", []):
|
||||||
|
for tag in info.get("tags", []):
|
||||||
|
xml_content += f" <tags>{NFOMakerPP._escape_text(tag)}</tags>\n"
|
||||||
|
if info.get("year"):
|
||||||
|
xml_content += f" <year>{info.get('year')}</year>\n"
|
||||||
|
xml_content += " <status>Continuing</status>\n"
|
||||||
|
xml_content += "</tvshow>\n"
|
||||||
|
|
||||||
|
filename.write_text(xml_content, encoding="utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
from yt_dlp.utils.networking import random_user_agent
|
||||||
|
|
||||||
|
ytdlp_args: dict = task.get_ytdlp_opts().get_all()
|
||||||
|
opts: dict[str, Any] = {
|
||||||
|
"headers": {
|
||||||
|
"User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if proxy := ytdlp_args.get("proxy"):
|
||||||
|
opts["proxy"] = proxy
|
||||||
|
|
||||||
|
try:
|
||||||
|
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt
|
||||||
|
|
||||||
|
opts["transport"] = AsyncCurlTransport(
|
||||||
|
impersonate="chrome",
|
||||||
|
default_headers=True,
|
||||||
|
curl_options={CurlOpt.FRESH_CONNECT: True},
|
||||||
|
)
|
||||||
|
opts.pop("headers", None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(**opts) as client:
|
||||||
|
for key in info.get("thumbnails", {}):
|
||||||
|
try:
|
||||||
|
url = info["thumbnails"][key]
|
||||||
|
LOG.info(f"Fetching thumbnail '{key}' from '{url}'")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_url(url, allow_internal=config.allow_internal_urls)
|
||||||
|
except ValueError:
|
||||||
|
LOG.warning(f"Invalid thumbnail url '{url}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
resp = await client.request(method="GET", url=url, follow_redirects=True)
|
||||||
|
|
||||||
|
filename = save_path / f"{key}.jpg"
|
||||||
|
filename.write_bytes(resp.content)
|
||||||
|
info["thumbnails"][key] = str(filename.relative_to(config.download_path))
|
||||||
|
except Exception as e:
|
||||||
|
LOG.warning(f"Failed to fetch thumbnail '{key}' from '{url}'. '{e!s}'")
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
LOG.warning(f"Failed to fetch thumbnails. '{e!s}'")
|
||||||
|
|
||||||
|
return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
|
except ValueError as e:
|
||||||
|
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,13 @@
|
||||||
<span>Run now</span>
|
<span>Run now</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink class="dropdown-item" @click="generateMeta(item)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-photo-film" /></span>
|
||||||
|
<span>Generate metadata</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
|
|
||||||
<NuxtLink class="dropdown-item" @click="() => inspectTask = item">
|
<NuxtLink class="dropdown-item" @click="() => inspectTask = item">
|
||||||
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
|
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
|
||||||
<span>Inspect Handler</span>
|
<span>Inspect Handler</span>
|
||||||
|
|
@ -243,6 +250,9 @@
|
||||||
<NuxtLink target="_blank" :href="item.url">
|
<NuxtLink target="_blank" :href="item.url">
|
||||||
{{ remove_tags(item.name) }}
|
{{ remove_tags(item.name) }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<span class="icon" v-if="item.in_progress">
|
||||||
|
<i class="fa-solid fa-spinner fa-spin has-text-info" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header-icon">
|
<div class="card-header-icon">
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
|
|
@ -329,6 +339,13 @@
|
||||||
<span>Run now</span>
|
<span>Run now</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink class="dropdown-item" @click="generateMeta(item)">
|
||||||
|
<span class="icon"><i class="fa-solid fa-photo-film" /></span>
|
||||||
|
<span>Generate metadata</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<hr class="dropdown-divider" />
|
||||||
|
|
||||||
<NuxtLink class="dropdown-item" @click="() => inspectTask = item">
|
<NuxtLink class="dropdown-item" @click="() => inspectTask = item">
|
||||||
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
|
<span class="icon"><i class="fa-solid fa-magnifying-glass" /></span>
|
||||||
<span>Inspect Handler</span>
|
<span>Inspect Handler</span>
|
||||||
|
|
@ -857,4 +874,55 @@ const unarchiveAll = async (item: task_item) => {
|
||||||
item.in_progress = false
|
item.in_progress = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const generateMeta = async (item: task_item) => {
|
||||||
|
try {
|
||||||
|
let path = '/';
|
||||||
|
if (item.folder) {
|
||||||
|
path = `/${sTrim(item.folder, '/')}`
|
||||||
|
}
|
||||||
|
const { status } = await cDialog({
|
||||||
|
rawHTML: `
|
||||||
|
<p>
|
||||||
|
Generate '${item.name}' metadata in '<b class="has-text-danger">${path}</b>'? you will be notified when it is done.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>This action will generate:</b>
|
||||||
|
<ul>
|
||||||
|
<li><strong>tvshow.nfo</strong> - for media center compatibility</li>
|
||||||
|
<li><strong>title [id].info.json</strong> - yt-dlp metadata file</li>
|
||||||
|
<li>
|
||||||
|
<strong>Thumbnails</strong>: poster.jpg, fanart.jpg, thumb.jpg, banner.jpg, icon.jpg, landscape.jpg
|
||||||
|
<u>if they are available</u>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
<p class="has-text-danger">
|
||||||
|
<span class="icon"><i class="fa-solid fa-triangle-exclamation"></i></span>
|
||||||
|
<span>Warning</span>: This will overwrite existing metadata files if they exist.
|
||||||
|
</p>`
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
if (true !== status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.in_progress = true
|
||||||
|
const response = await request(`/api/tasks/${item.id}/metadata`, { method: 'POST' })
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data?.error) {
|
||||||
|
toast.error(data.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Metadata generation completed.')
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(`Failed to generate metadata. ${e.message || 'Unknown error.'}`)
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
item.in_progress = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -366,20 +366,16 @@ const iTrim = (str: string, delim: string, position: 'start' | 'end' | 'both' =
|
||||||
throw new Error('Delimiter is required')
|
throw new Error('Delimiter is required')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (']' === delim) {
|
// Escape special regex characters for use in character class
|
||||||
delim = '\\]'
|
// Characters that need escaping in character classes: \ ] ^ -
|
||||||
}
|
const escapedDelim = delim.replace(/[\\^\-\]]/g, '\\$&')
|
||||||
|
|
||||||
if ('\\' === delim) {
|
|
||||||
delim = '\\\\'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (['both', 'start'].includes(position)) {
|
if (['both', 'start'].includes(position)) {
|
||||||
str = str.replace(new RegExp(`^[${delim}]+`, 'g'), '')
|
str = str.replace(new RegExp(`^[${escapedDelim}]+`, 'g'), '')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['both', 'end'].includes(position)) {
|
if (['both', 'end'].includes(position)) {
|
||||||
str = str.replace(new RegExp(`[${delim}]+$`, 'g'), '')
|
str = str.replace(new RegExp(`[${escapedDelim}]+$`, 'g'), '')
|
||||||
}
|
}
|
||||||
|
|
||||||
return str
|
return str
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,65 @@ describe('string manipulation helpers', () => {
|
||||||
expect(utils.iTrim('value::', ':', 'end')).toBe('value')
|
expect(utils.iTrim('value::', ':', 'end')).toBe('value')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('iTrim handles forward slash delimiter', () => {
|
||||||
|
expect(utils.iTrim('//value//', '/', 'both')).toBe('value')
|
||||||
|
expect(utils.iTrim('/value', '/', 'start')).toBe('value')
|
||||||
|
expect(utils.iTrim('value/', '/', 'end')).toBe('value')
|
||||||
|
expect(utils.iTrim('///multiple///', '/', 'both')).toBe('multiple')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iTrim handles backslash delimiter', () => {
|
||||||
|
expect(utils.iTrim('\\\\value\\\\', '\\', 'both')).toBe('value')
|
||||||
|
expect(utils.iTrim('\\value', '\\', 'start')).toBe('value')
|
||||||
|
expect(utils.iTrim('value\\', '\\', 'end')).toBe('value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iTrim handles hyphen delimiter', () => {
|
||||||
|
expect(utils.iTrim('--value--', '-', 'both')).toBe('value')
|
||||||
|
expect(utils.iTrim('-value', '-', 'start')).toBe('value')
|
||||||
|
expect(utils.iTrim('value-', '-', 'end')).toBe('value')
|
||||||
|
expect(utils.iTrim('---multiple---', '-', 'both')).toBe('multiple')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iTrim handles caret delimiter', () => {
|
||||||
|
expect(utils.iTrim('^^value^^', '^', 'both')).toBe('value')
|
||||||
|
expect(utils.iTrim('^value', '^', 'start')).toBe('value')
|
||||||
|
expect(utils.iTrim('value^', '^', 'end')).toBe('value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iTrim handles bracket delimiters', () => {
|
||||||
|
expect(utils.iTrim('[[value]]', '[', 'both')).toBe('value]]')
|
||||||
|
expect(utils.iTrim(']]value[[', ']', 'both')).toBe('value[[')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iTrim handles dot delimiter', () => {
|
||||||
|
expect(utils.iTrim('..value..', '.', 'both')).toBe('value')
|
||||||
|
expect(utils.iTrim('.value', '.', 'start')).toBe('value')
|
||||||
|
expect(utils.iTrim('value.', '.', 'end')).toBe('value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iTrim handles special regex characters', () => {
|
||||||
|
expect(utils.iTrim('**value**', '*', 'both')).toBe('value')
|
||||||
|
expect(utils.iTrim('++value++', '+', 'both')).toBe('value')
|
||||||
|
expect(utils.iTrim('??value??', '?', 'both')).toBe('value')
|
||||||
|
expect(utils.iTrim('||value||', '|', 'both')).toBe('value')
|
||||||
|
expect(utils.iTrim('((value))', '(', 'both')).toBe('value))')
|
||||||
|
expect(utils.iTrim('((value))', ')', 'both')).toBe('((value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iTrim handles empty string', () => {
|
||||||
|
expect(utils.iTrim('', '/', 'both')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iTrim throws error when delimiter is empty', () => {
|
||||||
|
expect(() => utils.iTrim('value', '', 'both')).toThrow('Delimiter is required')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('iTrim preserves middle occurrences', () => {
|
||||||
|
expect(utils.iTrim('/path/to/file/', '/', 'both')).toBe('path/to/file')
|
||||||
|
expect(utils.iTrim('//path//to//file//', '/', 'both')).toBe('path//to//file')
|
||||||
|
})
|
||||||
|
|
||||||
it('eTrim and sTrim delegate to iTrim ends', () => {
|
it('eTrim and sTrim delegate to iTrim ends', () => {
|
||||||
expect(utils.eTrim('##name##', '#')).toBe('##name')
|
expect(utils.eTrim('##name##', '#')).toBe('##name')
|
||||||
expect(utils.sTrim('##name##', '#')).toBe('name##')
|
expect(utils.sTrim('##name##', '#')).toBe('name##')
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue