From d87995c4b05161bea85eabf44ad9ea4e081a5471 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Fri, 16 Jan 2026 19:20:27 +0300 Subject: [PATCH 01/24] fix: popover on mobile going over edge --- ui/app/components/Popover.vue | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ui/app/components/Popover.vue b/ui/app/components/Popover.vue index 50614d9a..9f19f8ca 100644 --- a/ui/app/components/Popover.vue +++ b/ui/app/components/Popover.vue @@ -203,16 +203,18 @@ const updatePosition = () => { return } + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const constrainedMaxWidth = Math.min(props.maxWidth, viewportWidth * 0.96) + popover.value.style.minWidth = `${props.minWidth}px` - popover.value.style.maxWidth = `${props.maxWidth}px` + popover.value.style.maxWidth = `${constrainedMaxWidth}px` popover.value.style.maxHeight = `${props.maxHeight}px` popover.value.style.overflowY = 'auto' const triggerRect = triggerRef.value.getBoundingClientRect() const popoverRect = popover.value.getBoundingClientRect() const offset = props.offset - const viewportWidth = window.innerWidth - const viewportHeight = window.innerHeight let effectivePlacement = props.placement @@ -263,7 +265,7 @@ const updatePosition = () => { top: `${clampedTop}px`, left: `${clampedLeft}px`, minWidth: `${props.minWidth}px`, - maxWidth: `${props.maxWidth}px`, + maxWidth: `${constrainedMaxWidth}px`, maxHeight: `${props.maxHeight}px`, overflowY: 'auto' } From ea572ec4c51c780ad1bacde940cd320416744d07 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Fri, 16 Jan 2026 20:42:51 +0300 Subject: [PATCH 02/24] feat: implement cache cleanup --- app/library/cache.py | 44 ++++++++++++++++++++++++++ app/main.py | 2 ++ app/tests/test_cache.py | 69 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/app/library/cache.py b/app/library/cache.py index 34aa7bbe..cce18362 100644 --- a/app/library/cache.py +++ b/app/library/cache.py @@ -1,10 +1,18 @@ import hashlib +import logging import threading import time from typing import Any +from aiohttp import web + +from app.library.Services import Services + +from .Scheduler import Scheduler from .Singleton import ThreadSafe +LOG = logging.getLogger("cache") + class Cache(metaclass=ThreadSafe): def __init__(self) -> None: @@ -14,6 +22,25 @@ class Cache(metaclass=ThreadSafe): self._cache: dict[str, tuple[Any, float | None]] = {} self._lock = threading.Lock() + @staticmethod + def get_instance() -> "Cache": + """ + Get the singleton instance of Cache. + + Returns: + Cache: The singleton instance of Cache. + + """ + return Cache() + + def attach(self, _: web.Application) -> None: + Services.get_instance().add("cache", self) + Scheduler.get_instance().add( + timer="* * * * *", + func=self.cleanup, + id=f"{__class__.__name__}.{__class__.cleanup.__name__}", + ) + def set(self, key: str, value: Any, ttl: float | None = None) -> None: """ Synchronously set a value in the cache with an optional time-to-live. @@ -132,3 +159,20 @@ class Cache(metaclass=ThreadSafe): Asynchronously generate a SHA-256 hash for the given input string. """ return self.hash(key=key) + + async def cleanup(self) -> None: + """ + Remove all expired entries from the cache. + Called periodically by the scheduler. + """ + with self._lock: + now = time.time() + expired_keys: list[str] = [ + key for key, (_, expire_at) in self._cache.items() if expire_at is not None and now >= expire_at + ] + + for key in expired_keys: + del self._cache[key] + + if expired_keys: + LOG.debug(f"Cleaned up {len(expired_keys)} expired cache entries.") diff --git a/app/main.py b/app/main.py index c9860b63..c3b3c036 100644 --- a/app/main.py +++ b/app/main.py @@ -15,6 +15,7 @@ import magic from aiohttp import web from app.library.BackgroundWorker import BackgroundWorker +from app.library.cache import Cache from app.library.conditions import Conditions from app.library.config import Config from app.library.dl_fields import DLFields @@ -110,6 +111,7 @@ class Main: SqliteStore.get_instance(db_path=self._config.db_file).attach(self._app) BackgroundWorker.get_instance().attach(self._app) Scheduler.get_instance().attach(self._app) + Cache.get_instance().attach(self._app) self._socket.attach(self._app) self._http.attach(self._app) diff --git a/app/tests/test_cache.py b/app/tests/test_cache.py index 8b03cda2..b76017b2 100644 --- a/app/tests/test_cache.py +++ b/app/tests/test_cache.py @@ -18,6 +18,7 @@ All cache operations and edge cases are covered. import asyncio import threading import time +from unittest.mock import MagicMock import pytest @@ -255,6 +256,74 @@ class TestCache: retrieved = self.cache.get("object_key") assert retrieved == custom_obj + @pytest.mark.asyncio + async def test_cleanup_removes_expired_entries(self): + """Test that _cleanup removes only expired entries.""" + # Set some keys with different TTLs + self.cache.set("permanent", "value") + self.cache.set("short", "value1", ttl=0.1) + self.cache.set("medium", "value2", ttl=1.0) + + # Wait for short to expire + time.sleep(0.15) + + # Run cleanup + await self.cache.cleanup() + + # Verify only expired key was removed + assert self.cache.get("permanent") == "value", "Should keep permanent key" + assert self.cache.get("medium") == "value2", "Should keep non-expired key" + assert self.cache.get("short") is None, "Should remove expired key" + + @pytest.mark.asyncio + async def test_cleanup_with_no_expired_entries(self): + """Test that _cleanup handles cache with no expired entries.""" + self.cache.set("key1", "value1") + self.cache.set("key2", "value2", ttl=1.0) + + # Run cleanup when nothing is expired + await self.cache.cleanup() + + # Verify all keys still exist + assert self.cache.get("key1") == "value1", "Should keep non-expired key" + assert self.cache.get("key2") == "value2", "Should keep non-expired key" + + @pytest.mark.asyncio + async def test_cleanup_with_empty_cache(self): + """Test that _cleanup handles empty cache without errors.""" + # Clear cache first + self.cache.clear() + + # Should not raise any errors + await self.cache.cleanup() + + @pytest.mark.asyncio + async def test_attach_registers_with_services(self): + """Test that attach method registers cache with Services and schedules cleanup.""" + from app.library.Scheduler import Scheduler + from app.library.Services import Services + + # Reset singletons + Cache._reset_singleton() + Scheduler._reset_singleton() + Services._reset_singleton() + + # Get event loop for scheduler + loop = asyncio.get_event_loop() + + # Create cache and attach + cache = Cache.get_instance() + mock_app = MagicMock() + cache.attach(mock_app) + + # Verify cache is registered with Services + services = Services.get_instance() + assert services.get("cache") is cache, "Should register cache with Services" + + # Verify cleanup job is scheduled + scheduler = Scheduler.get_instance(loop=loop) + assert scheduler.has(f"{Cache.__name__}.{Cache.cleanup.__name__}"), "Should schedule cleanup job" + if __name__ == "__main__": pytest.main([__file__]) From 795943196e4f162a1ee07f2f2149def6ffe7d5dd Mon Sep 17 00:00:00 2001 From: arabcoders Date: Fri, 16 Jan 2026 20:45:10 +0300 Subject: [PATCH 03/24] Refactor: replace direct extract_info calls to async fetch_info --- app/library/Events.py | 2 +- app/library/HttpAPI.py | 2 +- app/library/Tasks.py | 25 ++-- app/library/UpdateChecker.py | 2 +- app/library/Utils.py | 57 +++++++++ app/library/downloads/item_adder.py | 27 ++--- app/library/downloads/queue_manager.py | 2 - app/library/task_handlers/generic.py | 4 +- app/library/task_handlers/rss.py | 4 +- app/routes/api/conditions.py | 25 ++-- app/routes/api/tasks.py | 14 +-- app/routes/api/yt_dlp.py | 25 ++-- app/tests/test_generic_task_handler.py | 6 +- app/tests/test_tasks.py | 157 +++++++++++++++++++++---- ui/app/components/Dropdown.vue | 2 +- 15 files changed, 245 insertions(+), 109 deletions(-) diff --git a/app/library/Events.py b/app/library/Events.py index 05afb8cd..b0f57aa0 100644 --- a/app/library/Events.py +++ b/app/library/Events.py @@ -9,7 +9,7 @@ from typing import Any from .BackgroundWorker import BackgroundWorker from .Singleton import Singleton -LOG: logging.Logger = logging.getLogger("library.events") +LOG: logging.Logger = logging.getLogger("events") class Events: diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index 7c00ee2d..f8dc5f00 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -28,7 +28,7 @@ class HttpAPI: self.config: Config = Config.get_instance() self._notify: EventBus = EventBus.get_instance() self.rootPath: Path = root_path - self.cache = Cache() + self.cache: Cache = Cache.get_instance() self.app: web.Application | None = None services: Services = Services.get_instance() diff --git a/app/library/Tasks.py b/app/library/Tasks.py index f1a78dcc..b008c733 100644 --- a/app/library/Tasks.py +++ b/app/library/Tasks.py @@ -21,7 +21,7 @@ from .ItemDTO import Item, ItemDTO from .Scheduler import Scheduler from .Services import Services from .Singleton import Singleton -from .Utils import archive_add, archive_delete, archive_read, extract_info, init_class, validate_url +from .Utils import archive_add, archive_delete, archive_read, fetch_info, init_class, validate_url from .YTDLPOpts import YTDLPOpts LOG: logging.Logger = logging.getLogger("tasks") @@ -82,7 +82,7 @@ class Task: return params - def mark(self) -> tuple[bool, str]: + async def mark(self) -> tuple[bool, str]: """ Mark the task's items as downloaded in the archive file. @@ -90,7 +90,7 @@ class Task: tuple[bool, str]: A tuple indicating success and a message. """ - ret = self._mark_logic() + ret: tuple[bool, str] | set[tuple[Path, set[str]]] = await self._mark_logic() if isinstance(ret, tuple): return ret @@ -102,7 +102,7 @@ class Task: return (True, f"Task '{self.name}' items marked as downloaded.") - def unmark(self) -> tuple[bool, str]: + async def unmark(self) -> tuple[bool, str]: """ Unmark the task's items from the archive file. @@ -110,7 +110,7 @@ class Task: tuple[bool, str]: A tuple indicating success and a message. """ - ret: tuple[bool, str] | set[tuple[Path, set[str]]] = self._mark_logic() + ret: tuple[bool, str] | set[tuple[Path, set[str]]] = await self._mark_logic() if isinstance(ret, tuple): return ret @@ -122,7 +122,7 @@ class Task: return (True, f"Removed '{self.name}' items from archive file.") - def fetch_metadata(self, full: bool = False) -> tuple[dict[str, Any] | None, bool, str]: + async def fetch_metadata(self, full: bool = False) -> tuple[dict[str, Any] | None, bool, str]: """ Fetch metadata for the task's URL. @@ -143,15 +143,20 @@ class Task: params = params.get_all() - ie_info: dict | None = extract_info( - params, self.url, no_archive=True, follow_redirect=False, sanitize_info=True + ie_info: dict | None = await fetch_info( + params, + self.url, + no_archive=True, + follow_redirect=False, + sanitize_info=True, ) + if not ie_info or not isinstance(ie_info, dict): return ({}, False, "Failed to extract information from URL.") return (ie_info, True, "") - def _mark_logic(self) -> tuple[bool, str] | set[tuple[Path, set[str]]]: + async def _mark_logic(self) -> tuple[bool, str] | set[tuple[Path, set[str]]]: if not self.url: return (False, "No URL found in task parameters.") @@ -161,7 +166,7 @@ class Task: archive_file: Path = Path(archive_file) - ie_info: dict | None = extract_info(params, self.url, no_archive=True, follow_redirect=True) + ie_info: dict | None = await fetch_info(params, self.url, no_archive=True, follow_redirect=True) if not ie_info or not isinstance(ie_info, dict): return (False, "Failed to extract information from URL.") diff --git a/app/library/UpdateChecker.py b/app/library/UpdateChecker.py index a6c939e7..379eaf43 100644 --- a/app/library/UpdateChecker.py +++ b/app/library/UpdateChecker.py @@ -45,7 +45,7 @@ class UpdateChecker(metaclass=Singleton): self._notify: EventBus = notify or EventBus.get_instance() "Instance of EventBus for notifications." - self._cache: Cache = Cache() + self._cache: Cache = Cache.get_instance() "Instance of Cache for caching check results." self._job_id: str | None = None diff --git a/app/library/Utils.py b/app/library/Utils.py index 37f541f3..00a50b03 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -1,5 +1,7 @@ +import asyncio import base64 import copy +import functools import glob import ipaddress import json @@ -86,6 +88,9 @@ 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?") "Regex to match ISO 8601 datetime strings." +EXTRACTORS_SEMAPHORE: asyncio.Semaphore | None = None +"Global semaphore for limiting concurrent extractor operations." + class StreamingError(Exception): """Raised when an error occurs during streaming.""" @@ -416,6 +421,58 @@ def extract_info( 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. diff --git a/app/library/downloads/item_adder.py b/app/library/downloads/item_adder.py index d0a23473..53356789 100644 --- a/app/library/downloads/item_adder.py +++ b/app/library/downloads/item_adder.py @@ -11,7 +11,6 @@ This module handles the complete flow of adding items to the download queue: """ import asyncio -import functools import logging import time import uuid @@ -29,7 +28,7 @@ from app.library.Utils import ( archive_read, arg_converter, create_cookies_file, - extract_info, + fetch_info, get_extras, merge_dict, ytdlp_reject, @@ -205,22 +204,14 @@ async def add( LOG.info(f"[P] Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.") if not entry: - async with queue.extractors: - LOG.info(f"Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.") - entry: dict | None = await asyncio.wait_for( - fut=asyncio.get_running_loop().run_in_executor( - None, - functools.partial( - extract_info, - config=yt_conf, - url=item.url, - debug=bool(queue.config.ytdlp_debug), - no_archive=False, - follow_redirect=True, - ), - ), - timeout=queue.config.extract_info_timeout, - ) + LOG.info(f"Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.") + entry: dict | None = await fetch_info( + config=yt_conf, + url=item.url, + debug=bool(queue.config.ytdlp_debug), + no_archive=False, + follow_redirect=True, + ) if not entry: LOG.error(f"Unable to extract info for '{item.url}'. Logs: {logs}") diff --git a/app/library/downloads/queue_manager.py b/app/library/downloads/queue_manager.py index b9d66ad3..88a35ce6 100644 --- a/app/library/downloads/queue_manager.py +++ b/app/library/downloads/queue_manager.py @@ -43,8 +43,6 @@ class DownloadQueue(metaclass=Singleton): "DataStore for the download queue." self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency) "Semaphore to limit the number of concurrent processors." - self.extractors = asyncio.Semaphore(self.config.extract_info_concurrency) - "Semaphore to limit the number of concurrent extract_info calls." self.pool = PoolManager(queue=self, config=self.config) "Pool manager for coordinating download execution." diff --git a/app/library/task_handlers/generic.py b/app/library/task_handlers/generic.py index 24f1dc56..aac2d466 100644 --- a/app/library/task_handlers/generic.py +++ b/app/library/task_handlers/generic.py @@ -23,7 +23,7 @@ from app.library.cache import Cache from app.library.config import Config from app.library.httpx_client import async_client from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult -from app.library.Utils import extract_info, get_archive_id +from app.library.Utils import fetch_info, get_archive_id from ._base_handler import BaseHandler @@ -714,7 +714,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." ) - info = extract_info( + info = await fetch_info( config=task.get_ytdlp_opts().get_all(), url=url, no_archive=True, diff --git a/app/library/task_handlers/rss.py b/app/library/task_handlers/rss.py index 40bb62bc..6486faa3 100644 --- a/app/library/task_handlers/rss.py +++ b/app/library/task_handlers/rss.py @@ -6,7 +6,7 @@ from xml.etree.ElementTree import Element from app.library.cache import Cache from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult -from app.library.Utils import extract_info, get_archive_id +from app.library.Utils import fetch_info, get_archive_id from ._base_handler import BaseHandler @@ -179,7 +179,7 @@ class RssGenericHandler(BaseHandler): "Doing real request to fetch yt-dlp archive ID." ) - info = extract_info( + info = await fetch_info( config=params, url=url, no_archive=True, diff --git a/app/routes/api/conditions.py b/app/routes/api/conditions.py index eecc001b..cdfce765 100644 --- a/app/routes/api/conditions.py +++ b/app/routes/api/conditions.py @@ -1,5 +1,3 @@ -import asyncio -import functools import logging import uuid from collections import OrderedDict @@ -14,7 +12,7 @@ from app.library.encoder import Encoder from app.library.Events import EventBus, Events from app.library.mini_filter import match_str from app.library.router import route -from app.library.Utils import extract_info, init_class, validate_uuid +from app.library.Utils import fetch_info, init_class, validate_uuid from app.library.YTDLPOpts import YTDLPOpts LOG: logging.Logger = logging.getLogger(__name__) @@ -164,20 +162,13 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf preset: str = params.get("preset", config.default_preset) key: str = cache.hash(url + str(preset)) if not cache.has(key): - data: dict | None = await asyncio.wait_for( - fut=asyncio.get_running_loop().run_in_executor( - None, - functools.partial( - extract_info, - config=YTDLPOpts.get_instance().preset(name=preset).get_all(), - url=url, - debug=False, - no_archive=True, - follow_redirect=True, - sanitize_info=True, - ), - ), - timeout=120, + data: dict | None = await fetch_info( + config=YTDLPOpts.get_instance().preset(name=preset).get_all(), + url=url, + debug=False, + no_archive=True, + follow_redirect=True, + sanitize_info=True, ) if not data: diff --git a/app/routes/api/tasks.py b/app/routes/api/tasks.py index 4ed97dca..9ae1beac 100644 --- a/app/routes/api/tasks.py +++ b/app/routes/api/tasks.py @@ -1,5 +1,3 @@ -import asyncio -import functools import logging import uuid from pathlib import Path @@ -177,7 +175,7 @@ async def task_mark(request: Request, encoder: Encoder) -> Response: data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code ) - _status, _message = task.mark() + _status, _message = await task.mark() if not _status: return web.json_response(data={"error": _message}, status=web.HTTPBadRequest.status_code) @@ -212,7 +210,7 @@ async def task_unmark(request: Request, encoder: Encoder) -> Response: data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code ) - _status, _message = task.unmark() + _status, _message = await task.unmark() if not _status: return web.json_response(data={"error": _message}, status=web.HTTPBadRequest.status_code) @@ -255,13 +253,7 @@ async def task_metadata(request: Request, config: Config, encoder: Encoder) -> R if not save_path.exists(): save_path.mkdir(parents=True, exist_ok=True) - metadata, status, message = await asyncio.wait_for( - fut=asyncio.get_running_loop().run_in_executor( - None, - functools.partial(task.fetch_metadata, full=False), - ), - timeout=120, - ) + metadata, status, message = await task.fetch_metadata() if not status: return web.json_response(data={"error": message}, status=web.HTTPBadRequest.status_code) diff --git a/app/routes/api/yt_dlp.py b/app/routes/api/yt_dlp.py index eeb6d9f6..de87e4cf 100644 --- a/app/routes/api/yt_dlp.py +++ b/app/routes/api/yt_dlp.py @@ -1,5 +1,3 @@ -import asyncio -import functools import json import logging import time @@ -19,7 +17,7 @@ from app.library.Utils import ( REMOVE_KEYS, archive_read, arg_converter, - extract_info, + fetch_info, get_archive_id, validate_url, ) @@ -165,20 +163,13 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response: }, } - data: dict | None = await asyncio.wait_for( - fut=asyncio.get_running_loop().run_in_executor( - None, - functools.partial( - extract_info, - config=ytdlp_opts, - url=url, - debug=False, - no_archive=True, - follow_redirect=True, - sanitize_info=True, - ), - ), - timeout=120, + data: dict | None = await fetch_info( + config=ytdlp_opts, + url=url, + debug=False, + no_archive=True, + follow_redirect=True, + sanitize_info=True, ) if not data or not isinstance(data, dict): diff --git a/app/tests/test_generic_task_handler.py b/app/tests/test_generic_task_handler.py index 49cad7f3..6777bd0c 100644 --- a/app/tests/test_generic_task_handler.py +++ b/app/tests/test_generic_task_handler.py @@ -309,11 +309,11 @@ async def test_generic_task_handler_inspect(monkeypatch): monkeypatch.setattr(GenericTaskHandler, "_fetch_content", staticmethod(fake_fetch_content)) - # Mock extract_info to return valid info with required fields for archive ID generation - def fake_extract_info(config, url, **kwargs): # noqa: ARG001 + # Mock fetch_info to return valid info with required fields for archive ID generation + async def fake_fetch_info(config, url, **kwargs): # noqa: ARG001 return {"id": "test_video_1", "extractor_key": "Example"} - with patch("app.library.task_handlers.generic.extract_info", side_effect=fake_extract_info): + with patch("app.library.task_handlers.generic.fetch_info", side_effect=fake_fetch_info): task = Task(id="inspect", name="Inspect", url="https://example.com/api") result: TaskResult | TaskFailure = await GenericTaskHandler.extract(task) diff --git a/app/tests/test_tasks.py b/app/tests/test_tasks.py index 92163ca3..41e5d65c 100644 --- a/app/tests/test_tasks.py +++ b/app/tests/test_tasks.py @@ -140,12 +140,13 @@ class TestTask: mock_opts_instance.preset.assert_called_once_with(name="test_preset") mock_preset_opts.add_cli.assert_called_once_with("--extract-flat", from_user=True) + @pytest.mark.asyncio @patch("app.library.Tasks.archive_add") - @patch("app.library.Tasks.extract_info") - def test_task_mark_success(self, mock_extract_info, mock_archive_add): + @patch("app.library.Tasks.fetch_info") + async def test_task_mark_success(self, mock_fetch_info, mock_archive_add): """Test successful task marking.""" - # Mock extract_info to return playlist data - mock_extract_info.return_value = { + # Mock fetch_info to return playlist data + mock_fetch_info.return_value = { "_type": "playlist", "entries": [ { @@ -167,7 +168,7 @@ class TestTask: task = Task(id="test_id", name="test_task", url="https://example.com/playlist") - success, message = task.mark() + success, message = await task.mark() assert success is True assert "marked as downloaded" in message @@ -180,33 +181,36 @@ class TestTask: assert "youtube video1" in items assert "youtube video2" in items - def test_task_mark_no_url(self): + @pytest.mark.asyncio + async def test_task_mark_no_url(self): """Test task marking with no URL.""" task = Task(id="test_id", name="test_task", url="") - success, message = task.mark() + success, message = await task.mark() assert success is False assert "No URL found" in message - def test_task_mark_no_archive_file(self): + @pytest.mark.asyncio + async def test_task_mark_no_archive_file(self): """Test task marking with no archive file configured.""" with patch.object(Task, "get_ytdlp_opts") as mock_get_opts: mock_get_opts.return_value.get_all.return_value = {} task = Task(id="test_id", name="test_task", url="https://example.com/video") - success, message = task.mark() + success, message = await task.mark() assert success is False assert "No archive file found" in message + @pytest.mark.asyncio @patch("app.library.Tasks.archive_delete") - @patch("app.library.Tasks.extract_info") - def test_task_unmark_success(self, mock_extract_info, mock_archive_delete): + @patch("app.library.Tasks.fetch_info") + async def test_task_unmark_success(self, mock_fetch_info, mock_archive_delete): """Test successful task unmarking.""" - # Mock extract_info to return playlist data - mock_extract_info.return_value = { + # Mock fetch_info to return playlist data + mock_fetch_info.return_value = { "_type": "playlist", "entries": [ { @@ -223,34 +227,36 @@ class TestTask: task = Task(id="test_id", name="test_task", url="https://example.com/playlist") - success, message = task.unmark() + success, message = await task.unmark() assert success is True assert "Removed" in message assert "items from archive file" in message mock_archive_delete.assert_called_once() - @patch("app.library.Tasks.extract_info") - def test_task_mark_logic_invalid_extract_info(self, mock_extract_info): - """Test _mark_logic with invalid extract_info response.""" - mock_extract_info.return_value = None + @pytest.mark.asyncio + @patch("app.library.Tasks.fetch_info") + async def test_task_mark_logic_invalid_extract_info(self, mock_fetch_info): + """Test _mark_logic with invalid fetch_info response.""" + mock_fetch_info.return_value = None with patch.object(Task, "get_ytdlp_opts") as mock_get_opts: mock_get_opts.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"} task = Task(id="test_id", name="test_task", url="https://example.com/video") - result = task._mark_logic() + result = await task._mark_logic() assert isinstance(result, tuple) success, message = result assert success is False assert "Failed to extract information" in message - @patch("app.library.Tasks.extract_info") - def test_task_mark_logic_not_playlist(self, mock_extract_info): + @pytest.mark.asyncio + @patch("app.library.Tasks.fetch_info") + async def test_task_mark_logic_not_playlist(self, mock_fetch_info): """Test _mark_logic with non-playlist type.""" - mock_extract_info.return_value = { + mock_fetch_info.return_value = { "_type": "video", "id": "video1", } @@ -260,13 +266,118 @@ class TestTask: task = Task(id="test_id", name="test_task", url="https://example.com/video") - result = task._mark_logic() + result = await task._mark_logic() assert isinstance(result, tuple) success, message = result assert success is False assert "Expected a playlist type" in message + @pytest.mark.asyncio + async def test_task_fetch_metadata_no_url(self): + """Test fetch_metadata with no URL.""" + task = Task(id="test_id", name="test_task", url="") + + metadata, success, message = await task.fetch_metadata() + + assert success is False, "Should return False when URL is missing" + assert metadata == {}, "Should return empty dict for metadata" + assert "No URL found" in message, "Should indicate missing URL" + + @pytest.mark.asyncio + @patch("app.library.Tasks.fetch_info") + async def test_task_fetch_metadata_success_not_full(self, mock_fetch_info): + """Test fetch_metadata without full flag.""" + mock_fetch_info.return_value = { + "_type": "playlist", + "id": "playlist123", + "title": "Test Playlist", + "entries": [{"id": "video1"}], + } + + with patch.object(Task, "get_ytdlp_opts") as mock_get_opts: + mock_opts = Mock() + mock_opts.add_cli.return_value = mock_opts + mock_opts.get_all.return_value = {"download_archive": "/tmp/archive.txt"} + mock_get_opts.return_value = mock_opts + + task = Task(id="test_id", name="test_task", url="https://example.com/playlist") + + metadata, success, message = await task.fetch_metadata(full=False) + + assert success is True, "Should return True on successful fetch" + assert metadata["_type"] == "playlist", "Should return playlist metadata" + assert metadata["id"] == "playlist123", "Should return correct ID" + assert message == "", "Should have empty message on success" + mock_opts.add_cli.assert_called_once_with("-I0", from_user=False) + mock_fetch_info.assert_called_once() + call_kwargs = mock_fetch_info.call_args[1] + assert call_kwargs["no_archive"] is True, "Should disable archive" + assert call_kwargs["follow_redirect"] is False, "Should not follow redirects" + assert call_kwargs["sanitize_info"] is True, "Should sanitize info" + + @pytest.mark.asyncio + @patch("app.library.Tasks.fetch_info") + async def test_task_fetch_metadata_success_full(self, mock_fetch_info): + """Test fetch_metadata with full flag.""" + mock_fetch_info.return_value = { + "_type": "playlist", + "id": "playlist123", + "title": "Test Playlist", + "entries": [ + {"id": "video1", "title": "Video 1"}, + {"id": "video2", "title": "Video 2"}, + ], + } + + with patch.object(Task, "get_ytdlp_opts") as mock_get_opts: + mock_opts = Mock() + mock_opts.get_all.return_value = {} + mock_get_opts.return_value = mock_opts + + task = Task(id="test_id", name="test_task", url="https://example.com/playlist") + + metadata, success, message = await task.fetch_metadata(full=True) + + assert success is True, "Should return True on successful fetch" + assert len(metadata["entries"]) == 2, "Should return full entries" + assert message == "", "Should have empty message on success" + mock_opts.add_cli.assert_not_called() + + @pytest.mark.asyncio + @patch("app.library.Tasks.fetch_info") + async def test_task_fetch_metadata_fetch_info_returns_none(self, mock_fetch_info): + """Test fetch_metadata when fetch_info returns None.""" + mock_fetch_info.return_value = None + + with patch.object(Task, "get_ytdlp_opts") as mock_get_opts: + mock_get_opts.return_value.get_all.return_value = {} + + task = Task(id="test_id", name="test_task", url="https://example.com/video") + + metadata, success, message = await task.fetch_metadata() + + assert success is False, "Should return False when fetch_info fails" + assert metadata == {}, "Should return empty dict for metadata" + assert "Failed to extract information" in message, "Should indicate extraction failure" + + @pytest.mark.asyncio + @patch("app.library.Tasks.fetch_info") + async def test_task_fetch_metadata_fetch_info_returns_invalid_type(self, mock_fetch_info): + """Test fetch_metadata when fetch_info returns non-dict.""" + mock_fetch_info.return_value = "invalid" + + with patch.object(Task, "get_ytdlp_opts") as mock_get_opts: + mock_get_opts.return_value.get_all.return_value = {} + + task = Task(id="test_id", name="test_task", url="https://example.com/video") + + metadata, success, message = await task.fetch_metadata() + + assert success is False, "Should return False when fetch_info returns invalid type" + assert metadata == {}, "Should return empty dict for metadata" + assert "Failed to extract information" in message, "Should indicate extraction failure" + class TestTasks: """Test the Tasks singleton manager.""" diff --git a/ui/app/components/Dropdown.vue b/ui/app/components/Dropdown.vue index f4f74c2b..1abddf80 100644 --- a/ui/app/components/Dropdown.vue +++ b/ui/app/components/Dropdown.vue @@ -14,7 +14,7 @@