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
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:
commit
f65cdddbfe
19 changed files with 476 additions and 157 deletions
|
|
@ -1,7 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
|
@ -12,7 +11,7 @@ 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")
|
||||
LOG: logging.Logger = logging.getLogger("player.thumbnail")
|
||||
|
||||
IMAGE_TYPES: tuple[str, ...] = (".jpg", ".jpeg", ".png", ".webp")
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
|
|
@ -198,20 +191,21 @@ async def _run_ffmpeg(media_file: Path, output_file: Path) -> Path | None:
|
|||
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()
|
||||
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"
|
||||
)
|
||||
sidecar: bool = bool(config.thumb_sidecar)
|
||||
if not sidecar and not item_id:
|
||||
msg = "item_id is required when thumbnail sidecar is disabled."
|
||||
raise ValueError(msg)
|
||||
|
||||
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:
|
||||
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):
|
||||
return None
|
||||
|
||||
task = _IN_PROCESS.get(key)
|
||||
task = _IN_PROCESS.get(thumb_id)
|
||||
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
|
||||
task = asyncio.create_task(_run_ffmpeg(media_file, cache_file), name=f"thumb-{item_id or media_file.stem}")
|
||||
_IN_PROCESS[thumb_id] = task
|
||||
LOG.debug(f"Starting thumbnail generation task for '{media_file}' -> '{cache_file}'.")
|
||||
|
||||
try:
|
||||
|
|
@ -248,6 +242,6 @@ async def ensure_thumb(media_file: Path, cache_root: Path) -> Path | None:
|
|||
raise
|
||||
finally:
|
||||
async with _LOCK:
|
||||
current = _IN_PROCESS.get(key)
|
||||
current = _IN_PROCESS.get(thumb_id)
|
||||
if current is task and task.done():
|
||||
_IN_PROCESS.pop(key, None)
|
||||
_IN_PROCESS.pop(thumb_id, None)
|
||||
|
|
|
|||
|
|
@ -30,11 +30,13 @@ async def test_singleflight(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-singleflight") as temp_dir:
|
||||
media = temp_dir / "video.mp4"
|
||||
cache_root = temp_dir / "cache"
|
||||
media.write_text("video")
|
||||
item_id = "item-1"
|
||||
|
||||
calls = {"count": 0}
|
||||
|
||||
|
|
@ -48,8 +50,8 @@ async def test_singleflight(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
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),
|
||||
thumbnail.ensure_thumb(media, cache_root, item_id=item_id),
|
||||
thumbnail.ensure_thumb(media, cache_root, item_id=item_id),
|
||||
)
|
||||
|
||||
assert first == second
|
||||
|
|
@ -63,13 +65,15 @@ async def test_cache_hit(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-cache") as temp_dir:
|
||||
media = temp_dir / "video.mp4"
|
||||
cache_root = temp_dir / "cache"
|
||||
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.write_text("image")
|
||||
|
||||
|
|
@ -78,7 +82,7 @@ async def test_cache_hit(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
|
||||
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
|
||||
|
||||
|
|
@ -88,6 +92,7 @@ async def test_disabled(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-disabled") as temp_dir:
|
||||
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})()),
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -111,12 +116,13 @@ async def test_sidecar_mode(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
thumbnail._IN_PROCESS.clear()
|
||||
thumbnail._SEM = None
|
||||
thumbnail._SEM_LIMIT = None
|
||||
thumbnail.Cache.get_instance().clear()
|
||||
|
||||
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")
|
||||
sidecar = media.with_name(f"{media.name}.jpg")
|
||||
|
||||
monkeypatch.setattr(
|
||||
thumbnail.Config,
|
||||
|
|
@ -152,6 +158,7 @@ async def test_miss_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
with temporary_test_dir("thumb-miss") as temp_dir:
|
||||
media = temp_dir / "video.mp4"
|
||||
media.write_text("video")
|
||||
item_id = "item-1"
|
||||
|
||||
monkeypatch.setattr(
|
||||
thumbnail.Config,
|
||||
|
|
@ -173,8 +180,8 @@ async def test_miss_cache(monkeypatch: pytest.MonkeyPatch) -> 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")
|
||||
first = await thumbnail.ensure_thumb(media, temp_dir / "cache", item_id=item_id)
|
||||
second = await thumbnail.ensure_thumb(media, temp_dir / "cache", item_id=item_id)
|
||||
|
||||
assert first is None
|
||||
assert second is None
|
||||
|
|
@ -219,6 +226,30 @@ def test_seek_bounds() -> None:
|
|||
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
|
||||
async def test_retry_no_seek(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
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:
|
||||
media = temp_dir / "video.mp4"
|
||||
output = temp_dir / "thumb.jpg"
|
||||
output = temp_dir / ".jpg"
|
||||
media.write_text("video")
|
||||
|
||||
ff_info = FFProbeResult()
|
||||
|
|
|
|||
|
|
@ -266,7 +266,12 @@ def extract_info_sync(
|
|||
params["verbose"] = True
|
||||
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)
|
||||
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'}"
|
||||
|
|
|
|||
|
|
@ -86,8 +86,9 @@ class LogTarget:
|
|||
|
||||
|
||||
class LogWrapper:
|
||||
def __init__(self):
|
||||
def __init__(self, suppress: list[str] | tuple[str, ...] | None = None):
|
||||
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):
|
||||
"""
|
||||
|
|
@ -118,7 +119,13 @@ class LogWrapper:
|
|||
def has_targets(self):
|
||||
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):
|
||||
if self._skip(msg):
|
||||
return
|
||||
|
||||
for target in self.targets:
|
||||
if level < target.level:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -51,6 +51,14 @@ def _get_ignored_conditions(extras: dict | None) -> list[str]:
|
|||
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(
|
||||
queue: "DownloadQueue",
|
||||
entry: dict,
|
||||
|
|
@ -209,6 +217,7 @@ async def add(
|
|||
follow_redirect=True,
|
||||
capture_logs=logging.WARNING,
|
||||
budget_sleep=True,
|
||||
suppress_logs=_task_ignored_logs(item),
|
||||
)
|
||||
|
||||
if not entry:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.library.ag_utils import ag
|
||||
|
|
@ -171,3 +172,36 @@ async def delete_old_history(queue: "DownloadQueue") -> None:
|
|||
|
||||
if titles:
|
||||
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.")
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from app.library.Utils import calc_download_path
|
|||
|
||||
from .core import Download
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -67,6 +67,12 @@ class DownloadQueue(metaclass=Singleton):
|
|||
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:
|
||||
Scheduler.get_instance().add(
|
||||
timer="8 */1 * * *",
|
||||
|
|
|
|||
|
|
@ -312,8 +312,8 @@ async def item_thumbnail(request: Request, queue: DownloadQueue, config: Config)
|
|||
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()
|
||||
miss_key: str = f"thumb_missing:{id}"
|
||||
cache: Cache = Cache.get_instance()
|
||||
|
||||
item: Download | None = await queue.done.get_by_id(id)
|
||||
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):
|
||||
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():
|
||||
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)
|
||||
local_thumb: Path | None = 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")
|
||||
generated: Path | None = await ensure_thumb(
|
||||
filepath, Path(config.temp_path) / "thumbnails", item_id=item.info._id
|
||||
)
|
||||
except OSError as e:
|
||||
LOG.warning(f"Failed to generate thumbnail for '{filepath}'. {e!s}")
|
||||
generated = None
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import os
|
|||
import signal
|
||||
from multiprocessing.reduction import ForkingPickler
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
|
|
@ -1204,6 +1205,28 @@ class TestStatusTracker:
|
|||
|
||||
|
||||
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
|
||||
async def test_cancel_running_live_item_defers_close(self) -> None:
|
||||
queue_manager = object.__new__(DownloadQueue)
|
||||
|
|
|
|||
|
|
@ -220,11 +220,11 @@ async def test_item_thumbnail_generated(monkeypatch: pytest.MonkeyPatch) -> None
|
|||
media = temp_dir / "video.mp4"
|
||||
media.write_text("video")
|
||||
cache_dir = temp_dir / "tmp"
|
||||
generated = cache_dir / "thumbnails" / "gen.jpg"
|
||||
generated = cache_dir / "thumbnails" / "item-1.jpg"
|
||||
|
||||
request = _FakeRequest()
|
||||
request.match_info["id"] = "item-1"
|
||||
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)))
|
||||
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}
|
||||
|
||||
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
|
||||
assert item_id == request.match_info["id"]
|
||||
generated.parent.mkdir(parents=True, exist_ok=True)
|
||||
generated.write_text("generated")
|
||||
return generated
|
||||
|
|
|
|||
|
|
@ -618,14 +618,14 @@
|
|||
|
||||
<UModal
|
||||
v-if="videoItem"
|
||||
:open="Boolean(videoItem)"
|
||||
:open="videoOpen"
|
||||
title="Video"
|
||||
:dismissible="true"
|
||||
:ui="{
|
||||
content: lightsOut ? 'w-full sm:max-w-5xl shadow-2xl' : 'w-full sm:max-w-5xl',
|
||||
body: 'p-0',
|
||||
}"
|
||||
@update:open="(open) => !open && closePlayer()"
|
||||
@update:open="handleVideoOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<VideoPlayer
|
||||
|
|
@ -635,7 +635,7 @@
|
|||
:isControls="true"
|
||||
:item="videoItem"
|
||||
class="w-full"
|
||||
@closeModel="closePlayer"
|
||||
@closeModel="() => void requestCloseVideo()"
|
||||
@error="async (error: string) => await box.alert(error)"
|
||||
@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 { ItemStatus, StoreItem } from '~/types/store';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
|
||||
import { useHistoryState } from '~/composables/useHistoryState';
|
||||
import { useMediaQuery } from '~/composables/useMediaQuery';
|
||||
import { usePresetOptions } from '~/composables/usePresetOptions';
|
||||
|
|
@ -733,6 +734,16 @@ const embedUrl = ref('');
|
|||
const videoItem = ref<StoreItem | null>(null);
|
||||
const playingNow = ref(false);
|
||||
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 formPreset = ref(app.value.default_preset || '');
|
||||
|
|
@ -1050,6 +1061,18 @@ const closePlayer = (): void => {
|
|||
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 direct = (item.description ?? '').toString().trim();
|
||||
if (direct) {
|
||||
|
|
|
|||
|
|
@ -346,7 +346,7 @@ import {
|
|||
requestElementFullscreen,
|
||||
} from '~/utils/fullscreen';
|
||||
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 type { StoreItem } from '~/types/store';
|
||||
|
|
@ -407,10 +407,13 @@ let controlsHideTimeout = 0;
|
|||
let pendingVideoClickTimeout = 0;
|
||||
let unbindMediaSession: null | (() => void) = 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 mediaFile = computed(() => props.item.filename || '');
|
||||
const id = computed(() => props.item._id || '');
|
||||
const subtitleManifestUrl = computed(() => currentPlaybackUrl('api/player/subtitles/manifest'));
|
||||
const canPlay = computed(() => Boolean(mediaFile.value && !loadingError.value));
|
||||
const shouldRender = computed(() => active.value && !loading.value);
|
||||
|
|
@ -525,12 +528,17 @@ function handleVideoLoadedMetadata() {
|
|||
updateMediaSessionPosition(videoElement.value);
|
||||
}
|
||||
|
||||
if (pendingResume) {
|
||||
void resumeMedia(videoElement.value, pendingResume).finally(() => {
|
||||
pendingResume = null;
|
||||
if (pendingSeek !== null) {
|
||||
const time = pendingSeek;
|
||||
const play = pendingPlay;
|
||||
pendingSeek = null;
|
||||
pendingPlay = false;
|
||||
void restoreSwitch(time, play).finally(() => {
|
||||
syncVideoState();
|
||||
showControls();
|
||||
});
|
||||
} else {
|
||||
restoreStoredProgress();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -539,6 +547,8 @@ function handleVideoTimeUpdate() {
|
|||
if (videoElement.value) {
|
||||
updateMediaSessionPosition(videoElement.value);
|
||||
}
|
||||
|
||||
persistProgress(false);
|
||||
}
|
||||
|
||||
function handleVideoPlay() {
|
||||
|
|
@ -552,6 +562,7 @@ function handleVideoPause() {
|
|||
syncVideoState();
|
||||
clearControlsHideTimeout();
|
||||
controlsVisible.value = true;
|
||||
persistProgress(true);
|
||||
emitter('playback-state-change', false);
|
||||
}
|
||||
|
||||
|
|
@ -624,6 +635,85 @@ function handleMediaVolumeChange(event: Event) {
|
|||
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) {
|
||||
if (!playerContainer.value || isTouchDevice.value) {
|
||||
return;
|
||||
|
|
@ -721,6 +811,12 @@ function syncVideoState() {
|
|||
duration.value = nextDuration;
|
||||
currentTime.value = nextTime;
|
||||
paused.value = video.paused;
|
||||
|
||||
if (video.ended || nearEnd(video)) {
|
||||
clear(id.value);
|
||||
lastSaveAt = 0;
|
||||
}
|
||||
|
||||
emitter('playback-state-change', !video.paused);
|
||||
}
|
||||
|
||||
|
|
@ -1050,12 +1146,17 @@ async function src_error(event: Event) {
|
|||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingResume = resume;
|
||||
pendingSeek = time;
|
||||
pendingPlay = play;
|
||||
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
|
|
@ -1160,6 +1261,7 @@ onBeforeUnmount(() => {
|
|||
}
|
||||
|
||||
if (videoElement.value) {
|
||||
persistProgress(true);
|
||||
destroyed.value = true;
|
||||
try {
|
||||
videoElement.value.pause();
|
||||
|
|
|
|||
|
|
@ -375,13 +375,12 @@ const deleteTask = async (
|
|||
* @returns Inspect result or null on error
|
||||
*/
|
||||
const inspectTaskHandler = async (
|
||||
request: TaskInspectRequest,
|
||||
payload: TaskInspectRequest,
|
||||
): Promise<TaskInspectResponse | null> => {
|
||||
try {
|
||||
const response = await fetch('/api/tasks/inspect', {
|
||||
const response = await request('/api/tasks/inspect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
|
|
|
|||
|
|
@ -492,11 +492,11 @@
|
|||
|
||||
<UModal
|
||||
v-if="model_item"
|
||||
:open="Boolean(model_item)"
|
||||
:open="previewOpen"
|
||||
:title="previewTitle"
|
||||
:dismissible="true"
|
||||
:ui="previewModalUi"
|
||||
@update:open="(open) => !open && closeModel()"
|
||||
@update:open="handlePreviewOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<VideoPlayer
|
||||
|
|
@ -507,7 +507,7 @@
|
|||
:isControls="true"
|
||||
:item="model_item"
|
||||
class="w-full"
|
||||
@closeModel="closeModel"
|
||||
@closeModel="() => void requestClosePreview()"
|
||||
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
||||
/>
|
||||
|
||||
|
|
@ -533,6 +533,7 @@
|
|||
import moment from 'moment';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import type { DropdownMenuItem } from '@nuxt/ui';
|
||||
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
|
||||
import type { FileItem } from '~/types/filebrowser';
|
||||
import { formatPageTitle } from '~/utils';
|
||||
import { requirePageShell } from '~/utils/topLevelNavigation';
|
||||
|
|
@ -628,6 +629,16 @@ const isUpdating = ref(false);
|
|||
|
||||
const model_item = ref<any | null>(null);
|
||||
const playingNow = ref(false);
|
||||
const previewOpen = computed<boolean>({
|
||||
get: () => Boolean(model_item.value),
|
||||
set: (value: boolean) => {
|
||||
if (value) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeModel();
|
||||
},
|
||||
});
|
||||
|
||||
const previewTitle = computed(() => {
|
||||
if (!model_item.value) {
|
||||
|
|
@ -746,6 +757,18 @@ const closeModel = (): void => {
|
|||
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 => {
|
||||
localSearch.value = '';
|
||||
show_filter.value = false;
|
||||
|
|
|
|||
|
|
@ -718,11 +718,11 @@
|
|||
|
||||
<UModal
|
||||
v-if="video_item"
|
||||
:open="Boolean(video_item)"
|
||||
:open="videoOpen"
|
||||
:dismissible="true"
|
||||
:title="video_item?.title || 'Player'"
|
||||
: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>
|
||||
<VideoPlayer
|
||||
|
|
@ -732,7 +732,7 @@
|
|||
:isControls="true"
|
||||
:item="video_item"
|
||||
class="w-full"
|
||||
@closeModel="closeVideo()"
|
||||
@closeModel="() => void requestCloseVideo()"
|
||||
@error="async (error: string) => await box.alert(error)"
|
||||
@playback-state-change="(playing: boolean) => (playingNow = playing)"
|
||||
/>
|
||||
|
|
@ -768,6 +768,7 @@ import { toRaw } from 'vue';
|
|||
import moment from 'moment';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useConfirm } from '~/composables/useConfirm';
|
||||
import { useDirtyCloseGuard } from '~/composables/useDirtyCloseGuard';
|
||||
import { useDialog } from '~/composables/useDialog';
|
||||
import { useAppSocket } from '~/composables/useAppSocket';
|
||||
import { useExpandableMeta } from '~/composables/useExpandableMeta';
|
||||
|
|
@ -843,6 +844,16 @@ const contentStyle = computed<'grid' | 'list'>(() =>
|
|||
);
|
||||
const showThumbnails = computed(() => show_thumbnail.value && !hideThumbnail.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(() => ({
|
||||
...pagination.value,
|
||||
isLoading: isLoading.value,
|
||||
|
|
@ -894,6 +905,18 @@ const closeVideo = (): void => {
|
|||
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 = (
|
||||
url: string,
|
||||
useUrl: boolean = false,
|
||||
|
|
|
|||
|
|
@ -57,11 +57,11 @@
|
|||
size="sm"
|
||||
icon="i-lucide-wrap-text"
|
||||
: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' : '']"
|
||||
@click="textWrap = !textWrap"
|
||||
>
|
||||
Wrap lines
|
||||
Wrap
|
||||
</UButton>
|
||||
|
||||
<UInput
|
||||
|
|
|
|||
|
|
@ -1,6 +1,53 @@
|
|||
type ResumeState = {
|
||||
time: number;
|
||||
shouldPlay: boolean;
|
||||
import { useLocalCache } from '~/utils/cache';
|
||||
|
||||
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 = (
|
||||
|
|
@ -31,38 +78,4 @@ const clampResumeTime = (
|
|||
|
||||
return time;
|
||||
};
|
||||
|
||||
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 };
|
||||
export { clampResumeTime, clear, nearEnd, read, save };
|
||||
|
|
|
|||
|
|
@ -426,8 +426,8 @@ describe('useTasks', () => {
|
|||
},
|
||||
}
|
||||
|
||||
const fetchSpy = spyOn(globalThis, 'fetch')
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
|
|
@ -436,14 +436,17 @@ describe('useTasks', () => {
|
|||
)
|
||||
|
||||
const tasks = useTasks()
|
||||
const result = await tasks.inspectTaskHandler({
|
||||
url: 'https://www.youtube.com/channel/UCtest123',
|
||||
})
|
||||
try {
|
||||
const result = await tasks.inspectTaskHandler({
|
||||
url: 'https://www.youtube.com/channel/UCtest123',
|
||||
})
|
||||
|
||||
expect(result).toEqual(inspectResponse)
|
||||
expect(result?.matched).toBe(true)
|
||||
expect(result?.handler).toBe('YoutubeHandler')
|
||||
fetchSpy.mockRestore()
|
||||
expect(result).toEqual(inspectResponse)
|
||||
expect(result?.matched).toBe(true)
|
||||
expect(result?.handler).toBe('YoutubeHandler')
|
||||
} finally {
|
||||
requestSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('handle_unsupported_handler', async () => {
|
||||
|
|
@ -455,8 +458,8 @@ describe('useTasks', () => {
|
|||
metadata: null,
|
||||
}
|
||||
|
||||
const fetchSpy = spyOn(globalThis, 'fetch')
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
|
|
@ -465,27 +468,33 @@ describe('useTasks', () => {
|
|||
)
|
||||
|
||||
const tasks = useTasks()
|
||||
const result = await tasks.inspectTaskHandler({
|
||||
url: 'https://unsupported.com',
|
||||
})
|
||||
try {
|
||||
const result = await tasks.inspectTaskHandler({
|
||||
url: 'https://unsupported.com',
|
||||
})
|
||||
|
||||
expect(result?.supported).toBe(false)
|
||||
expect(result?.matched).toBe(false)
|
||||
fetchSpy.mockRestore()
|
||||
expect(result?.supported).toBe(false)
|
||||
expect(result?.matched).toBe(false)
|
||||
} finally {
|
||||
requestSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('store_inspect_error', async () => {
|
||||
const fetchSpy = spyOn(globalThis, 'fetch')
|
||||
fetchSpy.mockRejectedValueOnce(new Error('Network error'))
|
||||
const requestSpy = spyOn(utils, 'request')
|
||||
requestSpy.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const tasks = useTasks()
|
||||
const result = await tasks.inspectTaskHandler({
|
||||
url: 'invalid',
|
||||
})
|
||||
try {
|
||||
const result = await tasks.inspectTaskHandler({
|
||||
url: 'invalid',
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(tasks.lastError.value).toBe('Network error')
|
||||
fetchSpy.mockRestore()
|
||||
expect(result).toBeNull()
|
||||
expect(tasks.lastError.value).toBe('Network error')
|
||||
} finally {
|
||||
requestSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
it('clamp_seekable_range', async () => {
|
||||
const { clampResumeTime } = await import('~/utils/media');
|
||||
|
||||
it('clamp_seekable_range', () => {
|
||||
const seekable = {
|
||||
length: 1,
|
||||
start: () => 12,
|
||||
end: () => 48,
|
||||
} as TimeRanges;
|
||||
|
||||
expect(clampResumeTime({ duration: Number.NaN, seekable }, 4)).toBe(12);
|
||||
expect(clampResumeTime({ duration: Number.NaN, seekable }, 22)).toBe(22);
|
||||
expect(clampResumeTime({ duration: Number.NaN, seekable }, 60)).toBe(48);
|
||||
expect(media.clampResumeTime({ duration: Number.NaN, seekable }, 4)).toBe(12);
|
||||
expect(media.clampResumeTime({ duration: Number.NaN, seekable }, 22)).toBe(22);
|
||||
expect(media.clampResumeTime({ duration: Number.NaN, seekable }, 60)).toBe(48);
|
||||
});
|
||||
|
||||
it('resume_keep_time_and_play', async () => {
|
||||
const { resumeMedia } = await import('~/utils/media');
|
||||
|
||||
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('store_resume_state', () => {
|
||||
media.save('item-1', 42);
|
||||
expect(media.read('item-1')).toBe(42);
|
||||
});
|
||||
|
||||
it('read_resume_state', async () => {
|
||||
const { readResumeState } = await import('~/utils/media');
|
||||
it('clear_resume_state', () => {
|
||||
media.save('item-1', 17);
|
||||
media.clear('item-1');
|
||||
|
||||
const target = {
|
||||
currentTime: 19,
|
||||
paused: false,
|
||||
ended: false,
|
||||
} as HTMLMediaElement;
|
||||
expect(cache.remove).toHaveBeenCalledWith('video:item-1');
|
||||
expect(media.read('item-1')).toBe(0);
|
||||
});
|
||||
|
||||
expect(readResumeState(target)).toEqual({ time: 19, shouldPlay: true });
|
||||
expect(readResumeState(null)).toEqual({ time: 0, shouldPlay: false });
|
||||
it('clear_resume_near_end', () => {
|
||||
expect(media.nearEnd({ currentTime: 97, duration: 100 })).toBe(true);
|
||||
expect(media.nearEnd({ currentTime: 80, duration: 100 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue