Merge pull request #552 from arabcoders/dev

Fix: offload info extraction to ProcessPoolExecutor
This commit is contained in:
Abdulmohsen 2026-01-27 11:53:15 +03:00 committed by GitHub
commit 8659f74a3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1021 additions and 662 deletions

1
FAQ.md
View file

@ -46,7 +46,6 @@ or the `environment:` section in `compose.yaml` file.
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` | | YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
| YTP_LIVE_PREMIERE_BUFFER | buffer time in minutes to add to video duration | `5` | | YTP_LIVE_PREMIERE_BUFFER | buffer time in minutes to add to video duration | `5` |
| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` | | YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
| YTP_PLAYLIST_ITEMS_CONCURRENCY | The number of playlist items be to processed at same time | `1` |
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` | | YTP_TEMP_DISABLED | Disable temp files handling. | `false` |
| YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` | | YTP_DOWNLOAD_PATH_DEPTH | How many subdirectories to show in auto complete. | `1` |
| YTP_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` | | YTP_ALLOW_INTERNAL_URLS | Allow requests to internal URLs | `false` |

View file

@ -87,10 +87,10 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
preset: str = params.get("preset", config.default_preset) preset: str = params.get("preset", config.default_preset)
key: str = cache.hash(url + str(preset)) key: str = cache.hash(url + str(preset))
if not cache.has(key): if not cache.has(key):
from app.library.Utils import fetch_info from app.library.downloads.extractor import fetch_info
from app.library.YTDLPOpts import YTDLPOpts from app.library.YTDLPOpts import YTDLPOpts
data: dict | None = await fetch_info( (data, _) = await fetch_info(
config=YTDLPOpts.get_instance().preset(name=preset).get_all(), config=YTDLPOpts.get_instance().preset(name=preset).get_all(),
url=url, url=url,
debug=False, debug=False,

View file

@ -1,12 +1,12 @@
# flake8: noqa: ARG004 # flake8: noqa: ARG004
from typing import Any from typing import TYPE_CHECKING, Any
import httpx
from yt_dlp.utils.networking import random_user_agent
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskResult from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskResult
from app.library.config import Config from app.library.config import Config
from app.library.httpx_client import async_client from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
if TYPE_CHECKING:
import httpx
class BaseHandler: class BaseHandler:
@ -33,7 +33,7 @@ class BaseHandler:
@staticmethod @staticmethod
async def request( async def request(
url: str, headers: dict | None = None, ytdlp_opts: dict | None = None, **kwargs url: str, headers: dict | None = None, ytdlp_opts: dict | None = None, **kwargs
) -> httpx.Response: ) -> "httpx.Response":
""" """
Make an HTTP request. Make an HTTP request.
@ -50,31 +50,21 @@ class BaseHandler:
headers = {} if not isinstance(headers, dict) else headers headers = {} if not isinstance(headers, dict) else headers
ytdlp_opts = {} if not isinstance(ytdlp_opts, dict) else ytdlp_opts ytdlp_opts = {} if not isinstance(ytdlp_opts, dict) else ytdlp_opts
opts: dict[str, Any] = { use_curl = resolve_curl_transport()
"headers": { request_headers = build_request_headers(
"User-Agent": random_user_agent(), base_headers=headers,
}, user_agent=Globals.get_random_agent(),
} use_curl=use_curl,
)
try: proxy = ytdlp_opts.get("proxy", None)
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt client = get_async_client(proxy=proxy, use_curl=use_curl)
method = kwargs.pop("method", "GET").upper()
opts["transport"] = AsyncCurlTransport( timeout = ytdlp_opts.get("timeout", ytdlp_opts.get("socket_timeout", 120))
impersonate="chrome", return await client.request(
default_headers=True, method=method,
curl_options={CurlOpt.FRESH_CONNECT: True}, url=url,
) headers=request_headers,
opts["headers"].pop("User-Agent", None) timeout=timeout,
except Exception: **kwargs,
pass )
for k, v in headers.items():
opts["headers"][k] = v
if proxy := ytdlp_opts.get("proxy", None):
opts["proxy"] = proxy
async with async_client(**opts) as client:
method = kwargs.pop("method", "GET").upper()
timeout = ytdlp_opts.get("timeout", ytdlp_opts.get("socket_timeout", 120))
return await client.request(method=method, url=url, timeout=timeout, **kwargs)

View file

@ -14,7 +14,6 @@ from urllib.parse import urljoin
import jmespath import jmespath
from parsel import Selector from parsel import Selector
from yt_dlp.utils.networking import random_user_agent
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.features.tasks.definitions.schemas import ( from app.features.tasks.definitions.schemas import (
@ -23,8 +22,9 @@ from app.features.tasks.definitions.schemas import (
) )
from app.library.cache import Cache from app.library.cache import Cache
from app.library.config import Config from app.library.config import Config
from app.library.httpx_client import async_client from app.library.downloads.extractor import fetch_info
from app.library.Utils import fetch_info, get_archive_id from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
from app.library.Utils import get_archive_id
from ._base_handler import BaseHandler from ._base_handler import BaseHandler
@ -34,7 +34,7 @@ if TYPE_CHECKING:
import httpx import httpx
from parsel.selector import SelectorList from parsel.selector import SelectorList
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger("handlers.generic")
CACHE: Cache = Cache() CACHE: Cache = Cache()
@ -48,7 +48,7 @@ class GenericTaskHandler(BaseHandler):
"""Modification times of source files to detect changes.""" """Modification times of source files to detect changes."""
@classmethod @classmethod
async def refresh_definitions(cls, force: bool = False) -> None: async def refresh_definitions(cls, force: bool = False) -> list[TaskDefinition]:
""" """
Refresh the cached task definitions if source files have changed. Refresh the cached task definitions if source files have changed.
@ -66,12 +66,7 @@ class GenericTaskHandler(BaseHandler):
repo = TaskDefinitionsRepository.get_instance() repo = TaskDefinitionsRepository.get_instance()
models = await repo.list() models = await repo.list()
definitions: list[TaskDefinition] = [] cls._definitions = [model_to_schema(model) for model in models]
for model in models:
td = model_to_schema(model)
definitions.append(td)
cls._definitions = definitions
return cls._definitions return cls._definitions
except Exception as exc: except Exception as exc:
LOG.error(f"Failed to load task definitions from database: {exc}") LOG.error(f"Failed to load task definitions from database: {exc}")
@ -97,10 +92,10 @@ class GenericTaskHandler(BaseHandler):
try: try:
for matcher in definition.match_url: for matcher in definition.match_url:
pattern_str = None pattern_str: str | None = None
if matcher.startswith("/") and matcher.endswith("/") and len(matcher) > 2: if matcher.startswith("/") and matcher.endswith("/") and len(matcher) > 2:
pattern_str: str = matcher[1:-1] pattern_str = matcher[1:-1]
else: else:
pattern_str = fnmatch.translate(matcher) pattern_str = fnmatch.translate(matcher)
@ -187,7 +182,7 @@ class GenericTaskHandler(BaseHandler):
f"[{definition.name}]: '{task.name}': Unable to generate static archive id for '{url}' in feed. Doing real request to fetch yt-dlp archive id." f"[{definition.name}]: '{task.name}': Unable to generate static archive id for '{url}' in feed. Doing real request to fetch yt-dlp archive id."
) )
info = await fetch_info( (info, _) = await fetch_info(
config=task.get_ytdlp_opts().get_all(), config=task.get_ytdlp_opts().get_all(),
url=url, url=url,
no_archive=True, no_archive=True,
@ -274,53 +269,37 @@ class GenericTaskHandler(BaseHandler):
""" """
headers: dict[str, str] = {**definition.definition.request.headers} headers: dict[str, str] = {**definition.definition.request.headers}
client_options: dict[str, Any] = { use_curl = resolve_curl_transport()
"headers": { request_headers = build_request_headers(
"User-Agent": random_user_agent(), base_headers=headers,
} user_agent=Globals.get_random_agent(),
} use_curl=use_curl,
)
try:
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt
client_options["transport"] = AsyncCurlTransport(
impersonate="chrome",
default_headers=True,
curl_options={CurlOpt.FRESH_CONNECT: True},
)
client_options["headers"].pop("User-Agent", None)
except Exception:
pass
if headers:
client_options["headers"].update(headers)
if proxy := ytdlp_opts.get("proxy"):
client_options["proxy"] = proxy
timeout_value: float | Any = definition.definition.request.timeout or ytdlp_opts.get("socket_timeout", 120) timeout_value: float | Any = definition.definition.request.timeout or ytdlp_opts.get("socket_timeout", 120)
async with async_client(**client_options) as client: client = get_async_client(proxy=ytdlp_opts.get("proxy"), use_curl=use_curl)
response: httpx.Response = await client.request( response: httpx.Response = await client.request(
method=definition.definition.request.method.upper(), method=definition.definition.request.method.upper(),
url=url, url=url,
params=definition.definition.request.params or None, params=definition.definition.request.params or None,
data=definition.definition.request.data, data=definition.definition.request.data,
json=definition.definition.request.json_data, json=definition.definition.request.json_data,
timeout=timeout_value, timeout=timeout_value,
) headers=request_headers,
response.raise_for_status() )
response.raise_for_status()
if "json" == definition.definition.response.type: if "json" == definition.definition.response.type:
try: try:
json_data: dict[str, Any] = response.json() json_data: dict[str, Any] = response.json()
except Exception as exc: except Exception as exc:
LOG.error(f"Failed to decode JSON response from '{url}': {exc}") LOG.error(f"Failed to decode JSON response from '{url}': {exc}")
return response.text, None return response.text, None
return response.text, json_data return response.text, json_data
return response.text, None return response.text, None
@staticmethod @staticmethod
async def _fetch_with_selenium( async def _fetch_with_selenium(

View file

@ -6,14 +6,15 @@ from xml.etree.ElementTree import Element
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.library.cache import Cache from app.library.cache import Cache
from app.library.Utils import fetch_info, get_archive_id from app.library.downloads.extractor import fetch_info
from app.library.Utils import get_archive_id
from ._base_handler import BaseHandler from ._base_handler import BaseHandler
if TYPE_CHECKING: if TYPE_CHECKING:
from xml.etree.ElementTree import Element from xml.etree.ElementTree import Element
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger("handlers.rss")
CACHE: Cache = Cache() CACHE: Cache = Cache()
@ -179,7 +180,7 @@ class RssGenericHandler(BaseHandler):
"Doing real request to fetch yt-dlp archive ID." "Doing real request to fetch yt-dlp archive ID."
) )
info = await fetch_info( (info, _) = await fetch_info(
config=params, config=params,
url=url, url=url,
no_archive=True, no_archive=True,

View file

@ -6,7 +6,7 @@ from app.library.Utils import get_archive_id
from ._base_handler import BaseHandler from ._base_handler import BaseHandler
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger("handlers.tver")
class TverHandler(BaseHandler): class TverHandler(BaseHandler):

View file

@ -11,7 +11,7 @@ from ._base_handler import BaseHandler
if TYPE_CHECKING: if TYPE_CHECKING:
from xml.etree.ElementTree import Element from xml.etree.ElementTree import Element
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger("handlers.twitch")
class TwitchHandler(BaseHandler): class TwitchHandler(BaseHandler):

View file

@ -11,7 +11,7 @@ from ._base_handler import BaseHandler
if TYPE_CHECKING: if TYPE_CHECKING:
from xml.etree.ElementTree import Element from xml.etree.ElementTree import Element
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger("handlers.youtube")
class YoutubeHandler(BaseHandler): class YoutubeHandler(BaseHandler):

View file

@ -101,7 +101,7 @@ class HandleTask(TaskSchema):
params_dict = params.get_all() params_dict = params.get_all()
ie_info: dict | None = await fetch_info( (ie_info, _) = await fetch_info(
params_dict, params_dict,
self.url, self.url,
no_archive=True, no_archive=True,
@ -133,7 +133,7 @@ class HandleTask(TaskSchema):
archive_file: Path = Path(archive_file) archive_file: Path = Path(archive_file)
ie_info: dict | None = await fetch_info(params, self.url, no_archive=True, follow_redirect=True) (ie_info, _) = await fetch_info(params, self.url, no_archive=True, follow_redirect=True)
if not ie_info or not isinstance(ie_info, dict): if not ie_info or not isinstance(ie_info, dict):
return (False, "Failed to extract information from URL.") return (False, "Failed to extract information from URL.")

View file

@ -22,7 +22,7 @@ if TYPE_CHECKING:
from app.library.config import Config from app.library.config import Config
from app.library.Scheduler import Scheduler from app.library.Scheduler import Scheduler
LOG: logging.Logger = logging.getLogger("tasks.definitions.service") LOG: logging.Logger = logging.getLogger("definitions.service")
class TaskHandle: class TaskHandle:

View file

@ -339,7 +339,7 @@ async def test_generic_task_handler_inspect(monkeypatch):
# Mock fetch_info to return valid info with required fields for archive ID generation # Mock fetch_info to return valid info with required fields for archive ID generation
async def fake_fetch_info(config, url, **kwargs): # noqa: ARG001 async def fake_fetch_info(config, url, **kwargs): # noqa: ARG001
return {"id": "test_video_1", "extractor_key": "Example"} return ({"id": "test_video_1", "extractor_key": "Example"}, [])
with patch("app.features.tasks.definitions.handlers.generic.fetch_info", side_effect=fake_fetch_info): with patch("app.features.tasks.definitions.handlers.generic.fetch_info", side_effect=fake_fetch_info):
task = HandleTask(id=1, name="Inspect", url="https://example.com/api") task = HandleTask(id=1, name="Inspect", url="https://example.com/api")

View file

@ -327,7 +327,8 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
The response object. The response object.
""" """
task_id = request.match_info.get("id") if not (task_id := request.match_info.get("id")):
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
try: try:
if not (model := await repo.get(int(task_id))): if not (model := await repo.get(int(task_id))):
@ -335,7 +336,6 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
) )
# Convert to extended Task with handler methods
task = ExtendedTask.model_validate(model) task = ExtendedTask.model_validate(model)
(save_path, _) = get_file(config.download_path, task.folder) (save_path, _) = get_file(config.download_path, task.folder)
@ -348,6 +348,11 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
metadata, status, message = await task.fetch_metadata() metadata, status, message = await task.fetch_metadata()
if not status: if not status:
return web.json_response(data={"error": message}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": message}, status=web.HTTPBadRequest.status_code)
if not isinstance(metadata, dict):
return web.json_response(
data={"error": "Failed to get metadata."},
status=web.HTTPBadRequest.status_code,
)
if not task.folder: if not task.folder:
try: try:
@ -395,7 +400,7 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP
title: str = sanitize_filename(info.get("title")) title: str = sanitize_filename(str(info.get("title") or ""))
info_file: Path = save_path / f"{title} [{info.get('id')}].info.json" info_file: Path = save_path / f"{title} [{info.get('id')}].info.json"
info_file.write_text(encoder.encode(metadata), encoding="utf-8") info_file.write_text(encoder.encode(metadata), encoding="utf-8")
info["json_file"] = str(info_file.relative_to(config.download_path)) info["json_file"] = str(info_file.relative_to(config.download_path))
@ -406,9 +411,7 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
xml_content = "<tvshow>\n" xml_content = "<tvshow>\n"
xml_content += f" <title>{NFOMakerPP._escape_text(info.get('title'))}</title>\n" xml_content += f" <title>{NFOMakerPP._escape_text(info.get('title'))}</title>\n"
if info.get("description"): if info.get("description"):
xml_content += ( xml_content += f" <plot>{NFOMakerPP._escape_text(NFOMakerPP._clean_description(str(info.get('description') or '')))}</plot>\n"
f" <plot>{NFOMakerPP._escape_text(NFOMakerPP._clean_description(info.get('description')))}</plot>\n"
)
if info.get("id"): if info.get("id"):
xml_content += f" <id>{NFOMakerPP._escape_text(info.get('id'))}</id>\n" xml_content += f" <id>{NFOMakerPP._escape_text(info.get('id'))}</id>\n"
if info.get("id_type") and info.get("id"): if info.get("id_type") and info.get("id"):
@ -425,53 +428,53 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
xml_file.write_text(xml_content, encoding="utf-8") xml_file.write_text(xml_content, encoding="utf-8")
try: try:
from yt_dlp.utils.networking import random_user_agent from app.library.httpx_client import (
Globals,
from app.library.httpx_client import async_client build_request_headers,
get_async_client,
resolve_curl_transport,
)
ytdlp_args: dict = task.get_ytdlp_opts().get_all() ytdlp_args: dict = task.get_ytdlp_opts().get_all()
opts: dict[str, Any] = { use_curl = resolve_curl_transport()
"headers": { request_headers = build_request_headers(
"User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())), user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())),
}, use_curl=use_curl,
} )
if proxy := ytdlp_args.get("proxy"):
opts["proxy"] = proxy
try: client = get_async_client(proxy=ytdlp_args.get("proxy"), use_curl=use_curl)
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt thumbnails = info.get("thumbnails", {})
if not isinstance(thumbnails, dict):
opts["transport"] = AsyncCurlTransport( thumbnails = {}
impersonate="chrome", info["thumbnails"] = thumbnails
default_headers=True, for key in thumbnails:
curl_options={CurlOpt.FRESH_CONNECT: True}, url: str | None = None
) try:
opts.pop("headers", None) url = thumbnails.get(key)
except Exception: LOG.info(f"Fetching thumbnail '{key}' from '{url}'")
pass if not url:
async with async_client(**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)
img_file = save_path / f"{key}.jpg"
img_file.write_bytes(resp.content)
info["thumbnails"][key] = str(img_file.relative_to(config.download_path))
except Exception as e:
LOG.warning(f"Failed to fetch thumbnail '{key}' from '{url}'. '{e!s}'")
continue 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,
headers=request_headers,
)
img_file = save_path / f"{key}.jpg"
img_file.write_bytes(resp.content)
thumbnails[key] = str(img_file.relative_to(config.download_path))
except Exception as e:
url_log = url or "unknown"
LOG.warning(f"Failed to fetch thumbnail '{key}' from '{url_log}'. '{e!s}'")
continue
except Exception as e: except Exception as e:
LOG.warning(f"Failed to fetch thumbnails. '{e!s}'") LOG.warning(f"Failed to fetch thumbnails. '{e!s}'")

View file

@ -14,7 +14,7 @@ from app.library.Singleton import Singleton
if TYPE_CHECKING: if TYPE_CHECKING:
from aiohttp import web from aiohttp import web
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger("tasks.service")
class Tasks(metaclass=Singleton): class Tasks(metaclass=Singleton):

View file

@ -1,3 +1,4 @@
import asyncio
import functools import functools
import json import json
import logging import logging
@ -186,10 +187,20 @@ class HttpSocket:
await handle_connect(sid) await handle_connect(sid)
async def handle_message_safe(sid: str, message: str) -> None:
"""Wrapper to handle message with error logging"""
try:
await handle_message(sid, message)
except Exception as e:
LOG.exception(e)
LOG.error(f"Error handling WebSocket message from client '{sid}': {e}")
try: try:
i: int = 0
async for msg in ws: async for msg in ws:
if msg.type == web.WSMsgType.TEXT: if msg.type == web.WSMsgType.TEXT:
await handle_message(sid, msg.data) i = i + 1
asyncio.create_task(handle_message_safe(sid, msg.data), name=f"ws_msg_{sid}_{i}")
elif msg.type == web.WSMsgType.ERROR: elif msg.type == web.WSMsgType.ERROR:
LOG.error(f"WebSocket connection closed with exception {ws.exception()}") LOG.error(f"WebSocket connection closed with exception {ws.exception()}")
finally: finally:

View file

@ -8,7 +8,7 @@ from aiohttp import web
from .cache import Cache from .cache import Cache
from .config import Config from .config import Config
from .Events import EventBus, Events from .Events import EventBus, Events
from .httpx_client import async_client from .httpx_client import get_async_client
from .Scheduler import Scheduler from .Scheduler import Scheduler
from .Singleton import Singleton from .Singleton import Singleton
from .version import APP_VERSION from .version import APP_VERSION
@ -147,36 +147,37 @@ class UpdateChecker(metaclass=Singleton):
try: try:
LOG.info(f"Checking for {name} updates...") LOG.info(f"Checking for {name} updates...")
async with async_client(timeout=10.0) as client: client = get_async_client(use_curl=False)
response = await client.get( response = await client.get(
api_url, api_url,
headers={"Accept": "application/vnd.github+json"}, headers={"Accept": "application/vnd.github+json"},
) timeout=10.0,
)
if 200 != response.status_code: if 200 != response.status_code:
LOG.warning(f"Failed to check for {name} updates: HTTP {response.status_code}") LOG.warning(f"Failed to check for {name} updates: HTTP {response.status_code}")
return ("error", None) return ("error", None)
data: dict[str, Any] = response.json() data: dict[str, Any] = response.json()
latest_tag: str = data.get("tag_name", "") latest_tag: str = data.get("tag_name", "")
if not latest_tag: if not latest_tag:
LOG.warning(f"No tag_name found in {name} GitHub release data.") LOG.warning(f"No tag_name found in {name} GitHub release data.")
return ("error", None) return ("error", None)
compare_current: str = current_version.lstrip("v") if strip_v_prefix else current_version compare_current: str = current_version.lstrip("v") if strip_v_prefix else current_version
compare_latest: str = latest_tag.lstrip("v") if strip_v_prefix else latest_tag compare_latest: str = latest_tag.lstrip("v") if strip_v_prefix else latest_tag
if self._compare_versions(compare_current, compare_latest): if self._compare_versions(compare_current, compare_latest):
LOG.warning(f"{name} update available: {current_version} -> {latest_tag}") LOG.warning(f"{name} update available: {current_version} -> {latest_tag}")
result: tuple[str, str] = ("update_available", latest_tag) result = ("update_available", latest_tag)
await self._cache.aset(cache_key, result, self.CACHE_DURATION)
return result
LOG.info(f"No {name} updates available.")
result: tuple[str, None] = ("up_to_date", None)
await self._cache.aset(cache_key, result, self.CACHE_DURATION) await self._cache.aset(cache_key, result, self.CACHE_DURATION)
return result return result
LOG.info(f"No {name} updates available.")
result = ("up_to_date", None)
await self._cache.aset(cache_key, result, self.CACHE_DURATION)
return result
except Exception as e: except Exception as e:
LOG.exception(e) LOG.exception(e)
LOG.error(f"Error checking for {name} updates: {e!s}") LOG.error(f"Error checking for {name} updates: {e!s}")

View file

@ -1,7 +1,5 @@
import asyncio
import base64 import base64
import copy import copy
import functools
import glob import glob
import ipaddress import ipaddress
import json import json
@ -22,8 +20,6 @@ from typing import Any
from Crypto.Cipher import AES from Crypto.Cipher import AES
from yt_dlp.utils import age_restricted from yt_dlp.utils import age_restricted
from .LogWrapper import LogWrapper
from .mini_filter import match_str
from .ytdlp import YTDLP, make_archive_id from .ytdlp import YTDLP, make_archive_id
LOG: logging.Logger = logging.getLogger("Utils") LOG: logging.Logger = logging.getLogger("Utils")
@ -88,9 +84,6 @@ TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?") DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
"Regex to match ISO 8601 datetime strings." "Regex to match ISO 8601 datetime strings."
EXTRACTORS_SEMAPHORE: asyncio.Semaphore | None = None
"Global semaphore for limiting concurrent extractor operations."
class StreamingError(Exception): class StreamingError(Exception):
"""Raised when an error occurs during streaming.""" """Raised when an error occurs during streaming."""
@ -324,156 +317,7 @@ def calc_download_path(base_path: str | Path, folder: str | None = None, create_
return str(download_path) return str(download_path)
def extract_info( def _is_safe_key(key: Any) -> bool:
config: dict,
url: str,
debug: bool = False,
no_archive: bool = False,
follow_redirect: bool = False,
sanitize_info: bool = False,
**kwargs,
) -> dict:
"""
Extracts video information from the given URL.
Args:
config (dict): Configuration options.
url (str): URL to extract information from.
debug (bool): Enable debug logging.
no_archive (bool): Disable download archive.
follow_redirect (bool): Follow URL redirects.
sanitize_info (bool): Sanitize the extracted information
**kwargs: Additional arguments.
Returns:
dict: Video information.
"""
params: dict = {
**config,
"simulate": True,
"color": "no_color",
"extract_flat": True,
"skip_download": True,
"ignoreerrors": True,
"ignore_no_formats_error": True,
}
if debug:
params["verbose"] = True
else:
params["quiet"] = True
log_wrapper = LogWrapper()
idDict: dict[str, str | None] = get_archive_id(url=url)
archive_id: str | None = f".{idDict['id']}" if idDict.get("id") else None
log_wrapper.add_target(
target=logging.getLogger(f"yt-dlp{archive_id if archive_id else '.extract_info'}"),
level=logging.DEBUG if debug else logging.WARNING,
)
if "callback" in params:
if isinstance(params["callback"], dict):
log_wrapper.add_target(
target=params["callback"]["func"],
level=params["callback"]["level"] or logging.ERROR,
name=params["callback"]["name"] or "callback",
)
else:
log_wrapper.add_target(target=params["callback"], level=logging.ERROR, name="callback")
params.pop("callback", None)
if log_wrapper.has_targets():
if "logger" in params:
log_wrapper.add_target(target=params["logger"], level=logging.DEBUG)
params["logger"] = log_wrapper
if kwargs.get("no_log", False):
params["logger"] = LogWrapper()
params["quiet"] = True
params["no_warnings"] = True
if no_archive and "download_archive" in params:
del params["download_archive"]
data: dict[str, Any] | None = YTDLP(params=params).extract_info(url, download=False)
if data and follow_redirect and "_type" in data and "url" == data["_type"]:
return extract_info(
config,
data["url"],
debug=debug,
no_archive=no_archive,
follow_redirect=follow_redirect,
sanitize_info=sanitize_info,
)
if not data:
return data
data["is_premiere"] = match_str("media_type=video & duration & is_live", data)
if not data["is_premiere"]:
data["is_premiere"] = "video" == data.get("media_type") and "is_upcoming" == data.get("live_status")
return YTDLP.sanitize_info(data, remove_private_keys=True) if sanitize_info else data
async def fetch_info(
config: dict,
url: str,
debug: bool = False,
no_archive: bool = False,
follow_redirect: bool = False,
sanitize_info: bool = False,
**kwargs,
) -> dict:
"""
Extracts video information from the given URL.
Args:
config (dict): Configuration options.
url (str): URL to extract information from.
debug (bool): Enable debug logging.
no_archive (bool): Disable download archive.
follow_redirect (bool): Follow URL redirects.
sanitize_info (bool): Sanitize the extracted information
**kwargs: Additional arguments.
Returns:
dict: Video information.
"""
from .config import Config
global EXTRACTORS_SEMAPHORE # noqa: PLW0603
conf = Config.get_instance()
if EXTRACTORS_SEMAPHORE is None:
EXTRACTORS_SEMAPHORE = asyncio.Semaphore(conf.extract_info_concurrency)
async with EXTRACTORS_SEMAPHORE:
return await asyncio.wait_for(
fut=asyncio.get_running_loop().run_in_executor(
None,
functools.partial(
extract_info,
config=config,
url=url,
debug=debug,
no_archive=no_archive,
follow_redirect=follow_redirect,
sanitize_info=sanitize_info,
**kwargs,
),
),
timeout=conf.extract_info_timeout,
)
def _is_safe_key(key: any) -> bool:
""" """
Check if a dictionary key is safe for merging. Check if a dictionary key is safe for merging.

View file

@ -207,9 +207,6 @@ class Config(metaclass=Singleton):
live_premiere_buffer: int = 5 live_premiere_buffer: int = 5
"""The buffer time in minutes to add to video duration to wait before starting premiere download.""" """The buffer time in minutes to add to video duration to wait before starting premiere download."""
playlist_items_concurrency: int = 4
"""The number of concurrent playlist items to be processed at same time."""
auto_clear_history_days: int = 0 auto_clear_history_days: int = 0
"""Number of days after which completed download history is automatically cleared. 0 to disable.""" """Number of days after which completed download history is automatically cleared. 0 to disable."""
@ -268,7 +265,6 @@ class Config(metaclass=Singleton):
"max_workers_per_extractor", "max_workers_per_extractor",
"extract_info_timeout", "extract_info_timeout",
"debugpy_port", "debugpy_port",
"playlist_items_concurrency",
"download_path_depth", "download_path_depth",
"download_info_expires", "download_info_expires",
"auto_clear_history_days", "auto_clear_history_days",

View file

@ -15,9 +15,10 @@ import yt_dlp.utils
from app.library.config import Config from app.library.config import Config
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.Utils import create_cookies_file, extract_info, extract_ytdlp_logs from app.library.Utils import create_cookies_file, extract_ytdlp_logs
from app.library.ytdlp import YTDLP from app.library.ytdlp import YTDLP
from .extractor import extract_info_sync
from .hooks import HookHandlers, NestedLogger from .hooks import HookHandlers, NestedLogger
from .process_manager import ProcessManager from .process_manager import ProcessManager
from .status_tracker import StatusTracker from .status_tracker import StatusTracker
@ -156,21 +157,17 @@ class Download:
if not self.info_dict or not isinstance(self.info_dict, dict): if not self.info_dict or not isinstance(self.info_dict, dict):
self.logger.info(f"Extracting info for '{self.info.url}'.") self.logger.info(f"Extracting info for '{self.info.url}'.")
self.logs = []
ie_params: dict = params.copy() ie_params: dict = params.copy()
ie_params["callback"] = {
"func": lambda _, msg: self.logs.append(msg),
"level": logging.WARNING,
"name": "callback-logger",
}
info: dict = extract_info( (info, logs) = extract_info_sync(
config=ie_params, config=ie_params,
url=self.info.url, url=self.info.url,
debug=self.debug, debug=self.debug,
no_archive=not params.get("download_archive", False), no_archive=not params.get("download_archive", False),
follow_redirect=True, follow_redirect=True,
capture_logs=logging.WARNING,
) )
self.logs = logs
if info: if info:
self.info_dict = info self.info_dict = info

View file

@ -0,0 +1,405 @@
import asyncio
import functools
import logging
import pickle
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
from typing import Any
from app.library.LogWrapper import LogWrapper
from app.library.mini_filter import match_str
from app.library.Singleton import Singleton
from app.library.ytdlp import YTDLP
LOG: logging.Logger = logging.getLogger("downloads.extractor")
class ExtractorConfig:
"""Configuration for the extractor."""
def __init__(
self,
concurrency: int = 4,
timeout: float = 60.0,
wait_threshold: float = 0.2,
):
"""
Initialize extractor configuration.
Args:
concurrency: Maximum number of concurrent extract operations
timeout: Timeout for extract operations in seconds
wait_threshold: Log warning if waiting exceeds this threshold in seconds
"""
self.concurrency = concurrency
self.timeout = timeout
self.wait_threshold = wait_threshold
class ExtractorPool(metaclass=Singleton):
"""
Manages process pool and semaphore for video information extraction.
This class uses the Singleton pattern to ensure only one instance exists.
"""
def __init__(self):
"""Initialize the extractor pool."""
self._pool: ProcessPoolExecutor | None = None
self._semaphore: asyncio.Semaphore | None = None
self._config: ExtractorConfig | None = None
@classmethod
def get_instance(cls) -> "ExtractorPool":
"""
Get the singleton instance.
Returns:
ExtractorPool instance
"""
return cls()
def _ensure_initialized(self, config: ExtractorConfig) -> None:
"""
Ensure pool and semaphore are initialized.
Args:
config: Extractor configuration
"""
if self._config is None or self._config.concurrency != config.concurrency:
self._config = config
if self._semaphore is None:
self._semaphore = asyncio.Semaphore(config.concurrency)
if self._pool is None:
self._pool = ProcessPoolExecutor(max_workers=config.concurrency)
LOG.info("Initialized extractor process pool with %s workers", config.concurrency)
def get_pool(self, config: ExtractorConfig) -> ProcessPoolExecutor:
"""
Get the process pool executor.
Args:
config: Extractor configuration
Returns:
ProcessPoolExecutor instance
"""
self._ensure_initialized(config)
if self._pool is None:
msg = "Process pool not initialized"
raise RuntimeError(msg)
return self._pool
def get_semaphore(self, config: ExtractorConfig) -> asyncio.Semaphore:
"""
Get the semaphore for limiting concurrency.
Args:
config: Extractor configuration
Returns:
asyncio.Semaphore instance
"""
self._ensure_initialized(config)
if self._semaphore is None:
msg = "Semaphore not initialized"
raise RuntimeError(msg)
return self._semaphore
async def shutdown(self) -> None:
"""Shutdown the extractor pool and clean up resources."""
if self._pool is not None:
try:
self._pool.shutdown(wait=False, cancel_futures=False)
LOG.debug("Extractor process pool shutdown complete")
except Exception as exc:
LOG.error("Error shutting down extractor process pool: %s", exc)
else:
self._pool = None
self._semaphore = None
self._config = None
def _is_picklable(value: Any) -> bool:
"""
Check if a value can be pickled.
Args:
value: Value to check
Returns:
bool: True if value can be pickled, False otherwise
"""
try:
pickle.dumps(value)
return True
except Exception:
return False
def _sanitize_picklable(value: Any) -> Any:
"""
Convert a value to a picklable format.
Args:
value: Value to sanitize
Returns:
Sanitized value that can be pickled
"""
if isinstance(value, Path):
return str(value)
if isinstance(value, dict):
return {k: _sanitize_picklable(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [_sanitize_picklable(v) for v in value]
if _is_picklable(value):
return value
return str(value)
def _sanitize_config(config: dict[str, Any]) -> dict[str, Any]:
"""
Sanitize configuration for use in process pool.
Removes unpicklable keys and converts values to picklable formats.
Args:
config: Configuration dictionary
Returns:
Sanitized configuration dictionary
"""
sanitized: dict[str, Any] = {}
for key, value in config.items():
if key in {"logger", "progress_hooks", "postprocessor_hooks", "postprocessors", "post_hooks"}:
continue
sanitized[key] = _sanitize_picklable(value)
return sanitized
def _get_archive_id_dict(url: str) -> dict[str, str | None]:
"""
Get archive ID for logging purposes.
Args:
url: URL to get archive ID for
Returns:
Dictionary with id, ie_key, and archive_id
"""
from app.library.Utils import get_archive_id
return get_archive_id(url=url)
def extract_info_sync(
config: dict[str, Any],
url: str,
debug: bool = False,
no_archive: bool = False,
follow_redirect: bool = False,
sanitize_info: bool = False,
capture_logs: int | None = None,
**kwargs,
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
"""
Extract video information from a URL.
Args:
config: yt-dlp configuration options
url: URL to extract information from
debug: Enable debug logging
no_archive: Disable download archive
follow_redirect: Follow URL redirects
sanitize_info: Sanitize the extracted information
capture_logs: If provided (e.g., logging.WARNING), capture logs at this level.
**kwargs: Additional arguments
Returns:
tuple[dict | None, list[dict]]: Extracted information and captured logs.
"""
params: dict[str, Any] = {
**config,
"simulate": True,
"color": "no_color",
"extract_flat": True,
"skip_download": True,
"ignoreerrors": True,
"ignore_no_formats_error": True,
}
if debug:
params["verbose"] = True
else:
params["quiet"] = True
log_wrapper = LogWrapper()
id_dict: dict[str, str | None] = _get_archive_id_dict(url=url)
archive_id: str | None = f".{id_dict['id']}" if id_dict.get("id") else None
log_wrapper.add_target(
target=logging.getLogger(f"yt-dlp{archive_id if archive_id else '.extract_info'}"),
level=logging.DEBUG if debug else logging.WARNING,
)
captured_logs: list[str] = kwargs.get("captured_logs", [])
if capture_logs is not None:
log_wrapper.add_target(
target=lambda _, msg: captured_logs.append(msg),
level=capture_logs,
name="log-capture",
)
if log_wrapper.has_targets():
if "logger" in params:
log_wrapper.add_target(target=params["logger"], level=logging.DEBUG)
params["logger"] = log_wrapper
if kwargs.get("no_log", False):
params["logger"] = LogWrapper()
params["quiet"] = True
params["no_warnings"] = True
if no_archive and "download_archive" in params:
del params["download_archive"]
data: dict[str, Any] | None = YTDLP(params=params).extract_info(url, download=False)
if data and follow_redirect and "_type" in data and "url" == data["_type"]:
return extract_info_sync(
config,
data["url"],
debug=debug,
no_archive=no_archive,
follow_redirect=follow_redirect,
sanitize_info=sanitize_info,
capture_logs=capture_logs,
captured_logs=captured_logs,
**kwargs,
)
if not data:
return (data, captured_logs)
data["is_premiere"] = match_str("media_type=video & duration & is_live", data)
if not data["is_premiere"]:
data["is_premiere"] = "video" == data.get("media_type") and "is_upcoming" == data.get("live_status")
result = YTDLP.sanitize_info(data, remove_private_keys=True) if sanitize_info else data
return (result, captured_logs)
async def fetch_info(
config: dict[str, Any],
url: str,
debug: bool = False,
no_archive: bool = False,
follow_redirect: bool = False,
sanitize_info: bool = False,
capture_logs: int | None = None,
extractor_config: ExtractorConfig | None = None,
**kwargs,
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
"""
Extract video information from a URL.
This function uses a process pool to avoid blocking the event loop.
If the process pool fails, it falls back to using a thread pool.
Args:
config: yt-dlp configuration options
url: URL to extract information from
debug: Enable debug logging
no_archive: Disable download archive
follow_redirect: Follow URL redirects
sanitize_info: Sanitize the extracted information
capture_logs: If provided (e.g., logging.WARNING), capture logs
extractor_config: Configuration for the extractor
**kwargs: Additional arguments
Returns:
tuple[dict | None, list[dict]]: Extracted information and captured logs.
"""
if extractor_config is None:
from app.library.config import Config
conf = Config.get_instance()
extractor_config = ExtractorConfig(
concurrency=conf.extract_info_concurrency,
timeout=conf.extract_info_timeout,
wait_threshold=0.2,
)
pool_manager: ExtractorPool = ExtractorPool.get_instance()
semaphore: asyncio.Semaphore = pool_manager.get_semaphore(extractor_config)
await semaphore.acquire()
loop = asyncio.get_running_loop()
safe_config = _sanitize_config(config)
try:
try:
executor: ProcessPoolExecutor = pool_manager.get_pool(extractor_config)
return await asyncio.wait_for(
fut=loop.run_in_executor(
executor,
functools.partial(
extract_info_sync,
config=safe_config,
url=url,
debug=debug,
no_archive=no_archive,
follow_redirect=follow_redirect,
sanitize_info=sanitize_info,
capture_logs=capture_logs,
**kwargs,
),
),
timeout=extractor_config.timeout,
)
except Exception as exc:
LOG.warning("extract_info process pool failed, falling back to thread pool url=%s error=%s", url, exc)
return await asyncio.wait_for(
fut=loop.run_in_executor(
None,
functools.partial(
extract_info_sync,
config=config,
url=url,
debug=debug,
no_archive=no_archive,
follow_redirect=follow_redirect,
sanitize_info=sanitize_info,
capture_logs=capture_logs,
**kwargs,
),
),
timeout=extractor_config.timeout,
)
finally:
semaphore.release()
async def shutdown_extractor() -> None:
await ExtractorPool.get_instance().shutdown()

View file

@ -16,13 +16,13 @@ from app.library.Utils import (
archive_read, archive_read,
arg_converter, arg_converter,
create_cookies_file, create_cookies_file,
fetch_info,
get_extras, get_extras,
merge_dict, merge_dict,
ytdlp_reject, ytdlp_reject,
) )
from .core import Download from .core import Download
from .extractor import fetch_info
from .playlist_processor import process_playlist from .playlist_processor import process_playlist
from .video_processor import add_video from .video_processor import add_video
@ -121,16 +121,7 @@ async def add(
already.add(item.url) already.add(item.url)
try: try:
logs: list = [] yt_conf: dict = item.get_ytdlp_opts().get_all()
yt_conf: dict = {
"callback": {
"func": lambda _, msg: logs.append(msg),
"level": logging.WARNING,
"name": "callback-logger",
},
**item.get_ytdlp_opts().get_all(),
}
if yt_conf.get("external_downloader"): if yt_conf.get("external_downloader"):
LOG.warning(f"Using external downloader '{yt_conf.get('external_downloader')}' for '{item.url}'.") LOG.warning(f"Using external downloader '{yt_conf.get('external_downloader')}' for '{item.url}'.")
@ -193,12 +184,13 @@ async def add(
if not entry: if not entry:
LOG.info(f"Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.") LOG.info(f"Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.")
entry: dict | None = await fetch_info( (entry, logs) = await fetch_info(
config=yt_conf, config=yt_conf,
url=item.url, url=item.url,
debug=bool(queue.config.ytdlp_debug), debug=bool(queue.config.ytdlp_debug),
no_archive=False, no_archive=False,
follow_redirect=True, follow_redirect=True,
capture_logs=logging.WARNING,
) )
if not entry: if not entry:

View file

@ -1,13 +1,10 @@
"""Playlist processing.""" """Playlist processing."""
import asyncio
import logging import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from app.library.Utils import merge_dict, ytdlp_reject from app.library.Utils import merge_dict, ytdlp_reject
from .utils import handle_task_exception
if TYPE_CHECKING: if TYPE_CHECKING:
from app.library.ItemDTO import Item from app.library.ItemDTO import Item
@ -59,87 +56,54 @@ async def process_playlist(
"n_entries": len(entries), "n_entries": len(entries),
} }
async def playlist_processor(i: int, etr: dict): async def process_item(i: int, etr: dict) -> dict[str, str]:
acquired = False """Process a single playlist item."""
try: item_name: str = (
item_name: str = ( f"'{entry.get('title')}: {i}/{playlist_keys['n_entries']}' - '{etr.get('id')}: {etr.get('title')}'"
f"'{entry.get('title')}: {i}/{playlist_keys['n_entries']}' - '{etr.get('id')}: {etr.get('title')}'" )
) LOG.info(f"Processing '{item_name}'.")
LOG.debug(f"Waiting to acquire lock for {item_name}")
await queue.processors.acquire()
acquired = True
LOG.debug(f"Acquired lock for {item_name}")
LOG.info(f"Processing '{item_name}'.") _status, _msg = ytdlp_reject(entry=etr, yt_params=yt_params)
if not _status:
return {"status": "error", "msg": _msg}
_status, _msg = ytdlp_reject(entry=etr, yt_params=yt_params) extras: dict[str, Any] = {
if not _status: **playlist_keys,
return {"status": "error", "msg": _msg} "playlist_index": i,
"playlist_index_number": i,
"playlist_autonumber": i,
}
extras: dict[str, Any] = { for property in ("id", "title", "uploader", "uploader_id"):
**playlist_keys, if property in entry:
"playlist_index": i, extras[f"playlist_{property}"] = entry.get(property)
"playlist_index_number": i,
"playlist_autonumber": i,
}
for property in ("id", "title", "uploader", "uploader_id"): extractor_key = entry.get("ie_key") or entry.get("extractor_key") or entry.get("extractor") or ""
if property in entry: if "thumbnail" not in etr and "youtube" in str(extractor_key).lower():
extras[f"playlist_{property}"] = entry.get(property) extras["thumbnail"] = "https://img.youtube.com/vi/{id}/maxresdefault.jpg".format(**etr)
extractor_key = entry.get("ie_key") or entry.get("extractor_key") or entry.get("extractor") or "" newItem: Item = item.new_with(url=etr.get("url") or etr.get("webpage_url"), extras=extras)
if "thumbnail" not in etr and "youtube" in str(extractor_key).lower():
extras["thumbnail"] = "https://img.youtube.com/vi/{id}/maxresdefault.jpg".format(**etr)
newItem: Item = item.new_with(url=etr.get("url") or etr.get("webpage_url"), extras=extras) if ("video" == etr.get("_type") and etr.get("url")) or (
"formats" in etr and isinstance(etr["formats"], list) and len(etr["formats"]) > 0
):
dct = merge_dict(merge_dict({"_type": "video"}, etr), entry)
dct.pop("entries", None)
return await queue.add(item=newItem, entry=dct, already=already)
if ("video" == etr.get("_type") and etr.get("url")) or ( return await queue.add(item=newItem, already=already)
"formats" in etr and isinstance(etr["formats"], list) and len(etr["formats"]) > 0
):
dct = merge_dict(merge_dict({"_type": "video"}, etr), entry)
dct.pop("entries", None)
return await queue.add(item=newItem, entry=dct, already=already)
return await queue.add(item=newItem, already=already)
finally:
if acquired:
queue.processors.release()
max_downloads: int = -1 max_downloads: int = -1
ytdlp_opts: dict[str, Any] = item.get_ytdlp_opts().get_all() ytdlp_opts: dict[str, Any] = item.get_ytdlp_opts().get_all()
if ytdlp_opts.get("max_downloads") and isinstance(ytdlp_opts.get("max_downloads"), int): if ytdlp_opts.get("max_downloads") and isinstance(ytdlp_opts.get("max_downloads"), int):
max_downloads: int = ytdlp_opts.get("max_downloads") max_downloads: int = ytdlp_opts.get("max_downloads")
batch_size: int = max(50, int(queue.config.playlist_items_concurrency) * 10) results: list[dict[str, str]] = []
async def run_batch(batch: list[tuple[int, dict]]) -> list[dict]:
tasks: list[asyncio.Task] = []
for i, etr in batch:
task = asyncio.create_task(
playlist_processor(i, etr),
name=f"playlist_processor_{etr.get('id')}_{i}",
)
task.add_done_callback(lambda t: handle_task_exception(t, LOG))
tasks.append(task)
batch_results: list[dict] = await asyncio.gather(*tasks)
await asyncio.sleep(0)
return batch_results
results: list[dict] = []
batch: list[tuple[int, dict]] = []
for i, etr in enumerate(entries, start=1): for i, etr in enumerate(entries, start=1):
if max_downloads > 0 and i > max_downloads: if max_downloads > 0 and i > max_downloads:
break break
batch.append((i, etr)) results.append(await process_item(i, etr))
if len(batch) >= batch_size:
results.extend(await run_batch(batch))
batch.clear()
if batch:
results.extend(await run_batch(batch))
log_msg: str = f"Playlist '{playlist_name}' processing completed with '{len(results)}' entries." log_msg: str = f"Playlist '{playlist_name}' processing completed with '{len(results)}' entries."
if max_downloads > 0 and len(entries) > max_downloads: if max_downloads > 0 and len(entries) > max_downloads:

View file

@ -1,4 +1,3 @@
import asyncio
import functools import functools
import glob import glob
import logging import logging
@ -9,6 +8,7 @@ from typing import TYPE_CHECKING
from aiohttp import web from aiohttp import web
from app.library.config import Config from app.library.config import Config
from app.library.downloads.extractor import shutdown_extractor
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item, ItemDTO from app.library.ItemDTO import Item, ItemDTO
from app.library.Scheduler import Scheduler from app.library.Scheduler import Scheduler
@ -41,8 +41,6 @@ class DownloadQueue(metaclass=Singleton):
"DataStore for the completed downloads." "DataStore for the completed downloads."
self.queue = DataStore(type=StoreType.QUEUE, connection=SqliteStore.get_instance()) self.queue = DataStore(type=StoreType.QUEUE, connection=SqliteStore.get_instance())
"DataStore for the download queue." "DataStore for the download queue."
self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency)
"Semaphore to limit the number of concurrent processors."
self.pool = PoolManager(queue=self, config=self.config) self.pool = PoolManager(queue=self, config=self.config)
"Pool manager for coordinating download execution." "Pool manager for coordinating download execution."
@ -50,7 +48,7 @@ class DownloadQueue(metaclass=Singleton):
def get_instance(config: Config | None = None) -> "DownloadQueue": def get_instance(config: Config | None = None) -> "DownloadQueue":
return DownloadQueue(config=config) return DownloadQueue(config=config)
def attach(self, _: web.Application) -> None: def attach(self, app: web.Application) -> None:
Services.get_instance().add("queue", self) Services.get_instance().add("queue", self)
async def event_handler(_, __): async def event_handler(_, __):
@ -77,7 +75,7 @@ class DownloadQueue(metaclass=Singleton):
id=delete_old_history.__name__, id=delete_old_history.__name__,
) )
# app.on_shutdown.append(self.on_shutdown) app.on_shutdown.append(self.on_shutdown)
async def test(self) -> bool: async def test(self) -> bool:
await self.done.test() await self.done.test()
@ -219,7 +217,8 @@ class DownloadQueue(metaclass=Singleton):
return self.pool.is_paused() return self.pool.is_paused()
async def on_shutdown(self, _: web.Application): async def on_shutdown(self, _: web.Application):
await self.pool.shutdown() # await self.pool.shutdown()
await shutdown_extractor()
async def add(self, item: Item, already: set | None = None, entry: dict | None = None) -> dict[str, str]: async def add(self, item: Item, already: set | None = None, entry: dict | None = None) -> dict[str, str]:
""" """

View file

@ -1,17 +1,61 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import functools
import logging import logging
from typing import Any import threading
from dataclasses import dataclass
from typing import Any, Literal, cast, overload
import httpx import httpx
from .cf_solver_shared import is_cf_challenge, solver from .cf_solver_shared import is_cf_challenge, solver
__all__: list[str] = ["async_client", "sync_client"] __all__: list[str] = [
"async_client",
"build_request_headers",
"close_shared_clients",
"get_async_client",
"get_sync_client",
"resolve_curl_transport",
"sync_client",
]
LOG: logging.Logger = logging.getLogger("httpx_cf") LOG: logging.Logger = logging.getLogger("httpx_cf")
class Globals:
random_agent: str | None = None
SHARED_ASYNC_CLIENTS: dict[_AsyncClientKey, httpx.AsyncClient] = {}
SHARED_SYNC_CLIENTS: dict[_SyncClientKey, httpx.Client] = {}
SHARED_CLIENT_LOCK = threading.Lock()
def get_random_agent() -> str:
if Globals.random_agent:
return Globals.random_agent
from yt_dlp.utils.networking import random_user_agent
Globals.random_agent = random_user_agent()
return Globals.random_agent
@dataclass(frozen=True)
class _AsyncClientKey:
enable_cf: bool
proxy: str | None
use_curl: bool
curl_impersonate: str
curl_default_headers: bool
@dataclass(frozen=True)
class _SyncClientKey:
enable_cf: bool
proxy: str | None
def _parse_cookie_header(cookie_header: str | None) -> dict[str, str]: def _parse_cookie_header(cookie_header: str | None) -> dict[str, str]:
cookies: dict[str, str] = {} cookies: dict[str, str] = {}
@ -37,17 +81,95 @@ def _merge_cookies(existing_header: str | None, new_cookies: list[dict[str, str]
return merged return merged
def _normalize_proxy(proxy: str | dict[str, str] | None) -> str | None:
if proxy is None:
return None
if isinstance(proxy, str):
cleaned: str = proxy.strip()
return cleaned or None
if isinstance(proxy, dict):
return "|".join(f"{key}={value}" for key, value in sorted(proxy.items()))
return str(proxy)
@functools.lru_cache(maxsize=1)
def _curl_available() -> bool:
try:
import httpx_curl_cffi # noqa: F401
return True
except Exception:
return False
def resolve_curl_transport(use_curl: bool = True) -> bool:
return use_curl and _curl_available()
def _build_async_curl_transport(
use_curl: bool,
curl_impersonate: str,
curl_default_headers: bool,
) -> httpx.AsyncBaseTransport | None:
if not resolve_curl_transport(use_curl):
return None
from httpx_curl_cffi import AsyncCurlTransport
return AsyncCurlTransport(
impersonate=curl_impersonate,
default_headers=curl_default_headers,
)
@overload
def _get_transport(
enable_cf: bool,
is_async: Literal[True],
transport: httpx.AsyncBaseTransport | None,
) -> httpx.AsyncBaseTransport: ...
@overload
def _get_transport(
enable_cf: bool,
is_async: Literal[False],
transport: httpx.BaseTransport | None,
) -> httpx.BaseTransport: ...
def _get_transport( def _get_transport(
enable_cf: bool, enable_cf: bool,
is_async: bool, is_async: bool,
transport: httpx.AsyncBaseTransport | None, transport: httpx.AsyncBaseTransport | httpx.BaseTransport | None,
) -> httpx.AsyncBaseTransport: ) -> httpx.AsyncBaseTransport | httpx.BaseTransport:
if enable_cf: if enable_cf:
return CFAsyncTransport(base=transport) if is_async else CFTransport(base=transport) if is_async:
async_transport = cast("httpx.AsyncBaseTransport | None", transport)
return CFAsyncTransport(base=async_transport)
sync_transport = cast("httpx.BaseTransport | None", transport)
return CFTransport(base=sync_transport)
return transport or (httpx.AsyncHTTPTransport() if is_async else httpx.HTTPTransport()) return transport or (httpx.AsyncHTTPTransport() if is_async else httpx.HTTPTransport())
def build_request_headers(
base_headers: dict[str, str] | None = None,
user_agent: str | None = None,
use_curl: bool = True,
) -> dict[str, str]:
headers: dict[str, str] = base_headers.copy() if isinstance(base_headers, dict) else {}
if user_agent and not use_curl:
headers.setdefault("User-Agent", user_agent)
return headers
class CFAsyncTransport(httpx.AsyncBaseTransport): class CFAsyncTransport(httpx.AsyncBaseTransport):
def __init__(self, base: httpx.AsyncBaseTransport | None = None): def __init__(self, base: httpx.AsyncBaseTransport | None = None):
self.base: httpx.AsyncBaseTransport | httpx.AsyncHTTPTransport = base or httpx.AsyncHTTPTransport() self.base: httpx.AsyncBaseTransport | httpx.AsyncHTTPTransport = base or httpx.AsyncHTTPTransport()
@ -82,7 +204,9 @@ class CFAsyncTransport(httpx.AsyncBaseTransport):
return await self.base.handle_async_request(request) return await self.base.handle_async_request(request)
def close(self) -> None: def close(self) -> None:
self.base.close() close_fn = getattr(self.base, "close", None)
if callable(close_fn):
close_fn()
class CFTransport(httpx.BaseTransport): class CFTransport(httpx.BaseTransport):
@ -134,8 +258,9 @@ def async_client(enable_cf: bool = True, **kwargs: Any) -> httpx.AsyncClient:
httpx.AsyncClient: The configured httpx.AsyncClient instance. httpx.AsyncClient: The configured httpx.AsyncClient instance.
""" """
transport = kwargs.pop("transport", None) transport = cast("httpx.AsyncBaseTransport | None", kwargs.pop("transport", None))
return httpx.AsyncClient(transport=_get_transport(enable_cf, is_async=True, transport=transport), **kwargs) async_transport = _get_transport(enable_cf, is_async=True, transport=transport)
return httpx.AsyncClient(transport=async_transport, **kwargs)
def sync_client(enable_cf: bool = True, **kwargs: Any) -> httpx.Client: def sync_client(enable_cf: bool = True, **kwargs: Any) -> httpx.Client:
@ -150,5 +275,81 @@ def sync_client(enable_cf: bool = True, **kwargs: Any) -> httpx.Client:
httpx.Client: The configured httpx.Client instance. httpx.Client: The configured httpx.Client instance.
""" """
transport = kwargs.pop("transport", None) transport = cast("httpx.BaseTransport | None", kwargs.pop("transport", None))
return httpx.Client(transport=_get_transport(enable_cf, is_async=False, transport=transport), **kwargs) sync_transport = _get_transport(enable_cf, is_async=False, transport=transport)
return httpx.Client(transport=sync_transport, **kwargs)
def get_async_client(
enable_cf: bool = True,
proxy: str | dict[str, str] | None = None,
use_curl: bool = True,
curl_impersonate: str = "chrome",
curl_default_headers: bool = True,
) -> httpx.AsyncClient:
proxy_key = _normalize_proxy(proxy)
use_curl = resolve_curl_transport(use_curl)
key = _AsyncClientKey(
enable_cf=enable_cf,
proxy=proxy_key,
use_curl=use_curl,
curl_impersonate=curl_impersonate,
curl_default_headers=curl_default_headers,
)
with Globals.SHARED_CLIENT_LOCK:
if key in Globals.SHARED_ASYNC_CLIENTS:
return Globals.SHARED_ASYNC_CLIENTS[key]
transport = _build_async_curl_transport(
use_curl=use_curl,
curl_impersonate=curl_impersonate,
curl_default_headers=curl_default_headers,
)
client = httpx.AsyncClient(
transport=cast(
"httpx.AsyncBaseTransport",
_get_transport(enable_cf, is_async=True, transport=transport),
),
proxy=cast("Any", proxy),
)
Globals.SHARED_ASYNC_CLIENTS[key] = client
return client
def get_sync_client(
enable_cf: bool = True,
proxy: str | dict[str, str] | None = None,
) -> httpx.Client:
proxy_key = _normalize_proxy(proxy)
key = _SyncClientKey(enable_cf=enable_cf, proxy=proxy_key)
with Globals.SHARED_CLIENT_LOCK:
if key in Globals.SHARED_SYNC_CLIENTS:
return Globals.SHARED_SYNC_CLIENTS[key]
client = httpx.Client(
transport=cast(
"httpx.BaseTransport",
_get_transport(enable_cf, is_async=False, transport=None),
),
proxy=cast("Any", proxy),
)
Globals.SHARED_SYNC_CLIENTS[key] = client
return client
async def close_shared_clients(_: Any | None = None) -> None:
async_clients = list(Globals.SHARED_ASYNC_CLIENTS.values())
sync_clients = list(Globals.SHARED_SYNC_CLIENTS.values())
Globals.SHARED_ASYNC_CLIENTS.clear()
Globals.SHARED_SYNC_CLIENTS.clear()
for client in sync_clients:
try:
client.close()
except Exception:
pass
if async_clients:
await asyncio.gather(*(client.aclose() for client in async_clients), return_exceptions=True)

View file

@ -27,6 +27,7 @@ from app.library.downloads import DownloadQueue
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.HttpAPI import HttpAPI from app.library.HttpAPI import HttpAPI
from app.library.HttpSocket import HttpSocket from app.library.HttpSocket import HttpSocket
from app.library.httpx_client import close_shared_clients
from app.library.Scheduler import Scheduler from app.library.Scheduler import Scheduler
from app.library.Services import Services from app.library.Services import Services
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
@ -124,6 +125,7 @@ class Main:
get_task_definitions_repo().attach(self._app) get_task_definitions_repo().attach(self._app)
DownloadQueue.get_instance().attach(self._app) DownloadQueue.get_instance().attach(self._app)
UpdateChecker.get_instance().attach(self._app) UpdateChecker.get_instance().attach(self._app)
self._app.on_shutdown.append(close_shared_clients)
EventBus.get_instance().emit( EventBus.get_instance().emit(
Events.LOADED, Events.LOADED,

View file

@ -1,14 +1,13 @@
import logging import logging
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
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 yt_dlp.utils.networking import random_user_agent
from app.library.cache import Cache from app.library.cache import Cache
from app.library.config import Config from app.library.config import Config
from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
from app.library.router import add_route, route from app.library.router import add_route, route
from app.library.YTDLPOpts import YTDLPOpts from app.library.YTDLPOpts import YTDLPOpts
@ -65,48 +64,38 @@ async def get_doc(request: Request, config: Config, cache: Cache) -> Response:
try: try:
ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all() ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all()
opts: dict[str, Any] = { use_curl = resolve_curl_transport()
request_headers = build_request_headers(
user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())),
use_curl=use_curl,
)
proxy = ytdlp_args.get("proxy")
client = get_async_client(proxy=proxy, use_curl=use_curl)
LOG.debug(f"Fetching doc from '{url}'.")
response = await client.request(
method="GET",
url=url,
follow_redirects=True,
headers=request_headers,
)
dct = {
"body": response.content,
"headers": { "headers": {
"User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())), "Content-Type": EXT_TO_MIME.get(file[file.rfind(".") :], "text/plain"),
"Pragma": "public",
"Access-Control-Allow-Origin": "*",
"Cache-Control": f"public, max-age={time.time() + 3600}",
"Expires": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT",
datetime.fromtimestamp(time.time() + 3600, tz=UTC).timetuple(),
),
}, },
} }
if proxy := ytdlp_args.get("proxy"):
opts["proxy"] = proxy
try: cache.set(cache_key, dct, ttl=3600)
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt
opts["transport"] = AsyncCurlTransport( return web.Response(**dct)
impersonate="chrome",
default_headers=True,
curl_options={CurlOpt.FRESH_CONNECT: True},
)
opts.pop("headers", None)
except Exception:
pass
from app.library.httpx_client import async_client
async with async_client(**opts) as client:
LOG.debug(f"Fetching doc from '{url}'.")
response = await client.request(method="GET", url=url, follow_redirects=True)
dct = {
"body": response.content,
"headers": {
"Content-Type": EXT_TO_MIME.get(file[file.rfind(".") :], "text/plain"),
"Pragma": "public",
"Access-Control-Allow-Origin": "*",
"Cache-Control": f"public, max-age={time.time() + 3600}",
"Expires": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT",
datetime.fromtimestamp(time.time() + 3600, tz=UTC).timetuple(),
),
},
}
cache.set(cache_key, dct, ttl=3600)
return web.Response(**dct)
except Exception as e: except Exception as e:
LOG.error(f"Failed to request doc from '{url}'.'. '{e!s}'.") LOG.error(f"Failed to request doc from '{url}'.'. '{e!s}'.")
return web.json_response(data={"error": "Failed to get doc."}, status=web.HTTPInternalServerError.status_code) return web.json_response(data={"error": "Failed to get doc."}, status=web.HTTPInternalServerError.status_code)

View file

@ -7,11 +7,11 @@ from urllib.parse import urlparse
from aiohttp import web from aiohttp import web
from aiohttp.web import Request, Response from aiohttp.web import Request, Response
from yt_dlp.utils.networking import random_user_agent
from app.library.ag_utils import ag from app.library.ag_utils import ag
from app.library.cache import Cache from app.library.cache import Cache
from app.library.config import Config from app.library.config import Config
from app.library.httpx_client import Globals, build_request_headers, get_async_client, resolve_curl_transport
from app.library.router import route from app.library.router import route
from app.library.Utils import validate_url from app.library.Utils import validate_url
from app.library.YTDLPOpts import YTDLPOpts from app.library.YTDLPOpts import YTDLPOpts
@ -45,52 +45,39 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
try: try:
ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all() ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all()
opts: dict[str, Any] = { use_curl = resolve_curl_transport()
"headers": { request_headers = build_request_headers(
"User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())), user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())),
use_curl=use_curl,
)
proxy = ytdlp_args.get("proxy")
client = get_async_client(proxy=proxy, use_curl=use_curl)
LOG.debug(f"Fetching thumbnail from '{url}'.")
response = await client.request(
method="GET",
url=url,
follow_redirects=True,
headers=request_headers,
)
if response.status_code != web.HTTPOk.status_code:
LOG.error(f"Failed to fetch thumbnail from '{url}'. Status code: {response.status_code}.")
return web.json_response(data={"error": "failed to retrieve the thumbnail."}, status=response.status_code)
return web.Response(
body=response.content,
headers={
"Content-Type": response.headers.get("Content-Type"),
"Pragma": "public",
"Access-Control-Allow-Origin": "*",
"Cache-Control": f"public, max-age={time.time() + 31536000}",
"Expires": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT",
datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple(),
),
}, },
} )
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
from app.library.httpx_client import async_client
async with async_client(**opts) as client:
LOG.debug(f"Fetching thumbnail from '{url}'.")
response = await client.request(method="GET", url=url, follow_redirects=True)
if response.status_code != web.HTTPOk.status_code:
LOG.error(f"Failed to fetch thumbnail from '{url}'. Status code: {response.status_code}.")
return web.json_response(
data={"error": "failed to retrieve the thumbnail."}, status=response.status_code
)
return web.Response(
body=response.content,
headers={
"Content-Type": response.headers.get("Content-Type"),
"Pragma": "public",
"Access-Control-Allow-Origin": "*",
"Cache-Control": f"public, max-age={time.time() + 31536000}",
"Expires": time.strftime(
"%a, %d %b %Y %H:%M:%S GMT",
datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple(),
),
},
)
except Exception as e: except Exception as e:
LOG.error(f"Error fetching thumbnail from '{url}'. '{e}'.") LOG.error(f"Error fetching thumbnail from '{url}'. '{e}'.")
return web.json_response( return web.json_response(
@ -126,95 +113,100 @@ async def get_background(request: Request, config: Config, cache: Cache) -> Resp
CACHE_KEY = "random_background" CACHE_KEY = "random_background"
if cache.has(CACHE_KEY) and not request.query.get("force", False): if cache.has(CACHE_KEY) and not request.query.get("force", False):
data = await cache.aget(CACHE_KEY) cached_data = await cache.aget(CACHE_KEY)
return web.Response( if isinstance(cached_data, dict):
body=data.get("content"), cached_headers = cached_data.get("headers")
headers={ if not isinstance(cached_headers, dict):
"X-Cache": "HIT", cached_headers = {}
"X-Cache-TTL": str(await cache.attl(CACHE_KEY)), cached_headers = {str(key): str(value) for key, value in cached_headers.items()}
"X-Image-Via": data.get("backend"), cached_backend = cached_data.get("backend")
**data.get("headers"), if not isinstance(cached_backend, str):
}, cached_backend = "" if cached_backend is None else str(cached_backend)
) return web.Response(
body=cached_data.get("content"),
ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all() headers={
opts: dict[str, Any] = { "X-Cache": "HIT",
"headers": { "X-Cache-TTL": str(await cache.attl(CACHE_KEY)),
"User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())), "X-Image-Via": cached_backend,
}, **cached_headers,
} },
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
from app.library.httpx_client import async_client
async with async_client(**opts) as client:
if backend.startswith("https://www.bing.com/HPImageArchive.aspx"):
if not cache.has(CACHE_KEY_BING):
response = await client.request(method="GET", url=backend)
if response.status_code != web.HTTPOk.status_code:
return web.json_response(
data={"error": "failed to retrieve the random background image."},
status=web.HTTPInternalServerError.status_code,
)
img_url: str | None = ag(response.json(), "images.0.url")
if not img_url:
return web.json_response(
data={"error": "failed to retrieve the random background image."},
status=web.HTTPInternalServerError.status_code,
)
backend = f"https://www.bing.com{img_url}"
await cache.aset(key=CACHE_KEY_BING, value=backend, ttl=3600 * 24)
else:
backend: str = await cache.aget(CACHE_KEY_BING)
LOG.debug(f"Requesting random picture from '{backend!s}'.")
response = await client.request(method="GET", url=backend, follow_redirects=True)
if response.status_code != web.HTTPOk.status_code:
return web.json_response(
data={"error": "failed to retrieve the random background image."},
status=web.HTTPInternalServerError.status_code,
) )
data: dict[str, Any] = { ytdlp_args: dict = YTDLPOpts.get_instance().preset(name=config.default_preset).get_all()
"content": response.content, use_curl = resolve_curl_transport()
"backend": urlparse(backend).netloc, request_headers = build_request_headers(
"headers": { user_agent=request.headers.get("User-Agent", ytdlp_args.get("user_agent", Globals.get_random_agent())),
"Content-Type": response.headers.get("Content-Type", "image/jpeg"), use_curl=use_curl,
"Content-Length": str(len(response.content)), )
}, proxy = ytdlp_args.get("proxy")
}
await cache.aset(key=CACHE_KEY, value=data, ttl=3600) client = get_async_client(proxy=proxy, use_curl=use_curl)
if backend.startswith("https://www.bing.com/HPImageArchive.aspx"):
if not cache.has(CACHE_KEY_BING):
response = await client.request(method="GET", url=backend, headers=request_headers)
if response.status_code != web.HTTPOk.status_code:
return web.json_response(
data={"error": "failed to retrieve the random background image."},
status=web.HTTPInternalServerError.status_code,
)
LOG.debug(f"Random background image from '{backend!s}' cached.") img_url: str | None = ag(response.json(), "images.0.url")
if not img_url:
return web.json_response(
data={"error": "failed to retrieve the random background image."},
status=web.HTTPInternalServerError.status_code,
)
return web.Response( backend = f"https://www.bing.com{img_url}"
body=data.get("content"), await cache.aset(key=CACHE_KEY_BING, value=backend, ttl=3600 * 24)
headers={ else:
"X-Cache": "MISS", backend = await cache.aget(CACHE_KEY_BING)
"X-Cache-TTL": "3600",
"X-Image-Via": data.get("backend"), if not isinstance(backend, str) or not backend:
**data.get("headers"), return web.json_response(
}, data={"error": "failed to retrieve the random background image."},
status=web.HTTPInternalServerError.status_code,
) )
LOG.debug(f"Requesting random picture from '{backend!s}'.")
response = await client.request(
method="GET",
url=backend,
follow_redirects=True,
headers=request_headers,
)
if response.status_code != web.HTTPOk.status_code:
return web.json_response(
data={"error": "failed to retrieve the random background image."},
status=web.HTTPInternalServerError.status_code,
)
data: dict[str, Any] = {
"content": response.content,
"backend": urlparse(backend).netloc,
"headers": {
"Content-Type": response.headers.get("Content-Type", "image/jpeg"),
"Content-Length": str(len(response.content)),
},
}
response_headers: dict[str, str] = data["headers"]
image_via = str(data.get("backend") or "")
await cache.aset(key=CACHE_KEY, value=data, ttl=3600)
LOG.debug(f"Random background image from '{backend!s}' cached.")
return web.Response(
body=data.get("content"),
headers={
"X-Cache": "MISS",
"X-Cache-TTL": "3600",
"X-Image-Via": image_via,
**response_headers,
},
)
except Exception as e: except Exception as e:
LOG.error(f"Failed to request random background image from '{backend!s}'.'. '{e!s}'.") LOG.error(f"Failed to request random background image from '{backend!s}'.'. '{e!s}'.")
return web.json_response( return web.json_response(

View file

@ -10,6 +10,7 @@ from aiohttp.web import Request, Response
from app.features.presets.service import Presets from app.features.presets.service import Presets
from app.library.cache import Cache from app.library.cache import Cache
from app.library.config import Config from app.library.config import Config
from app.library.downloads.extractor import fetch_info
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.ItemDTO import Item from app.library.ItemDTO import Item
from app.library.router import route from app.library.router import route
@ -17,7 +18,6 @@ from app.library.Utils import (
REMOVE_KEYS, REMOVE_KEYS,
archive_read, archive_read,
arg_converter, arg_converter,
fetch_info,
get_archive_id, get_archive_id,
validate_url, validate_url,
) )
@ -152,24 +152,16 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
} }
return web.json_response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code) return web.json_response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code)
logs: list = [] ytdlp_opts: dict = opts.get_all()
ytdlp_opts: dict = { (data, logs) = await fetch_info(
**opts.get_all(),
"callback": {
"func": lambda _, msg: logs.append(msg),
"level": logging.WARNING,
"name": "callback-logger",
},
}
data: dict | None = await fetch_info(
config=ytdlp_opts, config=ytdlp_opts,
url=url, url=url,
debug=False, debug=False,
no_archive=True, no_archive=True,
follow_redirect=True, follow_redirect=True,
sanitize_info=True, sanitize_info=True,
capture_logs=logging.WARNING,
) )
if not data or not isinstance(data, dict): if not data or not isinstance(data, dict):

View file

@ -153,7 +153,7 @@ class TestUpdateChecker:
assert "" == config.yt_new_version, "Should not update yt_new_version when disabled" assert "" == config.yt_new_version, "Should not update yt_new_version when disabled"
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("app.library.UpdateChecker.async_client") @patch("app.library.UpdateChecker.get_async_client")
async def test_check_for_updates_finds_newer_version(self, mock_client): async def test_check_for_updates_finds_newer_version(self, mock_client):
"""Test that check_for_updates detects when a newer version is available.""" """Test that check_for_updates detects when a newer version is available."""
from app.library.config import Config from app.library.config import Config
@ -173,9 +173,9 @@ class TestUpdateChecker:
mock_ytdlp_response.json.return_value = {"tag_name": "9999.12.31"} mock_ytdlp_response.json.return_value = {"tag_name": "9999.12.31"}
mock_get = AsyncMock(side_effect=[mock_app_response, mock_ytdlp_response]) mock_get = AsyncMock(side_effect=[mock_app_response, mock_ytdlp_response])
mock_context = AsyncMock() mock_http = MagicMock()
mock_context.__aenter__.return_value.get = mock_get mock_http.get = mock_get
mock_client.return_value = mock_context mock_client.return_value = mock_http
with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"): with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"):
checker = UpdateChecker.get_instance(config=config) checker = UpdateChecker.get_instance(config=config)
@ -190,7 +190,7 @@ class TestUpdateChecker:
assert checker._job_id is None, "Should stop scheduled task after finding app update" assert checker._job_id is None, "Should stop scheduled task after finding app update"
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("app.library.UpdateChecker.async_client") @patch("app.library.UpdateChecker.get_async_client")
async def test_check_for_updates_no_update_available(self, mock_client): async def test_check_for_updates_no_update_available(self, mock_client):
"""Test that check_for_updates correctly handles when no update is available.""" """Test that check_for_updates correctly handles when no update is available."""
from app.library.config import Config from app.library.config import Config
@ -210,9 +210,9 @@ class TestUpdateChecker:
mock_ytdlp_response.json.return_value = {"tag_name": "2020.01.01"} mock_ytdlp_response.json.return_value = {"tag_name": "2020.01.01"}
mock_get = AsyncMock(side_effect=[mock_app_response, mock_ytdlp_response]) mock_get = AsyncMock(side_effect=[mock_app_response, mock_ytdlp_response])
mock_context = AsyncMock() mock_http = MagicMock()
mock_context.__aenter__.return_value.get = mock_get mock_http.get = mock_get
mock_client.return_value = mock_context mock_client.return_value = mock_http
checker = UpdateChecker.get_instance(config=config) checker = UpdateChecker.get_instance(config=config)
checker._job_id = "test-job" checker._job_id = "test-job"
@ -228,7 +228,7 @@ class TestUpdateChecker:
assert "test-job" == checker._job_id, "Should keep scheduled task running" assert "test-job" == checker._job_id, "Should keep scheduled task running"
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("app.library.UpdateChecker.async_client") @patch("app.library.UpdateChecker.get_async_client")
async def test_check_for_updates_handles_http_error(self, mock_client): async def test_check_for_updates_handles_http_error(self, mock_client):
"""Test that check_for_updates handles HTTP errors gracefully.""" """Test that check_for_updates handles HTTP errors gracefully."""
from app.library.config import Config from app.library.config import Config
@ -242,9 +242,9 @@ class TestUpdateChecker:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 404 mock_response.status_code = 404
mock_context = AsyncMock() mock_http = MagicMock()
mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response) mock_http.get = AsyncMock(return_value=mock_response)
mock_client.return_value = mock_context mock_client.return_value = mock_http
checker = UpdateChecker.get_instance(config=config) checker = UpdateChecker.get_instance(config=config)
@ -258,7 +258,7 @@ class TestUpdateChecker:
assert "" == config.yt_new_version, "Should not set yt_new_version on HTTP error" assert "" == config.yt_new_version, "Should not set yt_new_version on HTTP error"
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("app.library.UpdateChecker.async_client") @patch("app.library.UpdateChecker.get_async_client")
async def test_check_for_updates_handles_exception(self, mock_client): async def test_check_for_updates_handles_exception(self, mock_client):
"""Test that check_for_updates handles exceptions gracefully.""" """Test that check_for_updates handles exceptions gracefully."""
from app.library.config import Config from app.library.config import Config
@ -269,9 +269,9 @@ class TestUpdateChecker:
config.new_version = "" config.new_version = ""
config.yt_new_version = "" config.yt_new_version = ""
mock_context = AsyncMock() mock_http = MagicMock()
mock_context.__aenter__.return_value.get = AsyncMock(side_effect=Exception("Network error")) mock_http.get = AsyncMock(side_effect=Exception("Network error"))
mock_client.return_value = mock_context mock_client.return_value = mock_http
checker = UpdateChecker.get_instance(config=config) checker = UpdateChecker.get_instance(config=config)
@ -337,10 +337,10 @@ class TestUpdateChecker:
mock_ytdlp_response.json.return_value = {"tag_name": "2026.01.01"} mock_ytdlp_response.json.return_value = {"tag_name": "2026.01.01"}
mock_get = AsyncMock(side_effect=[mock_app_response, mock_ytdlp_response]) mock_get = AsyncMock(side_effect=[mock_app_response, mock_ytdlp_response])
mock_context = AsyncMock() mock_http = MagicMock()
mock_context.__aenter__.return_value.get = mock_get mock_http.get = mock_get
with patch("app.library.UpdateChecker.async_client", return_value=mock_context): with patch("app.library.UpdateChecker.get_async_client", return_value=mock_http):
with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"): with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"):
checker = UpdateChecker.get_instance(config=config) checker = UpdateChecker.get_instance(config=config)
await checker.check_for_updates() await checker.check_for_updates()
@ -383,7 +383,7 @@ class TestUpdateChecker:
loop.close() loop.close()
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("app.library.UpdateChecker.async_client") @patch("app.library.UpdateChecker.get_async_client")
async def test_check_ytdlp_version_finds_newer_version(self, mock_client): async def test_check_ytdlp_version_finds_newer_version(self, mock_client):
"""Test that yt-dlp check detects when a newer version is available.""" """Test that yt-dlp check detects when a newer version is available."""
from app.library.config import Config from app.library.config import Config
@ -397,9 +397,9 @@ class TestUpdateChecker:
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.json.return_value = {"tag_name": "9999.12.31"} mock_response.json.return_value = {"tag_name": "9999.12.31"}
mock_context = AsyncMock() mock_http = MagicMock()
mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response) mock_http.get = AsyncMock(return_value=mock_response)
mock_client.return_value = mock_context mock_client.return_value = mock_http
checker = UpdateChecker.get_instance(config=config) checker = UpdateChecker.get_instance(config=config)
status, new_version = await checker._check_ytdlp_version() status, new_version = await checker._check_ytdlp_version()
@ -409,7 +409,7 @@ class TestUpdateChecker:
assert "9999.12.31" == config.yt_new_version, "Should store new yt-dlp version tag" assert "9999.12.31" == config.yt_new_version, "Should store new yt-dlp version tag"
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("app.library.UpdateChecker.async_client") @patch("app.library.UpdateChecker.get_async_client")
async def test_check_ytdlp_version_no_update_available(self, mock_client): async def test_check_ytdlp_version_no_update_available(self, mock_client):
"""Test that yt-dlp check correctly handles when no update is available.""" """Test that yt-dlp check correctly handles when no update is available."""
from app.library.config import Config from app.library.config import Config
@ -423,9 +423,9 @@ class TestUpdateChecker:
mock_response.status_code = 200 mock_response.status_code = 200
mock_response.json.return_value = {"tag_name": "2020.01.01"} mock_response.json.return_value = {"tag_name": "2020.01.01"}
mock_context = AsyncMock() mock_http = MagicMock()
mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response) mock_http.get = AsyncMock(return_value=mock_response)
mock_client.return_value = mock_context mock_client.return_value = mock_http
checker = UpdateChecker.get_instance(config=config) checker = UpdateChecker.get_instance(config=config)
status, new_version = await checker._check_ytdlp_version() status, new_version = await checker._check_ytdlp_version()
@ -435,7 +435,7 @@ class TestUpdateChecker:
assert "" == config.yt_new_version, "Should clear yt_new_version when no update available" assert "" == config.yt_new_version, "Should clear yt_new_version when no update available"
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("app.library.UpdateChecker.async_client") @patch("app.library.UpdateChecker.get_async_client")
async def test_check_ytdlp_version_handles_http_error(self, mock_client): async def test_check_ytdlp_version_handles_http_error(self, mock_client):
"""Test that yt-dlp check handles HTTP errors gracefully.""" """Test that yt-dlp check handles HTTP errors gracefully."""
from app.library.config import Config from app.library.config import Config
@ -448,9 +448,9 @@ class TestUpdateChecker:
mock_response = MagicMock() mock_response = MagicMock()
mock_response.status_code = 500 mock_response.status_code = 500
mock_context = AsyncMock() mock_http = MagicMock()
mock_context.__aenter__.return_value.get = AsyncMock(return_value=mock_response) mock_http.get = AsyncMock(return_value=mock_response)
mock_client.return_value = mock_context mock_client.return_value = mock_http
checker = UpdateChecker.get_instance(config=config) checker = UpdateChecker.get_instance(config=config)
status, new_version = await checker._check_ytdlp_version() status, new_version = await checker._check_ytdlp_version()
@ -460,7 +460,7 @@ class TestUpdateChecker:
assert "" == config.yt_new_version, "Should not set yt_new_version on HTTP error" assert "" == config.yt_new_version, "Should not set yt_new_version on HTTP error"
@pytest.mark.asyncio @pytest.mark.asyncio
@patch("app.library.UpdateChecker.async_client") @patch("app.library.UpdateChecker.get_async_client")
async def test_check_for_updates_caches_separately(self, mock_client): async def test_check_for_updates_caches_separately(self, mock_client):
"""Test that app and yt-dlp checks are cached separately.""" """Test that app and yt-dlp checks are cached separately."""
from app.library.config import Config from app.library.config import Config
@ -480,9 +480,9 @@ class TestUpdateChecker:
mock_ytdlp_response.json.return_value = {"tag_name": "2026.01.01"} mock_ytdlp_response.json.return_value = {"tag_name": "2026.01.01"}
mock_get = AsyncMock(side_effect=[mock_app_response, mock_ytdlp_response]) mock_get = AsyncMock(side_effect=[mock_app_response, mock_ytdlp_response])
mock_context = AsyncMock() mock_http = MagicMock()
mock_context.__aenter__.return_value.get = mock_get mock_http.get = mock_get
mock_client.return_value = mock_context mock_client.return_value = mock_http
with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"): with patch("app.library.UpdateChecker.APP_VERSION", "v1.0.0"):
checker = UpdateChecker.get_instance(config=config) checker = UpdateChecker.get_instance(config=config)

View file

@ -25,7 +25,6 @@ from app.library.Utils import (
delete_dir, delete_dir,
dt_delta, dt_delta,
encrypt_data, encrypt_data,
extract_info,
extract_ytdlp_logs, extract_ytdlp_logs,
get, get,
get_archive_id, get_archive_id,
@ -54,6 +53,7 @@ from app.library.Utils import (
validate_uuid, validate_uuid,
ytdlp_reject, ytdlp_reject,
) )
from app.library.downloads.extractor import extract_info_sync
class TestStreamingError: class TestStreamingError:
@ -1353,7 +1353,7 @@ class TestArchiveFunctions:
class TestExtractInfo: class TestExtractInfo:
"""Test the extract_info function.""" """Test the extract_info function."""
@patch("app.library.Utils.YTDLP") @patch("app.library.downloads.extractor.YTDLP")
def test_extract_info_basic(self, mock_ytdlp_class): def test_extract_info_basic(self, mock_ytdlp_class):
"""Test basic extract_info functionality.""" """Test basic extract_info functionality."""
mock_ytdlp = MagicMock() mock_ytdlp = MagicMock()
@ -1363,11 +1363,12 @@ class TestExtractInfo:
config = {"quiet": True} config = {"quiet": True}
url = "https://example.com/video" url = "https://example.com/video"
result = extract_info(config, url) (result, logs) = extract_info_sync(config, url)
assert isinstance(result, dict) assert isinstance(result, dict), "Result should be a dictionary"
assert isinstance(logs, list), "Logs should be a list"
mock_ytdlp.extract_info.assert_called_once() mock_ytdlp.extract_info.assert_called_once()
@patch("app.library.Utils.YTDLP") @patch("app.library.downloads.extractor.YTDLP")
def test_extract_info_with_debug(self, mock_ytdlp_class): def test_extract_info_with_debug(self, mock_ytdlp_class):
"""Test extract_info with debug enabled.""" """Test extract_info with debug enabled."""
mock_ytdlp = MagicMock() mock_ytdlp = MagicMock()
@ -1377,8 +1378,9 @@ class TestExtractInfo:
config = {} config = {}
url = "https://example.com/video" url = "https://example.com/video"
result = extract_info(config, url, debug=True) (result, logs) = extract_info_sync(config, url, debug=True)
assert isinstance(result, dict) assert isinstance(result, dict), "Result should be a dictionary"
assert isinstance(logs, list), "Logs should be a list"
class TestCheckId: class TestCheckId: