From 5c0027b54be94fbe81dedb50a91ecbfb006dc28a Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:29:30 -0400 Subject: [PATCH] fix(metadata): dedupe availability during re-id --- ...e_episode_availability_during_reid_test.go | 51 +++++ ...edupe_episode_availability_during_reid.sql | 185 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 migrations/dedupe_episode_availability_during_reid_test.go create mode 100644 migrations/sql/20260625172243_dedupe_episode_availability_during_reid.sql diff --git a/migrations/dedupe_episode_availability_during_reid_test.go b/migrations/dedupe_episode_availability_during_reid_test.go new file mode 100644 index 00000000..7d833abc --- /dev/null +++ b/migrations/dedupe_episode_availability_during_reid_test.go @@ -0,0 +1,51 @@ +package migrations + +import ( + "strings" + "testing" +) + +func TestDedupeEpisodeAvailabilityDuringReIDMigrationContract(t *testing.T) { + migrationBytes, err := FS.ReadFile("sql/20260625172243_dedupe_episode_availability_during_reid.sql") + if err != nil { + t.Fatalf("read migration: %v", err) + } + + migration := string(migrationBytes) + parts := strings.Split(migration, "-- +goose Down") + if len(parts) != 2 { + t.Fatalf("expected one goose down section") + } + up := normalizeSQL(parts[0]) + down := normalizeSQL(parts[1]) + + episodeDedupe := strings.Index(up, "IF to_regclass('public.episode_availability') IS NOT NULL THEN") + if episodeDedupe < 0 { + t.Fatal("up migration must special-case episode_availability before generic content-id rewrite") + } + genericRewrite := strings.Index(up, "FOR c IN SELECT cl.oid::regclass AS rel") + if genericRewrite < 0 { + t.Fatal("up migration missing generic content-id rewrite loop") + } + if episodeDedupe > genericRewrite { + t.Fatal("episode_availability dedupe must run before the generic rewrite loop") + } + + for _, want := range []string{ + "AND dest.series_id = p_to AND dest.episode_key = src.episode_key WHERE src.series_id = p_from", + "SET available_at = conflicts.available_at, created_at = conflicts.created_at", + "DELETE FROM public.episode_availability dest USING conflicts", + "AND u.episode_id = conflicts.source_episode_id", + "IF to_regclass('public.movie_availability') IS NOT NULL THEN", + "AND dest.item_id = p_to WHERE src.item_id = p_from", + "DELETE FROM public.movie_availability dest USING conflicts", + } { + if !strings.Contains(up, normalizeSQL(want)) { + t.Fatalf("up migration missing %q", want) + } + } + + if strings.Contains(down, "episode_availability") || strings.Contains(down, "movie_availability") { + t.Fatal("down migration should restore the pre-dedupe rename helper") + } +} diff --git a/migrations/sql/20260625172243_dedupe_episode_availability_during_reid.sql b/migrations/sql/20260625172243_dedupe_episode_availability_during_reid.sql new file mode 100644 index 00000000..7cc11855 --- /dev/null +++ b/migrations/sql/20260625172243_dedupe_episode_availability_during_reid.sql @@ -0,0 +1,185 @@ +-- +goose Up +-- `silo_rename_content_id` updates soft-reference columns after a local item +-- receives its provider-backed deterministic ID. Availability tables are +-- insert-only historical facts, so stale rows can already exist for the target +-- ID even when the target media_items row does not. Merge those rows before the +-- broad scalar rewrite so unique availability keys do not block manual +-- matching. +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION silo_rename_content_id(p_from text, p_to text) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + c RECORD; +BEGIN + IF p_from IS NULL OR p_to IS NULL OR p_from = p_to THEN + RETURN; + END IF; + + IF to_regclass('public.episode_availability') IS NOT NULL THEN + WITH conflicts AS ( + SELECT + src.library_id, + src.episode_id AS source_episode_id, + dest.episode_id AS target_episode_id, + LEAST(src.available_at, dest.available_at) AS available_at, + LEAST(src.created_at, dest.created_at) AS created_at + FROM public.episode_availability src + JOIN public.episode_availability dest + ON dest.library_id = src.library_id + AND dest.series_id = p_to + AND dest.episode_key = src.episode_key + WHERE src.series_id = p_from + ), + updated_source AS ( + UPDATE public.episode_availability src + SET available_at = conflicts.available_at, + created_at = conflicts.created_at + FROM conflicts + WHERE src.library_id = conflicts.library_id + AND src.episode_id = conflicts.source_episode_id + RETURNING src.library_id, src.episode_id + ) + DELETE FROM public.episode_availability dest + USING conflicts + WHERE dest.library_id = conflicts.library_id + AND dest.episode_id = conflicts.target_episode_id + AND EXISTS ( + SELECT 1 + FROM updated_source u + WHERE u.library_id = conflicts.library_id + AND u.episode_id = conflicts.source_episode_id + ); + END IF; + + IF to_regclass('public.movie_availability') IS NOT NULL THEN + WITH conflicts AS ( + SELECT + src.library_id, + LEAST(src.available_at, dest.available_at) AS available_at, + LEAST(src.created_at, dest.created_at) AS created_at + FROM public.movie_availability src + JOIN public.movie_availability dest + ON dest.library_id = src.library_id + AND dest.item_id = p_to + WHERE src.item_id = p_from + ), + updated_source AS ( + UPDATE public.movie_availability src + SET available_at = conflicts.available_at, + created_at = conflicts.created_at + FROM conflicts + WHERE src.library_id = conflicts.library_id + AND src.item_id = p_from + RETURNING src.library_id, src.item_id + ) + DELETE FROM public.movie_availability dest + USING conflicts + WHERE dest.library_id = conflicts.library_id + AND dest.item_id = p_to + AND EXISTS ( + SELECT 1 + FROM updated_source u + WHERE u.library_id = conflicts.library_id + AND u.item_id = p_from + ); + END IF; + + FOR c IN + SELECT cl.oid::regclass AS rel, a.attname AS col + FROM pg_class cl + JOIN pg_namespace n ON n.oid = cl.relnamespace + JOIN pg_attribute a ON a.attrelid = cl.oid AND a.attnum > 0 AND NOT a.attisdropped + JOIN pg_type t ON t.oid = a.atttypid + WHERE cl.relkind IN ('r', 'p') + AND n.nspname = 'public' + AND t.typname IN ('text', 'varchar', 'bpchar') + AND a.attname IN ( + 'media_item_id', 'series_id', 'season_id', 'episode_id', 'content_id', + 'season_content_id', 'episode_content_id', 'library_item_id', 'cover_item', + 'item_id', 'similar_item_id', 'source_item_id' + ) + AND cl.relname NOT LIKE 'content_id_migration%' + -- Skip real FK children of the family; ON UPDATE CASCADE moves those. + AND NOT EXISTS ( + SELECT 1 + FROM pg_constraint con + JOIN unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord) ON TRUE + WHERE con.contype = 'f' + AND con.conrelid = cl.oid + AND con.confrelid IN ('media_items'::regclass, 'seasons'::regclass, 'episodes'::regclass) + AND k.attnum = a.attnum + ) + LOOP + EXECUTE format('UPDATE %s SET %I = $2 WHERE %I = $1', c.rel, c.col, c.col) + USING p_from, p_to; + END LOOP; + + -- Array-valued soft references are not covered by the scalar loop above + -- (text[] is not in the type filter and cannot carry an FK), matching the + -- gap closed in 20260612130000 Step 6b. Negligible at runtime because a + -- freshly matched local item is rarely already in a trending snapshot, but + -- kept in lockstep so a rename never leaves a stale array element. + IF to_regclass('public.trending_discover_snapshots') IS NOT NULL THEN + UPDATE trending_discover_snapshots + SET content_ids = array_replace(content_ids, p_from, p_to) + WHERE p_from = ANY(content_ids); + END IF; +END; +$$; +-- +goose StatementEnd + +-- +goose Down +-- Restore the pre-dedupe rename function from +-- 20260614120000_content_id_online_reid.sql. +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION silo_rename_content_id(p_from text, p_to text) +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + c RECORD; +BEGIN + IF p_from IS NULL OR p_to IS NULL OR p_from = p_to THEN + RETURN; + END IF; + + FOR c IN + SELECT cl.oid::regclass AS rel, a.attname AS col + FROM pg_class cl + JOIN pg_namespace n ON n.oid = cl.relnamespace + JOIN pg_attribute a ON a.attrelid = cl.oid AND a.attnum > 0 AND NOT a.attisdropped + JOIN pg_type t ON t.oid = a.atttypid + WHERE cl.relkind IN ('r', 'p') + AND n.nspname = 'public' + AND t.typname IN ('text', 'varchar', 'bpchar') + AND a.attname IN ( + 'media_item_id', 'series_id', 'season_id', 'episode_id', 'content_id', + 'season_content_id', 'episode_content_id', 'library_item_id', 'cover_item', + 'item_id', 'similar_item_id', 'source_item_id' + ) + AND cl.relname NOT LIKE 'content_id_migration%' + -- Skip real FK children of the family; ON UPDATE CASCADE moves those. + AND NOT EXISTS ( + SELECT 1 + FROM pg_constraint con + JOIN unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord) ON TRUE + WHERE con.contype = 'f' + AND con.conrelid = cl.oid + AND con.confrelid IN ('media_items'::regclass, 'seasons'::regclass, 'episodes'::regclass) + AND k.attnum = a.attnum + ) + LOOP + EXECUTE format('UPDATE %s SET %I = $2 WHERE %I = $1', c.rel, c.col, c.col) + USING p_from, p_to; + END LOOP; + + IF to_regclass('public.trending_discover_snapshots') IS NOT NULL THEN + UPDATE trending_discover_snapshots + SET content_ids = array_replace(content_ids, p_from, p_to) + WHERE p_from = ANY(content_ids); + END IF; +END; +$$; +-- +goose StatementEnd