diff --git a/backend/api/posters.py b/backend/api/posters.py index 14a2fcb6..5448bcce 100644 --- a/backend/api/posters.py +++ b/backend/api/posters.py @@ -29,7 +29,7 @@ from backend.modules.sync_gdrive import SyncGDrive from backend.modules.unmatched_assets import UnmatchedAssets from backend.util.config import ConfigError -from backend.util.database import ChubDB, escape_like +from backend.util.database import ChubDB from backend.util.helper import get_static_dir router = APIRouter( @@ -233,7 +233,8 @@ async def get_poster_stats( } } }, - } + }, + 500: {"description": "Failed to read the poster collections"}, }, ) async def get_poster_collections( @@ -251,31 +252,12 @@ async def get_poster_collections( """ try: logger.debug("Serving GET /api/posters/collections") - collections = ( - db.poster.execute_query( - "SELECT * FROM poster_collections ORDER BY name", fetch_all=True - ) - or [] - ) + collections = db.poster.get_collections() # Hydrate each collection with its poster contents + count. One join # per collection is fine — the table is small and rarely fetched. for col in collections: - posters = ( - db.poster.execute_query( - """ - SELECT p.id, p.asset_type, p.title, p.year, p.season_number, - p.folder, p.file, p.style - FROM poster_collection_items pci - JOIN poster_cache p ON p.id = pci.poster_id - WHERE pci.collection_id = ? - ORDER BY p.title - """, - (col["id"],), - fetch_all=True, - ) - or [] - ) + posters = db.poster.get_collection_posters(col["id"]) col["posters"] = posters col["poster_count"] = len(posters) @@ -705,7 +687,10 @@ async def upload_poster( } } }, - } + }, + 400: {"description": "Field 'name' is required"}, + 413: {"description": "Request body too large"}, + 500: {"description": "Failed to create the collection"}, }, ) async def create_poster_collection( @@ -737,17 +722,8 @@ async def create_poster_collection( description = payload.get("description", "") created_at = datetime.datetime.now(datetime.timezone.utc).isoformat() - row_id = db.poster.execute_query( - "INSERT INTO poster_collections (name, description, created_at) VALUES (?, ?, ?)", - (name, description, created_at), - last_row_id=True, - ) - - created = db.poster.execute_query( - "SELECT * FROM poster_collections WHERE id=?", - (row_id,), - fetch_one=True, - ) + row_id = db.poster.create_collection(name, description, created_at) + created = db.poster.get_collection(row_id) return ok("Poster collection created", {"collection": created}) @@ -776,7 +752,11 @@ async def create_poster_collection( } } }, - } + }, + 400: {"description": "Field 'poster_id' is required"}, + 404: {"description": "Poster collection not found"}, + 413: {"description": "Request body too large"}, + 500: {"description": "Failed to add the poster to the collection"}, }, ) async def add_to_collection( @@ -814,11 +794,7 @@ async def add_to_collection( ) # Verify collection exists - collection = db.poster.execute_query( - "SELECT * FROM poster_collections WHERE id=?", - (collection_id,), - fetch_one=True, - ) + collection = db.poster.get_collection(collection_id) if not collection: return error( f"Poster collection {collection_id} not found", @@ -826,10 +802,7 @@ async def add_to_collection( status_code=404, ) - db.poster.execute_query( - "INSERT OR IGNORE INTO poster_collection_items (collection_id, poster_id) VALUES (?, ?)", - (collection_id, poster_id), - ) + db.poster.add_collection_item(collection_id, poster_id) return ok( "Poster added to collection", @@ -861,7 +834,9 @@ async def add_to_collection( } } }, - } + }, + 404: {"description": "Poster not in this collection"}, + 500: {"description": "Failed to remove the poster from the collection"}, }, ) async def remove_from_collection( @@ -888,10 +863,7 @@ async def remove_from_collection( f"Serving DELETE /api/posters/collections/{collection_id}/remove/{poster_id}" ) - rows_deleted = db.poster.execute_query( - "DELETE FROM poster_collection_items WHERE collection_id=? AND poster_id=?", - (collection_id, poster_id), - ) + rows_deleted = db.poster.remove_collection_item(collection_id, poster_id) if rows_deleted == 0: return error( @@ -919,20 +891,21 @@ async def remove_from_collection( summary="Delete poster collection", description="Delete a poster collection and all of its membership rows. " "The underlying poster files are not touched.", + responses={ + 404: {"description": "Poster collection not found"}, + 500: {"description": "Failed to delete the collection"}, + }, ) async def delete_poster_collection( collection_id: int, logger: Any = Depends(get_logger), db: ChubDB = Depends(get_database), ) -> JSONResponse: + """Delete one collection and its membership rows, leaving the posters alone.""" try: logger.debug(f"Serving DELETE /api/posters/collections/{collection_id}") - existing = db.poster.execute_query( - "SELECT id FROM poster_collections WHERE id=?", - (collection_id,), - fetch_one=True, - ) + existing = db.poster.get_collection(collection_id) if not existing: return error( f"Poster collection {collection_id} not found", @@ -940,13 +913,7 @@ async def delete_poster_collection( status_code=404, ) - db.poster.execute_query( - "DELETE FROM poster_collection_items WHERE collection_id=?", - (collection_id,), - ) - db.poster.execute_query( - "DELETE FROM poster_collections WHERE id=?", (collection_id,) - ) + db.poster.delete_collection(collection_id) return ok( "Poster collection deleted", @@ -1615,6 +1582,11 @@ async def get_artwork_candidates( description="Link a specific logo/background/squareart file to one (media, " "image_type), apply it (copy to Kometa / upload to Plex), and lock it so a " "re-run reuses it. The artwork counterpart of the poster apply endpoint.", + responses={ + 400: {"description": "Unknown image_type, or the file is a different type"}, + 404: {"description": "Media row or artwork file not found"}, + 500: {"description": "Failed to apply the artwork"}, + }, ) def apply_artwork( media_id: int, @@ -1624,6 +1596,7 @@ def apply_artwork( logger: Any = Depends(get_logger), db: ChubDB = Depends(get_database), ) -> JSONResponse: + """Link one artwork file to a media/collection row, apply it, and lock it.""" if image_type not in _ARTWORK_IMAGE_TYPES: return error( f"image_type must be one of {sorted(_ARTWORK_IMAGE_TYPES)}, got '{image_type}'", @@ -1642,9 +1615,7 @@ def apply_artwork( ) if not row: return error("Media row not found", code="NOT_FOUND", status_code=404) - poster = db.poster.execute_query( - "SELECT * FROM poster_cache WHERE id=?", (poster_id,), fetch_one=True - ) + poster = db.poster.get_by_integer_id(poster_id) if not poster: return error("Artwork file not found", code="NOT_FOUND", status_code=404) poster = dict(poster) @@ -1767,6 +1738,7 @@ async def ignore_match( summary="Approve a needs-review match", description="Confirm a needs-review media/collection row, promoting it to " "the 'matched' state and clearing any conflict flags.", + responses={500: {"description": "Failed to approve the match"}}, ) async def approve_match( media_id: int, @@ -1779,13 +1751,8 @@ async def approve_match( logger.debug( f"Serving POST /api/posters/match/{media_id}/approve (kind={kind})" ) - table = "collections_cache" if kind == "collection" else "media_cache" iface = db.collection if kind == "collection" else db.media - iface.execute_query( - f"UPDATE {table} SET match_status='matched', match_confidence=1.0, " - "conflict_ids='[]' WHERE id=?", - (media_id,), - ) + iface.approve_match(media_id) # Lock the confirmed match so a future re-scan can't revert it (Fix B). iface.set_user_confirmed(media_id, True) return ok("Match approved", {"id": media_id, "match_status": "matched"}) @@ -1804,6 +1771,7 @@ async def approve_match( description="Clear the user_confirmed lock on a media/collection row and put " "it back into the 'needs_review' queue so the matcher can recompute it (or the " "user can re-pick) on the next run.", + responses={500: {"description": "Failed to unlock the match"}}, ) async def unlock_match( media_id: int, @@ -1814,12 +1782,8 @@ async def unlock_match( """Release a manual lock and send the row back to Needs Review.""" try: logger.debug(f"Serving POST /api/posters/match/{media_id}/unlock (kind={kind})") - table = "collections_cache" if kind == "collection" else "media_cache" iface = db.collection if kind == "collection" else db.media - iface.execute_query( - f"UPDATE {table} SET match_status='needs_review' WHERE id=?", - (media_id,), - ) + iface.reopen_for_review(media_id) # Drop the lock so the next scheduled run is free to recompute the match. iface.set_user_confirmed(media_id, False) return ok( @@ -1957,6 +1921,10 @@ async def get_match_candidates( summary="Manually apply a chosen poster to a media row", description="Link a specific poster to a media/collection row and copy it " "to the destination. Used by the manual poster picker.", + responses={ + 404: {"description": "Media row or poster not found"}, + 500: {"description": "Failed to apply the poster"}, + }, ) def apply_match( media_id: int, @@ -1965,6 +1933,7 @@ def apply_match( logger: Any = Depends(get_logger), db: ChubDB = Depends(get_database), ) -> JSONResponse: + """Link one poster to a media/collection row and apply it to the destination.""" try: logger.debug( f"Serving POST /api/posters/match/{media_id}/apply (poster={poster_id})" @@ -1976,9 +1945,7 @@ def apply_match( ) if not row: return error("Media row not found", code="NOT_FOUND", status_code=404) - poster = db.poster.execute_query( - "SELECT * FROM poster_cache WHERE id=?", (poster_id,), fetch_one=True - ) + poster = db.poster.get_by_integer_id(poster_id) if not poster: return error("Poster not found", code="NOT_FOUND", status_code=404) @@ -2867,6 +2834,7 @@ def upload_collection_posters( description="Walk poster_cache rows missing width/height and populate " "them by opening the file with PIL. Processes up to `limit` rows per call " "so it can be run incrementally.", + responses={500: {"description": "Failed to backfill poster dimensions"}}, ) def backfill_poster_dimensions( limit: int = 200, @@ -2878,15 +2846,7 @@ def backfill_poster_dimensions( # endpoint in a threadpool instead of stalling the event loop. try: limit = max(1, min(limit, 2000)) - rows = ( - db.worker.execute_query( - "SELECT id, file FROM poster_cache WHERE width IS NULL OR height IS NULL " - "LIMIT ?", - (limit,), - fetch_all=True, - ) - or [] - ) + rows = db.poster.find_missing_dimensions(limit) from PIL import Image @@ -3905,6 +3865,8 @@ def download_poster( }, }, 404: {"description": "Poster not found"}, + 413: {"description": "Request body too large"}, + 500: {"description": "Malformed config, or the delete failed"}, }, ) async def delete_poster( @@ -3963,12 +3925,14 @@ async def delete_poster( # membership in a configured root. file_deleted = False if delete_file and full_path: - from backend.util.path_safety import is_path_allowed + from backend.util.path_safety import resolve_confined - real = os.path.realpath(full_path) - if real == os.path.realpath(os.sep) or not is_path_allowed(real, config): - logger.error(f"Refusing to delete poster file outside roots: {real}") - elif os.path.exists(real): + real = resolve_confined(full_path, config) + if real is None or str(real) == os.path.realpath(os.sep): + logger.error( + f"Refusing to delete poster file outside roots: {full_path}" + ) + elif real.exists(): os.remove(real) file_deleted = True logger.info(f"Deleted poster file: {real}") @@ -3980,16 +3944,8 @@ async def delete_poster( try: # Find media items that were matched to this poster by original_file if full_path: - # Escape LIKE metacharacters so a basename with %/_ can't - # unmatch the wrong media rows. - basename_like = escape_like(os.path.basename(full_path)) - media_items = ( - db.media.execute_query( - "SELECT id, title, instance_name, asset_type, year, season_number FROM media_cache WHERE original_file LIKE ? ESCAPE '\\'", - (f"%{basename_like}%",), - fetch_all=True, - ) - or [] + media_items = db.media.find_by_original_file_basename( + os.path.basename(full_path) ) for item in media_items: db.media.update( diff --git a/backend/util/database/collection_cache.py b/backend/util/database/collection_cache.py index 16bc25f8..b4f800d0 100755 --- a/backend/util/database/collection_cache.py +++ b/backend/util/database/collection_cache.py @@ -236,6 +236,21 @@ def set_user_confirmed(self, id: int, confirmed: bool) -> None: (int(bool(confirmed)), id), ) + def approve_match(self, id: int) -> None: + """Promote one reviewed collection row to a full-confidence match.""" + self.execute_query( + "UPDATE collections_cache SET match_status='matched', " + "match_confidence=1.0, conflict_ids='[]' WHERE id=?", + (id,), + ) + + def reopen_for_review(self, id: int) -> None: + """Send one collection row back to the needs-review queue.""" + self.execute_query( + "UPDATE collections_cache SET match_status='needs_review' WHERE id=?", + (id,), + ) + def set_match_provenance( self, id: int, matched_at: Optional[str], matched_poster_file: Optional[str] ) -> None: diff --git a/backend/util/database/media_cache.py b/backend/util/database/media_cache.py index 383698cc..41953746 100755 --- a/backend/util/database/media_cache.py +++ b/backend/util/database/media_cache.py @@ -547,6 +547,21 @@ def set_user_confirmed(self, id: int, confirmed: bool) -> None: (int(bool(confirmed)), id), ) + def approve_match(self, id: int) -> None: + """Promote one reviewed media row to a full-confidence match.""" + self.execute_query( + "UPDATE media_cache SET match_status='matched', match_confidence=1.0, " + "conflict_ids='[]' WHERE id=?", + (id,), + ) + + def reopen_for_review(self, id: int) -> None: + """Send one media row back to the needs-review queue.""" + self.execute_query( + "UPDATE media_cache SET match_status='needs_review' WHERE id=?", + (id,), + ) + def set_match_provenance( self, id: int, matched_at: Optional[str], matched_poster_file: Optional[str] ) -> None: @@ -1672,6 +1687,20 @@ def find_by_tag(self, tag: str) -> list: or [] ) + def find_by_original_file_basename(self, basename: str) -> list: + """Return the identity fields of rows whose original_file holds `basename`.""" + # Escape LIKE metacharacters so a filename carrying %/_ can't reach + # rows belonging to a different poster. + return ( + self.execute_query( + "SELECT id, title, instance_name, asset_type, year, season_number " + "FROM media_cache WHERE original_file LIKE ? ESCAPE '\\'", + (f"%{escape_like(basename)}%",), + fetch_all=True, + ) + or [] + ) + def record_edit( self, media_id: int, diff --git a/backend/util/database/poster_cache.py b/backend/util/database/poster_cache.py index a2af1286..de7d0955 100755 --- a/backend/util/database/poster_cache.py +++ b/backend/util/database/poster_cache.py @@ -271,6 +271,20 @@ def record_dimensions(self, poster_id: int, width: int, height: int) -> None: (int(width), int(height), int(poster_id)), ) + def find_missing_dimensions(self, limit: int = 200) -> list: + """Return up to `limit` rows whose width/height are still unrecorded.""" + # id-ordered so an incremental backfill walks the table in a stable + # order instead of re-drawing an arbitrary batch each call. + return ( + self.execute_query( + "SELECT id, file FROM poster_cache " + "WHERE width IS NULL OR height IS NULL ORDER BY id ASC LIMIT ?", + (int(limit),), + fetch_all=True, + ) + or [] + ) + def find_low_resolution( self, min_width: int = 1000, @@ -720,3 +734,65 @@ def add_collection_item(self, collection_id: int, poster_id: int) -> None: "(collection_id, poster_id) VALUES (?, ?)", (collection_id, poster_id), ) + + def get_collections(self) -> list: + """Return every poster collection, name-ascending.""" + # `name` isn't unique — id breaks the tie so the list order is stable. + return ( + self.execute_query( + "SELECT * FROM poster_collections ORDER BY name ASC, id ASC", + fetch_all=True, + ) + or [] + ) + + def get_collection(self, collection_id: int) -> Optional[dict]: + """Return one poster collection by id, or None.""" + return self.execute_query( + "SELECT * FROM poster_collections WHERE id=?", + (collection_id,), + fetch_one=True, + ) + + def get_collection_posters(self, collection_id: int) -> list: + """Return the display fields of every poster in one collection.""" + return ( + self.execute_query( + "SELECT p.id, p.asset_type, p.title, p.year, p.season_number, " + "p.folder, p.file, p.style " + "FROM poster_collection_items pci " + "JOIN poster_cache p ON p.id = pci.poster_id " + "WHERE pci.collection_id = ? " + "ORDER BY p.title ASC, p.id ASC", + (collection_id,), + fetch_all=True, + ) + or [] + ) + + def remove_collection_item(self, collection_id: int, poster_id: int) -> int: + """Drop one poster from one collection; returns rows deleted.""" + # Both columns: (collection_id, poster_id) is the pair's unique key, so + # the same poster stays in every other collection. + return ( + self.execute_query( + "DELETE FROM poster_collection_items " + "WHERE collection_id=? AND poster_id=?", + (collection_id, poster_id), + ) + or 0 + ) + + def delete_collection(self, collection_id: int) -> None: + """Delete a collection and its membership rows in one transaction.""" + # One transaction: a half-applied delete would leave orphaned items + # pointing at a collection that no longer exists. + self.execute_transaction( + [ + ( + "DELETE FROM poster_collection_items WHERE collection_id=?", + (collection_id,), + ), + ("DELETE FROM poster_collections WHERE id=?", (collection_id,)), + ] + ) diff --git a/tests/test_posters_queries.py b/tests/test_posters_queries.py new file mode 100644 index 00000000..80b1d809 --- /dev/null +++ b/tests/test_posters_queries.py @@ -0,0 +1,462 @@ +"""Tests for the posters.py query methods that moved into the DB interfaces. + +Two layers: the new PosterCache / MediaCache / CollectionCache methods against a +real temp database, then the poster handlers whose contract nothing else pinned. +""" + +import os +import sys + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from backend.util.config import ConfigError # noqa: E402 +from backend.util.database import ChubDB # noqa: E402 + + +class _StubLog: + """Swallows every log call; get_adapter returns itself.""" + + def __getattr__(self, _): + """Any log method is a no-op.""" + return lambda *a, **k: None + + def get_adapter(self, *_a, **_kw): + """Adapters are the same sink.""" + return self + + +@pytest.fixture +def db(tmp_path): + """A ChubDB backed by a real (temporary) sqlite file.""" + with ChubDB(_StubLog(), db_path=str(tmp_path / "chub.db")) as database: + yield database + + +def _client(db): + """Mount the posters router on a bare app carrying main.py's ConfigError handler.""" + import backend.api.main as apimain + import backend.api.posters as posters + + app = FastAPI() + app.state.logger = _StubLog() + app.state.db = db + app.add_exception_handler(ConfigError, apimain.handle_config_error) + app.include_router(posters.router) + return TestClient(app, raise_server_exceptions=False) + + +def _seed_poster(db, title, file=None, folder="/src", **fields): + """Upsert one poster_cache row and return its integer id.""" + path = file or f"/src/{title}.jpg" + db.poster.upsert( + { + "title": title, + "normalized_title": title.lower(), + "year": 2021, + "tmdb_id": None, + "tvdb_id": None, + "imdb_id": None, + "season_number": None, + "folder": folder, + "file": path, + "asset_type": "movie", + **fields, + } + ) + row = db.poster.execute_query( + "SELECT id FROM poster_cache WHERE file=?", (path,), fetch_one=True + ) + return row["id"] + + +def _seed_media(db, title, instance_name="radarr", original_file=None, **fields): + """Upsert one media row (optionally matched to a poster file) and return its id.""" + item = {"title": title, "normalized_title": title.lower(), "year": 2021, **fields} + db.media.upsert(item, "movie", "radarr", instance_name) + row = db.media.execute_query( + "SELECT id FROM media_cache WHERE title=? AND instance_name=?", + (title, instance_name), + fetch_one=True, + ) + if original_file: + db.media.update( + asset_type="movie", + title=title, + year=2021, + instance_name=instance_name, + matched_value=1, + original_file=original_file, + id=row["id"], + ) + return row["id"] + + +# --- PosterCache.get_collections / get_collection --------------------------- + + +def test_get_collections_orders_by_name_not_insertion(db): + """The list is name-ascending, so insertion order must not leak through.""" + db.poster.create_collection("Zebra", None, "2026-01-01T00:00:00") + db.poster.create_collection("Alpha", None, "2026-01-02T00:00:00") + + assert [c["name"] for c in db.poster.get_collections()] == ["Alpha", "Zebra"] + + +def test_get_collection_returns_the_row_or_none(db): + """A known id resolves its own row; an unknown id is None, never a stray row.""" + first = db.poster.create_collection("Halloween", "spooky", "2026-01-01T00:00:00") + db.poster.create_collection("Xmas", "merry", "2026-01-02T00:00:00") + + row = db.poster.get_collection(first) + assert row["name"] == "Halloween" and row["description"] == "spooky" + assert db.poster.get_collection(999999) is None + + +# --- PosterCache.get_collection_posters ------------------------------------ + + +def test_get_collection_posters_is_scoped_and_title_ordered(db): + """Only this collection's posters come back, title-ascending.""" + zebra = _seed_poster(db, "Zebra") + alpha = _seed_poster(db, "Alpha") + other = _seed_poster(db, "Other") + mine = db.poster.create_collection("Mine", None, "2026-01-01T00:00:00") + theirs = db.poster.create_collection("Theirs", None, "2026-01-01T00:00:00") + db.poster.add_collection_item(mine, zebra) + db.poster.add_collection_item(mine, alpha) + db.poster.add_collection_item(theirs, other) + + rows = db.poster.get_collection_posters(mine) + assert [r["title"] for r in rows] == ["Alpha", "Zebra"] + assert set(rows[0]) == { + "id", + "asset_type", + "title", + "year", + "season_number", + "folder", + "file", + "style", + } + + +def test_get_collection_posters_drops_membership_rows_with_no_poster(db): + """The JOIN is the filter: an item pointing at a deleted poster isn't returned.""" + kept = _seed_poster(db, "Kept") + coll = db.poster.create_collection("Mine", None, "2026-01-01T00:00:00") + db.poster.add_collection_item(coll, kept) + db.poster.add_collection_item(coll, 999999) + + assert [r["id"] for r in db.poster.get_collection_posters(coll)] == [kept] + + +# --- PosterCache.remove_collection_item / delete_collection ----------------- + + +def test_remove_collection_item_reports_the_rows_it_deleted(db): + """The count is the DB's rowcount, so a repeat removal reports zero.""" + poster = _seed_poster(db, "Dune") + coll = db.poster.create_collection("Mine", None, "2026-01-01T00:00:00") + db.poster.add_collection_item(coll, poster) + + assert db.poster.remove_collection_item(coll, poster) == 1 + assert db.poster.remove_collection_item(coll, poster) == 0 + + +def test_remove_collection_item_leaves_the_poster_in_other_collections(db): + """(collection_id, poster_id) is the unique key — both must be in the WHERE.""" + poster = _seed_poster(db, "Dune") + mine = db.poster.create_collection("Mine", None, "2026-01-01T00:00:00") + theirs = db.poster.create_collection("Theirs", None, "2026-01-01T00:00:00") + db.poster.add_collection_item(mine, poster) + db.poster.add_collection_item(theirs, poster) + + db.poster.remove_collection_item(mine, poster) + assert db.poster.get_collection_posters(mine) == [] + assert [r["id"] for r in db.poster.get_collection_posters(theirs)] == [poster] + + +def test_delete_collection_takes_its_membership_rows_with_it(db): + """The collection and its items go; a sibling collection is untouched.""" + poster = _seed_poster(db, "Dune") + doomed = db.poster.create_collection("Doomed", None, "2026-01-01T00:00:00") + keeper = db.poster.create_collection("Keeper", None, "2026-01-01T00:00:00") + db.poster.add_collection_item(doomed, poster) + db.poster.add_collection_item(keeper, poster) + + db.poster.delete_collection(doomed) + assert db.poster.get_collection(doomed) is None + assert db.poster.get_collection_posters(doomed) == [] + assert [r["id"] for r in db.poster.get_collection_posters(keeper)] == [poster] + + +# --- PosterCache.find_missing_dimensions ----------------------------------- + + +def test_find_missing_dimensions_skips_rows_that_already_have_both(db): + """A row is only pending while width OR height is still NULL.""" + measured = _seed_poster(db, "Measured") + pending = _seed_poster(db, "Pending") + db.poster.record_dimensions(measured, 1000, 1500) + + assert [r["id"] for r in db.poster.find_missing_dimensions()] == [pending] + + +def test_find_missing_dimensions_walks_ids_in_order_under_the_limit(db): + """Batches are id-ordered so an incremental backfill can't loop on one page.""" + ids = [_seed_poster(db, t) for t in ("One", "Two", "Three")] + + rows = db.poster.find_missing_dimensions(limit=2) + assert [r["id"] for r in rows] == ids[:2] + assert set(rows[0]) == {"id", "file"} + + +# --- MediaCache / CollectionCache approve_match + reopen_for_review --------- + + +def test_approve_match_promotes_the_row_and_clears_conflicts(db): + """Approval writes matched/1.0 and empties the conflict list.""" + mid = _seed_media(db, "Dune") + db.media.update( + asset_type="movie", + title="Dune", + year=2021, + instance_name="radarr", + match_status="needs_review", + match_confidence=0.4, + conflict_ids='[{"tmdb_id": 1}]', + id=mid, + ) + + db.media.approve_match(mid) + + row = db.media.get_by_id(mid) + assert row["match_status"] == "matched" + assert row["match_confidence"] == 1.0 + assert row["conflict_ids"] == "[]" + + +def test_approve_match_touches_only_the_requested_media_row(db): + """The WHERE carries the row id — a sibling keeps its review state.""" + target = _seed_media(db, "Dune") + bystander = _seed_media(db, "Sicario") + for mid in (target, bystander): + db.media.reopen_for_review(mid) + + db.media.approve_match(target) + + assert db.media.get_by_id(bystander)["match_status"] == "needs_review" + + +def test_reopen_for_review_sends_a_media_row_back(db): + """Unlocking flips match_status back to needs_review.""" + mid = _seed_media(db, "Dune") + db.media.approve_match(mid) + + db.media.reopen_for_review(mid) + + assert db.media.get_by_id(mid)["match_status"] == "needs_review" + + +def test_collection_approve_and_reopen_round_trip(db): + """CollectionCache carries the same pair against collections_cache.""" + db.collection.upsert({"title": "Marvel", "library_name": "Movies"}, "plex1") + cid = db.collection.get_by_title_and_instance("Marvel", "plex1", "Movies")["id"] + + db.collection.approve_match(cid) + row = db.collection.get_by_id(cid) + assert row["match_status"] == "matched" and row["match_confidence"] == 1.0 + assert row["conflict_ids"] == "[]" + + db.collection.reopen_for_review(cid) + assert db.collection.get_by_id(cid)["match_status"] == "needs_review" + + +# --- MediaCache.find_by_original_file_basename ----------------------------- + + +def test_find_by_original_file_basename_escapes_like_metacharacters(db): + """`_` in a filename must be literal, not a single-character wildcard.""" + _seed_media(db, "Dune", original_file="/src/Dune_2021.jpg") + _seed_media(db, "Sicario", original_file="/src/DuneX2021.jpg") + + rows = db.media.find_by_original_file_basename("Dune_2021.jpg") + assert [r["title"] for r in rows] == ["Dune"] + + +def test_find_by_original_file_basename_returns_the_unmatch_fields(db): + """The caller re-keys media_cache.update from these columns.""" + _seed_media(db, "Dune", original_file="/src/Dune (2021).jpg") + + rows = db.media.find_by_original_file_basename("Dune (2021).jpg") + assert set(rows[0]) == { + "id", + "title", + "instance_name", + "asset_type", + "year", + "season_number", + } + + +# --- Route contracts not covered elsewhere --------------------------------- + + +def test_collections_route_hydrates_each_collection_with_its_posters(db): + """GET /collections returns the join result and its count per collection.""" + poster = _seed_poster(db, "Dune") + filled = db.poster.create_collection("Filled", None, "2026-01-01T00:00:00") + db.poster.create_collection("Empty", None, "2026-01-02T00:00:00") + db.poster.add_collection_item(filled, poster) + + body = _client(db).get("/api/posters/collections").json() + collections = body["data"]["collections"] + assert [c["name"] for c in collections] == ["Empty", "Filled"] + assert collections[0]["poster_count"] == 0 + assert collections[1]["poster_count"] == 1 + assert [p["title"] for p in collections[1]["posters"]] == ["Dune"] + + +def test_create_collection_route_returns_the_row_it_just_inserted(db): + """The read-back is by the new id, so an older same-named row can't win.""" + db.poster.create_collection("Halloween", "older", "2026-01-01T00:00:00") + + resp = _client(db).post( + "/api/posters/collections", json={"name": "Halloween", "description": "newer"} + ) + assert resp.status_code == 200 + created = resp.json()["data"]["collection"] + assert created["description"] == "newer" + assert created["id"] == db.poster.get_collection_id_by_name("Halloween") + + +def test_create_collection_route_requires_a_name(db): + """A nameless collection is a 400, not an unnamed row.""" + resp = _client(db).post("/api/posters/collections", json={"description": "x"}) + assert resp.status_code == 400 + assert resp.json()["error_code"] == "MISSING_NAME" + assert db.poster.get_collections() == [] + + +def test_add_to_collection_route_404s_for_an_unknown_collection(db): + """The existence check runs before the insert, so no orphan item is written.""" + poster = _seed_poster(db, "Dune") + + resp = _client(db).post( + "/api/posters/collections/999999/add", json={"poster_id": poster} + ) + assert resp.status_code == 404 + assert resp.json()["error_code"] == "COLLECTION_NOT_FOUND" + + +def test_add_to_collection_route_is_idempotent(db): + """Re-adding the same poster keeps one membership row, not two.""" + poster = _seed_poster(db, "Dune") + coll = db.poster.create_collection("Mine", None, "2026-01-01T00:00:00") + client = _client(db) + + for _ in range(2): + resp = client.post( + f"/api/posters/collections/{coll}/add", json={"poster_id": poster} + ) + assert resp.status_code == 200 + assert len(db.poster.get_collection_posters(coll)) == 1 + + +def test_remove_from_collection_route_404s_when_the_pair_is_absent(db): + """The 404 is driven by the deleted rowcount, not by a separate lookup.""" + poster = _seed_poster(db, "Dune") + coll = db.poster.create_collection("Mine", None, "2026-01-01T00:00:00") + db.poster.add_collection_item(coll, poster) + client = _client(db) + + first = client.delete(f"/api/posters/collections/{coll}/remove/{poster}") + second = client.delete(f"/api/posters/collections/{coll}/remove/{poster}") + assert first.status_code == 200 + assert second.status_code == 404 + assert second.json()["error_code"] == "ITEM_NOT_FOUND" + + +def test_delete_collection_route_404s_then_deletes_everything(db): + """An unknown id is a 404; a known one takes its membership rows with it.""" + poster = _seed_poster(db, "Dune") + coll = db.poster.create_collection("Mine", None, "2026-01-01T00:00:00") + db.poster.add_collection_item(coll, poster) + client = _client(db) + + missing = client.delete("/api/posters/collections/999999") + assert missing.status_code == 404 + deleted = client.delete(f"/api/posters/collections/{coll}") + assert deleted.status_code == 200 + assert db.poster.get_collection(coll) is None + assert db.poster.get_collection_posters(coll) == [] + + +def test_approve_then_unlock_route_round_trips_a_media_row(db): + """Approve locks the row; unlock reopens it and drops the lock.""" + mid = _seed_media(db, "Dune") + client = _client(db) + + approved = client.post(f"/api/posters/match/{mid}/approve") + assert approved.status_code == 200 + row = db.media.get_by_id(mid) + assert row["match_status"] == "matched" and row["user_confirmed"] == 1 + + unlocked = client.post(f"/api/posters/match/{mid}/unlock") + assert unlocked.status_code == 200 + row = db.media.get_by_id(mid) + assert row["match_status"] == "needs_review" and row["user_confirmed"] == 0 + + +def test_approve_route_writes_to_collections_when_kind_is_collection(db): + """kind=collection must reach CollectionCache, not the media table.""" + db.collection.upsert({"title": "Marvel", "library_name": "Movies"}, "plex1") + cid = db.collection.get_by_title_and_instance("Marvel", "plex1", "Movies")["id"] + decoy = _seed_media(db, "Dune") + + resp = _client(db).post(f"/api/posters/match/{cid}/approve?kind=collection") + assert resp.status_code == 200 + assert db.collection.get_by_id(cid)["match_status"] == "matched" + assert db.media.get_by_id(decoy)["match_status"] is None + + +def test_backfill_dimensions_route_measures_only_the_unmeasured(db, tmp_path): + """Rows with a readable file get width/height; a missing file is skipped.""" + from PIL import Image + + real = tmp_path / "Dune.jpg" + Image.new("RGB", (400, 600)).save(real) + measurable = _seed_poster(db, "Dune", file=str(real)) + _seed_poster(db, "Ghost", file=str(tmp_path / "gone.jpg")) + + body = _client(db).post("/api/posters/backfill-dimensions").json() + assert body["data"] == {"updated": 1, "skipped": 1, "batch_size": 200} + row = db.poster.get_by_integer_id(measurable) + assert (row["width"], row["height"]) == (400, 600) + + +def test_delete_poster_route_unmatches_the_media_it_was_applied_to(db): + """Deleting the poster row clears the media rows pointing at that file.""" + poster = _seed_poster(db, "Dune", file="Dune (2021).jpg", folder="/src") + mid = _seed_media(db, "Dune", original_file="/src/Dune (2021).jpg") + + body = _client(db).delete(f"/api/posters/{poster}").json() + assert body["data"]["media_unmatched"] == 1 + row = db.media.get_by_id(mid) + assert row["matched"] == 0 and row["original_file"] == "" + + +def test_delete_poster_route_leaves_lookalike_filenames_matched(db): + """`_` in the deleted poster's name must not wildcard onto another row.""" + poster = _seed_poster(db, "Dune", file="Dune_2021.jpg", folder="/src") + literal = _seed_media(db, "Dune", original_file="/src/Dune_2021.jpg") + lookalike = _seed_media(db, "Sicario", original_file="/src/DuneX2021.jpg") + + body = _client(db).delete(f"/api/posters/{poster}").json() + assert body["data"]["media_unmatched"] == 1 + assert db.media.get_by_id(literal)["matched"] == 0 + assert db.media.get_by_id(lookalike)["matched"] == 1