"""Keep marks (ยง6.6). The single most important test in the suite is test_marks_survive_every_rating_key_being_reassigned. Its failure mode in v2 is deleting content someone explicitly protected. """ import pytest from mediashelf import keeps from mediashelf.keeps import KeepError def kept_ids(db): return {r["id"] for r in db.query("SELECT id FROM media_item WHERE kept=1")} def a_movie(db): return db.one("SELECT * FROM media_item WHERE kind='movie' " "AND guid IS NOT NULL ORDER BY size_bytes DESC LIMIT 1") def a_season(db): return db.one("SELECT * FROM media_item WHERE kind='season' " "AND show_guid IS NOT NULL ORDER BY size_bytes DESC LIMIT 1") def test_marks_survive_every_rating_key_being_reassigned(scanned): """Plex reassigns ratingKeys on library rebuilds. Marks must not detach.""" movie, season = a_movie(scanned), a_season(scanned) keeps.create_from_item(scanned, movie["id"], "keep", "expensive to re-acquire") keeps.create_from_item(scanned, season["id"], "keep", "might watch someday") keeps.resolve_all(scanned) before = kept_ids(scanned) assert len(before) == 2 for table in ("media_item", "episode", "watch_event"): scanned.execute( f"UPDATE {table} SET provider_item_id = 'REBUILD-' || provider_item_id") keeps.resolve_all(scanned) assert kept_ids(scanned) == before, "keeps detached when rating keys changed" def test_same_guid_in_two_libraries_marks_independently(scanned): """Movies and 4K Movies share GUIDs; an unscoped mark would keep both.""" dupe_guid = scanned.scalar( "SELECT guid FROM media_item WHERE kind='movie' AND guid IS NOT NULL " "GROUP BY guid HAVING COUNT(*) > 1 LIMIT 1") assert dupe_guid, "fixture has no cross-library duplicates" copies = scanned.query( "SELECT * FROM media_item WHERE guid=? AND kind='movie' ORDER BY library_id", (dupe_guid,)) assert len(copies) == 2 keeps.create_from_item(scanned, copies[0]["id"], "keep") keeps.resolve_all(scanned) assert scanned.scalar("SELECT kept FROM media_item WHERE id=?", (copies[0]["id"],)) == 1 assert scanned.scalar("SELECT kept FROM media_item WHERE id=?", (copies[1]["id"],)) == 0 def test_library_rule_keeps_everything_in_it(scanned): lib_id = scanned.scalar("SELECT id FROM library WHERE kind='movie' LIMIT 1") n = scanned.scalar("SELECT COUNT(*) FROM media_item WHERE library_id=?", (lib_id,)) keeps.set_library_keep_all(scanned, lib_id, True) keeps.resolve_all(scanned) assert scanned.scalar( "SELECT COUNT(*) FROM media_item WHERE library_id=? AND kept=1", (lib_id,)) == n def test_explicit_exclude_overrides_a_library_rule(scanned): """Without this, 'keep all of X except one' is a dead end.""" lib_id = scanned.scalar("SELECT id FROM library WHERE kind='movie' LIMIT 1") keeps.set_library_keep_all(scanned, lib_id, True) keeps.resolve_all(scanned) victim = scanned.one( "SELECT * FROM media_item WHERE library_id=? AND kind='movie' " "AND guid IS NOT NULL LIMIT 1", (lib_id,)) assert scanned.scalar("SELECT kept FROM media_item WHERE id=?", (victim["id"],)) == 1 keeps.create_from_item(scanned, victim["id"], "exclude", "actually don't want this") keeps.resolve_all(scanned) assert scanned.scalar("SELECT kept FROM media_item WHERE id=?", (victim["id"],)) == 0 def test_show_mark_keeps_all_its_seasons(scanned): show = scanned.one( "SELECT * FROM media_item WHERE kind='show' AND guid IS NOT NULL " "AND (SELECT COUNT(*) FROM media_item s WHERE s.parent_id = media_item.id) > 1 " "LIMIT 1") assert show, "fixture has no multi-season show" keeps.create_from_item(scanned, show["id"], "keep", "whole series") keeps.resolve_all(scanned) unkept = scanned.scalar( "SELECT COUNT(*) FROM media_item WHERE parent_id=? AND kept=0", (show["id"],)) assert unkept == 0 def test_season_mark_keeps_only_that_season(scanned): season = a_season(scanned) keeps.create_from_item(scanned, season["id"], "keep") keeps.resolve_all(scanned) siblings = scanned.query( "SELECT id, kept FROM media_item WHERE parent_id=? AND id != ?", (season["parent_id"], season["id"])) assert scanned.scalar("SELECT kept FROM media_item WHERE id=?", (season["id"],)) == 1 assert all(s["kept"] == 0 for s in siblings) def test_orphan_detection(scanned): movie = a_movie(scanned) keeps.create_from_item(scanned, movie["id"], "keep") keeps.resolve_all(scanned) assert keeps.stamp_matches(scanned, 1) == 0 # content leaves the library entirely scanned.execute("DELETE FROM media_item WHERE id=?", (movie["id"],)) keeps.resolve_all(scanned) assert keeps.stamp_matches(scanned, 2) == 1, "orphaned mark was not detected" assert scanned.scalar("SELECT COUNT(*) FROM keep_mark") == 1, \ "an orphaned mark must never be auto-deleted" def test_export_import_round_trip(scanned): movie, season = a_movie(scanned), a_season(scanned) keeps.create_from_item(scanned, movie["id"], "keep", "note one") keeps.create_from_item(scanned, season["id"], "keep", "note two") lib_id = scanned.scalar("SELECT id FROM library WHERE kind='movie' LIMIT 1") keeps.set_library_keep_all(scanned, lib_id, True) keeps.resolve_all(scanned) before = kept_ids(scanned) payload = keeps.export(scanned) assert payload["version"] == 1 assert len(payload["marks"]) == 2 assert payload["library_keep_all"] scanned.execute("DELETE FROM keep_mark") scanned.execute("UPDATE library SET keep_all=0") keeps.resolve_all(scanned) assert kept_ids(scanned) == set() keeps.import_(scanned, payload) assert kept_ids(scanned) == before, "restore did not reproduce the keep state" def test_refuses_to_mark_an_item_with_no_guid(scanned): """A mark that cannot survive a rebuild is worse than no mark.""" movie = a_movie(scanned) scanned.execute("UPDATE media_item SET guid=NULL WHERE id=?", (movie["id"],)) with pytest.raises(KeepError, match="GUID"): keeps.create_from_item(scanned, movie["id"], "keep") def test_kept_items_are_hidden_from_the_grid_by_default(client, scanned): total = client.get("/api/v1/items?page_size=1").get_json()["total"] movie = a_movie(scanned) r = client.post("/api/v1/keeps", json={"item_id": movie["id"], "note": "x"}) assert r.status_code == 201 assert client.get("/api/v1/items?page_size=1").get_json()["total"] == total - 1 assert client.get( "/api/v1/items?page_size=1&include_kept=1").get_json()["total"] == total