From d9dbbb18eaebeee2526fda9bc16eaf83c93d1072 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Thu, 14 May 2026 23:22:27 +0300 Subject: [PATCH] feat: thumbnail generation --- API.md | 21 ++ FAQ.md | 3 + app/features/streaming/library/thumbnail.py | 253 ++++++++++++++ .../streaming/tests/test_thumbnail.py | 326 ++++++++++++++++++ app/library/config.py | 12 + app/routes/api/history.py | 43 +++ app/tests/test_history_routes.py | 111 +++++- ui/app/components/Simple.vue | 26 +- ui/app/components/VideoPlayer.vue | 114 +----- ui/app/pages/history.vue | 24 +- ui/app/utils/index.ts | 24 +- 11 files changed, 837 insertions(+), 120 deletions(-) create mode 100644 app/features/streaming/library/thumbnail.py create mode 100644 app/features/streaming/tests/test_thumbnail.py diff --git a/API.md b/API.md index 3d735f60..c1a5d5a5 100644 --- a/API.md +++ b/API.md @@ -24,6 +24,7 @@ This document describes the available endpoints and their usage. All endpoints r - [DELETE /api/history](#delete-apihistory) - [POST /api/history/{id}](#post-apihistoryid) - [GET /api/history/{id}](#get-apihistoryid) + - [GET /api/history/{id}/thumbnail](#get-apihistoryidthumbnail) - [POST /api/history/{id}/rename](#post-apihistoryidrename) - [GET /api/history](#get-apihistory) - [GET /api/history/live](#get-apihistorylive) @@ -533,6 +534,26 @@ or an error: --- +### GET /api/history/{id}/thumbnail +**Purpose**: Return thumbnail for a downloaded item. + +**Path Parameter**: +- `id` = item ID. + +**Behavior**: +- Returns an existing local sidecar or artwork image when available. +- Otherwise generates a representative frame thumbnail with `ffmpeg` and caches it if enabled. + +**Response**: +- `200 OK` with an image file response. +- `404 Not Found` if the item, downloaded file, or local thumbnail is not available. +- `400 Bad Request` if `id` is missing. + +**Notes**: +- Audio-only files return `404` unless a local image sidecar already exists. + +--- + ### POST /api/history/{id}/rename **Purpose**: Rename a downloaded history file and its sidecars. diff --git a/FAQ.md b/FAQ.md index 21fa8c13..f2bc1df1 100644 --- a/FAQ.md +++ b/FAQ.md @@ -57,6 +57,9 @@ or the `environment:` section in `compose.yaml` file. | YTP_IGNORE_ARCHIVED_ITEMS | Don't report archived items in the download history. | `false` | | YTP_CHECK_FOR_UPDATES | Whether to check for application updates. | `true` | | YTP_EXTRACT_INFO_CONCURRENCY | The number of concurrent extract info operations. | `4` | +| YTP_THUMB_CONCURRENCY | The number of concurrent ffmpeg thumbnail generations allowed. | `2` | +| YTP_THUMB_GENERATE | Enable ffmpeg thumbnail generation when no local thumbnail exists. | `true` | +| YTP_THUMB_SIDECAR | Save generated thumbnails next to media instead of temp cache. | `false` | > [!NOTE] > To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_`. diff --git a/app/features/streaming/library/thumbnail.py b/app/features/streaming/library/thumbnail.py new file mode 100644 index 00000000..21b592bc --- /dev/null +++ b/app/features/streaming/library/thumbnail.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import asyncio +import hashlib +import logging +import os +import subprocess +from pathlib import Path + +from app.features.streaming.library.ffprobe import ffprobe +from app.library.cache import Cache +from app.library.config import Config +from app.library.Utils import FILES_TYPE, get_file_sidecar + +LOG: logging.Logger = logging.getLogger("streaming.thumbnail") + +IMAGE_TYPES: tuple[str, ...] = (".jpg", ".jpeg", ".png", ".webp") +FOLDER_IMAGE_ORDER: tuple[str, ...] = ("thumbnail", "poster", "artwork", "cover", "fanart") +THUMBNAIL_SAMPLE_FRAMES = 200 +THUMBNAIL_DEFAULT_SEEK_SECONDS = 3.0 +THUMBNAIL_MIN_SEEK_SECONDS = 1.0 +THUMBNAIL_MAX_SEEK_SECONDS = 15.0 +THUMBNAIL_END_MARGIN_SECONDS = 0.1 +THUMBNAIL_MISS_TTL = 3600.0 + +_LOCK = asyncio.Lock() +_IN_PROCESS: dict[str, asyncio.Task[Path | None]] = {} +_SEM: asyncio.Semaphore | None = None +_SEM_LIMIT: int | None = None + + +def _get_semaphore() -> asyncio.Semaphore: + global _SEM, _SEM_LIMIT # noqa: PLW0603 + + limit: int = max(1, int(Config.get_instance().thumb_concurrency)) + if _SEM is None or _SEM_LIMIT != limit: + _SEM = asyncio.Semaphore(limit) + _SEM_LIMIT = limit + LOG.info(f"Configured thumbnail generation concurrency limit: {limit}") + + return _SEM + + +def _is_same_stem_image(media_file: Path, image_file: Path) -> bool: + if image_file.parent != media_file.parent: + return False + + for image_type in FILES_TYPE: + if "image" != image_type.get("type"): + continue + + rx = image_type.get("rx") + if rx and rx.search(image_file.name): + break + else: + return False + + return image_file.stem == media_file.stem + + +def pick_local_thumb(media_file: Path) -> Path | None: + sidecar: dict[str, list[dict[str, str]]] = get_file_sidecar(media_file) + images: list[dict[str, str]] = sidecar.get("image", []) + local_images: list[Path] = [Path(item["file"]) for item in images if isinstance(item.get("file"), Path)] + + for image_file in local_images: + if image_file.suffix.lower() in IMAGE_TYPES and _is_same_stem_image(media_file, image_file): + return image_file + + by_name: dict[str, Path] = { + image_file.stem.lower(): image_file for image_file in local_images if image_file.suffix.lower() in IMAGE_TYPES + } + for name in FOLDER_IMAGE_ORDER: + if image_file := by_name.get(name): + return image_file + + return None + + +def _cache_key(media_file: Path) -> str: + stat = media_file.stat() + payload: str = f"{media_file.resolve()}:{stat.st_size}:{stat.st_mtime_ns}" + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _seek_seconds(ff_info) -> float | None: + duration: float = 0.0 + try: + duration = float(ff_info.metadata.get("duration") or 0.0) + except (TypeError, ValueError): + duration = 0.0 + + if duration <= 0: + return THUMBNAIL_DEFAULT_SEEK_SECONDS + + if duration <= THUMBNAIL_END_MARGIN_SECONDS: + return None + + return min( + max(duration * 0.10, THUMBNAIL_MIN_SEEK_SECONDS), + THUMBNAIL_MAX_SEEK_SECONDS, + duration - THUMBNAIL_END_MARGIN_SECONDS, + ) + + +def _build_ffmpeg_args(media_file: Path, output_file: Path, *, seek_seconds: float | None) -> list[str]: + args: list[str] = [ + "ffmpeg", + "-nostdin", + "-hide_banner", + "-loglevel", + "error", + "-y", + ] + + if seek_seconds is not None and seek_seconds > 0: + args.extend(["-ss", f"{seek_seconds:.3f}"]) + + args.extend( + [ + "-i", + str(media_file), + "-vf", + f"thumbnail={THUMBNAIL_SAMPLE_FRAMES},scale=1280:-1:force_original_aspect_ratio=decrease", + "-frames:v", + "1", + "-q:v", + "3", + "-an", + str(output_file), + ] + ) + return args + + +async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None: + ff_info = await ffprobe(media_file) + if not ff_info.has_video(): + LOG.debug(f"Skipping thumbnail generation for '{media_file}' because no video stream exists.") + return None + + output_file.parent.mkdir(parents=True, exist_ok=True) + temp_file = output_file.with_suffix(".tmp.jpg") + + if temp_file.exists(): + temp_file.unlink(missing_ok=True) + + seek_seconds: float | None = _seek_seconds(ff_info) + attempts: list[float | None] = [seek_seconds] + if seek_seconds is not None and seek_seconds > 0: + attempts.append(None) + + sem = _get_semaphore() + if sem.locked(): + limit = _SEM_LIMIT or 1 + LOG.debug(f"Waiting for thumbnail generation slot for '{media_file}'. limit={limit}") + + async with sem: + last_error: str = "ffmpeg produced an empty thumbnail file" + for idx, attempt_seek in enumerate(attempts, start=1): + temp_file.unlink(missing_ok=True) + args: list[str] = _build_ffmpeg_args(media_file, temp_file, seek_seconds=attempt_seek) + LOG.debug( + f"Generating thumbnail for '{media_file}'. attempt={idx}/{len(attempts)} seek={'none' if attempt_seek is None else f'{attempt_seek:.3f}s'}" + ) + + try: + proc = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + except FileNotFoundError as exc: + msg = "ffmpeg not found." + raise OSError(msg) from exc + + stdout, stderr = await proc.communicate() + if 0 == proc.returncode and temp_file.exists() and temp_file.stat().st_size > 0: + temp_file.replace(output_file) + LOG.info(f"Generated thumbnail '{output_file}' for '{media_file}'.") + return output_file + + if 0 != proc.returncode: + last_error: str = ( + f"ffmpeg thumbnail generation failed (rc={proc.returncode}).\n" + f"stdout:\n{stdout.decode('utf-8', errors='replace')}\n" + f"stderr:\n{stderr.decode('utf-8', errors='replace')}" + ) + else: + last_error: str = "ffmpeg produced an empty thumbnail file" + + LOG.debug( + f"Thumbnail generation attempt failed for '{media_file}'. seek={'none' if attempt_seek is None else f'{attempt_seek:.3f}s'}" + ) + + temp_file.unlink(missing_ok=True) + raise OSError(last_error) + + +async def ensure_thumb(media_file: Path, cache_root: Path) -> Path | None: + config: Config = Config.get_instance() + cache: Cache = Cache.get_instance() + if bool(config.thumb_generate) is not True: + return None + + key: str = _cache_key(media_file) + cache_file: Path = ( + media_file.with_name(f"{media_file.name}.thumb.jpg") + if bool(config.thumb_sidecar) + else cache_root / f"{key}.jpg" + ) + + miss_key: str = f"thumbnail-miss:{key}" + + if cache_file.exists() and cache_file.stat().st_size > 0: + cache.delete(miss_key) + return cache_file + + if cache.has(miss_key): + return None + + async with _LOCK: + if cache_file.exists() and cache_file.stat().st_size > 0: + cache.delete(miss_key) + return cache_file + + if cache.has(miss_key): + return None + + task = _IN_PROCESS.get(key) + if task is not None and not task.done(): + LOG.debug(f"Waiting for thumbnail generation for '{media_file}'.") + else: + task = asyncio.create_task(_run_ffmpeg(media_file, cache_file), name=f"thumb-{key[:8]}") + _IN_PROCESS[key] = task + LOG.debug(f"Starting thumbnail generation task for '{media_file}' -> '{cache_file}'.") + + try: + result: Path | None = await task + if result is None: + cache.set(miss_key, value=True, ttl=THUMBNAIL_MISS_TTL) + else: + cache.delete(miss_key) + return result + except OSError: + cache.set(miss_key, value=True, ttl=THUMBNAIL_MISS_TTL) + raise + finally: + async with _LOCK: + current = _IN_PROCESS.get(key) + if current is task and task.done(): + _IN_PROCESS.pop(key, None) diff --git a/app/features/streaming/tests/test_thumbnail.py b/app/features/streaming/tests/test_thumbnail.py new file mode 100644 index 00000000..53fd2366 --- /dev/null +++ b/app/features/streaming/tests/test_thumbnail.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from app.tests.helpers import temporary_test_dir + + +@pytest.mark.asyncio +async def test_pick_same_stem() -> None: + from app.features.streaming.library.thumbnail import pick_local_thumb + + with temporary_test_dir("thumb-local") as temp_dir: + media = temp_dir / "video.mp4" + poster = temp_dir / "poster.jpg" + thumb = temp_dir / "video.jpg" + + media.write_text("video") + poster.write_text("poster") + thumb.write_text("thumb") + + assert pick_local_thumb(media) == thumb + + +@pytest.mark.asyncio +async def test_singleflight(monkeypatch: pytest.MonkeyPatch) -> None: + from app.features.streaming.library import thumbnail + + thumbnail._IN_PROCESS.clear() + + with temporary_test_dir("thumb-singleflight") as temp_dir: + media = temp_dir / "video.mp4" + cache_root = temp_dir / "cache" + media.write_text("video") + + calls = {"count": 0} + + async def fake_run_ffmpeg(_file: Path, output_file: Path) -> Path: + calls["count"] += 1 + await asyncio.sleep(0.01) + output_file.parent.mkdir(parents=True, exist_ok=True) + output_file.write_text("image") + return output_file + + monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg) + + first, second = await asyncio.gather( + thumbnail.ensure_thumb(media, cache_root), + thumbnail.ensure_thumb(media, cache_root), + ) + + assert first == second + assert first is not None + assert calls["count"] == 1 + assert thumbnail._IN_PROCESS == {} + + +@pytest.mark.asyncio +async def test_cache_hit(monkeypatch: pytest.MonkeyPatch) -> None: + from app.features.streaming.library import thumbnail + + thumbnail._IN_PROCESS.clear() + + with temporary_test_dir("thumb-cache") as temp_dir: + media = temp_dir / "video.mp4" + cache_root = temp_dir / "cache" + media.write_text("video") + + cache_file = cache_root / f"{thumbnail._cache_key(media)}.jpg" + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_text("image") + + async def fake_run_ffmpeg(_file: Path, _output_file: Path) -> Path: + raise AssertionError("ffmpeg should not run when cache exists") + + monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg) + + result = await thumbnail.ensure_thumb(media, cache_root) + + assert result == cache_file + + +@pytest.mark.asyncio +async def test_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + from app.features.streaming.library import thumbnail + + thumbnail._IN_PROCESS.clear() + + with temporary_test_dir("thumb-disabled") as temp_dir: + media = temp_dir / "video.mp4" + media.write_text("video") + + monkeypatch.setattr( + thumbnail.Config, + "get_instance", + staticmethod(lambda: type("Cfg", (), {"thumb_generate": False, "thumb_sidecar": False})()), + ) + + result = await thumbnail.ensure_thumb(media, temp_dir / "cache") + + assert result is None + + +@pytest.mark.asyncio +async def test_sidecar_mode(monkeypatch: pytest.MonkeyPatch) -> None: + from app.features.streaming.library import thumbnail + + thumbnail._IN_PROCESS.clear() + thumbnail._SEM = None + thumbnail._SEM_LIMIT = None + + with temporary_test_dir("thumb-sidecar") as temp_dir: + media = temp_dir / "video.mp4" + media.write_text("video") + + sidecar = media.with_name(f"{media.name}.thumb.jpg") + + monkeypatch.setattr( + thumbnail.Config, + "get_instance", + staticmethod( + lambda: type( + "Cfg", + (), + {"thumb_generate": True, "thumb_sidecar": True, "thumb_concurrency": 1}, + )() + ), + ) + + async def fake_run_ffmpeg(_file: Path, out_file: Path) -> Path: + out_file.write_text("image") + return out_file + + monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg) + + result = await thumbnail.ensure_thumb(media, temp_dir / "cache") + + assert result == sidecar + assert sidecar.exists() + + +@pytest.mark.asyncio +async def test_miss_cache(monkeypatch: pytest.MonkeyPatch) -> None: + from app.features.streaming.library import thumbnail + + thumbnail._IN_PROCESS.clear() + thumbnail.Cache.get_instance().clear() + + with temporary_test_dir("thumb-miss") as temp_dir: + media = temp_dir / "video.mp4" + media.write_text("video") + + monkeypatch.setattr( + thumbnail.Config, + "get_instance", + staticmethod( + lambda: type( + "Cfg", + (), + {"thumb_generate": True, "thumb_sidecar": False, "thumb_concurrency": 1}, + )() + ), + ) + + calls = {"count": 0} + + async def fake_run_ffmpeg(_file: Path, _out_file: Path) -> Path | None: + calls["count"] += 1 + return None + + monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg) + + first = await thumbnail.ensure_thumb(media, temp_dir / "cache") + second = await thumbnail.ensure_thumb(media, temp_dir / "cache") + + assert first is None + assert second is None + assert calls["count"] == 1 + + +def test_sem_limit(monkeypatch: pytest.MonkeyPatch) -> None: + from app.features.streaming.library import thumbnail + + thumbnail._SEM = None + thumbnail._SEM_LIMIT = None + + monkeypatch.setattr( + thumbnail.Config, + "get_instance", + staticmethod( + lambda: type("Cfg", (), {"thumb_concurrency": 3, "thumb_generate": True, "thumb_sidecar": False})() + ), + ) + + sem = thumbnail._get_semaphore() + + assert sem._value == 3 + assert thumbnail._SEM_LIMIT == 3 + + +def test_seek_bounds() -> None: + from app.features.streaming.library.ffprobe import FFProbeResult + from app.features.streaming.library.thumbnail import _seek_seconds + + ff_info = FFProbeResult() + ff_info.metadata = {"duration": "120.0"} + assert _seek_seconds(ff_info) == 12.0 + + ff_info.metadata = {"duration": "2.0"} + assert _seek_seconds(ff_info) == 1.0 + + ff_info.metadata = {"duration": "0.05"} + assert _seek_seconds(ff_info) is None + + ff_info.metadata = {} + assert _seek_seconds(ff_info) == 3.0 + + +@pytest.mark.asyncio +async def test_retry_no_seek(monkeypatch: pytest.MonkeyPatch) -> None: + from app.features.streaming.library.ffprobe import FFProbeResult + from app.features.streaming.library import thumbnail + + with temporary_test_dir("thumb-retry") as temp_dir: + media = temp_dir / "video.mp4" + output = temp_dir / "thumb.jpg" + media.write_text("video") + + ff_info = FFProbeResult() + ff_info.metadata = {"duration": "120.0"} + ff_info.video = [type("Video", (), {"codec_type": "video", "codec_name": "h264"})()] + + monkeypatch.setattr(thumbnail, "ffprobe", AsyncMock(return_value=ff_info)) + + calls: list[list[str]] = [] + + class DummyProc: + def __init__(self, attempt: int, out_path: Path) -> None: + self.returncode = 1 if attempt == 1 else 0 + self._out_path = out_path + + async def communicate(self) -> tuple[bytes, bytes]: + if self.returncode == 0: + self._out_path.write_text("image") + return b"", b"" + return b"", b"seek failed" + + async def fake_create_subprocess_exec(*args, **kwargs): + del kwargs + calls.append([str(arg) for arg in args]) + return DummyProc(len(calls), Path(str(args[-1]))) + + monkeypatch.setattr(thumbnail.asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + + result = await thumbnail._run_ffmpeg(media, output) + + assert result == output + assert len(calls) == 2 + assert "-ss" in calls[0] + assert calls[0][calls[0].index("-ss") + 1] == "12.000" + assert "-ss" not in calls[1] + assert calls[0][calls[0].index("-vf") + 1] == "thumbnail=200,scale=1280:-1:force_original_aspect_ratio=decrease" + assert calls[0][-1].endswith(".tmp.jpg") + + +@pytest.mark.asyncio +async def test_limit_wait(monkeypatch: pytest.MonkeyPatch) -> None: + from app.features.streaming.library import thumbnail + from app.features.streaming.library.ffprobe import FFProbeResult + + thumbnail._SEM = None + thumbnail._SEM_LIMIT = None + thumbnail._IN_PROCESS.clear() + monkeypatch.setattr( + thumbnail.Config, + "get_instance", + staticmethod( + lambda: type("Cfg", (), {"thumb_concurrency": 1, "thumb_generate": True, "thumb_sidecar": False})() + ), + ) + + with temporary_test_dir("thumb-limit") as temp_dir: + media1 = temp_dir / "video1.mp4" + media2 = temp_dir / "video2.mp4" + out1 = temp_dir / "out1.jpg" + out2 = temp_dir / "out2.jpg" + media1.write_text("video") + media2.write_text("video") + + ff_info = FFProbeResult() + ff_info.metadata = {"duration": "60.0"} + ff_info.video = [type("Video", (), {"codec_type": "video", "codec_name": "h264"})()] + monkeypatch.setattr(thumbnail, "ffprobe", AsyncMock(return_value=ff_info)) + + active = {"count": 0, "max": 0} + + class DummyProc: + def __init__(self, out_path: Path) -> None: + self.returncode = 0 + self._out_path = out_path + + async def communicate(self) -> tuple[bytes, bytes]: + active["count"] += 1 + active["max"] = max(active["max"], active["count"]) + await asyncio.sleep(0.01) + self._out_path.write_text("image") + active["count"] -= 1 + return b"", b"" + + async def fake_create_subprocess_exec(*args, **kwargs): + del kwargs + return DummyProc(Path(str(args[-1]))) + + monkeypatch.setattr(thumbnail.asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + + first, second = await asyncio.gather( + thumbnail._run_ffmpeg(media1, out1), + thumbnail._run_ffmpeg(media2, out2), + ) + + assert first == out1 + assert second == out2 + assert active["max"] == 1 diff --git a/app/library/config.py b/app/library/config.py index 49ab48e3..b53e70fd 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -120,6 +120,15 @@ class Config(metaclass=Singleton): extract_info_concurrency: int = 4 """The number of concurrent extract_info calls allowed.""" + thumb_concurrency: int = 2 + """The number of concurrent ffmpeg thumbnail generations allowed.""" + + thumb_generate: bool = True + """Enable ffmpeg thumbnail generation when no local thumbnail exists.""" + + thumb_sidecar: bool = False + """Save generated thumbnails next to the media file instead of temp cache.""" + db_file: str = "{config_path}{os_sep}ytptube.db" """The path to the database file.""" @@ -267,6 +276,7 @@ class Config(metaclass=Singleton): "auto_clear_history_days", "default_pagination", "extract_info_concurrency", + "thumb_concurrency", "flaresolverr_max_timeout", "flaresolverr_client_timeout", "flaresolverr_cache_ttl", @@ -292,6 +302,8 @@ class Config(metaclass=Singleton): "simple_mode", "ignore_archived_items", "check_for_updates", + "thumb_generate", + "thumb_sidecar", ) "The variables that are booleans." diff --git a/app/routes/api/history.py b/app/routes/api/history.py index bb6bbffb..428ba26f 100644 --- a/app/routes/api/history.py +++ b/app/routes/api/history.py @@ -8,6 +8,8 @@ from aiohttp.web import Request, Response from app.features.presets.schemas import Preset from app.features.presets.service import Presets +from app.features.streaming.library.thumbnail import ensure_thumb, pick_local_thumb +from app.library.cache import Cache from app.library.config import Config from app.library.DataStore import StoreType from app.library.downloads import Download, DownloadQueue @@ -305,6 +307,47 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) -> return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode) +@route(["GET", "HEAD"], r"api/history/{id}/thumbnail", "history.item.thumbnail") +async def item_thumbnail(request: Request, queue: DownloadQueue, config: Config) -> Response: + if not (id := request.match_info.get("id")): + return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) + + miss_key = f"history-thumb-missing:{id}" + cache = Cache.get_instance() + + item: Download | None = await queue.done.get_by_id(id) + if not item or not item.info: + return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code) + + if cache.has(miss_key): + return web.json_response(data={"error": "thumbnail not found."}, status=web.HTTPNotFound.status_code) + + filepath = item.info.get_file(download_path=Path(config.download_path)) + if not filepath or not filepath.exists() or not filepath.is_file(): + cache.set(miss_key, value=True, ttl=30.0) + return web.json_response(data={"error": "thumbnail not found."}, status=web.HTTPNotFound.status_code) + + cache.delete(miss_key) + + local_thumb = pick_local_thumb(filepath) + if local_thumb and local_thumb.exists() and local_thumb.is_file(): + return web.FileResponse(path=str(local_thumb)) + + try: + generated = await ensure_thumb(filepath, Path(config.temp_path) / "thumbnails") + except OSError as e: + LOG.warning(f"Failed to generate thumbnail for '{filepath}'. {e!s}") + generated = None + except Exception as e: + LOG.exception(e) + generated = None + + if generated and generated.exists() and generated.is_file(): + return web.FileResponse(path=str(generated)) + + return web.json_response(data={"error": "thumbnail not found."}, status=web.HTTPNotFound.status_code) + + @route("POST", r"api/history/{id}/rename", "history.item.rename") async def item_rename( request: Request, diff --git a/app/tests/test_history_routes.py b/app/tests/test_history_routes.py index 9b7faf6b..26603890 100644 --- a/app/tests/test_history_routes.py +++ b/app/tests/test_history_routes.py @@ -1,15 +1,18 @@ import json -from pathlib import Path from types import SimpleNamespace +from pathlib import Path from typing import Any from unittest.mock import AsyncMock, Mock import pytest +from aiohttp import web from app.library.DataStore import StoreType +from app.library.cache import Cache from app.library.ItemDTO import ItemDTO from app.library.encoder import Encoder -from app.routes.api.history import item_rename, items_delete +from app.routes.api import history +from app.routes.api.history import item_rename, item_thumbnail, items_delete from app.tests.helpers import temporary_test_dir @@ -188,3 +191,107 @@ async def test_item_rename_conflict() -> None: assert body == {"error": "Destination 'renamed.mp4' already exists"} queue.done.put.assert_not_awaited() notify.emit.assert_not_called() + + +@pytest.mark.asyncio +async def test_item_thumbnail_sidecar() -> None: + with temporary_test_dir("history-thumb-sidecar") as temp_dir: + media = temp_dir / "video.mp4" + image = temp_dir / "video.jpg" + media.write_text("video") + image.write_text("image") + + request = _FakeRequest() + request.match_info["id"] = "item-1" + item = _make_download(filename="video.mp4", download_dir=str(temp_dir)) + queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item))) + config = SimpleNamespace(download_path=str(temp_dir), temp_path=str(temp_dir / "tmp")) + + response = await item_thumbnail(request, queue, config) + + assert isinstance(response, web.FileResponse) + assert response.status == 200 + assert response._path == image + + +@pytest.mark.asyncio +async def test_item_thumbnail_generated(monkeypatch: pytest.MonkeyPatch) -> None: + with temporary_test_dir("history-thumb-gen") as temp_dir: + media = temp_dir / "video.mp4" + media.write_text("video") + cache_dir = temp_dir / "tmp" + generated = cache_dir / "thumbnails" / "gen.jpg" + + request = _FakeRequest() + request.match_info["id"] = "item-1" + item = _make_download(filename="video.mp4", download_dir=str(temp_dir)) + queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item))) + config = SimpleNamespace(download_path=str(temp_dir), temp_path=str(cache_dir)) + + monkeypatch.setattr(history, "pick_local_thumb", lambda _file: None) + + called = {"count": 0} + + async def fake_ensure_thumb(_file: Path, _cache_root: Path) -> Path: + called["count"] += 1 + generated.parent.mkdir(parents=True, exist_ok=True) + generated.write_text("generated") + return generated + + monkeypatch.setattr(history, "ensure_thumb", fake_ensure_thumb) + + response = await item_thumbnail(request, queue, config) + + assert isinstance(response, web.FileResponse) + assert response.status == 200 + assert response._path == generated + assert called["count"] == 1 + + +@pytest.mark.asyncio +async def test_item_thumbnail_no_thumb(monkeypatch: pytest.MonkeyPatch) -> None: + with temporary_test_dir("history-thumb-miss") as temp_dir: + media = temp_dir / "video.mp4" + media.write_text("video") + + request = _FakeRequest() + request.match_info["id"] = "item-1" + item = _make_download(filename="video.mp4", download_dir=str(temp_dir)) + queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item))) + config = SimpleNamespace(download_path=str(temp_dir), temp_path=str(temp_dir / "tmp")) + + monkeypatch.setattr(history, "pick_local_thumb", lambda _file: None) + monkeypatch.setattr(history, "ensure_thumb", AsyncMock(return_value=None)) + + response = await item_thumbnail(request, queue, config) + + assert response.status == 404 + body = json.loads(response.body.decode("utf-8")) + assert body == {"error": "thumbnail not found."} + + +@pytest.mark.asyncio +async def test_item_thumbnail_missing_cache() -> None: + Cache.get_instance().clear() + + request = _FakeRequest() + request.match_info["id"] = "item-1" + + item = _make_download(filename="video.mp4", download_dir="/downloads") + seen = {"count": 0} + + def fake_get_file(download_path=None): + del download_path + seen["count"] += 1 + return None + + item.info.get_file = fake_get_file + queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item))) + config = SimpleNamespace(download_path="/downloads", temp_path="/tmp") + + first = await item_thumbnail(request, queue, config) + second = await item_thumbnail(request, queue, config) + + assert first.status == 404 + assert second.status == 404 + assert seen["count"] == 1 diff --git a/ui/app/components/Simple.vue b/ui/app/components/Simple.vue index 74290fa1..8bceac2f 100644 --- a/ui/app/components/Simple.vue +++ b/ui/app/components/Simple.vue @@ -259,7 +259,7 @@ :alt="item.title || 'Video thumbnail'" loading="lazy" class="aspect-video h-full w-full object-cover" - @error="onImgError" + @error="onImgError($event, item)" /> @@ -269,7 +269,7 @@ :alt="item.title || 'Video thumbnail'" loading="lazy" class="aspect-video h-full w-full object-cover" - @error="onImgError" + @error="onImgError($event, item)" /> @@ -475,7 +475,7 @@ :alt="item.title || 'Video thumbnail'" loading="lazy" class="aspect-video h-full w-full object-cover" - @error="onImgError" + @error="onImgError($event, item)" /> { return '/images/placeholder.png'; } + if (historyEntries.value.some((entry) => entry._id === item._id)) { + return getHistoryImage(item); + } + return getImage(configStore.app.download_path, item); }; @@ -1295,12 +1301,22 @@ const showMessage = (item: StoreItem): boolean => { return (item.msg?.length || 0) > 0; }; -const onImgError = (event: Event): void => { +const onImgError = (event: Event, item: StoreItem): void => { const target = event.target as HTMLImageElement; + const currentSrc = target.getAttribute('src') || ''; + if (target.src.endsWith('/images/placeholder.png')) { return; } + if (item) { + const fallback = getRemoteImage(item, false); + if (fallback && currentSrc !== fallback) { + target.src = fallback; + return; + } + } + target.src = '/images/placeholder.png'; }; diff --git a/ui/app/components/VideoPlayer.vue b/ui/app/components/VideoPlayer.vue index c55fbd95..833e79bf 100644 --- a/ui/app/components/VideoPlayer.vue +++ b/ui/app/components/VideoPlayer.vue @@ -330,8 +330,8 @@ import Hls from 'hls.js'; import { disableOpacity, enableOpacity, - encodePath, formatPageTitle, + getRemoteImage, makeDownload, request, uri, @@ -594,6 +594,14 @@ function handlePosterError(event: Event) { return; } + const fallback = getRemoteImage(props.item, false); + if (fallback && poster.value !== fallback) { + poster.value = fallback; + hasPoster.value = true; + target.src = uri(fallback); + return; + } + poster.value = '/images/placeholder.png'; hasPoster.value = false; target.src = uri('/images/placeholder.png'); @@ -803,31 +811,17 @@ function bindMediaSessionListeners(el: HTMLVideoElement) { const onTimeUpdate = (event: Event) => updateMediaSessionPosition(event.currentTarget); const onRateChange = (event: Event) => updateMediaSessionPosition(event.currentTarget); const onSeeked = (event: Event) => updateMediaSessionPosition(event.currentTarget); - const onPause = async (event: Event) => { - const target = (event.currentTarget as HTMLVideoElement) ?? null; - if (!target || destroyed.value) { - return; - } - const dataUrl = await captureFrame(target); - if (dataUrl) { - poster.value = dataUrl; - hasPoster.value = true; - applyMediaSessionMetadata(); - } - }; el.addEventListener('loadedmetadata', onLoadedMetadata); el.addEventListener('timeupdate', onTimeUpdate); el.addEventListener('ratechange', onRateChange); el.addEventListener('seeked', onSeeked); - el.addEventListener('pause', onPause); return () => { el.removeEventListener('loadedmetadata', onLoadedMetadata); el.removeEventListener('timeupdate', onTimeUpdate); el.removeEventListener('ratechange', onRateChange); el.removeEventListener('seeked', onSeeked); - el.removeEventListener('pause', onPause); }; } @@ -852,65 +846,6 @@ function updateMediaSessionPosition(target: EventTarget | null) { } catch {} } -async function captureFrame(el: HTMLVideoElement): Promise { - if (!el || destroyed.value) { - return ''; - } - if (el.videoWidth === 0 || el.videoHeight === 0) { - return ''; - } - - const width = el.videoWidth; - const height = el.videoHeight; - - try { - if ('OffscreenCanvas' in window) { - const canvas = new (window as any).OffscreenCanvas(width, height); - const ctx = canvas.getContext('2d'); - if (!ctx) { - return ''; - } - ctx.drawImage(el, 0, 0, width, height); - const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.86 }); - return await new Promise((resolve) => { - const fileReader = new FileReader(); - fileReader.onload = () => resolve(String(fileReader.result)); - fileReader.readAsDataURL(blob); - }); - } - - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d'); - if (!ctx) { - return ''; - } - ctx.drawImage(el, 0, 0, width, height); - return canvas.toDataURL('image/jpeg', 0.86); - } catch { - return ''; - } -} - -async function captureFirstFramePoster(el: HTMLVideoElement): Promise { - if (!el || destroyed.value || hasPoster.value) { - return; - } - if (el.videoWidth === 0 || el.videoHeight === 0) { - return; - } - - const dataUrl = await captureFrame(el); - if (!dataUrl) { - return; - } - - poster.value = dataUrl; - hasPoster.value = true; - applyMediaSessionMetadata(); -} - async function restoreDefaultTextTrack() { const el = videoElement.value; if (!el) { @@ -1018,12 +953,15 @@ async function loadPlayerInfo() { poster.value = '/images/placeholder.png'; hasPoster.value = false; - if (props.item.extras?.thumbnail) { - poster.value = `/api/thumbnail?url=${encodePath(props.item.extras.thumbnail)}`; + if (props.item._id && props.item.filename) { + poster.value = `/api/history/${encodeURIComponent(props.item._id)}/thumbnail`; hasPoster.value = true; } else if (response.sidecar?.image?.[0]?.file) { poster.value = makeDownload(config, { filename: response.sidecar.image[0].file }); hasPoster.value = true; + } else if (props.item.extras?.thumbnail) { + poster.value = getRemoteImage(props.item); + hasPoster.value = true; } hasVideo.value = @@ -1096,17 +1034,6 @@ function prepareVideoPlayer() { applyMediaState(videoElement.value); restoreDefaultTextTrack(); - - if (hasVideo.value) { - if ('requestVideoFrameCallback' in videoElement.value) { - (videoElement.value as any).requestVideoFrameCallback(() => - captureFirstFramePoster(videoElement.value!), - ); - } else { - const tryOnce = () => captureFirstFramePoster(videoElement.value!); - (videoElement.value as any).addEventListener('loadeddata', tryOnce, { once: true }); - } - } } async function src_error(event: Event) { @@ -1153,19 +1080,6 @@ function attach_hls(link: string, resume = readResumeState(videoElement.value)) await restoreDefaultTextTrack(); }); - hls.on(Hls.Events.LEVEL_LOADED, () => { - if (videoElement.value) { - if ('requestVideoFrameCallback' in videoElement.value) { - (videoElement.value as any).requestVideoFrameCallback(() => - captureFirstFramePoster(videoElement.value!), - ); - } else { - const once = () => captureFirstFramePoster(videoElement.value!); - (videoElement.value as any).addEventListener('loadeddata', once, { once: true }); - } - } - }); - hls.loadSource(link); hls.attachMedia(videoElement.value); usingHls.value = true; diff --git a/ui/app/pages/history.vue b/ui/app/pages/history.vue index 52381444..7be5694c 100644 --- a/ui/app/pages/history.vue +++ b/ui/app/pages/history.vue @@ -240,6 +240,7 @@ v-if="showThumbnails && getListImage(item)" :src="getListImage(item)" class="max-h-56 w-full rounded-md object-cover" + @error="onImgError($event, item)" />
@@ -486,7 +487,7 @@ v-if="getGridImage(item)" :src="getGridImage(item)" @load="pImg" - @error="onImgError" + @error="onImgError($event, item)" /> @@ -496,7 +497,7 @@ v-if="getGridImage(item)" :src="getGridImage(item)" @load="pImg" - @error="onImgError" + @error="onImgError($event, item)" /> @@ -778,7 +779,8 @@ import { deepIncludes, formatBytes, formatTime, - getImage, + getHistoryImage, + getRemoteImage, getPath, isDownloadSkipped, makeDownload, @@ -977,9 +979,8 @@ const toggleMasterSelection = (): void => { }; const getItemPath = (item: StoreItem): string => getPath(config.app.download_path, item) || ''; -const getListImage = (item: StoreItem): string => - getImage(config.app.download_path, item, false) || ''; -const getGridImage = (item: StoreItem): string => getImage(config.app.download_path, item) || ''; +const getListImage = (item: StoreItem): string => getHistoryImage(item, false) || ''; +const getGridImage = (item: StoreItem): string => getHistoryImage(item) || ''; const showRetryAction = (item: StoreItem): boolean => !item.filename && !isDownloadSkipped(item); const hasIncomplete = computed(() => historyItems.value.some((item) => item.status !== 'finished')); @@ -1395,13 +1396,20 @@ const pImg = (event: Event): void => { } }; -const onImgError = (event: Event): void => { +const onImgError = (event: Event, item: StoreItem): void => { const target = event.target as HTMLImageElement; + const fallback = item ? getRemoteImage(item, false) || '' : ''; + const currentSrc = target.getAttribute('src') || ''; if (target.src.endsWith('/images/placeholder.png')) { return; } + if (fallback && currentSrc !== uri(fallback)) { + target.src = uri(fallback); + return; + } + target.src = '/images/placeholder.png'; }; diff --git a/ui/app/utils/index.ts b/ui/app/utils/index.ts index ccfd9538..8b846f47 100644 --- a/ui/app/utils/index.ts +++ b/ui/app/utils/index.ts @@ -871,6 +871,22 @@ const getPath = (basePath: string, item: StoreItem): string => { ); }; +const getRemoteImage = (item: StoreItem, fallback: boolean = true): string => { + if (item?.extras?.thumbnail) { + return uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail)); + } + + return fallback ? uri('/images/placeholder.png') : ''; +}; + +const getHistoryImage = (item: StoreItem, fallback: boolean = true): string => { + if (item._id && item.filename) { + return uri(`/api/history/${encodeURIComponent(item._id)}/thumbnail`); + } + + return getRemoteImage(item, fallback); +}; + const getImage = (basePath: string, item: StoreItem, fallback: boolean = true): string => { if (item.sidecar?.image && item.sidecar.image.length > 0) { return uri( @@ -878,11 +894,7 @@ const getImage = (basePath: string, item: StoreItem, fallback: boolean = true): ); } - if (item?.extras?.thumbnail) { - return uri('/api/thumbnail?id=' + item._id + '&url=' + encodePath(item.extras.thumbnail)); - } - - return fallback ? uri('/images/placeholder.png') : ''; + return getRemoteImage(item, fallback); }; const parse_list_response = async (json: unknown): Promise> => { @@ -1028,6 +1040,8 @@ export { deepIncludes, getPath, getImage, + getHistoryImage, + getRemoteImage, parse_list_response, parse_api_response, parse_api_error,