feat: thumbnail generation
This commit is contained in:
parent
1d52ea61da
commit
d9dbbb18ea
11 changed files with 837 additions and 120 deletions
21
API.md
21
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.
|
||||
|
||||
|
|
|
|||
3
FAQ.md
3
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_<EXTRACTOR_NAME>`.
|
||||
|
|
|
|||
253
app/features/streaming/library/thumbnail.py
Normal file
253
app/features/streaming/library/thumbnail.py
Normal 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)
|
||||
326
app/features/streaming/tests/test_thumbnail.py
Normal file
326
app/features/streaming/tests/test_thumbnail.py
Normal 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
|
||||
|
|
@ -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."
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue