Merge pull request #604 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled

Refactor: use item guid as thumbnail name for easier cleanup
This commit is contained in:
Abdulmohsen 2026-05-15 19:40:35 +03:00 committed by GitHub
commit f65cdddbfe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 476 additions and 157 deletions

View file

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import logging import logging
import os import os
import subprocess import subprocess
@ -12,7 +11,7 @@ from app.library.cache import Cache
from app.library.config import Config from app.library.config import Config
from app.library.Utils import FILES_TYPE, get_file_sidecar from app.library.Utils import FILES_TYPE, get_file_sidecar
LOG: logging.Logger = logging.getLogger("streaming.thumbnail") LOG: logging.Logger = logging.getLogger("player.thumbnail")
IMAGE_TYPES: tuple[str, ...] = (".jpg", ".jpeg", ".png", ".webp") IMAGE_TYPES: tuple[str, ...] = (".jpg", ".jpeg", ".png", ".webp")
FOLDER_IMAGE_ORDER: tuple[str, ...] = ("thumbnail", "poster", "artwork", "cover", "fanart") FOLDER_IMAGE_ORDER: tuple[str, ...] = ("thumbnail", "poster", "artwork", "cover", "fanart")
@ -77,12 +76,6 @@ def pick_local_thumb(media_file: Path) -> Path | None:
return None 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: def _seek_seconds(ff_info) -> float | None:
duration: float = 0.0 duration: float = 0.0
try: try:
@ -198,20 +191,21 @@ async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None:
raise OSError(last_error) raise OSError(last_error)
async def ensure_thumb(media_file: Path, cache_root: Path) -> Path | None: async def ensure_thumb(media_file: Path, cache_root: Path, item_id: str | None = None) -> Path | None:
config: Config = Config.get_instance() config: Config = Config.get_instance()
cache: Cache = Cache.get_instance() cache: Cache = Cache.get_instance()
if bool(config.thumb_generate) is not True: if bool(config.thumb_generate) is not True:
return None return None
key: str = _cache_key(media_file) sidecar: bool = bool(config.thumb_sidecar)
cache_file: Path = ( if not sidecar and not item_id:
media_file.with_name(f"{media_file.name}.thumb.jpg") msg = "item_id is required when thumbnail sidecar is disabled."
if bool(config.thumb_sidecar) raise ValueError(msg)
else cache_root / f"{key}.jpg"
)
miss_key: str = f"thumbnail-miss:{key}" thumb_id: str = str(media_file.resolve()) if sidecar else str(item_id)
cache_file: Path = media_file.with_name(f"{media_file.name}.jpg") if sidecar else cache_root / f"{item_id}.jpg"
miss_key: str = f"thumbnail-miss:{thumb_id}"
if cache_file.exists() and cache_file.stat().st_size > 0: if cache_file.exists() and cache_file.stat().st_size > 0:
cache.delete(miss_key) cache.delete(miss_key)
@ -228,12 +222,12 @@ async def ensure_thumb(media_file: Path, cache_root: Path) -> Path | None:
if cache.has(miss_key): if cache.has(miss_key):
return None return None
task = _IN_PROCESS.get(key) task = _IN_PROCESS.get(thumb_id)
if task is not None and not task.done(): if task is not None and not task.done():
LOG.debug(f"Waiting for thumbnail generation for '{media_file}'.") LOG.debug(f"Waiting for thumbnail generation for '{media_file}'.")
else: else:
task = asyncio.create_task(_run_ffmpeg(media_file, cache_file), name=f"thumb-{key[:8]}") task = asyncio.create_task(_run_ffmpeg(media_file, cache_file), name=f"thumb-{item_id or media_file.stem}")
_IN_PROCESS[key] = task _IN_PROCESS[thumb_id] = task
LOG.debug(f"Starting thumbnail generation task for '{media_file}' -> '{cache_file}'.") LOG.debug(f"Starting thumbnail generation task for '{media_file}' -> '{cache_file}'.")
try: try:
@ -248,6 +242,6 @@ async def ensure_thumb(media_file: Path, cache_root: Path) -> Path | None:
raise raise
finally: finally:
async with _LOCK: async with _LOCK:
current = _IN_PROCESS.get(key) current = _IN_PROCESS.get(thumb_id)
if current is task and task.done(): if current is task and task.done():
_IN_PROCESS.pop(key, None) _IN_PROCESS.pop(thumb_id, None)

View file

@ -30,11 +30,13 @@ async def test_singleflight(monkeypatch: pytest.MonkeyPatch) -> None:
from app.features.streaming.library import thumbnail from app.features.streaming.library import thumbnail
thumbnail._IN_PROCESS.clear() thumbnail._IN_PROCESS.clear()
thumbnail.Cache.get_instance().clear()
with temporary_test_dir("thumb-singleflight") as temp_dir: with temporary_test_dir("thumb-singleflight") as temp_dir:
media = temp_dir / "video.mp4" media = temp_dir / "video.mp4"
cache_root = temp_dir / "cache" cache_root = temp_dir / "cache"
media.write_text("video") media.write_text("video")
item_id = "item-1"
calls = {"count": 0} calls = {"count": 0}
@ -48,8 +50,8 @@ async def test_singleflight(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg) monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg)
first, second = await asyncio.gather( first, second = await asyncio.gather(
thumbnail.ensure_thumb(media, cache_root), thumbnail.ensure_thumb(media, cache_root, item_id=item_id),
thumbnail.ensure_thumb(media, cache_root), thumbnail.ensure_thumb(media, cache_root, item_id=item_id),
) )
assert first == second assert first == second
@ -63,13 +65,15 @@ async def test_cache_hit(monkeypatch: pytest.MonkeyPatch) -> None:
from app.features.streaming.library import thumbnail from app.features.streaming.library import thumbnail
thumbnail._IN_PROCESS.clear() thumbnail._IN_PROCESS.clear()
thumbnail.Cache.get_instance().clear()
with temporary_test_dir("thumb-cache") as temp_dir: with temporary_test_dir("thumb-cache") as temp_dir:
media = temp_dir / "video.mp4" media = temp_dir / "video.mp4"
cache_root = temp_dir / "cache" cache_root = temp_dir / "cache"
media.write_text("video") media.write_text("video")
item_id = "item-1"
cache_file = cache_root / f"{thumbnail._cache_key(media)}.jpg" cache_file = cache_root / f"{item_id}.jpg"
cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text("image") cache_file.write_text("image")
@ -78,7 +82,7 @@ async def test_cache_hit(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg) monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg)
result = await thumbnail.ensure_thumb(media, cache_root) result = await thumbnail.ensure_thumb(media, cache_root, item_id=item_id)
assert result == cache_file assert result == cache_file
@ -88,6 +92,7 @@ async def test_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
from app.features.streaming.library import thumbnail from app.features.streaming.library import thumbnail
thumbnail._IN_PROCESS.clear() thumbnail._IN_PROCESS.clear()
thumbnail.Cache.get_instance().clear()
with temporary_test_dir("thumb-disabled") as temp_dir: with temporary_test_dir("thumb-disabled") as temp_dir:
media = temp_dir / "video.mp4" media = temp_dir / "video.mp4"
@ -99,7 +104,7 @@ async def test_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
staticmethod(lambda: type("Cfg", (), {"thumb_generate": False, "thumb_sidecar": False})()), staticmethod(lambda: type("Cfg", (), {"thumb_generate": False, "thumb_sidecar": False})()),
) )
result = await thumbnail.ensure_thumb(media, temp_dir / "cache") result = await thumbnail.ensure_thumb(media, temp_dir / "cache", item_id="item-1")
assert result is None assert result is None
@ -111,12 +116,13 @@ async def test_sidecar_mode(monkeypatch: pytest.MonkeyPatch) -> None:
thumbnail._IN_PROCESS.clear() thumbnail._IN_PROCESS.clear()
thumbnail._SEM = None thumbnail._SEM = None
thumbnail._SEM_LIMIT = None thumbnail._SEM_LIMIT = None
thumbnail.Cache.get_instance().clear()
with temporary_test_dir("thumb-sidecar") as temp_dir: with temporary_test_dir("thumb-sidecar") as temp_dir:
media = temp_dir / "video.mp4" media = temp_dir / "video.mp4"
media.write_text("video") media.write_text("video")
sidecar = media.with_name(f"{media.name}.thumb.jpg") sidecar = media.with_name(f"{media.name}.jpg")
monkeypatch.setattr( monkeypatch.setattr(
thumbnail.Config, thumbnail.Config,
@ -152,6 +158,7 @@ async def test_miss_cache(monkeypatch: pytest.MonkeyPatch) -> None:
with temporary_test_dir("thumb-miss") as temp_dir: with temporary_test_dir("thumb-miss") as temp_dir:
media = temp_dir / "video.mp4" media = temp_dir / "video.mp4"
media.write_text("video") media.write_text("video")
item_id = "item-1"
monkeypatch.setattr( monkeypatch.setattr(
thumbnail.Config, thumbnail.Config,
@ -173,8 +180,8 @@ async def test_miss_cache(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg) monkeypatch.setattr(thumbnail, "_run_ffmpeg", fake_run_ffmpeg)
first = await thumbnail.ensure_thumb(media, temp_dir / "cache") first = await thumbnail.ensure_thumb(media, temp_dir / "cache", item_id=item_id)
second = await thumbnail.ensure_thumb(media, temp_dir / "cache") second = await thumbnail.ensure_thumb(media, temp_dir / "cache", item_id=item_id)
assert first is None assert first is None
assert second is None assert second is None
@ -219,6 +226,30 @@ def test_seek_bounds() -> None:
assert _seek_seconds(ff_info) == 3.0 assert _seek_seconds(ff_info) == 3.0
@pytest.mark.asyncio
async def test_cache_name_uses_item_id(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-id-name") as temp_dir:
media = temp_dir / "video.mp4"
cache_root = temp_dir / "cache"
media.write_text("video")
async def fake_run_ffmpeg(_file: Path, output_file: Path) -> Path:
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)
result = await thumbnail.ensure_thumb(media, cache_root, item_id="item-1")
assert result == cache_root / "item-1.jpg"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_retry_no_seek(monkeypatch: pytest.MonkeyPatch) -> None: async def test_retry_no_seek(monkeypatch: pytest.MonkeyPatch) -> None:
from app.features.streaming.library.ffprobe import FFProbeResult from app.features.streaming.library.ffprobe import FFProbeResult
@ -226,7 +257,7 @@ async def test_retry_no_seek(monkeypatch: pytest.MonkeyPatch) -> None:
with temporary_test_dir("thumb-retry") as temp_dir: with temporary_test_dir("thumb-retry") as temp_dir:
media = temp_dir / "video.mp4" media = temp_dir / "video.mp4"
output = temp_dir / "thumb.jpg" output = temp_dir / ".jpg"
media.write_text("video") media.write_text("video")
ff_info = FFProbeResult() ff_info = FFProbeResult()

View file

@ -266,7 +266,12 @@ def extract_info_sync(
params["verbose"] = True params["verbose"] = True
params.pop("quiet", None) params.pop("quiet", None)
log_wrapper = LogWrapper() suppress: tuple[str, ...] = ()
patterns = kwargs.get("suppress_logs")
if isinstance(patterns, list | tuple):
suppress = tuple(value for value in patterns if isinstance(value, str) and value)
log_wrapper = LogWrapper(suppress=suppress)
id_dict: dict[str, str | None] = get_archive_id(url=url) id_dict: dict[str, str | None] = get_archive_id(url=url)
archive_id: str | None = f".{id_dict['id']}" if id_dict.get("id") else None archive_id: str | None = f".{id_dict['id']}" if id_dict.get("id") else None
logger_name: str = f"yt-dlp{archive_id or '.extract_info'}" logger_name: str = f"yt-dlp{archive_id or '.extract_info'}"

View file

@ -86,8 +86,9 @@ class LogTarget:
class LogWrapper: class LogWrapper:
def __init__(self): def __init__(self, suppress: list[str] | tuple[str, ...] | None = None):
self.targets: list[LogTarget] = [] self.targets: list[LogTarget] = []
self.suppress: tuple[str, ...] = tuple(value for value in (suppress or ()) if isinstance(value, str) and value)
def add_target(self, target: logging.Logger | Callable, level: int = logging.DEBUG, name: str | None = None): def add_target(self, target: logging.Logger | Callable, level: int = logging.DEBUG, name: str | None = None):
""" """
@ -118,7 +119,13 @@ class LogWrapper:
def has_targets(self): def has_targets(self):
return len(self.targets) > 0 return len(self.targets) > 0
def _skip(self, msg: Any) -> bool:
return isinstance(msg, str) and any(value in msg for value in self.suppress)
def _log(self, level, msg, *args, **kwargs): def _log(self, level, msg, *args, **kwargs):
if self._skip(msg):
return
for target in self.targets: for target in self.targets:
if level < target.level: if level < target.level:
continue continue

View file

@ -51,6 +51,14 @@ def _get_ignored_conditions(extras: dict | None) -> list[str]:
return ignored return ignored
def _task_ignored_logs(item: "Item") -> list[str] | None:
extras = item.extras if isinstance(item.extras, dict) else {}
if not extras.get("source_handler"):
return None
return ["has already been recorded in the archive"]
async def add_item( async def add_item(
queue: "DownloadQueue", queue: "DownloadQueue",
entry: dict, entry: dict,
@ -209,6 +217,7 @@ async def add(
follow_redirect=True, follow_redirect=True,
capture_logs=logging.WARNING, capture_logs=logging.WARNING,
budget_sleep=True, budget_sleep=True,
suppress_logs=_task_ignored_logs(item),
) )
if not entry: if not entry:

View file

@ -2,6 +2,7 @@
import logging import logging
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from app.library.ag_utils import ag from app.library.ag_utils import ag
@ -171,3 +172,36 @@ async def delete_old_history(queue: "DownloadQueue") -> None:
if titles: if titles:
LOG.info(f"Automatically cleared '{', '.join(titles)}' from download history due to age.") LOG.info(f"Automatically cleared '{', '.join(titles)}' from download history due to age.")
async def cleanup_thumbnails(queue: "DownloadQueue") -> None:
"""
Remove cached generated thumbnails whose history item no longer exists.
Args:
queue: DownloadQueue instance.
"""
if queue.config.thumb_sidecar:
return
cache_root = Path(queue.config.temp_path) / "thumbnails"
if not cache_root.exists() or not cache_root.is_dir():
return
removed = 0
for thumb in cache_root.glob("*.jpg"):
if not thumb.is_file():
continue
if await queue.done.get_by_id(thumb.stem):
continue
try:
thumb.unlink(missing_ok=True)
removed += 1
except OSError as exc:
LOG.warning(f"Failed to remove orphaned thumbnail '{thumb}'. {exc!s}")
if removed > 0:
LOG.info(f"Removed '{removed}' orphaned cached thumbnails.")

View file

@ -18,7 +18,7 @@ from app.library.Utils import calc_download_path
from .core import Download from .core import Download
from .item_adder import add as add_impl from .item_adder import add as add_impl
from .monitors import check_for_stale, check_live, delete_old_history from .monitors import check_for_stale, check_live, cleanup_thumbnails, delete_old_history
from .pool_manager import PoolManager from .pool_manager import PoolManager
if TYPE_CHECKING: if TYPE_CHECKING:
@ -67,6 +67,12 @@ class DownloadQueue(metaclass=Singleton):
id=check_live.__name__, id=check_live.__name__,
) )
Scheduler.get_instance().add(
timer="10 */1 * * *",
func=functools.partial(cleanup_thumbnails, self),
id=cleanup_thumbnails.__name__,
)
if self.config.auto_clear_history_days > 0: if self.config.auto_clear_history_days > 0:
Scheduler.get_instance().add( Scheduler.get_instance().add(
timer="8 */1 * * *", timer="8 */1 * * *",

View file

@ -312,8 +312,8 @@ async def item_thumbnail(request: Request, queue: DownloadQueue, config: Config)
if not (id := request.match_info.get("id")): if not (id := request.match_info.get("id")):
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
miss_key = f"history-thumb-missing:{id}" miss_key: str = f"thumb_missing:{id}"
cache = Cache.get_instance() cache: Cache = Cache.get_instance()
item: Download | None = await queue.done.get_by_id(id) item: Download | None = await queue.done.get_by_id(id)
if not item or not item.info: if not item or not item.info:
@ -322,19 +322,21 @@ async def item_thumbnail(request: Request, queue: DownloadQueue, config: Config)
if cache.has(miss_key): if cache.has(miss_key):
return web.json_response(data={"error": "thumbnail not found."}, status=web.HTTPNotFound.status_code) 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)) filepath: Path | None = item.info.get_file(download_path=Path(config.download_path))
if not filepath or not filepath.exists() or not filepath.is_file(): if not filepath or not filepath.exists() or not filepath.is_file():
cache.set(miss_key, value=True, ttl=30.0) cache.set(miss_key, value=True, ttl=30.0)
return web.json_response(data={"error": "thumbnail not found."}, status=web.HTTPNotFound.status_code) return web.json_response(data={"error": "thumbnail not found."}, status=web.HTTPNotFound.status_code)
cache.delete(miss_key) cache.delete(miss_key)
local_thumb = pick_local_thumb(filepath) local_thumb: Path | None = pick_local_thumb(filepath)
if local_thumb and local_thumb.exists() and local_thumb.is_file(): if local_thumb and local_thumb.exists() and local_thumb.is_file():
return web.FileResponse(path=str(local_thumb)) return web.FileResponse(path=str(local_thumb))
try: try:
generated = await ensure_thumb(filepath, Path(config.temp_path) / "thumbnails") generated: Path | None = await ensure_thumb(
filepath, Path(config.temp_path) / "thumbnails", item_id=item.info._id
)
except OSError as e: except OSError as e:
LOG.warning(f"Failed to generate thumbnail for '{filepath}'. {e!s}") LOG.warning(f"Failed to generate thumbnail for '{filepath}'. {e!s}")
generated = None generated = None

View file

@ -3,6 +3,7 @@ import os
import signal import signal
from multiprocessing.reduction import ForkingPickler from multiprocessing.reduction import ForkingPickler
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
@ -1204,6 +1205,28 @@ class TestStatusTracker:
class TestQueueManager: class TestQueueManager:
@pytest.mark.asyncio
async def test_cleanup_thumbnails(self, tmp_path: Path) -> None:
from app.library.downloads.monitors import cleanup_thumbnails
queue_manager = object.__new__(DownloadQueue)
queue_manager.config = SimpleNamespace(temp_path=str(tmp_path), thumb_sidecar=False)
queue_manager.done = Mock()
cache_root = tmp_path / "thumbnails"
cache_root.mkdir(parents=True, exist_ok=True)
keep = cache_root / "keep-id.jpg"
drop = cache_root / "drop-id.jpg"
keep.write_text("keep")
drop.write_text("drop")
queue_manager.done.get_by_id = AsyncMock(side_effect=lambda item_id: item_id == "keep-id")
await cleanup_thumbnails(queue_manager)
assert keep.exists()
assert not drop.exists()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cancel_running_live_item_defers_close(self) -> None: async def test_cancel_running_live_item_defers_close(self) -> None:
queue_manager = object.__new__(DownloadQueue) queue_manager = object.__new__(DownloadQueue)

View file

@ -220,11 +220,11 @@ async def test_item_thumbnail_generated(monkeypatch: pytest.MonkeyPatch) -> None
media = temp_dir / "video.mp4" media = temp_dir / "video.mp4"
media.write_text("video") media.write_text("video")
cache_dir = temp_dir / "tmp" cache_dir = temp_dir / "tmp"
generated = cache_dir / "thumbnails" / "gen.jpg" generated = cache_dir / "thumbnails" / "item-1.jpg"
request = _FakeRequest() request = _FakeRequest()
request.match_info["id"] = "item-1"
item = _make_download(filename="video.mp4", download_dir=str(temp_dir)) item = _make_download(filename="video.mp4", download_dir=str(temp_dir))
request.match_info["id"] = item.info._id
queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item))) queue = SimpleNamespace(done=SimpleNamespace(get_by_id=AsyncMock(return_value=item)))
config = SimpleNamespace(download_path=str(temp_dir), temp_path=str(cache_dir)) config = SimpleNamespace(download_path=str(temp_dir), temp_path=str(cache_dir))
@ -232,8 +232,9 @@ async def test_item_thumbnail_generated(monkeypatch: pytest.MonkeyPatch) -> None
called = {"count": 0} called = {"count": 0}
async def fake_ensure_thumb(_file: Path, _cache_root: Path) -> Path: async def fake_ensure_thumb(_file: Path, _cache_root: Path, item_id: str | None = None) -> Path:
called["count"] += 1 called["count"] += 1
assert item_id == request.match_info["id"]
generated.parent.mkdir(parents=True, exist_ok=True) generated.parent.mkdir(parents=True, exist_ok=True)
generated.write_text("generated") generated.write_text("generated")
return generated return generated

View file

@ -618,14 +618,14 @@
<UModal <UModal
v-if="videoItem" v-if="videoItem"
:open="Boolean(videoItem)" :open="videoOpen"
title="Video" title="Video"
:dismissible="true" :dismissible="true"
:ui="{ :ui="{
content: lightsOut ? 'w-full sm:max-w-5xl shadow-2xl' : 'w-full sm:max-w-5xl', content: lightsOut ? 'w-full sm:max-w-5xl shadow-2xl' : 'w-full sm:max-w-5xl',
body: 'p-0', body: 'p-0',
}" }"
@update:open="(open) => !open && closePlayer()" @update:open="handleVideoOpenChange"
> >
<template #body> <template #body>
<VideoPlayer <VideoPlayer
@ -635,7 +635,7 @@
:isControls="true" :isControls="true"
:item="videoItem" :item="videoItem"
class="w-full" class="w-full"
@closeModel="closePlayer" @closeModel="() => void requestCloseVideo()"
@error="async (error: string) => await box.alert(error)" @error="async (error: string) => await box.alert(error)"
@playback-state-change="(playing: boolean) => (playingNow = playing)" @playback-state-change="(playing: boolean) => (playingNow = playing)"
/> />
@ -667,6 +667,7 @@ import { useStorage } from '@vueuse/core';
import type { item_request } from '~/types/item'; import type { item_request } from '~/types/item';
import type { ItemStatus, StoreItem } from '~/types/store'; import type { ItemStatus, StoreItem } from '~/types/store';
import { useConfirm } from '~/composables/useConfirm'; import { useConfirm } from '~/composables/useConfirm';
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
import { useHistoryState } from '~/composables/useHistoryState'; import { useHistoryState } from '~/composables/useHistoryState';
import { useMediaQuery } from '~/composables/useMediaQuery'; import { useMediaQuery } from '~/composables/useMediaQuery';
import { usePresetOptions } from '~/composables/usePresetOptions'; import { usePresetOptions } from '~/composables/usePresetOptions';
@ -733,6 +734,16 @@ const embedUrl = ref('');
const videoItem = ref<StoreItem | null>(null); const videoItem = ref<StoreItem | null>(null);
const playingNow = ref(false); const playingNow = ref(false);
const autoRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null); const autoRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null);
const videoOpen = computed<boolean>({
get: () => Boolean(videoItem.value),
set: (value: boolean) => {
if (value) {
return;
}
closePlayer();
},
});
const formUrl = ref(''); const formUrl = ref('');
const formPreset = ref(app.value.default_preset || ''); const formPreset = ref(app.value.default_preset || '');
@ -1050,6 +1061,18 @@ const closePlayer = (): void => {
videoItem.value = null; videoItem.value = null;
}; };
const { handleOpenChange: handleVideoOpenChange, requestClose: requestCloseVideo } =
useDirtyCloseGuard(videoOpen, {
dirty: playingNow,
title: 'Close player?',
message: 'Playback is active. Do you want to close the player?',
confirmText: 'Close player',
cancelText: 'Keep playing',
onDiscard: async () => {
closePlayer();
},
});
const getDescription = (item: StoreItem): string => { const getDescription = (item: StoreItem): string => {
const direct = (item.description ?? '').toString().trim(); const direct = (item.description ?? '').toString().trim();
if (direct) { if (direct) {

View file

@ -346,7 +346,7 @@ import {
requestElementFullscreen, requestElementFullscreen,
} from '~/utils/fullscreen'; } from '~/utils/fullscreen';
import { clampMediaVolume } from '~/utils/keyboard'; import { clampMediaVolume } from '~/utils/keyboard';
import { readResumeState, resumeMedia } from '~/utils/media'; import { clear, clampResumeTime, nearEnd, read, save } from '~/utils/media';
import { nextTapVisible } from '~/utils/playerControls'; import { nextTapVisible } from '~/utils/playerControls';
import type { StoreItem } from '~/types/store'; import type { StoreItem } from '~/types/store';
@ -407,10 +407,13 @@ let controlsHideTimeout = 0;
let pendingVideoClickTimeout = 0; let pendingVideoClickTimeout = 0;
let unbindMediaSession: null | (() => void) = null; let unbindMediaSession: null | (() => void) = null;
let hls: Hls | null = null; let hls: Hls | null = null;
let pendingResume: null | { time: number; shouldPlay: boolean } = null; let pendingSeek: number | null = null;
let pendingPlay = false;
let lastSaveAt = 0;
const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent); const isApple = /(iPhone|iPod|iPad).*AppleWebKit/i.test(navigator.userAgent);
const mediaFile = computed(() => props.item.filename || ''); const mediaFile = computed(() => props.item.filename || '');
const id = computed(() => props.item._id || '');
const subtitleManifestUrl = computed(() => currentPlaybackUrl('api/player/subtitles/manifest')); const subtitleManifestUrl = computed(() => currentPlaybackUrl('api/player/subtitles/manifest'));
const canPlay = computed(() => Boolean(mediaFile.value && !loadingError.value)); const canPlay = computed(() => Boolean(mediaFile.value && !loadingError.value));
const shouldRender = computed(() => active.value && !loading.value); const shouldRender = computed(() => active.value && !loading.value);
@ -525,12 +528,17 @@ function handleVideoLoadedMetadata() {
updateMediaSessionPosition(videoElement.value); updateMediaSessionPosition(videoElement.value);
} }
if (pendingResume) { if (pendingSeek !== null) {
void resumeMedia(videoElement.value, pendingResume).finally(() => { const time = pendingSeek;
pendingResume = null; const play = pendingPlay;
pendingSeek = null;
pendingPlay = false;
void restoreSwitch(time, play).finally(() => {
syncVideoState(); syncVideoState();
showControls(); showControls();
}); });
} else {
restoreStoredProgress();
} }
} }
@ -539,6 +547,8 @@ function handleVideoTimeUpdate() {
if (videoElement.value) { if (videoElement.value) {
updateMediaSessionPosition(videoElement.value); updateMediaSessionPosition(videoElement.value);
} }
persistProgress(false);
} }
function handleVideoPlay() { function handleVideoPlay() {
@ -552,6 +562,7 @@ function handleVideoPause() {
syncVideoState(); syncVideoState();
clearControlsHideTimeout(); clearControlsHideTimeout();
controlsVisible.value = true; controlsVisible.value = true;
persistProgress(true);
emitter('playback-state-change', false); emitter('playback-state-change', false);
} }
@ -624,6 +635,85 @@ function handleMediaVolumeChange(event: Event) {
updateMediaSessionPosition(target); updateMediaSessionPosition(target);
} }
function restoreStoredProgress() {
if (!id.value || !videoElement.value) {
return;
}
const saved = read(id.value);
if (saved <= 0) {
return;
}
void seekTo(saved).finally(() => {
syncVideoState();
});
}
function readSwitchTime() {
const video = videoElement.value;
if (!video) {
return 0;
}
return Number.isFinite(video.currentTime) && video.currentTime > 0 ? video.currentTime : 0;
}
function isPlaying() {
const video = videoElement.value;
return Boolean(video && !video.paused && !video.ended);
}
async function seekTo(time: number) {
const video = videoElement.value;
if (!video) {
return;
}
const nextTime = clampResumeTime(video, time);
if (nextTime > 0) {
try {
video.currentTime = nextTime;
} catch {}
}
}
async function restoreSwitch(time: number, play: boolean) {
await seekTo(time);
if (play) {
try {
await videoElement.value?.play();
} catch {}
}
}
function persistProgress(force: boolean) {
const video = videoElement.value;
if (!id.value || !video) {
return;
}
if (nearEnd(video)) {
clear(id.value);
lastSaveAt = 0;
return;
}
const time = Number.isFinite(video.currentTime) && video.currentTime > 0 ? video.currentTime : 0;
if (time <= 0) {
return;
}
const now = Date.now();
if (!force && now - lastSaveAt < 1000) {
return;
}
save(id.value, time);
lastSaveAt = now;
}
function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
if (!playerContainer.value || isTouchDevice.value) { if (!playerContainer.value || isTouchDevice.value) {
return; return;
@ -721,6 +811,12 @@ function syncVideoState() {
duration.value = nextDuration; duration.value = nextDuration;
currentTime.value = nextTime; currentTime.value = nextTime;
paused.value = video.paused; paused.value = video.paused;
if (video.ended || nearEnd(video)) {
clear(id.value);
lastSaveAt = 0;
}
emitter('playback-state-change', !video.paused); emitter('playback-state-change', !video.paused);
} }
@ -1050,12 +1146,17 @@ async function src_error(event: Event) {
attach_hls(currentPlaybackUrl('m3u8', true)); attach_hls(currentPlaybackUrl('m3u8', true));
} }
function attach_hls(link: string, resume = readResumeState(videoElement.value)) { function attach_hls(
link: string,
time = pendingSeek ?? readSwitchTime(),
play = pendingPlay || isPlaying(),
) {
if (!videoElement.value) { if (!videoElement.value) {
return; return;
} }
pendingResume = resume; pendingSeek = time;
pendingPlay = play;
if (hls) { if (hls) {
hls.destroy(); hls.destroy();
@ -1160,6 +1261,7 @@ onBeforeUnmount(() => {
} }
if (videoElement.value) { if (videoElement.value) {
persistProgress(true);
destroyed.value = true; destroyed.value = true;
try { try {
videoElement.value.pause(); videoElement.value.pause();

View file

@ -375,13 +375,12 @@ const deleteTask = async (
* @returns Inspect result or null on error * @returns Inspect result or null on error
*/ */
const inspectTaskHandler = async ( const inspectTaskHandler = async (
request: TaskInspectRequest, payload: TaskInspectRequest,
): Promise<TaskInspectResponse | null> => { ): Promise<TaskInspectResponse | null> => {
try { try {
const response = await fetch('/api/tasks/inspect', { const response = await request('/api/tasks/inspect', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
body: JSON.stringify(request),
}); });
const json = await response.json(); const json = await response.json();

View file

@ -492,11 +492,11 @@
<UModal <UModal
v-if="model_item" v-if="model_item"
:open="Boolean(model_item)" :open="previewOpen"
:title="previewTitle" :title="previewTitle"
:dismissible="true" :dismissible="true"
:ui="previewModalUi" :ui="previewModalUi"
@update:open="(open) => !open && closeModel()" @update:open="handlePreviewOpenChange"
> >
<template #body> <template #body>
<VideoPlayer <VideoPlayer
@ -507,7 +507,7 @@
:isControls="true" :isControls="true"
:item="model_item" :item="model_item"
class="w-full" class="w-full"
@closeModel="closeModel" @closeModel="() => void requestClosePreview()"
@playback-state-change="(playing: boolean) => (playingNow = playing)" @playback-state-change="(playing: boolean) => (playingNow = playing)"
/> />
@ -533,6 +533,7 @@
import moment from 'moment'; import moment from 'moment';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import type { DropdownMenuItem } from '@nuxt/ui'; import type { DropdownMenuItem } from '@nuxt/ui';
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
import type { FileItem } from '~/types/filebrowser'; import type { FileItem } from '~/types/filebrowser';
import { formatPageTitle } from '~/utils'; import { formatPageTitle } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation'; import { requirePageShell } from '~/utils/topLevelNavigation';
@ -628,6 +629,16 @@ const isUpdating = ref(false);
const model_item = ref<any | null>(null); const model_item = ref<any | null>(null);
const playingNow = ref(false); const playingNow = ref(false);
const previewOpen = computed<boolean>({
get: () => Boolean(model_item.value),
set: (value: boolean) => {
if (value) {
return;
}
closeModel();
},
});
const previewTitle = computed(() => { const previewTitle = computed(() => {
if (!model_item.value) { if (!model_item.value) {
@ -746,6 +757,18 @@ const closeModel = (): void => {
model_item.value = null; model_item.value = null;
}; };
const { handleOpenChange: handlePreviewOpenChange, requestClose: requestClosePreview } =
useDirtyCloseGuard(previewOpen, {
dirty: computed(() => Boolean(model_item.value?.type === 'video' && playingNow.value)),
title: 'Close player?',
message: 'Playback is active. Do you want to close the player?',
confirmText: 'Close player',
cancelText: 'Keep playing',
onDiscard: async () => {
closeModel();
},
});
const clearFilter = (): void => { const clearFilter = (): void => {
localSearch.value = ''; localSearch.value = '';
show_filter.value = false; show_filter.value = false;

View file

@ -718,11 +718,11 @@
<UModal <UModal
v-if="video_item" v-if="video_item"
:open="Boolean(video_item)" :open="videoOpen"
:dismissible="true" :dismissible="true"
:title="video_item?.title || 'Player'" :title="video_item?.title || 'Player'"
:ui="{ content: lightsOut ? 'sm:max-w-5xl shadow-2xl' : 'sm:max-w-5xl', body: 'p-0' }" :ui="{ content: lightsOut ? 'sm:max-w-5xl shadow-2xl' : 'sm:max-w-5xl', body: 'p-0' }"
@update:open="(open) => !open && closeVideo()" @update:open="handleVideoOpenChange"
> >
<template #body> <template #body>
<VideoPlayer <VideoPlayer
@ -732,7 +732,7 @@
:isControls="true" :isControls="true"
:item="video_item" :item="video_item"
class="w-full" class="w-full"
@closeModel="closeVideo()" @closeModel="() => void requestCloseVideo()"
@error="async (error: string) => await box.alert(error)" @error="async (error: string) => await box.alert(error)"
@playback-state-change="(playing: boolean) => (playingNow = playing)" @playback-state-change="(playing: boolean) => (playingNow = playing)"
/> />
@ -768,6 +768,7 @@ import { toRaw } from 'vue';
import moment from 'moment'; import moment from 'moment';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { useConfirm } from '~/composables/useConfirm'; import { useConfirm } from '~/composables/useConfirm';
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
import { useDialog } from '~/composables/useDialog'; import { useDialog } from '~/composables/useDialog';
import { useAppSocket } from '~/composables/useAppSocket'; import { useAppSocket } from '~/composables/useAppSocket';
import { useExpandableMeta } from '~/composables/useExpandableMeta'; import { useExpandableMeta } from '~/composables/useExpandableMeta';
@ -843,6 +844,16 @@ const contentStyle = computed<'grid' | 'list'>(() =>
); );
const showThumbnails = computed(() => show_thumbnail.value && !hideThumbnail.value); const showThumbnails = computed(() => show_thumbnail.value && !hideThumbnail.value);
const lightsOut = computed(() => Boolean(video_item.value && playingNow.value)); const lightsOut = computed(() => Boolean(video_item.value && playingNow.value));
const videoOpen = computed<boolean>({
get: () => Boolean(video_item.value),
set: (value: boolean) => {
if (value) {
return;
}
closeVideo();
},
});
const paginationInfo = computed(() => ({ const paginationInfo = computed(() => ({
...pagination.value, ...pagination.value,
isLoading: isLoading.value, isLoading: isLoading.value,
@ -894,6 +905,18 @@ const closeVideo = (): void => {
video_item.value = null; video_item.value = null;
}; };
const { handleOpenChange: handleVideoOpenChange, requestClose: requestCloseVideo } =
useDirtyCloseGuard(videoOpen, {
dirty: playingNow,
title: 'Close player?',
message: 'Playback is active. Do you want to close the player?',
confirmText: 'Close player',
cancelText: 'Keep playing',
onDiscard: async () => {
closeVideo();
},
});
const view_info = ( const view_info = (
url: string, url: string,
useUrl: boolean = false, useUrl: boolean = false,

View file

@ -57,11 +57,11 @@
size="sm" size="sm"
icon="i-lucide-wrap-text" icon="i-lucide-wrap-text"
:aria-pressed="textWrap" :aria-pressed="textWrap"
:title="textWrap ? 'Wrap lines enabled' : 'Wrap lines disabled'" :title="textWrap ? 'Text wrap enabled' : 'Text wrap disabled'"
:class="['transition-all', textWrap ? '-translate-y-px ring ring-default shadow-xs' : '']" :class="['transition-all', textWrap ? '-translate-y-px ring ring-default shadow-xs' : '']"
@click="textWrap = !textWrap" @click="textWrap = !textWrap"
> >
Wrap lines Wrap
</UButton> </UButton>
<UInput <UInput

View file

@ -1,6 +1,53 @@
type ResumeState = { import { useLocalCache } from '~/utils/cache';
time: number;
shouldPlay: boolean; const KEY = 'video:';
const cache = useLocalCache();
const read = (id: string | null | undefined): number => {
if (!id) {
return 0;
}
try {
const time = Number(cache.get<number>(`${KEY}${id}`));
return Number.isFinite(time) && time > 0 ? time : 0;
} catch {
return 0;
}
};
const save = (id: string | null | undefined, time: number): void => {
if (!id || !Number.isFinite(time) || time <= 0) {
return;
}
cache.set(`${KEY}${id}`, time, 3600 * 24);
};
const clear = (id: string | null | undefined): void => {
if (!id) {
return;
}
cache.remove(`${KEY}${id}`);
};
const nearEnd = (
target: Pick<HTMLMediaElement, 'currentTime' | 'duration'> | null,
pad: number = 5,
): boolean => {
if (!target) {
return false;
}
const duration = target.duration;
if (!Number.isFinite(duration) || duration <= 0) {
return false;
}
const time =
Number.isFinite(target.currentTime) && target.currentTime > 0 ? target.currentTime : 0;
return duration - time <= pad;
}; };
const clampResumeTime = ( const clampResumeTime = (
@ -31,38 +78,4 @@ const clampResumeTime = (
return time; return time;
}; };
export { clampResumeTime, clear, nearEnd, read, save };
const readResumeState = (target: HTMLMediaElement | null): ResumeState => {
if (!target) {
return { time: 0, shouldPlay: false };
}
const time =
Number.isFinite(target.currentTime) && target.currentTime > 0 ? target.currentTime : 0;
return {
time,
shouldPlay: !target.paused && !target.ended,
};
};
const resumeMedia = async (target: HTMLMediaElement | null, state: ResumeState): Promise<void> => {
if (!target) {
return;
}
const nextTime = clampResumeTime(target, state.time);
if (nextTime > 0) {
try {
target.currentTime = nextTime;
} catch {}
}
if (state.shouldPlay) {
try {
await target.play();
} catch {}
}
};
export { clampResumeTime, readResumeState, resumeMedia };
export type { ResumeState };

View file

@ -426,8 +426,8 @@ describe('useTasks', () => {
}, },
} }
const fetchSpy = spyOn(globalThis, 'fetch') const requestSpy = spyOn(utils, 'request')
fetchSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
ok: true, ok: true,
status: 200, status: 200,
@ -436,14 +436,17 @@ describe('useTasks', () => {
) )
const tasks = useTasks() const tasks = useTasks()
const result = await tasks.inspectTaskHandler({ try {
url: 'https://www.youtube.com/channel/UCtest123', const result = await tasks.inspectTaskHandler({
}) url: 'https://www.youtube.com/channel/UCtest123',
})
expect(result).toEqual(inspectResponse) expect(result).toEqual(inspectResponse)
expect(result?.matched).toBe(true) expect(result?.matched).toBe(true)
expect(result?.handler).toBe('YoutubeHandler') expect(result?.handler).toBe('YoutubeHandler')
fetchSpy.mockRestore() } finally {
requestSpy.mockRestore()
}
}) })
it('handle_unsupported_handler', async () => { it('handle_unsupported_handler', async () => {
@ -455,8 +458,8 @@ describe('useTasks', () => {
metadata: null, metadata: null,
} }
const fetchSpy = spyOn(globalThis, 'fetch') const requestSpy = spyOn(utils, 'request')
fetchSpy.mockResolvedValueOnce( requestSpy.mockResolvedValueOnce(
createMockResponse({ createMockResponse({
ok: true, ok: true,
status: 200, status: 200,
@ -465,27 +468,33 @@ describe('useTasks', () => {
) )
const tasks = useTasks() const tasks = useTasks()
const result = await tasks.inspectTaskHandler({ try {
url: 'https://unsupported.com', const result = await tasks.inspectTaskHandler({
}) url: 'https://unsupported.com',
})
expect(result?.supported).toBe(false) expect(result?.supported).toBe(false)
expect(result?.matched).toBe(false) expect(result?.matched).toBe(false)
fetchSpy.mockRestore() } finally {
requestSpy.mockRestore()
}
}) })
it('store_inspect_error', async () => { it('store_inspect_error', async () => {
const fetchSpy = spyOn(globalThis, 'fetch') const requestSpy = spyOn(utils, 'request')
fetchSpy.mockRejectedValueOnce(new Error('Network error')) requestSpy.mockRejectedValueOnce(new Error('Network error'))
const tasks = useTasks() const tasks = useTasks()
const result = await tasks.inspectTaskHandler({ try {
url: 'invalid', const result = await tasks.inspectTaskHandler({
}) url: 'invalid',
})
expect(result).toBeNull() expect(result).toBeNull()
expect(tasks.lastError.value).toBe('Network error') expect(tasks.lastError.value).toBe('Network error')
fetchSpy.mockRestore() } finally {
requestSpy.mockRestore()
}
}) })
}) })

View file

@ -1,47 +1,62 @@
import { describe, expect, it, mock } from 'bun:test'; import { beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
const store = new Map<string, unknown>();
const cache = {
get: mock(<T>(key: string) => (store.has(key) ? (store.get(key) as T) : null)),
set: mock((key: string, value: unknown) => {
store.set(key, value);
}),
remove: mock((key: string) => {
store.delete(key);
}),
};
mock.module('~/utils/cache', () => ({
useLocalCache: () => cache,
}));
let media: Awaited<typeof import('~/utils/media')>;
beforeAll(async () => {
media = await import('~/utils/media');
});
beforeEach(() => {
store.clear();
cache.get.mockClear();
cache.set.mockClear();
cache.remove.mockClear();
});
describe('media utils', () => { describe('media utils', () => {
it('clamp_seekable_range', async () => { it('clamp_seekable_range', () => {
const { clampResumeTime } = await import('~/utils/media');
const seekable = { const seekable = {
length: 1, length: 1,
start: () => 12, start: () => 12,
end: () => 48, end: () => 48,
} as TimeRanges; } as TimeRanges;
expect(clampResumeTime({ duration: Number.NaN, seekable }, 4)).toBe(12); expect(media.clampResumeTime({ duration: Number.NaN, seekable }, 4)).toBe(12);
expect(clampResumeTime({ duration: Number.NaN, seekable }, 22)).toBe(22); expect(media.clampResumeTime({ duration: Number.NaN, seekable }, 22)).toBe(22);
expect(clampResumeTime({ duration: Number.NaN, seekable }, 60)).toBe(48); expect(media.clampResumeTime({ duration: Number.NaN, seekable }, 60)).toBe(48);
}); });
it('resume_keep_time_and_play', async () => { it('store_resume_state', () => {
const { resumeMedia } = await import('~/utils/media'); media.save('item-1', 42);
expect(media.read('item-1')).toBe(42);
const play = mock(async () => {});
const target = {
currentTime: 0,
duration: 100,
seekable: { length: 0 } as TimeRanges,
play,
} as unknown as HTMLMediaElement;
await resumeMedia(target, { time: 33, shouldPlay: true });
expect(target.currentTime).toBe(33);
expect(play).toHaveBeenCalledTimes(1);
}); });
it('read_resume_state', async () => { it('clear_resume_state', () => {
const { readResumeState } = await import('~/utils/media'); media.save('item-1', 17);
media.clear('item-1');
const target = { expect(cache.remove).toHaveBeenCalledWith('video:item-1');
currentTime: 19, expect(media.read('item-1')).toBe(0);
paused: false, });
ended: false,
} as HTMLMediaElement;
expect(readResumeState(target)).toEqual({ time: 19, shouldPlay: true }); it('clear_resume_near_end', () => {
expect(readResumeState(null)).toEqual({ time: 0, shouldPlay: false }); expect(media.nearEnd({ currentTime: 97, duration: 100 })).toBe(true);
expect(media.nearEnd({ currentTime: 80, duration: 100 })).toBe(false);
}); });
}); });