Skip to content

Commit 2435144

Browse files
authored
Merge pull request #540 from arabcoders/dev
feat: support using output template for metadata generation.
2 parents 1189d31 + c8e2fc5 commit 2435144

File tree

7 files changed

+287
-45
lines changed

7 files changed

+287
-45
lines changed

app/library/Download.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def _postprocessor_hook(self, data: dict) -> None:
189189
dataDict = {k: v for k, v in data.items() if k in self._ytdlp_fields}
190190
self.status_queue.put({"id": self.id, "action": "postprocessing", **dataDict, "status": "postprocessing"})
191191

192-
def post_hooks(self, filename: str | None = None) -> None:
192+
def _post_hooks(self, filename: str | None = None) -> None:
193193
if not filename:
194194
return
195195

@@ -227,7 +227,7 @@ def _download(self) -> None:
227227
{
228228
"progress_hooks": [self._progress_hook],
229229
"postprocessor_hooks": [self._postprocessor_hook],
230-
"post_hooks": [self.post_hooks],
230+
"post_hooks": [self._post_hooks],
231231
}
232232
)
233233

@@ -330,6 +330,8 @@ def mark_cancelled(*_) -> None:
330330

331331
signal.signal(signal.SIGUSR1, mark_cancelled)
332332

333+
self.status_queue.put({"id": self.id, "status": "downloading"})
334+
333335
if isinstance(self.info_dict, dict) and len(self.info_dict) > 1:
334336
self.logger.debug(f"Downloading '{self.info.url}' using pre-info.")
335337
_dct: dict = self.info_dict.copy()

app/library/Tasks.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ def get_ytdlp_opts(self) -> YTDLPOpts:
7777
if self.cli:
7878
params = params.add_cli(self.cli, from_user=True)
7979

80+
if self.template:
81+
params = params.add({"outtmpl": {"default": self.template}}, from_user=False)
82+
8083
return params
8184

8285
def mark(self) -> tuple[bool, str]:
@@ -140,7 +143,9 @@ def fetch_metadata(self, full: bool = False) -> tuple[dict[str, Any] | None, boo
140143

141144
params = params.get_all()
142145

143-
ie_info: dict | None = extract_info(params, self.url, no_archive=True, follow_redirect=False, cache=True)
146+
ie_info: dict | None = extract_info(
147+
params, self.url, no_archive=True, follow_redirect=False, sanitize_info=True
148+
)
144149
if not ie_info or not isinstance(ie_info, dict):
145150
return ({}, False, "Failed to extract information from URL.")
146151

app/library/Utils.py

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
]
6666
"Keys to remove from yt-dlp options at various levels."
6767

68-
YTDLP_INFO_CLS: YTDLP = None
68+
YTDLP_INFO_CLS: YTDLP | None = None
6969
"Cached YTDLP info class."
7070

7171
ALLOWED_SUBS_EXTENSIONS: set[str] = {".srt", ".vtt", ".ass"}
@@ -96,6 +96,32 @@ def formatTime(self, record, datefmt=None): # noqa: ARG002, N802
9696
return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds")
9797

9898

99+
def get_static_ytdlp(reload: bool = False) -> YTDLP:
100+
"""
101+
Get a static YTDLP instance for info extraction.
102+
103+
Args:
104+
reload (bool): If True, forces re-creation of the instance.
105+
106+
Returns:
107+
YTDLP: A static YTDLP instance.
108+
109+
"""
110+
global YTDLP_INFO_CLS # noqa: PLW0603
111+
if YTDLP_INFO_CLS is None or reload:
112+
YTDLP_INFO_CLS = YTDLP(
113+
params={
114+
"color": "no_color",
115+
"extract_flat": True,
116+
"skip_download": True,
117+
"ignoreerrors": True,
118+
"ignore_no_formats_error": True,
119+
"quiet": True,
120+
}
121+
)
122+
return YTDLP_INFO_CLS
123+
124+
99125
def patch_metadataparser() -> None:
100126
"""
101127
Patches yt_dlp MetadataParserPP action to handle subprocess pickling issues.
@@ -382,7 +408,7 @@ def extract_info(
382408
if not data["is_premiere"]:
383409
data["is_premiere"] = "video" == data.get("media_type") and "is_upcoming" == data.get("live_status")
384410

385-
return YTDLP.sanitize_info(data) if sanitize_info else data
411+
return YTDLP.sanitize_info(data, remove_private_keys=True) if sanitize_info else data
386412

387413

388414
def _is_safe_key(key: any) -> bool:
@@ -1406,27 +1432,13 @@ def get_archive_id(url: str) -> dict[str, str | None]:
14061432
}
14071433
14081434
"""
1409-
global YTDLP_INFO_CLS # noqa: PLW0603
1410-
14111435
idDict: dict[str, None] = {
14121436
"id": None,
14131437
"ie_key": None,
14141438
"archive_id": None,
14151439
}
14161440

1417-
if YTDLP_INFO_CLS is None:
1418-
YTDLP_INFO_CLS = YTDLP(
1419-
params={
1420-
"color": "no_color",
1421-
"extract_flat": True,
1422-
"skip_download": True,
1423-
"ignoreerrors": True,
1424-
"ignore_no_formats_error": True,
1425-
"quiet": True,
1426-
}
1427-
)
1428-
1429-
for key, _ie in YTDLP_INFO_CLS._ies.items():
1441+
for key, _ie in get_static_ytdlp()._ies.items():
14301442
try:
14311443
if not _ie.suitable(url):
14321444
continue
@@ -1923,3 +1935,18 @@ def get_extras(entry: dict, kind: str = "video") -> dict:
19231935
extras["thumbnail"] = "https://img.youtube.com/vi/{id}/maxresdefault.jpg".format(**entry)
19241936

19251937
return extras
1938+
1939+
1940+
def parse_outtmpl(output_template: str, info_dict: dict) -> str:
1941+
"""
1942+
Parse yt-dlp output template with given info_dict.
1943+
1944+
Args:
1945+
output_template (str): The output template string.
1946+
info_dict (dict): The info dictionary from yt-dlp.
1947+
1948+
Returns:
1949+
str: The parsed output string.
1950+
1951+
"""
1952+
return get_static_ytdlp().prepare_filename(info_dict=info_dict, outtmpl=output_template)

app/routes/api/tasks.py

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import asyncio
2+
import functools
13
import logging
24
import uuid
35
from typing import TYPE_CHECKING, Any
@@ -10,7 +12,7 @@
1012
from app.library.encoder import Encoder
1113
from app.library.router import route
1214
from app.library.Tasks import Task, TaskFailure, TaskResult, Tasks
13-
from app.library.Utils import get_channel_images, get_file, init_class, validate_url, validate_uuid
15+
from app.library.Utils import get_channel_images, get_file, init_class, parse_outtmpl, validate_url, validate_uuid
1416

1517
if TYPE_CHECKING:
1618
from pathlib import Path
@@ -252,10 +254,38 @@ async def task_metadata(request: Request, config: Config, encoder: Encoder) -> R
252254
if not save_path.exists():
253255
save_path.mkdir(parents=True, exist_ok=True)
254256

255-
metadata, status, message = task.fetch_metadata(full=False)
257+
metadata, status, message = await asyncio.wait_for(
258+
fut=asyncio.get_running_loop().run_in_executor(
259+
None,
260+
functools.partial(task.fetch_metadata, full=False),
261+
),
262+
timeout=120,
263+
)
256264
if not status:
257265
return web.json_response(data={"error": message}, status=web.HTTPBadRequest.status_code)
258266

267+
if not task.folder:
268+
try:
269+
outtmpl = parse_outtmpl(
270+
output_template=task.get_ytdlp_opts().get_all().get("outtmpl", {}).get("default", "{title} [{id}]"),
271+
info_dict=metadata,
272+
)
273+
if outtmpl:
274+
_path = save_path / outtmpl
275+
if not _path.is_dir():
276+
_path = _path.parent
277+
278+
(save_path, _) = get_file(config.download_path, _path.relative_to(config.download_path))
279+
if not str(save_path or "").startswith(str(config.download_path)):
280+
return web.json_response(
281+
data={"error": "Invalid final path folder."}, status=web.HTTPBadRequest.status_code
282+
)
283+
284+
if not save_path.exists():
285+
save_path.mkdir(parents=True, exist_ok=True)
286+
except Exception as e:
287+
LOG.warning(f"Failed to resolve final path from outtmpl. '{e!s}'")
288+
259289
info = {
260290
"id": ag(metadata, ["id", "channel_id"]),
261291
"id_type": metadata.get("extractor", "").split(":")[0].lower() if metadata.get("extractor") else None,
@@ -277,12 +307,12 @@ async def task_metadata(request: Request, config: Config, encoder: Encoder) -> R
277307
from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP
278308

279309
title: str = sanitize_filename(info.get("title"))
280-
filename: Path = save_path / f"{title} [{info.get('id')}].info.json"
281-
filename.write_text(encoder.encode(metadata), encoding="utf-8")
282-
info["json_file"] = str(filename.relative_to(config.download_path))
310+
info_file: Path = save_path / f"{title} [{info.get('id')}].info.json"
311+
info_file.write_text(encoder.encode(metadata), encoding="utf-8")
312+
info["json_file"] = str(info_file.relative_to(config.download_path))
283313

284-
filename = save_path / "tvshow.nfo"
285-
info["nfo_file"] = f"{save_path}/{filename}"
314+
xml_file: Path = save_path / "tvshow.nfo"
315+
info["nfo_file"] = str(xml_file.relative_to(config.download_path))
286316

287317
xml_content = "<tvshow>\n"
288318
xml_content += f" <title>{NFOMakerPP._escape_text(info.get('title'))}</title>\n"
@@ -303,8 +333,7 @@ async def task_metadata(request: Request, config: Config, encoder: Encoder) -> R
303333
xml_content += f" <year>{info.get('year')}</year>\n"
304334
xml_content += " <status>Continuing</status>\n"
305335
xml_content += "</tvshow>\n"
306-
307-
filename.write_text(xml_content, encoding="utf-8")
336+
xml_file.write_text(xml_content, encoding="utf-8")
308337

309338
try:
310339
from yt_dlp.utils.networking import random_user_agent
@@ -348,9 +377,9 @@ async def task_metadata(request: Request, config: Config, encoder: Encoder) -> R
348377

349378
resp = await client.request(method="GET", url=url, follow_redirects=True)
350379

351-
filename = save_path / f"{key}.jpg"
352-
filename.write_bytes(resp.content)
353-
info["thumbnails"][key] = str(filename.relative_to(config.download_path))
380+
img_file = save_path / f"{key}.jpg"
381+
img_file.write_bytes(resp.content)
382+
info["thumbnails"][key] = str(img_file.relative_to(config.download_path))
354383
except Exception as e:
355384
LOG.warning(f"Failed to fetch thumbnail '{key}' from '{url}'. '{e!s}'")
356385
continue

app/tests/test_download.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,9 @@ def test_post_hooks_pushes_filename(self) -> None:
152152
d = Download(make_item())
153153
q = DummyQueue()
154154
d.status_queue = q
155-
d.post_hooks(None)
155+
d._post_hooks(None)
156156
assert len(q.items) == 0
157-
d.post_hooks("name.ext")
157+
d._post_hooks("name.ext")
158158
assert len(q.items) == 1
159159
assert q.items[0]["filename"] == "name.ext"
160160

0 commit comments

Comments
 (0)