feat: thumbnail generation

This commit is contained in:
arabcoders 2026-05-14 23:22:27 +03:00
parent 1d52ea61da
commit d9dbbb18ea
11 changed files with 837 additions and 120 deletions

21
API.md
View file

@ -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.

3
FAQ.md
View file

@ -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_<EXTRACTOR_NAME>`.

View file

@ -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)

View file

@ -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

View file

@ -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."

View file

@ -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,

View file

@ -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

View file

@ -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)"
/>
</span>
@ -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)"
/>
<span
@ -465,7 +465,7 @@
:alt="item.title || 'Video thumbnail'"
loading="lazy"
class="aspect-video h-full w-full object-cover"
@error="onImgError"
@error="onImgError($event, item)"
/>
</span>
@ -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)"
/>
<span
@ -676,7 +676,9 @@ import { getEmbedable, isEmbedable } from '~/utils/embedable';
import {
ag,
formatTime,
getHistoryImage,
getImage,
getRemoteImage,
isDownloadSkipped,
makeDownload,
request,
@ -1019,6 +1021,10 @@ const resolveThumbnail = (item: StoreItem): string => {
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';
};

View file

@ -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<string> {
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<string>((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<void> {
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;

View file

@ -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)"
/>
<div
@ -469,7 +470,7 @@
v-if="getGridImage(item)"
:src="getGridImage(item)"
@load="pImg"
@error="onImgError"
@error="onImgError($event, item)"
/>
<img v-else src="/images/placeholder.png" />
</span>
@ -486,7 +487,7 @@
v-if="getGridImage(item)"
:src="getGridImage(item)"
@load="pImg"
@error="onImgError"
@error="onImgError($event, item)"
/>
<img v-else src="/images/placeholder.png" />
</span>
@ -496,7 +497,7 @@
v-if="getGridImage(item)"
:src="getGridImage(item)"
@load="pImg"
@error="onImgError"
@error="onImgError($event, item)"
/>
<img v-else src="/images/placeholder.png" />
</template>
@ -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';
};

View file

@ -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 <T>(json: unknown): Promise<Paginated<T>> => {
@ -1028,6 +1040,8 @@ export {
deepIncludes,
getPath,
getImage,
getHistoryImage,
getRemoteImage,
parse_list_response,
parse_api_response,
parse_api_error,