feat(matching): split wrongly merged versions with watch-state reattribution; anchor group keys on provider tags (#319)

* feat(matching): split wrongly merged versions, reattribute watch state, anchor group keys on provider tags

Wrong merges (two titles normalizing to the same title+year key) stacked
different films as fake "versions" of one item with no in-app repair, and
explicit {tmdb-…}/[imdb-…] folder tags could not prevent it because the
content-group key ignored provider IDs entirely. Merges also silently
orphaned all per-user watch state.

- Anchor group keys on structured provider tags: same tag always groups,
  different tags can never merge; untagged files keep title+year keys.
- media_identity_overrides: path-scoped (root/file) forced identities applied
  during group inference, so admin splits survive rescans.
- internal/catalog/reattribute: shared user-state mover — exact moves for
  file-linked rows, evidence-based user_watch_history classification via the
  playback session log, newest-wins progress conflicts; wired into
  rebindItemToExistingItem to stop merge orphaning (with S/E episode mapping).
- POST /admin/items/{id}/split (dry-run = full transaction + rollback, so
  previews are exact), POST /admin/items/{id}/merge, GET /admin/items/{id}/files.
- Web admin: Split Versions dialog (files by folder → candidate search →
  preview → split), Resolve link from ambiguous-roots diagnostics.

Part of #318

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reattribute): classify history before moving session log; cover managed downloads and series-scoped preferences

Review findings on #319, all reproduced against a migrated scratch database:

- moveFileSubset re-pointed playback_history_admin before the history
  evidence query ran, erasing exactly the evidence proving a profile's plays
  were all on moved files — their history stayed behind as ambiguous.
  History classification now runs first; the pre-fix code demonstrably fails
  TestRun_HistoryEvidenceClassification.
- Managed offline downloads (downloads.content_id/episode_id) were not
  remapped on split or merge, stranding rows on the old id. Now moved per
  file on splits and swept per id pair on merges/episode re-anchoring.
- Series merges left user_audio_preferences, user_subtitle_preferences,
  user_series_playback_preferences (series_id-keyed) and the denormalized
  user_home_item_dismissals.series_id behind. All four now move, mirroring
  the provider-merge remap.

All five reattribute DB tests now verified green against PostgreSQL, with
new coverage for managed downloads, subtitle preferences, and dismissal
series ids.

Part of #318

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-06 12:23:32 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent d08a4f232f
commit 0fb5afe479
24 changed files with 3644 additions and 58 deletions
+64
View File
@@ -2945,6 +2945,8 @@ export interface LibraryRoot {
first_seen_at: string;
last_seen_at: string;
active_override?: LibraryRootOverride;
/** Catalog item this group matched to, when known. */
content_id?: string;
}
export interface LibraryRootsResponse {
@@ -4135,6 +4137,68 @@ export interface ItemMatchApplyRequest {
library_id?: number;
}
// Split/merge (wrong version-grouping repair) types
export interface ItemFile {
id: number;
library_id: number;
file_path: string;
observed_root_path: string;
season_number?: number;
episode_number?: number;
}
export interface ItemFilesResponse {
files: ItemFile[];
}
export type SplitHistoryMode = "evidence" | "keep" | "move_all";
export interface ItemSplitTarget {
provider_ids?: Record<string, string>;
content_id?: string;
unmatched?: boolean;
title?: string;
year?: number;
}
export interface ItemSplitRequest {
file_ids: number[];
target: ItemSplitTarget;
history_mode?: SplitHistoryMode;
persist_override?: boolean;
dry_run?: boolean;
}
export interface ReattributionReport {
playback_session_log: number;
downloads: number;
progress_moved: number;
progress_conflicts: number;
history_moved: number;
history_stayed: number;
history_ambiguous: number;
intent_moved: number;
episode_pairs_moved: number;
ambiguous_history?: {
user_id: number;
profile_id: string;
watched_at: string;
}[];
}
export interface ItemSplitResponse {
dry_run: boolean;
source_content_id: string;
target_content_id: string;
target_created: boolean;
files_moved: number;
root_overrides: string[];
file_overrides: string[];
episode_pairs: number;
reattribution: ReattributionReport;
}
// Image selector types
export interface RemoteImage {
provider_id: string;