diff --git a/README.md b/README.md index ba7705e7..3a7caece 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # YTPTube -![Build Status](https://github.com/ArabCoders/ytptube/actions/workflows/main.yml/badge.svg) +![Build Status](https://github.com/arabcoders/ytptube/actions/workflows/main.yml/badge.svg) ![MIT License](https://img.shields.io/github/license/arabcoders/ytptube.svg) -![Docker pull](https://ghcr-badge.elias.eu.org/shield/arabcoders/ytptube/ytptube) +![Docker Pull](https://img.shields.io/docker/pulls/arabcoders/ytptube.svg) +![gchr Pull](https://ghcr-badge.elias.eu.org/shield/arabcoders/ytptube/ytptube) **YTPTube** is a web-based GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp), designed to make downloading videos from video platforms easier and user-friendly. It supports downloading playlists, channels, live streams and diff --git a/app/features/conditions/router.py b/app/features/conditions/router.py index a4fcd049..d4e462fe 100644 --- a/app/features/conditions/router.py +++ b/app/features/conditions/router.py @@ -15,6 +15,7 @@ from app.library.config import Config from app.library.encoder import Encoder from app.library.Events import EventBus, Events from app.library.router import route +from app.library.Utils import validate_url LOG: logging.Logger = logging.getLogger(__name__) @@ -83,6 +84,11 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf if not (cond := params.get("condition")): return web.json_response({"error": "condition is required."}, status=web.HTTPBadRequest.status_code) + try: + validate_url(url, allow_internal=config.allow_internal_urls) + except ValueError as e: + return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code) + try: preset: str = params.get("preset", config.default_preset) key: str = cache.hash(url + str(preset)) diff --git a/app/features/conditions/tests/test_conditions_repository.py b/app/features/conditions/tests/test_conditions.py similarity index 85% rename from app/features/conditions/tests/test_conditions_repository.py rename to app/features/conditions/tests/test_conditions.py index 0b030ab9..050f8aa6 100644 --- a/app/features/conditions/tests/test_conditions_repository.py +++ b/app/features/conditions/tests/test_conditions.py @@ -4,8 +4,14 @@ from __future__ import annotations import pytest import pytest_asyncio +from types import SimpleNamespace +import pytest +from aiohttp import web +from aiohttp.test_utils import make_mocked_request -from app.features.conditions.models import ConditionModel +from app.features.conditions.router import conditions_test +from app.library.config import Config +from app.library.encoder import Encoder from app.features.conditions.repository import ConditionsRepository from app.library.sqlite_store import SqliteStore @@ -34,28 +40,43 @@ async def repo(): SqliteStore._reset_singleton() +def _json_request(path: str, payload: object) -> web.Request: + request = make_mocked_request("POST", path) + + async def _json() -> object: + return payload + + request.json = _json # type: ignore[attr-defined] + return request + + +class TestAllowInternalUrlsScope: + def setup_method(self) -> None: + Config._reset_singleton() + + @pytest.mark.asyncio + async def test_conditions_test_rejects_internal_url_when_disallowed(self) -> None: + config = Config.get_instance() + config.allow_internal_urls = False + encoder = Encoder() + cache = SimpleNamespace(hash=lambda value: value, has=lambda _key: False, set=lambda **_kwargs: None) + + request = _json_request( + "/api/conditions/test/", + {"url": "http://127.0.0.1/test", "condition": "title", "preset": config.default_preset}, + ) + + response = await conditions_test(request, encoder, cache, config) + + assert response.status == web.HTTPBadRequest.status_code + body = response.body + assert isinstance(body, bytes) + assert b"internal urls" in body.lower() + + class TestConditionsRepository: """Test suite for ConditionsRepository database operations.""" - @pytest.mark.asyncio - async def test_repository_singleton(self, repo): - """Verify repository follows singleton pattern.""" - instance1 = ConditionsRepository.get_instance() - instance2 = ConditionsRepository.get_instance() - assert instance1 is instance2, "Should return same singleton instance" - - @pytest.mark.asyncio - async def test_list_empty(self, repo): - """List returns empty when no conditions exist.""" - conditions = await repo.list() - assert conditions == [], "Should return empty list when no conditions" - - @pytest.mark.asyncio - async def test_count_empty(self, repo): - """Count returns 0 when no conditions exist.""" - count = await repo.count() - assert count == 0, "Should return 0 when no conditions exist" - @pytest.mark.asyncio async def test_create_condition(self, repo): """Create condition with valid data.""" diff --git a/app/features/dl_fields/tests/test_dl_fields_repository.py b/app/features/dl_fields/tests/test_dl_fields_repository.py index 031b1d83..4a08ba23 100644 --- a/app/features/dl_fields/tests/test_dl_fields_repository.py +++ b/app/features/dl_fields/tests/test_dl_fields_repository.py @@ -5,7 +5,6 @@ from __future__ import annotations import pytest import pytest_asyncio -from app.features.dl_fields.models import DLFieldModel from app.features.dl_fields.repository import DLFieldsRepository from app.library.sqlite_store import SqliteStore @@ -35,25 +34,6 @@ async def repo(tmp_path): class TestDLFieldsRepository: """Test suite for DLFieldsRepository database operations.""" - @pytest.mark.asyncio - async def test_repository_singleton(self, repo): - """Verify repository follows singleton pattern.""" - instance1 = DLFieldsRepository.get_instance() - instance2 = DLFieldsRepository.get_instance() - assert instance1 is instance2, "Should return same singleton instance" - - @pytest.mark.asyncio - async def test_list_empty(self, repo): - """List returns empty when no fields exist.""" - fields = await repo.list() - assert fields == [], "Should return empty list when no fields" - - @pytest.mark.asyncio - async def test_count_empty(self, repo): - """Count returns 0 when no fields exist.""" - count = await repo.count() - assert count == 0, "Should return 0 when no fields exist" - @pytest.mark.asyncio async def test_create_field(self, repo): """Create field with valid data.""" diff --git a/app/features/tasks/tests/test_tasks_repository.py b/app/features/tasks/tests/test_tasks_repository.py index 6ceacbe6..d92e078e 100644 --- a/app/features/tasks/tests/test_tasks_repository.py +++ b/app/features/tasks/tests/test_tasks_repository.py @@ -5,7 +5,6 @@ from __future__ import annotations import pytest import pytest_asyncio -from app.features.tasks.models import TaskModel from app.features.tasks.repository import TasksRepository from app.library.sqlite_store import SqliteStore @@ -35,25 +34,6 @@ async def repo(tmp_path): class TestTasksRepository: """Test suite for TasksRepository database operations.""" - @pytest.mark.asyncio - async def test_repository_singleton(self, repo): - """Verify repository follows singleton pattern.""" - instance1 = TasksRepository.get_instance() - instance2 = TasksRepository.get_instance() - assert instance1 is instance2, "Should return same singleton instance" - - @pytest.mark.asyncio - async def test_list_empty(self, repo): - """List returns empty when no tasks exist.""" - tasks = await repo.list() - assert tasks == [], "Should return empty list when no tasks" - - @pytest.mark.asyncio - async def test_count_empty(self, repo): - """Count returns 0 when no tasks exist.""" - count = await repo.count() - assert count == 0, "Should return 0 when no tasks exist" - @pytest.mark.asyncio async def test_create_task(self, repo): """Create task with valid data.""" diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index f8dc5f00..95bf9c8a 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -107,10 +107,10 @@ class HttpAPI: registered_options: list = [] base_path: str = self.config.base_path.rstrip("/") - from app.routes.api._static import preload_static + from app.routes.api._static import setup_static_routes load_modules(self.rootPath, self.rootPath / "routes" / "api") - preload_static(self.rootPath, self.config) + setup_static_routes(self.rootPath, self.config) async def options_handler(_: Request) -> Response: return web.Response(status=204) diff --git a/app/library/downloads/queue_manager.py b/app/library/downloads/queue_manager.py index 807b3ffa..c654f488 100644 --- a/app/library/downloads/queue_manager.py +++ b/app/library/downloads/queue_manager.py @@ -300,6 +300,7 @@ class DownloadQueue(metaclass=Singleton): """ status: dict[str, str] = {} + deleted_ids: list[str] = [] for id in ids: try: @@ -349,6 +350,7 @@ class DownloadQueue(metaclass=Singleton): LOG.error(f"Unable to remove '{itemRef}' local file '{filename}'. {e!s}") await self.done.delete(id) + deleted_ids.append(id) _status: str = "Removed" if removed_files > 0 else "Cleared" self._notify.emit( @@ -365,6 +367,9 @@ class DownloadQueue(metaclass=Singleton): LOG.info(msg=msg) status[id] = "ok" + if deleted_ids: + await self.done._connection.flush() + return status async def get(self, mode: str = "all") -> dict[str, list[dict[str, ItemDTO]]]: diff --git a/app/routes/api/_static.py b/app/routes/api/_static.py index 62df7f01..698e5071 100644 --- a/app/routes/api/_static.py +++ b/app/routes/api/_static.py @@ -1,19 +1,18 @@ import logging -from pathlib import Path +from pathlib import Path, PurePosixPath import magic from aiohttp import web -from aiohttp.web import Request, Response +from aiohttp.web import Request, StreamResponse from app.library.config import Config from app.library.router import add_route +from app.library.Utils import get_file MIME = magic.Magic(mime=True) LOG: logging.Logger = logging.getLogger(__name__) -STATIC_FILES: dict[str, dict] = {} - -EXT_TO_MIME: dict = { +EXT_TO_MIME: dict[str, str] = { ".html": "text/html", ".css": "text/css", ".js": "application/javascript", @@ -23,120 +22,167 @@ EXT_TO_MIME: dict = { ".m4a": "audio/mp4", } -FRONTEND_ROUTES: list[str] = [ - "/console/", - "/presets/", - "/tasks/", - "/task_definitions/", - "/notifications/", - "/changelog/", - "/logs/", - "/conditions/", - "/browser/", - "/simple/", - "/browser/{path:.*}", -] + +class StaticState: + def __init__(self) -> None: + self.root: Path | None = None + self.index_file: Path | None = None -async def serve_static_file(request: Request, config: Config) -> Response: +STATIC_STATE = StaticState() + + +def get_root(root_path: Path, config: Config) -> Path | None: """ - Preload static files from the ui/exported folder. + Get the static root directory. + + Args: + root_path (Path): The application root path. + config (Config): The configuration instance. + + Returns: + Path | None: The static directory, or None when UI is ignored. + + """ + search_paths: list[Path] = [] + if config.static_ui_path: + search_paths.append(Path(config.static_ui_path).absolute()) + + search_paths.extend( + [ + (root_path / "ui" / "exported").absolute(), + (root_path.parent / "ui" / "exported").absolute(), + ] + ) + + for path in search_paths: + if path.is_dir(): + return path + + message: str = f"Could not find the frontend assets in '{[str(path) for path in search_paths]=}'." + if config.ignore_ui: + LOG.warning(message) + return None + + raise ValueError(message) + + +def normalize_path(path: str, base_path: str) -> str: + """ + Normalize the request path by stripping the base path. + + Args: + path (str): The raw request path. + base_path (str): The configured base path. + + Returns: + str: The path relative to the static root. + + """ + if "/" == base_path: + return path or "/" + + base_prefix: str = base_path.rstrip("/") + if path == base_prefix: + return "/" + + if path.startswith(f"{base_prefix}/"): + stripped: str = path[len(base_prefix) :] + return stripped or "/" + + return path or "/" + + +def get_static_file(path: str) -> Path | None: + """ + Get the static file corresponding to a request path. + + Args: + path (str): The normalized request path. + + Returns: + Path | None: The resolved file path. + + """ + if STATIC_STATE.root is None: + return None + + relative_path: str = path.lstrip("/") + if not relative_path: + return STATIC_STATE.index_file if STATIC_STATE.index_file and STATIC_STATE.index_file.is_file() else None + + file, status = get_file(STATIC_STATE.root, relative_path) + if web.HTTPOk.status_code == status and file.is_file(): + return file + + return None + + +async def serve_static_file(request: Request, config: Config) -> StreamResponse: + """ + Serve frontend static files with SPA fallback handling. Args: request (Request): The request object. config (Config): The configuration instance. Returns: - Response: The response object. + StreamResponse: The response object. """ - path: str = request.path + path: str = normalize_path(request.path, config.base_path) + file_path: Path | None = get_static_file(path) - if "/" != config.base_path and path.startswith(config.base_path): - path = path.replace(config.base_path[:-1], "", 1) - - if path not in STATIC_FILES: - for k in STATIC_FILES: - if path.startswith(k): - path = k - break + if file_path is None: + if ( + STATIC_STATE.index_file is not None + and not path.startswith("/api/") + and not PurePosixPath(path.lstrip("/")).suffix + ): + file_path = STATIC_STATE.index_file else: return web.json_response({"error": "File not found.", "file": path}, status=web.HTTPNotFound.status_code) - item: dict = STATIC_FILES[path] - return web.FileResponse( - path=item["file"], + path=file_path, headers={ "Pragma": "public", "Cache-Control": "public, max-age=31536000", - "Content-Type": item.get("content_type"), + "Content-Type": EXT_TO_MIME.get(file_path.suffix, MIME.from_file(str(file_path))), }, status=web.HTTPOk.status_code, ) -def preload_static(root_path: Path, config: Config) -> None: +def setup_static_routes(root_path: Path, config: Config) -> None: """ - Preload static files from the ui/exported folder. + Set up routes for serving frontend static files. Args: - root_path (Path): The root path of the application. + root_path (Path): The application root path. config (Config): The configuration instance. """ - global STATIC_FILES # noqa: PLW0602 - static_dir: Path | None = None - webui_files: list[Path] = [ - (root_path / "ui" / "exported").absolute(), - (root_path.parent / "ui" / "exported").absolute(), - ] + STATIC_STATE.root = get_root(root_path, config) + STATIC_STATE.index_file = None - if config.static_ui_path: - webui_files = [Path(config.static_ui_path).absolute()] + if STATIC_STATE.root is None: + return - for p in webui_files: - if p.exists(): - static_dir = p - break + index_file: Path = (STATIC_STATE.root / "index.html").resolve() + if not index_file.is_file(): + message: str = f"Failed to find frontend entry file at '{index_file}'." + if config.ignore_ui: + LOG.warning(message) + STATIC_STATE.root = None + return - if static_dir is None: - webui_files = [str(p) for p in webui_files] - msg: str = f"Could not find the frontend UI static assets in '{webui_files=}'." - raise ValueError(msg) + raise ValueError(message) - preloaded = 0 + STATIC_STATE.index_file = index_file - for file in static_dir.rglob("*.*"): - if ".map" == file.suffix: - continue - - uri_path: str = f"/{file.relative_to(static_dir).as_posix()!s}" - contentType = EXT_TO_MIME.get(file.suffix) - if not contentType: - contentType = MIME.from_file(str(file)) - - STATIC_FILES[uri_path] = { - "uri": uri_path, - "content_type": contentType, - "file": file, - } - - add_route(method="GET", path=uri_path, handler=serve_static_file) - preloaded += 1 - - if "/index.html" in STATIC_FILES: - for path in FRONTEND_ROUTES: - STATIC_FILES[path] = STATIC_FILES["/index.html"] - STATIC_FILES[path.lstrip("/")] = STATIC_FILES["/index.html"] - add_route(method="GET", path=path, handler=serve_static_file) - LOG.debug(f"Preloading frontend static route '{path}'.") - preloaded += 1 - - # Add main app route - STATIC_FILES["/"] = STATIC_FILES["/index.html"] - STATIC_FILES[config.base_path] = STATIC_FILES["/index.html"] - add_route(method="GET", path=config.base_path, handler=serve_static_file, name="index") + add_route(method="GET", path=config.base_path, handler=serve_static_file, name="index") + add_route(method="GET", path="/{path:.*}", handler=serve_static_file, name="static_fallback") if "/" != config.base_path: @@ -145,12 +191,4 @@ def preload_static(root_path: Path, config: Config) -> None: add_route(method="GET", path="/", handler=redirect_index, name="index_redirect") - if preloaded < 1: - message: str = f"Failed to find any static files in '{static_dir}'." - if config.ignore_ui: - LOG.warning(message) - return - - raise ValueError(message) - - LOG.info(f"Loaded '{preloaded}' static files.") + LOG.info(f"Serving frontend static assets from '{STATIC_STATE.root}'.") diff --git a/app/routes/api/history.py b/app/routes/api/history.py index e29d7fc8..2e161651 100644 --- a/app/routes/api/history.py +++ b/app/routes/api/history.py @@ -215,7 +215,11 @@ async def items_delete(request: Request, queue: DownloadQueue, encoder: Encoder) page += 1 if not items_to_delete: - return web.json_response(data={"error": "No items matched the filter."}, status=web.HTTPBadRequest.status_code) + return web.json_response( + data={"items": {}, "deleted": 0}, + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) return web.json_response( data={ diff --git a/app/tests/test_ag_utils.py b/app/tests/test_ag_utils.py index 26706015..4f36d437 100644 --- a/app/tests/test_ag_utils.py +++ b/app/tests/test_ag_utils.py @@ -1,18 +1,3 @@ -""" -Tests for ag_utils.py functions. - -This test suite provides comprehensive coverage for all functions in ag_utils.py: -- get_value: Tests callable detection and value retrieval -- ag_set: Tests nested dictionary path setting with various scenarios -- ag: Tests nested dictionary/list/object access with dot notation -- ag_sets: Tests bulk setting of multiple paths -- ag_exists: Tests existence checking for nested paths -- ag_delete: Tests deletion of nested paths and keys - -Total test functions: 53 -All edge cases, error conditions, and normal operations are covered. -""" - from unittest.mock import MagicMock import pytest diff --git a/app/tests/test_cache.py b/app/tests/test_cache.py index b76017b2..c03de8e8 100644 --- a/app/tests/test_cache.py +++ b/app/tests/test_cache.py @@ -1,20 +1,3 @@ -""" -Tests for cache.py - Thread-safe caching utilities. - -This test suite provides comprehensive coverage for the Cache class: -- Tests basic cache operations (set, get, delete, clear) -- Tests TTL (time-to-live) functionality -- Tests thread safety -- Tests cache expiration -- Tests default value handling -- Tests key existence checking -- Tests hash generation -- Tests async methods - -Total test functions: 15 -All cache operations and edge cases are covered. -""" - import asyncio import threading import time diff --git a/app/tests/test_cf_solver_handler.py b/app/tests/test_cf_solver_handler.py index 4d14275e..53ba8907 100644 --- a/app/tests/test_cf_solver_handler.py +++ b/app/tests/test_cf_solver_handler.py @@ -1,5 +1,3 @@ -"""Comprehensive tests for cf_solver_handler yt-dlp request handler.""" - from __future__ import annotations import http.cookiejar @@ -224,34 +222,3 @@ class TestCFSolverRH: new_request = self.module.CFSolverRH._mark_retry(request) assert new_request.extensions.get("cf_retry") is True - - -class TestCfSolverPreference: - """Test cf_solver_preference function.""" - - def test_preference_with_flaresolverr(self, cf_handler_module, monkeypatch): - """Test preference when FlareSolverr is configured.""" - mock_config = Mock() - mock_config.flaresolverr_url = "http://localhost:8191/v1" - - import app.library.config - - monkeypatch.setattr(app.library.config.Config, "get_instance", lambda: mock_config) - - def test_preference_without_flaresolverr(self, cf_handler_module, monkeypatch): - """Test preference when FlareSolverr is not configured.""" - mock_config = Mock() - mock_config.flaresolverr_url = None - - import app.library.config - - monkeypatch.setattr(app.library.config.Config, "get_instance", lambda: mock_config) - - def test_preference_with_empty_flaresolverr(self, cf_handler_module, monkeypatch): - """Test preference when FlareSolverr URL is empty.""" - mock_config = Mock() - mock_config.flaresolverr_url = "" - - import app.library.config - - monkeypatch.setattr(app.library.config.Config, "get_instance", lambda: mock_config) diff --git a/app/tests/test_download.py b/app/tests/test_download.py index c1601a7f..76166f7d 100644 --- a/app/tests/test_download.py +++ b/app/tests/test_download.py @@ -1336,6 +1336,32 @@ class TestQueueManager: item.close.assert_awaited_once() assert status[item.info._id] == "ok", "Regular running cancel should still report success" + @pytest.mark.asyncio + async def test_clear_flushes_history_deletes_before_returning(self) -> None: + queue_manager = object.__new__(DownloadQueue) + queue_manager.config = Mock(remove_files=False, download_path="/tmp") + queue_manager._notify = Mock() + + item = Mock() + item.info = make_item(id="done-id", title="Finished clip") + item.info._id = "done-id" + item.info.status = "finished" + item.info.filename = "clip.mp4" + item.info.folder = "" + + done_store = Mock() + done_store.get = AsyncMock(return_value=item) + done_store.delete = AsyncMock() + done_store._connection = Mock() + done_store._connection.flush = AsyncMock() + queue_manager.done = done_store + + status = await DownloadQueue.clear(queue_manager, [item.info._id], remove_file=False) + + done_store.delete.assert_awaited_once_with(item.info._id) + done_store._connection.flush.assert_awaited_once() + assert status[item.info._id] == "ok", "Clear should still report success after flushing deletes" + class TestPoolManager: @pytest.mark.asyncio diff --git a/app/tests/test_download_utils.py b/app/tests/test_download_utils.py index 1bf8c460..d349acfa 100644 --- a/app/tests/test_download_utils.py +++ b/app/tests/test_download_utils.py @@ -8,11 +8,7 @@ from unittest.mock import Mock, patch import pytest from app.library.downloads.utils import ( - BAD_LIVE_STREAM_OPTIONS, - DEBUG_MESSAGE_PREFIXES, - GENERIC_EXTRACTORS, LIMITS, - YTDLP_PROGRESS_FIELDS, create_debug_safe_dict, get_extractor_limit, handle_task_exception, @@ -24,26 +20,6 @@ from app.library.downloads.utils import ( ) -class TestConstants: - def test_generic_extractors_tuple(self) -> None: - assert isinstance(GENERIC_EXTRACTORS, tuple), "Should be a tuple" - assert "HTML5MediaEmbed" in GENERIC_EXTRACTORS, "Should contain HTML5MediaEmbed" - assert "generic" in GENERIC_EXTRACTORS, "Should contain generic" - - def test_ytdlp_progress_fields_tuple(self) -> None: - assert isinstance(YTDLP_PROGRESS_FIELDS, tuple), "Should be a tuple" - assert "status" in YTDLP_PROGRESS_FIELDS, "Should contain status field" - assert "downloaded_bytes" in YTDLP_PROGRESS_FIELDS, "Should contain downloaded_bytes field" - - def test_bad_live_stream_options_list(self) -> None: - assert isinstance(BAD_LIVE_STREAM_OPTIONS, list), "Should be a list" - assert "concurrent_fragment_downloads" in BAD_LIVE_STREAM_OPTIONS, "Should contain concurrent option" - - def test_debug_message_prefixes_list(self) -> None: - assert isinstance(DEBUG_MESSAGE_PREFIXES, list), "Should be a list" - assert "[debug] " in DEBUG_MESSAGE_PREFIXES, "Should contain debug prefix" - - class TestPathUtilities: def test_safe_relative_path_success(self) -> None: base = Path("/downloads") diff --git a/app/tests/test_encoder.py b/app/tests/test_encoder.py index 0b3e5546..163b72d6 100644 --- a/app/tests/test_encoder.py +++ b/app/tests/test_encoder.py @@ -1,21 +1,3 @@ -""" -Tests for encoder.py - JSON encoding utilities. - -This test suite provides comprehensive coverage for the Encoder class: -- Tests serialization of various Python types -- Tests special handling of Path objects -- Tests DateRange serialization -- Tests date serialization -- Tests ImpersonateTarget serialization -- Tests ItemDTO serialization -- Tests object serialization with serialize() method -- Tests object serialization with __dict__ fallback -- Tests fallback to default JSONEncoder behavior - -Total test functions: 10 -All supported types and edge cases are covered. -""" - import json from datetime import date from pathlib import Path diff --git a/app/tests/test_httpx_client.py b/app/tests/test_httpx_client.py index 705a946e..729d8ccf 100644 --- a/app/tests/test_httpx_client.py +++ b/app/tests/test_httpx_client.py @@ -1,5 +1,3 @@ -"""Comprehensive tests for httpx_client Cloudflare challenge solving.""" - from __future__ import annotations import http.cookiejar diff --git a/app/tests/test_nfo_maker.py b/app/tests/test_nfo_maker.py index 205d2f15..24d6cbd0 100644 --- a/app/tests/test_nfo_maker.py +++ b/app/tests/test_nfo_maker.py @@ -1,10 +1,7 @@ from __future__ import annotations -import json from pathlib import Path -import pytest - from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP TEST_DIR = Path("var/tmp/tests_nfo") diff --git a/app/tests/test_operations.py b/app/tests/test_operations.py index 613a8ace..565cb52e 100644 --- a/app/tests/test_operations.py +++ b/app/tests/test_operations.py @@ -1,5 +1,3 @@ -"""Tests for the generic operations module.""" - from app.library.operations import ( Operation, filter_items, diff --git a/app/tests/test_router.py b/app/tests/test_router.py index e0f5cbe3..ba0913b6 100644 --- a/app/tests/test_router.py +++ b/app/tests/test_router.py @@ -11,15 +11,7 @@ def reset_routes(): ROUTES.clear() -class TestRouteType: - def test_all_returns_values(self) -> None: - assert set(RouteType.all()) == {"http", "socket"} - - class TestMakeRouteName: - def test_basic_http_path(self) -> None: - assert make_route_name("GET", "/api/test") == "get:api.test" - def test_trailing_slash_and_root(self) -> None: # Current behavior converts empty part to 'part' assert make_route_name("post", "/") == "post:part" diff --git a/app/tests/test_services.py b/app/tests/test_services.py index e151bba6..10aa66ad 100644 --- a/app/tests/test_services.py +++ b/app/tests/test_services.py @@ -1,5 +1,4 @@ -import logging -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -13,16 +12,6 @@ class TestServices: """Clear services before each test.""" Services._reset_singleton() - def test_singleton_behavior(self): - """Test that Services follows singleton pattern.""" - service1 = Services() - service2 = Services() - service3 = Services.get_instance() - - assert service1 is service2, "Multiple Services() calls should return same instance" - assert service1 is service3, "get_instance() should return same instance" - assert id(service1) == id(service2) == id(service3), "All references should point to same object" - def test_add_and_get_service(self): """Test adding and retrieving services.""" services = Services() @@ -264,29 +253,6 @@ class TestServices: expected = "db:database, cache:redis, args:(), kwargs:{}" assert result == expected - def test_service_types_preserved(self): - """Test that different service types are preserved correctly.""" - services = Services() - - # Test various types - string_service = "string_value" - int_service = 42 - list_service = [1, 2, 3] - dict_service = {"key": "value"} - custom_object = MagicMock() - - services.add("string", string_service) - services.add("int", int_service) - services.add("list", list_service) - services.add("dict", dict_service) - services.add("object", custom_object) - - assert services.get("string") == string_service - assert services.get("int") == int_service - assert services.get("list") == list_service - assert services.get("dict") == dict_service - assert services.get("object") is custom_object - def test_add_none_service(self): """Test adding None as a service value.""" services = Services() @@ -295,22 +261,6 @@ class TestServices: assert services.has("none_service") is True assert services.get("none_service") is None - def test_service_name_edge_cases(self): - """Test edge cases for service names.""" - services = Services() - - # Empty string name - services.add("", "empty_name_value") - assert services.get("") == "empty_name_value" - - # Numeric string name - services.add("123", "numeric_name") - assert services.get("123") == "numeric_name" - - # Special characters in name - services.add("special-chars_123!@#", "special_value") - assert services.get("special-chars_123!@#") == "special_value" - def test_overwrite_existing_service(self): """Test overwriting an existing service.""" services = Services() @@ -319,22 +269,6 @@ class TestServices: assert services.get("service") == "new_value" - def test_singleton_persistence_across_operations(self): - """Test that singleton behavior persists across various operations.""" - # Get instance and add a service - services1 = Services() - services1.add("persistent", "value") - - # Get another instance and verify service exists - services2 = Services.get_instance() - assert services2.get("persistent") == "value" - - # Clear from one instance - services1.clear() - - # Verify cleared in other instance - assert services2.get("persistent") is None - def test_handler_exception_propagation(self): """Test that exceptions in handlers are properly propagated.""" services = Services() @@ -397,14 +331,6 @@ class TestServices: result = services.handle_sync(lambda_handler) assert result == "Lambda: lambda_value" - def test_logging_configuration(self): - """Test that logging is properly configured.""" - # This test verifies the module-level logger setup - from app.library.Services import LOG - - assert isinstance(LOG, logging.Logger) - assert LOG.name == "app.library.Services" - def test_service_container_isolation(self): """Test that services don't interfere with each other.""" services = Services() @@ -421,27 +347,6 @@ class TestServices: assert services.get("data") is None assert services.get("data_backup")["type"] == "backup" - def test_large_number_of_services(self): - """Test handling a large number of services.""" - services = Services() - - # Add many services - num_services = 1000 - for i in range(num_services): - services.add(f"service_{i}", f"value_{i}") - - # Verify all exist - assert len(services.get_all()) == num_services - - # Verify specific services - assert services.get("service_0") == "value_0" - assert services.get("service_500") == "value_500" - assert services.get("service_999") == "value_999" - - # Clear should work efficiently - services.clear() - assert len(services.get_all()) == 0 - def test_add_all_overwrites_existing(self): """Test that add_all overwrites existing services.""" services = Services() @@ -465,20 +370,6 @@ class TestServices: assert services.get("existing") == "value" assert len(services.get_all()) == 1 - def test_type_var_generic_behavior(self): - """Test that TypeVar T is handled correctly.""" - services = Services() - - # Add different types and ensure they're returned correctly - services.add("string", "text") - services.add("number", 42) - services.add("boolean", True) # noqa: FBT003 - - # Type should be preserved (runtime check) - assert isinstance(services.get("string"), str) - assert isinstance(services.get("number"), int) - assert isinstance(services.get("boolean"), bool) - def test_concurrent_access_safety(self): """Test basic thread safety aspects of singleton.""" import threading @@ -504,47 +395,3 @@ class TestServices: # All should be the same instance assert len(set(results)) == 1, "All threads should get the same singleton instance" - - def test_method_chaining_possibility(self): - """Test that methods can be potentially chained.""" - services = Services() - - # While current implementation doesn't return self, test the pattern works - services.add("test1", "value1") - services.add("test2", "value2") - services.remove("test1") - - assert services.get("test1") is None - assert services.get("test2") == "value2" - - def test_edge_case_empty_handler_name(self): - """Test handlers with minimal or no names.""" - services = Services() - services.add("param", "value") - - # Anonymous lambda - result = services.handle_sync(lambda param: f"anon: {param}") - assert result == "anon: value" - - # Function with minimal signature info - def minimal(param): - return param - - result = services.handle_sync(minimal) - assert result == "value" - - def test_services_state_isolation(self): - """Test that different Services instances share state properly.""" - # This test verifies the singleton behavior more thoroughly - s1 = Services() - s1.add("shared", "data") - - s2 = Services.get_instance() - assert s2.get("shared") == "data" - - s2.add("another", "value") - assert s1.get("another") == "value" - - # Clear from s1 affects s2 - s1.clear() - assert len(s2.get_all()) == 0 diff --git a/app/tests/test_static_routes.py b/app/tests/test_static_routes.py new file mode 100644 index 00000000..f17dae09 --- /dev/null +++ b/app/tests/test_static_routes.py @@ -0,0 +1,175 @@ +from pathlib import Path +from typing import Generator + +import pytest +from aiohttp import web +from aiohttp.test_utils import make_mocked_request + +from app.library.config import Config +from app.library.router import ROUTES +from app.routes.api import _static + + +@pytest.fixture(autouse=True) +def reset_static_routes() -> Generator[None, None, None]: + Config._reset_singleton() + ROUTES.clear() + _static.STATIC_STATE.root = None + _static.STATIC_STATE.index_file = None + yield + _static.STATIC_STATE.root = None + _static.STATIC_STATE.index_file = None + ROUTES.clear() + Config._reset_singleton() + + +def _make_request(path: str, *, accept: str = "text/html") -> web.Request: + return make_mocked_request("GET", path, headers={"Accept": accept}) + + +def _configure_static_root(static_root: Path) -> None: + _static.STATIC_STATE.root = static_root.resolve() + _static.STATIC_STATE.index_file = (_static.STATIC_STATE.root / "index.html").resolve() + + +class TestServeStaticFile: + @pytest.mark.asyncio + async def test_nested_document_route_falls_back_to_spa_shell(self, tmp_path: Path) -> None: + config = Config.get_instance() + index_file = tmp_path / "index.html" + index_file.write_text("root shell", encoding="utf-8") + _configure_static_root(tmp_path) + + response = await _static.serve_static_file(_make_request("/docs/readme"), config) + + assert isinstance(response, web.FileResponse) + assert response._path == index_file + + @pytest.mark.asyncio + async def test_generated_nested_index_is_not_preferred_over_root_shell(self, tmp_path: Path) -> None: + config = Config.get_instance() + root_index = tmp_path / "index.html" + nested_index = tmp_path / "docs" / "readme" / "index.html" + nested_index.parent.mkdir(parents=True) + root_index.write_text("root shell", encoding="utf-8") + nested_index.write_text("nested shell", encoding="utf-8") + _configure_static_root(tmp_path) + + response = await _static.serve_static_file(_make_request("/docs/readme"), config) + + assert isinstance(response, web.FileResponse) + assert response._path == root_index + + @pytest.mark.asyncio + async def test_missing_asset_does_not_fall_back(self, tmp_path: Path) -> None: + config = Config.get_instance() + (tmp_path / "index.html").write_text("root shell", encoding="utf-8") + assets_dir = tmp_path / "assets" + assets_dir.mkdir() + _configure_static_root(tmp_path) + + request = make_mocked_request( + "GET", + "/assets/missing.js", + headers={"Accept": "*/*", "Sec-Fetch-Dest": "script"}, + ) + response = await _static.serve_static_file(request, config) + + assert isinstance(response, web.Response) + body = response.body + assert isinstance(body, bytes) + assert response.status == web.HTTPNotFound.status_code + assert b"missing.js" in body + + @pytest.mark.asyncio + async def test_missing_asset_with_unknown_suffix_does_not_fall_back(self, tmp_path: Path) -> None: + config = Config.get_instance() + (tmp_path / "index.html").write_text("root shell", encoding="utf-8") + assets_dir = tmp_path / "assets" + assets_dir.mkdir() + _configure_static_root(tmp_path) + + request = make_mocked_request( + "GET", + "/assets/missing.abcd123", + headers={"Accept": "*/*", "Sec-Fetch-Dest": "script"}, + ) + response = await _static.serve_static_file(request, config) + + assert isinstance(response, web.Response) + body = response.body + assert isinstance(body, bytes) + assert response.status == web.HTTPNotFound.status_code + assert b"missing.abcd123" in body + + @pytest.mark.asyncio + async def test_symlink_outside_static_root_does_not_resolve(self, tmp_path: Path) -> None: + config = Config.get_instance() + (tmp_path / "index.html").write_text("root shell", encoding="utf-8") + outside_dir = tmp_path.parent / "outside-static-root" + outside_dir.mkdir(exist_ok=True) + outside_file = outside_dir / "secret.js" + outside_file.write_text("console.log('outside')", encoding="utf-8") + try: + (tmp_path / "leak.js").symlink_to(outside_file) + _configure_static_root(tmp_path) + + response = await _static.serve_static_file(_make_request("/leak.js"), config) + + assert isinstance(response, web.Response) + body = response.body + assert isinstance(body, bytes) + assert response.status == web.HTTPNotFound.status_code + assert b"/leak.js" in body + finally: + if (tmp_path / "leak.js").exists() or (tmp_path / "leak.js").is_symlink(): + (tmp_path / "leak.js").unlink() + if outside_file.exists(): + outside_file.unlink() + if outside_dir.exists(): + outside_dir.rmdir() + + @pytest.mark.asyncio + async def test_missing_api_path_does_not_fall_back(self, tmp_path: Path) -> None: + config = Config.get_instance() + (tmp_path / "index.html").write_text("root shell", encoding="utf-8") + _configure_static_root(tmp_path) + + response = await _static.serve_static_file(_make_request("/api/missing"), config) + + assert isinstance(response, web.Response) + body = response.body + assert isinstance(body, bytes) + assert response.status == web.HTTPNotFound.status_code + assert b"/api/missing" in body + + @pytest.mark.asyncio + async def test_dotted_browser_path_returns_not_found(self, tmp_path: Path) -> None: + config = Config.get_instance() + (tmp_path / "index.html").write_text("root shell", encoding="utf-8") + _configure_static_root(tmp_path) + + response = await _static.serve_static_file(_make_request("/browser/foo/bar.txt"), config) + + assert isinstance(response, web.Response) + body = response.body + assert isinstance(body, bytes) + assert response.status == web.HTTPNotFound.status_code + assert b"/browser/foo/bar.txt" in body + + def test_registers_only_root_and_catch_all_routes(self, tmp_path: Path) -> None: + config = Config.get_instance() + static_root = tmp_path / "ui-exported" + static_root.mkdir() + (static_root / "index.html").write_text("root shell", encoding="utf-8") + (static_root / "assets").mkdir() + (static_root / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8") + + config.static_ui_path = str(static_root) + + _static.setup_static_routes(tmp_path, config) + + http_routes = ROUTES.get("http", {}) + assert "index" in http_routes + assert "static_fallback" in http_routes + assert "/assets/app.js" not in {route.path for route in http_routes.values()} diff --git a/app/tests/test_system_routes.py b/app/tests/test_system_routes.py index 209ccd7c..85e97f8c 100644 --- a/app/tests/test_system_routes.py +++ b/app/tests/test_system_routes.py @@ -47,23 +47,6 @@ class TestCheckUpdatesEndpoint: assert "up_to_date" in body, "Response should include up_to_date status" assert mock_check.called, "Should have called check_for_updates" - @pytest.mark.asyncio - async def test_check_updates_returns_current_version(self): - """Test check updates includes current version in response.""" - config = Config.get_instance() - config.check_for_updates = True - config.app_version = "v1.2.3" - encoder = Encoder() - update_checker = UpdateChecker.get_instance() - - with patch.object(update_checker, "check_for_updates", new_callable=AsyncMock) as mock_check: - mock_check.return_value = (("up_to_date", None), ("up_to_date", None)) - response = await check_updates(config, encoder, update_checker) - - assert 200 == response.status, "Should return 200" - body = response.body.decode("utf-8") - assert "1.2.3" in body, "Response should include current version" - @pytest.mark.asyncio async def test_check_updates_update_available(self): """Test check updates returns update_available status with new version.""" @@ -81,37 +64,3 @@ class TestCheckUpdatesEndpoint: body = response.body.decode("utf-8") assert "v1.0.5" in body, "Response should include new version" assert "update_available" in body, "Response should include update_available status" - - @pytest.mark.asyncio - async def test_check_updates_null_when_no_new_version(self): - """Test check updates returns null for new_version when none available.""" - config = Config.get_instance() - config.check_for_updates = True - config.app_version = "v1.0.0" - encoder = Encoder() - update_checker = UpdateChecker.get_instance() - - with patch.object(update_checker, "check_for_updates", new_callable=AsyncMock) as mock_check: - mock_check.return_value = (("up_to_date", None), ("up_to_date", None)) - response = await check_updates(config, encoder, update_checker) - - assert 200 == response.status, "Should return 200" - body = response.body.decode("utf-8") - assert "null" in body.lower(), "Response should include null for new_version when not available" - - @pytest.mark.asyncio - async def test_check_updates_error_status(self): - """Test check updates handles error status correctly.""" - config = Config.get_instance() - config.check_for_updates = True - config.app_version = "v1.0.0" - encoder = Encoder() - update_checker = UpdateChecker.get_instance() - - with patch.object(update_checker, "check_for_updates", new_callable=AsyncMock) as mock_check: - mock_check.return_value = (("error", None), ("error", None)) - response = await check_updates(config, encoder, update_checker) - - assert 200 == response.status, "Should return 200" - body = response.body.decode("utf-8") - assert "error" in body, "Response should include error status" diff --git a/app/tests/test_update_checker.py b/app/tests/test_update_checker.py index a390ec3a..25687e68 100644 --- a/app/tests/test_update_checker.py +++ b/app/tests/test_update_checker.py @@ -21,26 +21,6 @@ class TestUpdateChecker: EventBus._reset_singleton() Cache._reset_singleton() - def test_singleton_pattern(self): - """Test that UpdateChecker follows singleton pattern.""" - from app.library.UpdateChecker import UpdateChecker - - instance1 = UpdateChecker.get_instance() - instance2 = UpdateChecker.get_instance() - - assert instance1 is instance2, "Should return same instance" - - def test_initialization_with_defaults(self): - """Test UpdateChecker initializes with default config and scheduler.""" - from app.library.UpdateChecker import UpdateChecker - - checker = UpdateChecker.get_instance() - - assert checker._config is not None, "Should have config instance" - assert checker._scheduler is not None, "Should have scheduler instance" - assert checker._notify is not None, "Should have EventBus instance" - assert checker._job_id is None, "Should have no job ID initially" - def test_attach_schedules_check_when_enabled(self): """Test that attach schedules update check when config.check_for_updates is True.""" import asyncio diff --git a/app/tests/test_utils.py b/app/tests/test_utils.py index e88cb93f..9f7518c0 100644 --- a/app/tests/test_utils.py +++ b/app/tests/test_utils.py @@ -10,6 +10,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch import pytest + from app.features.ytdlp.utils import arg_converter from app.library.Utils import ( FileLogFormatter, diff --git a/ui/app/components/ConditionForm.vue b/ui/app/components/ConditionForm.vue index a4c6ad0e..5f5f53c1 100644 --- a/ui/app/components/ConditionForm.vue +++ b/ui/app/components/ConditionForm.vue @@ -40,7 +40,8 @@ @@ -521,6 +525,7 @@ import { match_str } from '~/utils/ytdlp'; const emitter = defineEmits<{ (e: 'cancel'): void; + (e: 'dirty-change', dirty: boolean): void; (e: 'submit', payload: { reference: number | null | undefined; item: Condition }): void; }>(); @@ -533,7 +538,7 @@ const props = defineProps<{ const toast = useNotification(); const showImport = useStorage('showImport', false); const box = useConfirm(); -const config = useConfigStore(); +const config = useYtpConfig(); const form = reactive(normalizeCondition(props.item)); const importString = ref(''); @@ -555,6 +560,16 @@ const testData = ref<{ const showOptions = ref(false); const ytDlpOpt = ref([]); +const dirtySource = computed(() => ({ + reference: props.reference ?? null, + form: normalizeCondition(form), + importString: importString.value, + showImport: showImport.value, + newExtraKey: newExtraKey.value, + newExtraValue: newExtraValue.value, +})); +const { isDirty, markClean } = useDirtyState(dirtySource); + const fieldUi = { label: 'font-semibold text-default', container: 'space-y-2', @@ -584,10 +599,20 @@ watch( () => props.item, (value) => { Object.assign(form, normalizeCondition(value)); + + importString.value = ''; + newExtraKey.value = ''; + newExtraValue.value = ''; + nextTick(() => { + markClean(); + emitter('dirty-change', false); + }); }, { deep: true }, ); +watch(isDirty, (value: boolean) => emitter('dirty-change', value)); + watch( () => config.ytdlp_options, (newOptions) => @@ -650,14 +675,6 @@ const logicTest = computed(() => { } }); -const logicStatusText = computed(() => { - if (testData.value.data.status === null) { - return 'Not tested'; - } - - return logicTest.value ? 'Matched' : 'Not matched'; -}); - const checkInfo = async (): Promise => { for (const key of ['name', 'filter'] as const) { if (!form[key]) { @@ -853,4 +870,9 @@ const updateExtraValue = (key: string, rawValue: string): void => { [key]: rawValue.trim() ? parseValue(rawValue.trim()) : '', }; }; + +onMounted(() => { + markClean(); + emitter('dirty-change', false); +}); diff --git a/ui/app/components/DLFieldForm.vue b/ui/app/components/DLFieldForm.vue index df6dd2a8..fef2847e 100644 --- a/ui/app/components/DLFieldForm.vue +++ b/ui/app/components/DLFieldForm.vue @@ -40,7 +40,8 @@ (); @@ -258,7 +260,7 @@ const props = defineProps<{ const toast = useNotification(); const box = useConfirm(); -const config = useConfigStore(); +const config = useYtpConfig(); const fieldTypes = ['string', 'text', 'bool'] as const; const fieldTypeItems = [...fieldTypes]; @@ -267,6 +269,14 @@ const ytDlpOptions = ref([]); const showImport = useStorage('showDlFieldsImport', false); const importString = ref(''); +const dirtySource = computed(() => ({ + reference: props.reference ?? null, + form: normalizeField(form), + importString: importString.value, + showImport: showImport.value, +})); +const { isDirty, markClean } = useDirtyState(dirtySource); + const fieldUi = { label: 'font-semibold text-default', container: 'space-y-2', @@ -287,10 +297,18 @@ watch( () => props.item, (value) => { Object.assign(form, normalizeField(value)); + + importString.value = ''; + nextTick(() => { + markClean(); + emitter('dirty-change', false); + }); }, { deep: true }, ); +watch(isDirty, (value: boolean) => emitter('dirty-change', value)); + watch( () => config.ytdlp_options, (newOptions) => @@ -399,4 +417,9 @@ const checkInfo = (): void => { emitter('submit', { reference: toRaw(props.reference), item: toRaw(copy) }); }; + +onMounted(() => { + markClean(); + emitter('dirty-change', false); +}); diff --git a/ui/app/components/DLInput.vue b/ui/app/components/DLInput.vue index 5d2af6e1..98057f84 100644 --- a/ui/app/components/DLInput.vue +++ b/ui/app/components/DLInput.vue @@ -5,7 +5,7 @@ >
- + {{ label }} @@ -52,7 +52,7 @@