Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions photomap/backend/embeddings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions photomap/backend/metadata_modules/slide_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 32 additions & 6 deletions photomap/backend/routers/album.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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]:
Expand Down
26 changes: 21 additions & 5 deletions photomap/backend/routers/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
110 changes: 93 additions & 17 deletions photomap/backend/routers/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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/<key>/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 ``<video>`` scrubber able to seek. A
``StreamingResponse`` (as the HEIC conversion path uses) has no range
support and would silently break seeking.
Every video route shares this preamble, and the sharing is the point: the
checks are the arbitrary-file-read defense, not a convenience. Duplicating
them per route is how one of them ends up missing the ``is_video`` gate
and turns ``add_album(image_paths=["/etc"])`` into ``GET
/prepare_video/<key>/passwd``.
"""
# A NUL byte makes Path.resolve() raise ValueError (while .exists() merely
# returns False), and validate_image_access below calls resolve() — so
# without this the request escapes every handler as a 500 with a traceback
# instead of the 403/404 this route is designed to return.
# instead of the 403/404 these routes are designed to return.
if "\x00" in path:
raise HTTPException(status_code=404, detail="Video not found")

Expand All @@ -575,6 +567,29 @@ async def serve_video(
if not video_path.exists() or not video_path.is_file():
raise HTTPException(status_code=404, detail="File not found")

return video_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/<key>/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.

Returns a ``FileResponse`` specifically: Starlette implements HTTP Range
on it, which is what makes the ``<video>`` scrubber able to seek. A
``StreamingResponse`` (as the HEIC conversion path uses) has no range
support and would silently break seeking.
"""
video_path = _resolve_album_video(album_key, path, album_config)

return FileResponse(
video_path,
media_type=video_media_type(video_path),
Expand All @@ -587,6 +602,59 @@ async def serve_video(
)


@search_router.post("/prepare_video/{album_key}/{path:path}", tags=["Search"])
async def prepare_video(
album_key: str, path: str, album_config: AlbumDep
) -> TranscodeStatus:
"""Ensure a browser-playable copy of this video exists, and report progress.

POST because the first call starts work, but it is idempotent and doubles
as the poll: the player calls it about once a second while its progress
panel is up, and those calls are also what tell the backend somebody is
still waiting (see ``video_transcode``'s abandonment rule). Returns
immediately in every case; the conversion runs on a worker thread.

Only ``state == "ready"`` carries a ``url``, and it points at
``/transcoded_video/`` rather than the original — the source bytes are
exactly what the browser could not play.
"""
video_path = _resolve_album_video(album_key, path, album_config)
status = await asyncio.to_thread(request_transcode, album_key, video_path)
if status.state == "ready":
quoted_album = quote(album_key, safe="")
quoted_path = quote(path, safe="/")
status.url = f"transcoded_video/{quoted_album}/{quoted_path}"
return status


@search_router.get("/transcoded_video/{album_key}/{path:path}", tags=["Search"])
async def serve_transcoded_video(
album_key: str, path: str, album_config: AlbumDep
) -> FileResponse:
"""Serve the converted copy of a video, if one has been produced.

Guarded by the same resolution as the original bytes rather than by the
cache key alone: the cache is addressed by a digest of the *source* path,
so serving straight from it would let anyone who can name a file get its
converted contents without passing the album's access check.

A ``FileResponse`` again, for Range support — being able to seek is most
of the reason the conversion is written to disk instead of piped.
"""
video_path = _resolve_album_video(album_key, path, album_config)
cached = await asyncio.to_thread(TranscodeCache(album_key).get, video_path)
if cached is None:
raise HTTPException(
status_code=404, detail="This video has not been converted for playback"
)

return FileResponse(
cached,
media_type="video/mp4",
headers={"Cache-Control": "private, max-age=3600"},
)


@search_router.post(
"/download_images_zip/{album_key}",
tags=["Search"],
Expand Down Expand Up @@ -805,6 +873,14 @@ def create_slide_url(slide_metadata: SlideSummary, album_key: str) -> None:
# The playable bytes get their own field.
slide_metadata.image_url = f"video_frame/{quoted_album}/{slide_metadata.index}"
slide_metadata.video_url = f"videos/{quoted_album}/{quoted_path}"
# Where the player asks for a playable copy when the original turns
# out not to be one. Handed over rather than assembled in the frontend
# so the route shape stays a backend concern, and so a payload from an
# older server (empty string) degrades to the download-only fallback
# instead of a 404 the player would have to interpret.
slide_metadata.video_transcode_url = (
f"prepare_video/{quoted_album}/{quoted_path}"
)
slide_metadata.description += video_external_link_html(
slide_metadata.video_url
)
Expand Down
Loading