diff --git a/photomap/backend/embeddings.py b/photomap/backend/embeddings.py index a6d8b49a..34a61ad6 100644 --- a/photomap/backend/embeddings.py +++ b/photomap/backend/embeddings.py @@ -50,6 +50,7 @@ from .util import atomic_savez from .video import VIDEO_METADATA_KEY, extract_video_frame from .video_cache import VideoFrameCache +from .video_transcode import TranscodeCache logger = logging.getLogger(__name__) @@ -1337,6 +1338,27 @@ def _prune_video_frame_cache(self, filenames, modification_times) -> None: except Exception as e: logger.warning(f"Could not prune the video frame cache: {e}") + # The converted copies need the same sweep, and for a stronger reason: + # each one is a whole movie rather than a single JPEG, and the only + # other thing that reclaims them is a global byte budget that is not + # checked until some unrelated conversion finishes. Keyed differently + # from the frame cache (exact case), so the keep set is rebuilt rather + # than shared. + try: + transcodes = TranscodeCache(self.album_key) + if not transcodes.directory.is_dir(): + return + keep_converted = { + transcodes.key_for(Path(str(name)), float(mtime)) + for name, mtime in zip(filenames, modification_times, strict=False) + if is_video(Path(str(name))) + } + removed = transcodes.prune(keep_converted) + if removed: + logger.info(f"Removed {removed} stale converted video(s) from the cache") + except Exception as e: + logger.warning(f"Could not prune the video conversion cache: {e}") + @staticmethod def _path_compare_key(p: Path) -> str: """Canonical key for the new-vs-missing diff in diff --git a/photomap/backend/metadata_modules/slide_summary.py b/photomap/backend/metadata_modules/slide_summary.py index 03b39960..5e6c14f1 100644 --- a/photomap/backend/metadata_modules/slide_summary.py +++ b/photomap/backend/metadata_modules/slide_summary.py @@ -30,5 +30,10 @@ class SlideSummary(BaseModel): # something displayable — the extracted still — so existing consumers that # just want a picture keep working unchanged. video_url: str = "" + # Where to ask for a browser-playable conversion of ``video_url``, for the + # (common) case where the browser cannot decode the original. Empty for + # images, and empty from a server predating conversion support — the + # player treats that as "no conversion available" and offers a download. + video_transcode_url: str = "" # Duration / fps / resolution / codec / container, when known. video_info: dict | None = None diff --git a/photomap/backend/routers/album.py b/photomap/backend/routers/album.py index be0a8eb7..24408d8a 100644 --- a/photomap/backend/routers/album.py +++ b/photomap/backend/routers/album.py @@ -22,6 +22,7 @@ from ..encoders import default_encoder_spec, default_min_search_score from ..util import json_safe from ..video_cache import VideoFrameCache +from ..video_transcode import TranscodeCache, forget_album class UmapEpsSetRequest(BaseModel): @@ -248,16 +249,41 @@ def _cleanup_derived_index(album: Album | None) -> None: def _cleanup_video_frames(album_key: str) -> None: - """Remove an album's extracted video stills when the album goes away. + """Remove an album's derived video files when the album goes away. - The frame cache lives in the per-user cache directory, keyed by album, so - nothing else would ever reclaim it. Never raises: a failure here costs - disk space, not correctness. + Both caches live in the per-user cache directory, keyed by album, so + nothing else would ever reclaim them. The converted copies matter more + than the stills: those are whole movies, and the size-budget sweeper only + runs when something new is converted — an album deleted and never replaced + would otherwise leave gigabytes behind indefinitely. + + Each cache is cleared independently so a failure on one still reclaims the + other. Never raises: a failure here costs disk space, not correctness. """ + # Cancel first, then clear. A conversion already in flight would otherwise + # finish afterwards and recreate the directory removed below — publishing a + # whole movie into a cache keyed by an album that no longer exists, which + # nothing will ever clear again. Cancelling makes the worker discard its + # output instead, even if ffmpeg has already succeeded by then. try: - VideoFrameCache(album_key).clear() + cancelled = forget_album(album_key) + if cancelled: + logger.info( + f"Cancelled {cancelled} in-flight conversion(s) for '{album_key}'" + ) except Exception as e: - logger.warning(f"Could not clear video frame cache for '{album_key}': {e}") + logger.warning(f"Could not cancel conversions for '{album_key}': {e}") + + for cache_name, factory in ( + ("frame", VideoFrameCache), + ("conversion", TranscodeCache), + ): + try: + factory(album_key).clear() + except Exception as e: + logger.warning( + f"Could not clear video {cache_name} cache for '{album_key}': {e}" + ) def _album_public_dict(album: Album) -> dict[str, Any]: diff --git a/photomap/backend/routers/index.py b/photomap/backend/routers/index.py index 937685d1..1f88c78f 100644 --- a/photomap/backend/routers/index.py +++ b/photomap/backend/routers/index.py @@ -24,6 +24,7 @@ from ..media_types import is_video from ..progress import IndexingCancelled, progress_tracker from ..video_cache import VideoFrameCache +from ..video_transcode import TranscodeCache from .album import ( AlbumDep, EmbeddingsDep, @@ -376,13 +377,28 @@ def _remove_image_file(image_path: Path, move_to_trash: bool) -> None: def _discard_cached_frame(album_key: str, path: Path) -> None: - """Remove a deleted video's cached still. Never raises.""" + """Remove a deleted video's derived files. Never raises. + + Both the still and the converted copy, and the conversion is the one that + matters: it is a full, decodable copy of the video, so leaving it behind + means a user who deletes a private clip still has it sitting in + ``~/.cache``. (It is not reachable over HTTP once the source is gone — + every video route requires the source to exist — but "not served" is not + "not on disk".) + + Each cache is discarded independently so a failure on one still reclaims + the other. + """ if not is_video(path): return - try: - VideoFrameCache(album_key).discard(path) - except Exception as e: - logger.debug(f"Could not discard cached frame for {path}: {e}") + for name, cache in ( + ("frame", VideoFrameCache(album_key)), + ("conversion", TranscodeCache(album_key)), + ): + try: + cache.discard(path) + except Exception as e: + logger.debug(f"Could not discard cached {name} for {path}: {e}") @index_router.delete( diff --git a/photomap/backend/routers/search.py b/photomap/backend/routers/search.py index 73a4f244..2b912981 100644 --- a/photomap/backend/routers/search.py +++ b/photomap/backend/routers/search.py @@ -28,6 +28,7 @@ from ..metadata_modules import SlideSummary, video_external_link_html from ..util import is_cuda_oom from ..video_cache import VideoFrameCache +from ..video_transcode import TranscodeCache, TranscodeStatus, request_transcode from .album import ( AlbumDep, EmbeddingsDep, @@ -537,28 +538,19 @@ async def serve_image(album_key: str, path: str, album_config: AlbumDep): return FileResponse(image_path) -@search_router.get("/videos/{album_key}/{path:path}", tags=["Search"]) -async def serve_video( - album_key: str, path: str, album_config: AlbumDep -) -> FileResponse: - """Serve a video file's bytes for playback. - - A separate route rather than a widened ``/images/`` allowlist. - ``SUPPORTED_EXTENSIONS`` guards ``serve_image`` against the - ``add_album(image_paths=["/etc"])`` -> ``GET /images//passwd`` - arbitrary-file-read chain; widening it to admit videos would have loosened - that guard as a side effect. Two routes, two allowlists, neither able to - serve the other's file types. +def _resolve_album_video(album_key: str, path: str, album_config) -> Path: + """Resolve ``path`` inside ``album_key`` to a video on disk, or raise. - Returns a ``FileResponse`` specifically: Starlette implements HTTP Range - on it, which is what makes the ``