diff --git a/docs/superpowers/specs/2026-07-06-split-versions-reassign-design.md b/docs/superpowers/specs/2026-07-06-split-versions-reassign-design.md new file mode 100644 index 00000000..83334128 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-split-versions-reassign-design.md @@ -0,0 +1,355 @@ +# Split Versions / Per-Folder Reassign with History Reattribution — Design Spec + +**Date:** 2026-07-06 +**Status:** Proposed +**Scope:** scanner / catalog / metadata / admin API / web admin +**Related:** `docs/architecture/deterministic-content-id.md`, +`migrations/sql/078_content_groups_and_locations.sql`, +`migrations/sql/20260614120000_content_id_online_reid.sql` + +Commands and paths assume the repository root is the cwd. + +## Problem + +The scanner groups files into logical items by `ContentGroupKey` +(normalized title + year, `internal/naming/group_identity.go`) unless a +structured provider tag (`{tmdb-…}`, `[tvdbid-…]`, `tt…`) anchors the +identity. When two different titles parse to the same key (remakes, +sequels with sloppy names, "Movie (2019)" vs "Movie (2019) Directors Cut" +folders that are actually different films), their files are merged into a +single item as "versions". + +Today there is **no way to fix a wrong merge in-app**: + +- Item-level rematch (`POST /admin/items/{id}/match/search|apply`, + `internal/api/handlers/admin_match.go`) re-identifies the *whole* item — + every file follows. +- Group/root overrides (`internal/scanner/group_override_repo.go`, + `HandleUpsertRootOverride` in `internal/api/handlers/libraries.go`) force + an identity for an *entire* group, and the root-override endpoints + explicitly refuse ambiguous roots with 409 `ambiguous_root` — telling the + admin to "override the group after splitting", an operation that does not + exist. +- The only real fix is renaming folders on disk with provider tags and + rescanning — and even then, watch state accrued under the merged item is + not reattributed. + +Symmetrically, when a **wrong split** is merged back (two items that are +one title), watch state on the losing item is silently orphaned. + +## Goal + +An admin operation that: + +1. **Splits** a selected subset of an item's files (typically one folder) + into a different logical item — an existing item, a newly identified + one, or an unmatched `local-` placeholder. +2. Makes the assignment **durable across rescans** (path-scoped identity + overrides, not just a one-time row edit). +3. **Reattributes user watch state** (progress, history, downloads, + session records) to the item that actually owns the plays, using + per-file evidence where it exists. +4. Provides the same reattribution when items are **merged**, so the + existing `rebindItemToExistingItem` path stops orphaning state. + +Non-goals: automatic detection of wrong merges (ambiguity flagging already +exists and stays), cross-server state sync, file-level dedup. + +## Existing machinery this builds on + +| Asset | Where | Reused for | +| ----- | ----- | ---------- | +| Deterministic `content_id` (`movie-tmdb-…`) | `internal/contentid` | The split target's id is *derived*, never minted — splitting to TMDB 603 lands on `movie-tmdb-603` whether or not it already exists. | +| `silo_rename_content_id` + `ON UPDATE CASCADE` on the content-id FK family | `migrations/sql/20260614120000_content_id_online_reid.sql` | Whole-item renames during merge; the FK cascade also means per-row `UPDATE … SET media_item_id` is safe everywhere. | +| `rebindItemToExistingItem` / `canonicalizeLocalContentID` | `internal/metadata/canonicalize.go` | Merge path; gains reattribution. | +| Skeleton creation + match apply | `internal/metadata/service.go`, `internal/api/handlers/admin_match.go` | Creating/identifying the split-off item; candidate search UI already exists (`web/src/components/MatchItemDialog.tsx`). | +| `playback_history_admin` (per-session `media_file_id`, `media_item_id`, user/profile, timestamps, `completed`) | `migrations/sql/001_schema.sql` | The evidence table for attributing item-level history rows to files. | +| `user_watch_progress.last_file_id` | `migrations/sql/001_schema.sql` | Direct per-file attribution of resume state. | +| Group override application during inference | `internal/scanner/group_inference.go` (`applyGroupOverrides`) | Extended to path-scoped overrides so splits survive rescans. | + +## Design + +### 1. Unit of operation: files, addressed by folder + +The API operates on explicit `media_files.id` sets. The UI presents the +item's files grouped by `observed_root_path` with the parsed identity +evidence (`base_title`, `base_year`, `identity_confidence`, +`identity_json`), because "this folder is a different movie" is the +overwhelmingly common case. Selecting a folder selects its files. + +### 2. Durability: path-scoped identity overrides + +Wrong merges are produced by inference, so a one-time row edit would be +undone by the next scan. The split persists **identity overrides scoped to +paths**, generalizing the existing group override: + +New table `media_identity_overrides` (new timestamped migration via +`make migrate-create NAME=media_identity_overrides`): + +```sql +media_identity_overrides ( + id bigserial PRIMARY KEY, + media_folder_id int NOT NULL REFERENCES media_folders(id) ON DELETE CASCADE, + scope text NOT NULL CHECK (scope IN ('root', 'file')), + root_path text NOT NULL DEFAULT '', -- scope='root' + file_path text NOT NULL DEFAULT '', -- scope='file' + forced_type text NOT NULL DEFAULT '', + forced_title text NOT NULL DEFAULT '', + forced_year int NOT NULL DEFAULT 0, + forced_tmdb_id text NOT NULL DEFAULT '', + forced_imdb_id text NOT NULL DEFAULT '', + forced_tvdb_id text NOT NULL DEFAULT '', + note text NOT NULL DEFAULT '', + created_by_user_id int NULL, + updated_by_user_id int NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (media_folder_id, scope, root_path, file_path) +) +``` + +Application point: `internal/naming/group_identity.go` / +`internal/scanner/group_inference.go`. Before bucketing, each file checks +for an override — `file` scope wins over `root` scope, which wins over the +existing group-key override. A forced provider ID acts exactly like a +structured tag in the name (`hasStructuredIDAnchor` semantics): it anchors +identity, resolves title conflicts, and — because `makeContentGroupKey` +must incorporate the anchor for overridden files — guarantees the file +lands in its own group even when its parsed title+year collides with the +neighbor it was wrongly merged with. + +The existing `media_group_overrides` table stays for whole-group forcing; +`HandleUpsertRootOverride`'s 409 `ambiguous_root` branch is retired in +favor of pointing at the split flow. (Optionally, a follow-up migrates +group overrides into this table with `scope='group'`; not required for +v1 of this feature.) + +Overrides are visible and deletable in the admin UI (extend the existing +library "roots" listing, `HandleListLibraryRoots`, which already joins +overrides). + +### 3. The split operation + +`POST /admin/items/{id}/split` (admin-gated, additive API): + +```jsonc +{ + "file_ids": [123, 124], // required, non-empty, all owned by {id} + "target": { // one of: + "provider_ids": {"tmdb": "603"}, // identify (from match-candidate search) + "content_id": "movie-tmdb-603", // attach to an existing item + "unmatched": true // detach to a local- placeholder + }, + "history_mode": "evidence", // evidence | keep | move_all (default: evidence) + "persist_override": true, // default true; scope inferred (root if the + // selection covers whole roots, else file) + "dry_run": false +} +``` + +Validation guards: + +- Selecting **all** of the item's files is rejected with a hint to use + `match/apply` — that is a rematch, not a split. +- Target identity equal to the source item's identity is a no-op error. +- For series items, the selection must be root-aligned per series (you + split a show's folder, not half a season); episode-level moves between + two shows use the same call with `file` scope. + +Execution (single transaction, then async follow-ups): + +1. **Resolve target item.** Derive the deterministic `content_id` from the + target provider IDs (`internal/contentid`). If the item exists, use it; + otherwise create a skeleton via the metadata service (same path as + scanner skeleton creation). `unmatched` targets get a `local-` id + derived from the primary root path. +2. **Persist overrides** (unless `persist_override=false`): one `root` + override per fully-selected root, `file` overrides for partial roots. +3. **Re-point files.** `UPDATE media_files SET content_id = ` for + the selection. For series: re-derive `episode_id` for each moved file + from the target series anchor + parsed S/E numbers + (`contentid.ForEpisode`), creating season/episode skeleton rows as + needed — the same shape the scanner produces. +4. **Reattribute user state** (§4). +5. **Reconcile scanner state.** Recompute `scanned_media_groups`, + `media_group_locations`, `observed_media_locations`, and + `media_item_groups` for the affected folder/roots by re-running group + inference on the affected paths (not a full library scan). Locations + that were `ambiguous` because of the merge resolve to their overridden + groups. +6. **Post-commit:** queue a metadata refresh for the target item (and the + source item, whose aggregate fields — runtime, editions, trailers — + may have been polluted by the foreign files); invalidate jellycompat + caches for both ids; emit an audit log entry (actor, item ids, file + ids, history_mode, counts). + +`POST /admin/items/{id}/split` with `"dry_run": true` (or a sibling +`/split/preview`) returns the full plan without writing: target +content_id (and whether it already exists), files moved, overrides to be +written, and the per-table reattribution counts including the list of +history rows classified `ambiguous` (§4.2) — this is what the +confirmation UI renders. + +### 4. History reattribution + +All user-state moves live in one shared helper so split and merge use the +same logic — proposed package `internal/catalog/reattribute`: + +```go +// Moves user state tied to fromItem onto toItem, for the given moved files. +// movedFiles empty + wholeItem=true means "everything" (the merge case). +Reattribute(ctx, tx, fromItem, toItem string, movedFileIDs []int64, + mode HistoryMode) (Report, error) +``` + +#### 4.1 Rows with per-file evidence — exact moves + +| Table | Key | Rule | +| ----- | --- | ---- | +| `playback_history_admin` | `media_file_id` | `SET media_item_id = to` where `media_file_id` moved. Exact. | +| `user_downloads` | `media_file_id` | Same. | +| `user_playback_sessions`, `playback_sessions_sync` | `media_file_id` | Same; live sessions keep playing (file path unchanged), their item association is simply corrected. | +| `user_watch_progress` | `last_file_id` | Move rows whose `last_file_id` moved. PK is `(user_id, profile_id, media_item_id)`: on conflict with an existing progress row for the target item, keep the row with the newer `updated_at` and drop the other. Rows with NULL/unmoved `last_file_id` stay. | + +For **series splits** the mapping is even stronger: moved files carry +parsed S/E numbers, and episode content ids are +`episode----` — so every episode-level state row +maps old→new episode id deterministically. Progress/history/favorites +keyed on the *episode* ids of moved episodes move wholesale; ambiguity +only exists for movie-level rows. + +#### 4.2 `user_watch_history` — evidence-based attribution + +History rows carry no file reference; attribution uses +`playback_history_admin` as the evidence source. For each history row of +the source item (per user+profile): + +- **moved**: that profile's play sessions for the source item exist and + *all* of them are on moved files → row's `media_item_id` updated to the + target. +- **stays**: sessions exist and none are on moved files → row untouched. +- **ambiguous**: mixed sessions, or no session evidence (rows predating + `playback_history_admin`, imported history, retention gaps). + +`history_mode` controls ambiguous rows: `evidence` (default) leaves them +on the source item and reports the count; `keep` leaves everything +(evidence moves still apply to the exact tables in §4.1); `move_all` +moves every history row — for the "this item was 100% the other movie all +along" case where the admin knows better than the evidence. The dry-run +report lists ambiguous rows (user, watched_at) so the admin can decide. + +Watched/completed flags derived from history (continue-watching, watchlist +auto-removal via `watchlist.Maintainer`) recompute from the moved rows on +their normal paths; the helper fires the same completion-observer +notifications a rematch does, if any. + +#### 4.3 Item-level rows with no file dimension — deliberate defaults + +`user_favorites`, `user_watchlist`, `user_ratings`, +`user_personal_collection_items`, `library_collection_items`, +`user_home_item_dismissals`, `user_history_hidden_items`: a wrong merge +gives no signal about which title the user favorited. Default: **stay on +the source item**, counts surfaced in the report. `move_all` mode moves +these too (dedup on conflict). No copy-to-both — duplicating user intent +is worse than asking the user to re-favorite. + +Non-user state that references the item (`media_item_embeddings`, cached +artwork, localizations, trailers, credits) is *not* migrated — it is +metadata, and the post-split refresh rebuilds it for both items. + +#### 4.4 Merge reattribution + +`rebindItemToExistingItem` (and the admin-facing merge this spec enables: +`POST /admin/items/{id}/merge {"into": ""}`) calls the same +helper with `wholeItem=true`: every state row moves, with the same +PK-conflict rule (newer `updated_at` wins for progress; history rows +simply re-key; favorites/watchlist dedup on conflict). This closes the +existing orphaning bug where merged items strand their watch state. + +### 5. Admin UI + +- **Item page:** alongside "Fix match" (existing `MatchItemDialog`), a + "Split versions…" action, enabled when the item has >1 file. Dialog: + file list grouped by folder with identity evidence and per-file + checkboxes → identity picker (reuses the match-candidate search) → + dry-run confirmation showing the reattribution report → execute. +- **Library roots page:** ambiguous locations + (`observed_media_locations.content_group_count > 1` or group state + `ambiguous`) get a "Resolve…" action that opens the same dialog scoped + to that root. +- Overrides listed and revocable on the roots page (revoking does not + undo a past split; it only frees future scans to re-infer). + +### 6. Client / compat impact + +Split-off files become a *new* item id; jellycompat packs `content_id` +into the client-visible UUID, so clients simply see one item's version +list shrink and a new item appear on next library sync — no client-side +changes required in `silo-android` / `silo-apple`. Resume state moves +server-side, so "Continue Watching" follows the correct title +automatically. Active playback sessions are unaffected (keyed by file). + +### 7. Failure & idempotency + +- The split transaction is atomic: overrides + file re-point + + reattribution + scanner-state reconcile commit together; metadata + refresh and cache invalidation are post-commit and self-healing (refresh + debt already retries). +- Re-running the same split is a no-op (files already on target; override + upsert idempotent). +- A rescan after a crash converges: overrides are already persisted, and + group inference reproduces the same assignment. If the crash landed + *before* the transaction committed, nothing changed. +- `playback_history_admin` is best-effort evidence (subject to its + retention/cleanup, `internal/catalog/orphan_cleanup.go`); absence of + evidence degrades to `ambiguous`, never to a wrong move. + +### 8. Risks / open questions + +- **Group-key incorporation of overrides** must be deterministic and + stable (`makeContentGroupKey` gains an anchor component for overridden + files). Bump `group_key_version` handling is *not* needed — overridden + files just occupy distinct keys within v1. +- **Series episode re-derivation** assumes parsed S/E numbers are correct + for moved files; files with unparsable numbering are rejected from the + selection with a per-file reason (fix naming first). +- **`local-` targets** key on path; a later rename of the split-off folder + re-IDs the local item (accepted limitation, same as scanner behavior). +- **Retention window** of `playback_history_admin` bounds history + attribution quality; if it proves too short in practice, a follow-up + adds `media_file_id` (nullable) to `user_watch_history` written at + playback-stop time, making future attribution exact. + +## Addendum (2026-07-06): structured tags now anchor group keys + +A field report validated during implementation showed the highest-frequency +wrong-merge cause is worse than assumed: two *correctly tagged* folders — +"Passenger (2026) `[tmdb-1368314]`" and "The Passenger (2026) +`[tmdb-1285959]`" — merged into one item with state `resolved`, because +`makeContentGroupKey` is title+year only and title normalization strips the +leading article. The explicit provider tags were parsed but never entered the +group key. + +Fix shipped with this feature: `InferGroupIdentity` now derives the same +anchored key shape used for identity overrides (`v1|movie|anchor|tmdb-…`) +whenever structured provider IDs are present. Files tagged with the same +provider ID always share a group (cross-folder versions keep working); files +tagged apart can never merge. Untagged files keep title+year keys, so +untagged libraries see no key churn. Tagged libraries re-key on next scan; +content ids are unaffected (they are deterministic from the same tags), so +this is scanner-internal state churn only. Items merged *before* this fix +still need the split flow to repair. + +## Implementation order + +1. Migration: `media_identity_overrides`; inference application in + `internal/naming` / `internal/scanner` (+ tests: forced-ID splits a + colliding key, file scope beats root scope, rescan convergence). +2. `internal/catalog/reattribute` helper + tests (per-table rules, + PK-conflict handling, evidence classification) — wire into + `rebindItemToExistingItem` first (pure win, no new API). +3. Split endpoint (+ dry run) and merge endpoint in + `internal/api/handlers`; scanner-state reconcile. +4. Web admin dialog + roots-page "Resolve…" action; retire the + `ambiguous_root` 409 dead-end. +5. Audit log + docs; follow-up issue for `user_watch_history.media_file_id`. diff --git a/internal/api/handlers/admin_split.go b/internal/api/handlers/admin_split.go new file mode 100644 index 00000000..0a0bc3c9 --- /dev/null +++ b/internal/api/handlers/admin_split.go @@ -0,0 +1,708 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/api/middleware" + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/catalog/reattribute" + "github.com/Silo-Server/silo-server/internal/contentid" + "github.com/Silo-Server/silo-server/internal/metadata" + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/scanner" +) + +// ItemMerger merges one catalog item into another, moving files and user +// state (implemented by metadata.MetadataService.MergeItems). +type ItemMerger interface { + MergeItems(ctx context.Context, fromContentID, toContentID string) error +} + +// AdminSplitHandler implements the split/merge repair endpoints: +// +// POST /admin/items/{id}/split — move a subset of an item's files to another +// (possibly new) item, persist path-scoped identity overrides so rescans +// converge, and reattribute per-user watch state. +// POST /admin/items/{id}/merge — fold a duplicate item into another. +type AdminSplitHandler struct { + pool *pgxpool.Pool + items MatchItemLookup + metadata MatchMetadataService // post-commit identify of the target; may be nil + merger ItemMerger // may be nil (merge endpoint disabled) + refresher AdminMetadataRefresher + scanner *scanner.Scanner + folderRepo *catalog.FolderRepository + overrideRepo *scanner.MediaIdentityOverrideRepository +} + +// NewAdminSplitHandler wires the split/merge endpoints. metadataSvc, merger, +// refresher and scannerInstance are optional; nil disables the corresponding +// follow-up behavior (or the merge endpoint). +func NewAdminSplitHandler( + pool *pgxpool.Pool, + items MatchItemLookup, + metadataSvc MatchMetadataService, + merger ItemMerger, + refresher AdminMetadataRefresher, + scannerInstance *scanner.Scanner, + folderRepo *catalog.FolderRepository, +) *AdminSplitHandler { + return &AdminSplitHandler{ + pool: pool, + items: items, + metadata: metadataSvc, + merger: merger, + refresher: refresher, + scanner: scannerInstance, + folderRepo: folderRepo, + overrideRepo: scanner.NewMediaIdentityOverrideRepository(pool), + } +} + +type splitTargetRequest struct { + ProviderIDs map[string]string `json:"provider_ids,omitempty"` + ContentID string `json:"content_id,omitempty"` + Unmatched bool `json:"unmatched,omitempty"` + // Title/Year seed the skeleton row when the target does not exist yet + // (the post-commit identify replaces them with provider metadata). + Title string `json:"title,omitempty"` + Year int `json:"year,omitempty"` +} + +type splitItemRequest struct { + FileIDs []int `json:"file_ids"` + Target splitTargetRequest `json:"target"` + HistoryMode string `json:"history_mode,omitempty"` + PersistOverride *bool `json:"persist_override,omitempty"` + DryRun bool `json:"dry_run,omitempty"` +} + +type splitItemResponse struct { + DryRun bool `json:"dry_run"` + SourceContentID string `json:"source_content_id"` + TargetContentID string `json:"target_content_id"` + TargetCreated bool `json:"target_created"` + FilesMoved int `json:"files_moved"` + RootOverrides []string `json:"root_overrides"` + FileOverrides []string `json:"file_overrides"` + EpisodePairs int `json:"episode_pairs"` + Reattribution *reattribute.Report `json:"reattribution"` +} + +type mergeItemRequest struct { + Into string `json:"into"` +} + +// splitFile is the slice of media_files the split logic needs. +type splitFile struct { + ID int + MediaFolderID int + FilePath string + ObservedRootPath string + SeasonNumber int + EpisodeNumber int + EpisodeID string +} + +type itemFileResponse struct { + ID int `json:"id"` + LibraryID int `json:"library_id"` + FilePath string `json:"file_path"` + ObservedRootPath string `json:"observed_root_path"` + SeasonNumber int `json:"season_number,omitempty"` + EpisodeNumber int `json:"episode_number,omitempty"` +} + +// HandleListItemFiles handles GET /admin/items/{id}/files. It backs the split +// dialog: the raw media_files rows of an item, grouped client-side by folder. +func (h *AdminSplitHandler) HandleListItemFiles(w http.ResponseWriter, r *http.Request) { + contentID := chi.URLParam(r, "id") + if contentID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + if _, err := h.items.GetByID(r.Context(), contentID); err != nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + files, err := h.loadItemFiles(r.Context(), contentID) + if err != nil { + slog.Error("admin split: listing item files", "content_id", contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files") + return + } + resp := make([]itemFileResponse, 0, len(files)) + for _, f := range files { + resp = append(resp, itemFileResponse{ + ID: f.ID, + LibraryID: f.MediaFolderID, + FilePath: f.FilePath, + ObservedRootPath: f.ObservedRootPath, + SeasonNumber: f.SeasonNumber, + EpisodeNumber: f.EpisodeNumber, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"files": resp}) +} + +// HandleSplitItem handles POST /admin/items/{id}/split. +func (h *AdminSplitHandler) HandleSplitItem(w http.ResponseWriter, r *http.Request) { + sourceID := chi.URLParam(r, "id") + if sourceID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + var req splitItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + mode := reattribute.HistoryMode(strings.TrimSpace(req.HistoryMode)) + if mode == "" { + mode = reattribute.HistoryModeEvidence + } + if !reattribute.ValidHistoryMode(mode) { + writeError(w, http.StatusBadRequest, "bad_request", "history_mode must be evidence, keep, or move_all") + return + } + if len(req.FileIDs) == 0 { + writeError(w, http.StatusBadRequest, "bad_request", "file_ids is required") + return + } + + ctx := r.Context() + sourceItem, err := h.items.GetByID(ctx, sourceID) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + if sourceItem.Type != "movie" && sourceItem.Type != "series" { + writeError(w, http.StatusBadRequest, "bad_request", "Split is only supported for movie and series items") + return + } + + files, err := h.loadItemFiles(ctx, sourceID) + if err != nil { + slog.Error("admin split: loading item files", "content_id", sourceID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files") + return + } + byID := make(map[int]splitFile, len(files)) + for _, f := range files { + byID[f.ID] = f + } + moved := make([]splitFile, 0, len(req.FileIDs)) + seen := map[int]bool{} + for _, id := range req.FileIDs { + f, ok := byID[id] + if !ok { + writeError(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("File %d does not belong to this item", id)) + return + } + if !seen[id] { + seen[id] = true + moved = append(moved, f) + } + } + if len(moved) == len(files) { + writeError(w, http.StatusBadRequest, "bad_request", + "Selection covers every file; use match/apply to re-identify the whole item instead of splitting it") + return + } + + target, err := h.resolveSplitTarget(ctx, sourceItem, moved, req.Target) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + episodePairs := deriveEpisodePairs(sourceItem, moved, target.contentID) + + // Everything transactional happens here; a dry run rolls back at the end. + tx, err := h.pool.Begin(ctx) + if err != nil { + slog.Error("admin split: begin transaction", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start split") + return + } + defer func() { _ = tx.Rollback(ctx) }() + + if target.created { + if err := insertSkeletonItem(ctx, tx, target, sourceItem); err != nil { + slog.Error("admin split: creating target item", "target", target.contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create target item") + return + } + } + if err := moveFilesToItem(ctx, tx, target.contentID, moved); err != nil { + slog.Error("admin split: moving files", "target", target.contentID, "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to move files") + return + } + + rootOverrides, fileOverrides := []string{}, []string{} + persistOverride := req.PersistOverride == nil || *req.PersistOverride + if persistOverride && target.hasForcedIdentity() { + rootOverrides, fileOverrides, err = h.persistOverrides(ctx, tx, moved, target, middleware.GetUserID(ctx)) + if err != nil { + slog.Error("admin split: persisting overrides", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to persist identity overrides") + return + } + } + + report, err := reattribute.Run(ctx, tx, reattribute.Options{ + FromContentID: sourceID, + ToContentID: target.contentID, + MovedFileIDs: fileIDs(moved), + Mode: mode, + EpisodePairs: episodePairs, + }) + if err != nil { + slog.Error("admin split: reattributing user state", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to reattribute user state") + return + } + + if !req.DryRun { + if err := tx.Commit(ctx); err != nil { + slog.Error("admin split: commit", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to commit split") + return + } + slog.Info("admin split: item split", + "actor_user_id", middleware.GetUserID(ctx), + "source_content_id", sourceID, + "target_content_id", target.contentID, + "target_created", target.created, + "files_moved", len(moved), + "history_mode", mode, + "history_moved", report.HistoryMoved, + "history_ambiguous", report.HistoryAmbiguous, + "progress_moved", report.ProgressMoved, + ) + h.runPostSplitFollowUps(sourceID, target, moved) + } + + writeJSON(w, http.StatusOK, splitItemResponse{ + DryRun: req.DryRun, + SourceContentID: sourceID, + TargetContentID: target.contentID, + TargetCreated: target.created, + FilesMoved: len(moved), + RootOverrides: rootOverrides, + FileOverrides: fileOverrides, + EpisodePairs: len(episodePairs), + Reattribution: report, + }) +} + +// HandleMergeItem handles POST /admin/items/{id}/merge. +func (h *AdminSplitHandler) HandleMergeItem(w http.ResponseWriter, r *http.Request) { + sourceID := chi.URLParam(r, "id") + if sourceID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "Item ID is required") + return + } + if h.merger == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Merge is not available") + return + } + var req mergeItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + req.Into = strings.TrimSpace(req.Into) + if req.Into == "" { + writeError(w, http.StatusBadRequest, "bad_request", "into is required") + return + } + + if err := h.merger.MergeItems(r.Context(), sourceID, req.Into); err != nil { + if errors.Is(err, catalog.ErrItemNotFound) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + slog.Warn("admin merge: failed", "source", sourceID, "target", req.Into, "error", err) + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + slog.Info("admin merge: item merged", + "actor_user_id", middleware.GetUserID(r.Context()), + "source_content_id", sourceID, + "target_content_id", req.Into, + ) + if h.refresher != nil { + if err := h.refresher.RefreshItem(context.WithoutCancel(r.Context()), req.Into); err != nil { + slog.Warn("admin merge: target refresh failed", "content_id", req.Into, "error", err) + } + } + writeJSON(w, http.StatusOK, map[string]any{"merged_into": req.Into}) +} + +// splitTarget is the resolved destination of a split. +type splitTarget struct { + contentID string + created bool + itemType string + title string + year int + providerIDs map[string]string // normalized; empty for unmatched targets + folderIDs []int // folders of the moved files (library membership) +} + +func (t splitTarget) hasForcedIdentity() bool { + return len(t.providerIDs) > 0 || strings.TrimSpace(t.title) != "" +} + +func (h *AdminSplitHandler) resolveSplitTarget( + ctx context.Context, + sourceItem *models.MediaItem, + moved []splitFile, + req splitTargetRequest, +) (splitTarget, error) { + target := splitTarget{ + itemType: sourceItem.Type, + title: strings.TrimSpace(req.Title), + year: req.Year, + providerIDs: normalizeMatchProviderIDs(req.ProviderIDs), + folderIDs: distinctFolderIDs(moved), + } + + switch { + case strings.TrimSpace(req.ContentID) != "": + target.contentID = strings.TrimSpace(req.ContentID) + existing, err := h.items.GetByID(ctx, target.contentID) + if err != nil { + return target, fmt.Errorf("target item %s not found", target.contentID) + } + if existing.Type != sourceItem.Type { + return target, fmt.Errorf("target item is a %s, source is a %s", existing.Type, sourceItem.Type) + } + target.title = existing.Title + // Reuse the target's provider ids for override persistence so rescans + // route the moved files straight back to it. + if len(target.providerIDs) == 0 { + target.providerIDs = map[string]string{} + setMatchProviderID(target.providerIDs, "tmdb", existing.TmdbID) + setMatchProviderID(target.providerIDs, "imdb", existing.ImdbID) + setMatchProviderID(target.providerIDs, "tvdb", existing.TvdbID) + } + case len(target.providerIDs) > 0: + ids := contentid.ProviderIDs{ + Tmdb: target.providerIDs["tmdb"], + Imdb: target.providerIDs["imdb"], + Tvdb: target.providerIDs["tvdb"], + } + var derived string + var ok bool + if sourceItem.Type == "series" { + derived, ok = contentid.ForSeries(ids) + } else { + derived, ok = contentid.ForMovie(ids) + } + if !ok { + return target, fmt.Errorf("provider_ids must include a usable tmdb, imdb, or tvdb id") + } + target.contentID = derived + if existing, err := h.items.GetByID(ctx, derived); err == nil && existing != nil { + target.title = existing.Title + } else { + target.created = true + } + case req.Unmatched: + // Path-derived local id, matching scanner behavior for untagged items. + target.contentID = contentid.ForLocal(moved[0].FilePath) + target.providerIDs = nil + if _, err := h.items.GetByID(ctx, target.contentID); err != nil { + target.created = true + } + default: + return target, fmt.Errorf("target requires provider_ids, content_id, or unmatched") + } + + if target.contentID == sourceItem.ContentID { + return target, fmt.Errorf("target resolves to the source item; nothing to split") + } + if target.title == "" { + target.title = sourceItem.Title + } + return target, nil +} + +// deriveEpisodePairs maps moved files' current episode ids onto the target +// series' deterministic episode ids by parsed season/episode number. Only +// possible when the target id is provider-anchored; local targets get no +// episode-level reattribution (state stays behind, reported to the operator). +func deriveEpisodePairs(sourceItem *models.MediaItem, moved []splitFile, targetContentID string) []reattribute.IDPair { + if sourceItem.Type != "series" || !contentid.IsProviderAnchored(targetContentID) { + return nil + } + seen := map[string]bool{} + var pairs []reattribute.IDPair + for _, f := range moved { + if f.EpisodeID == "" || f.SeasonNumber <= 0 || f.EpisodeNumber <= 0 || seen[f.EpisodeID] { + continue + } + newID, ok := contentid.ForEpisode(targetContentID, f.SeasonNumber, f.EpisodeNumber) + if !ok || newID == f.EpisodeID { + continue + } + seen[f.EpisodeID] = true + pairs = append(pairs, reattribute.IDPair{From: f.EpisodeID, To: newID}) + } + return pairs +} + +func (h *AdminSplitHandler) loadItemFiles(ctx context.Context, contentID string) ([]splitFile, error) { + rows, err := h.pool.Query(ctx, ` + SELECT id, media_folder_id, file_path, + COALESCE(observed_root_path, ''), + COALESCE(season_number, 0), + COALESCE(episode_number, 0), + COALESCE(episode_id, '') + FROM media_files + WHERE content_id = $1 + ORDER BY file_path ASC + `, contentID) + if err != nil { + return nil, err + } + defer rows.Close() + + var files []splitFile + for rows.Next() { + var f splitFile + if err := rows.Scan(&f.ID, &f.MediaFolderID, &f.FilePath, &f.ObservedRootPath, &f.SeasonNumber, &f.EpisodeNumber, &f.EpisodeID); err != nil { + return nil, err + } + if f.ObservedRootPath == "" { + f.ObservedRootPath = filepath.Dir(f.FilePath) + } + files = append(files, f) + } + return files, rows.Err() +} + +func insertSkeletonItem(ctx context.Context, tx pgx.Tx, target splitTarget, sourceItem *models.MediaItem) error { + year := target.year + if year == 0 && target.providerIDs == nil { + // Unmatched local target: keep the source year so the shell is legible. + year = sourceItem.Year + } + status := "pending" + if target.providerIDs == nil { + status = "unmatched" + } + if _, err := tx.Exec(ctx, ` + INSERT INTO media_items (content_id, type, title, year, status, tmdb_id, imdb_id, tvdb_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (content_id) DO NOTHING + `, target.contentID, target.itemType, target.title, year, status, + target.providerIDs["tmdb"], target.providerIDs["imdb"], target.providerIDs["tvdb"]); err != nil { + return err + } + for _, folderID := range target.folderIDs { + if _, err := tx.Exec(ctx, ` + INSERT INTO media_item_libraries (content_id, media_folder_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + `, target.contentID, folderID); err != nil { + return err + } + } + return nil +} + +func moveFilesToItem(ctx context.Context, tx pgx.Tx, targetContentID string, moved []splitFile) error { + // episode_id resets: the metadata refresh / scanner relink recreates the + // links against the target series' (deterministic) episode rows. + _, err := tx.Exec(ctx, ` + UPDATE media_files + SET content_id = $1, + episode_id = NULL, + match_attempted_at = NULL, + updated_at = NOW() + WHERE id = ANY($2::int[]) + `, targetContentID, fileIDs(moved)) + if err != nil { + return err + } + // Library membership for the target in every folder that received files. + for _, folderID := range distinctFolderIDs(moved) { + if _, err := tx.Exec(ctx, ` + INSERT INTO media_item_libraries (content_id, media_folder_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + `, targetContentID, folderID); err != nil { + return err + } + } + return nil +} + +// persistOverrides writes the durable path-scoped identity overrides: one +// root-scope override per observed root whose files ALL moved, file-scope +// overrides for partially-moved roots. +func (h *AdminSplitHandler) persistOverrides( + ctx context.Context, + tx pgx.Tx, + moved []splitFile, + target splitTarget, + actorUserID int, +) (rootPaths []string, filePaths []string, err error) { + movedByRoot := map[string][]splitFile{} + folderByRoot := map[string]int{} + for _, f := range moved { + movedByRoot[f.ObservedRootPath] = append(movedByRoot[f.ObservedRootPath], f) + folderByRoot[f.ObservedRootPath] = f.MediaFolderID + } + + roots := make([]string, 0, len(movedByRoot)) + for root := range movedByRoot { + roots = append(roots, root) + } + sort.Strings(roots) + + base := models.MediaIdentityOverride{ + ForcedType: target.itemType, + ForcedTitle: target.title, + ForcedYear: target.year, + ForcedTmdbID: target.providerIDs["tmdb"], + ForcedImdbID: target.providerIDs["imdb"], + ForcedTvdbID: target.providerIDs["tvdb"], + Note: fmt.Sprintf("split to %s", target.contentID), + } + if actorUserID > 0 { + base.CreatedByUserID = &actorUserID + base.UpdatedByUserID = &actorUserID + } + + for _, root := range roots { + movedHere := movedByRoot[root] + folderID := folderByRoot[root] + + // Root scope only when the moved selection covers every file under the + // root (any item): otherwise the override would drag neighbors along. + var totalAtRoot int + if err := tx.QueryRow(ctx, ` + SELECT count(*) FROM media_files + WHERE media_folder_id = $1 AND observed_root_path = $2 + `, folderID, root).Scan(&totalAtRoot); err != nil { + return nil, nil, err + } + + override := base + override.MediaFolderID = folderID + if totalAtRoot == len(movedHere) { + override.Scope = models.IdentityOverrideScopeRoot + override.RootPath = root + if err := h.overrideRepo.UpsertTx(ctx, tx, override); err != nil { + return nil, nil, err + } + rootPaths = append(rootPaths, root) + continue + } + for _, f := range movedHere { + fileOverride := override + fileOverride.Scope = models.IdentityOverrideScopeFile + fileOverride.FilePath = f.FilePath + if err := h.overrideRepo.UpsertTx(ctx, tx, fileOverride); err != nil { + return nil, nil, err + } + filePaths = append(filePaths, f.FilePath) + } + } + return rootPaths, filePaths, nil +} + +// runPostSplitFollowUps performs the self-healing, non-transactional steps: +// identify the target against its providers, refresh the source's aggregates, +// and rescan the affected subtrees so scanner snapshots converge now instead +// of at the next scheduled scan. +func (h *AdminSplitHandler) runPostSplitFollowUps(sourceID string, target splitTarget, moved []splitFile) { + folderIDs := distinctFolderIDs(moved) + roots := map[int]map[string]bool{} + for _, f := range moved { + if roots[f.MediaFolderID] == nil { + roots[f.MediaFolderID] = map[string]bool{} + } + roots[f.MediaFolderID][f.ObservedRootPath] = true + } + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + if h.metadata != nil && len(target.providerIDs) > 0 { + folderIDStr := "" + if len(folderIDs) == 1 { + folderIDStr = fmt.Sprintf("%d", folderIDs[0]) + } + if _, err := h.metadata.Process(ctx, metadata.ProcessRequest{ + ContentID: target.contentID, + ProviderIDs: target.providerIDs, + FolderID: folderIDStr, + Mode: metadata.ModeIdentify, + }); err != nil { + slog.Warn("admin split: target identify failed (will retry via refresh debt)", + "content_id", target.contentID, "error", err) + } + } + if h.refresher != nil { + if err := h.refresher.RefreshItem(ctx, sourceID); err != nil { + slog.Warn("admin split: source refresh failed", "content_id", sourceID, "error", err) + } + } + if h.scanner != nil && h.folderRepo != nil { + for folderID, folderRoots := range roots { + folder, err := h.folderRepo.GetByID(ctx, folderID) + if err != nil { + slog.Warn("admin split: folder lookup for rescan failed", "folder_id", folderID, "error", err) + continue + } + for root := range folderRoots { + if _, err := h.scanner.ScanSubtree(ctx, folder, root); err != nil { + slog.Warn("admin split: subtree rescan failed", + "folder_id", folderID, "root", root, "error", err) + } + } + } + } + }() +} + +func fileIDs(files []splitFile) []int { + ids := make([]int, 0, len(files)) + for _, f := range files { + ids = append(ids, f.ID) + } + return ids +} + +func distinctFolderIDs(files []splitFile) []int { + seen := map[int]bool{} + var ids []int + for _, f := range files { + if !seen[f.MediaFolderID] { + seen[f.MediaFolderID] = true + ids = append(ids, f.MediaFolderID) + } + } + sort.Ints(ids) + return ids +} diff --git a/internal/api/handlers/admin_split_test.go b/internal/api/handlers/admin_split_test.go new file mode 100644 index 00000000..b8151957 --- /dev/null +++ b/internal/api/handlers/admin_split_test.go @@ -0,0 +1,132 @@ +package handlers + +import ( + "context" + "fmt" + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) + +type fakeItemLookup struct { + items map[string]*models.MediaItem +} + +func (f *fakeItemLookup) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) { + if item, ok := f.items[contentID]; ok { + return item, nil + } + return nil, fmt.Errorf("item %s not found", contentID) +} + +func splitTestHandler(items map[string]*models.MediaItem) *AdminSplitHandler { + return &AdminSplitHandler{items: &fakeItemLookup{items: items}} +} + +func TestResolveSplitTarget_ProviderIDsDeriveDeterministicID(t *testing.T) { + t.Parallel() + source := &models.MediaItem{ContentID: "movie-tmdb-100", Type: "movie", Title: "The Grudge", Year: 2004} + h := splitTestHandler(map[string]*models.MediaItem{source.ContentID: source}) + moved := []splitFile{{ID: 1, MediaFolderID: 3, FilePath: "/m/a.mkv", ObservedRootPath: "/m"}} + + target, err := h.resolveSplitTarget(context.Background(), source, moved, splitTargetRequest{ + ProviderIDs: map[string]string{"TMDB": " 11838 "}, + }) + if err != nil { + t.Fatalf("resolveSplitTarget: %v", err) + } + if target.contentID != "movie-tmdb-11838" { + t.Fatalf("contentID = %q, want movie-tmdb-11838", target.contentID) + } + if !target.created { + t.Fatal("expected target.created for unknown derived id") + } + if target.providerIDs["tmdb"] != "11838" { + t.Fatalf("providerIDs = %v, want normalized tmdb 11838", target.providerIDs) + } + // Falls back to the source title for the skeleton row. + if target.title != source.Title { + t.Fatalf("title = %q, want %q", target.title, source.Title) + } +} + +func TestResolveSplitTarget_ExistingContentIDAdoptsProviderIDs(t *testing.T) { + t.Parallel() + source := &models.MediaItem{ContentID: "movie-tmdb-100", Type: "movie", Title: "Crash"} + existing := &models.MediaItem{ContentID: "movie-tmdb-10723", Type: "movie", Title: "Crash (1996)", TmdbID: "10723"} + h := splitTestHandler(map[string]*models.MediaItem{ + source.ContentID: source, + existing.ContentID: existing, + }) + moved := []splitFile{{ID: 1, MediaFolderID: 3, FilePath: "/m/a.mkv", ObservedRootPath: "/m"}} + + target, err := h.resolveSplitTarget(context.Background(), source, moved, splitTargetRequest{ + ContentID: existing.ContentID, + }) + if err != nil { + t.Fatalf("resolveSplitTarget: %v", err) + } + if target.created { + t.Fatal("existing target must not be flagged created") + } + if target.providerIDs["tmdb"] != "10723" { + t.Fatalf("providerIDs = %v, want target's tmdb id for override persistence", target.providerIDs) + } +} + +func TestResolveSplitTarget_Rejections(t *testing.T) { + t.Parallel() + source := &models.MediaItem{ContentID: "movie-tmdb-100", Type: "movie", Title: "A"} + series := &models.MediaItem{ContentID: "series-tvdb-5", Type: "series", Title: "B"} + h := splitTestHandler(map[string]*models.MediaItem{ + source.ContentID: source, + series.ContentID: series, + }) + moved := []splitFile{{ID: 1, MediaFolderID: 3, FilePath: "/m/a.mkv", ObservedRootPath: "/m"}} + + cases := []struct { + name string + req splitTargetRequest + }{ + {"no target", splitTargetRequest{}}, + {"type mismatch", splitTargetRequest{ContentID: series.ContentID}}, + {"unknown content id", splitTargetRequest{ContentID: "movie-tmdb-404"}}, + {"target equals source", splitTargetRequest{ContentID: source.ContentID}}, + {"unusable provider ids", splitTargetRequest{ProviderIDs: map[string]string{"anidb": "1"}}}, + } + for _, tc := range cases { + if _, err := h.resolveSplitTarget(context.Background(), source, moved, tc.req); err == nil { + t.Errorf("%s: expected error", tc.name) + } + } +} + +func TestDeriveEpisodePairs(t *testing.T) { + t.Parallel() + series := &models.MediaItem{ContentID: "series-tvdb-100", Type: "series"} + moved := []splitFile{ + {ID: 1, EpisodeID: "episode-tvdb-100-1-1", SeasonNumber: 1, EpisodeNumber: 1}, + {ID: 2, EpisodeID: "episode-tvdb-100-1-2", SeasonNumber: 1, EpisodeNumber: 2}, + {ID: 3, EpisodeID: "episode-tvdb-100-1-2", SeasonNumber: 1, EpisodeNumber: 2}, // dup version + {ID: 4, EpisodeID: "", SeasonNumber: 1, EpisodeNumber: 3}, // never linked + {ID: 5, EpisodeID: "episode-tvdb-100-0-0", SeasonNumber: 0, EpisodeNumber: 0}, // unparsed + } + + pairs := deriveEpisodePairs(series, moved, "series-tvdb-200") + if len(pairs) != 2 { + t.Fatalf("pairs = %v, want 2", pairs) + } + if pairs[0].To != "episode-tvdb-200-1-1" || pairs[1].To != "episode-tvdb-200-1-2" { + t.Fatalf("pair targets = %v, want re-anchored deterministic ids", pairs) + } + + // Local target: no deterministic anchor, no pairs. + if got := deriveEpisodePairs(series, moved, "local-abcdef"); got != nil { + t.Fatalf("local target pairs = %v, want nil", got) + } + // Movies never pair. + movie := &models.MediaItem{ContentID: "movie-tmdb-1", Type: "movie"} + if got := deriveEpisodePairs(movie, moved, "movie-tmdb-2"); got != nil { + t.Fatalf("movie pairs = %v, want nil", got) + } +} diff --git a/internal/api/handlers/libraries.go b/internal/api/handlers/libraries.go index e342c73b..8523226b 100644 --- a/internal/api/handlers/libraries.go +++ b/internal/api/handlers/libraries.go @@ -299,6 +299,9 @@ type libraryRootResponse struct { FirstSeenAt time.Time `json:"first_seen_at"` LastSeenAt time.Time `json:"last_seen_at"` ActiveOverride *rootOverride `json:"active_override,omitempty"` + // ContentID is the catalog item this group matched to, when known — it + // lets the admin UI jump from an ambiguous root to the item's split flow. + ContentID string `json:"content_id,omitempty"` } type rootOverride struct { @@ -2313,6 +2316,29 @@ func (h *LibraryHandler) HandleListRoots(w http.ResponseWriter, r *http.Request) } } + contentIDByGroup := map[string]string{} + if h.pool != nil { + claimRows, err := h.pool.Query(r.Context(), ` + SELECT group_key_version, content_group_key, content_id + FROM media_item_groups + WHERE media_folder_id = $1 + `, libraryID) + if err != nil { + slog.Warn("listing group claims", "library_id", libraryID, "error", err) + } else { + defer claimRows.Close() + for claimRows.Next() { + var version int + var groupKey, contentID string + if err := claimRows.Scan(&version, &groupKey, &contentID); err != nil { + slog.Warn("scanning group claim", "library_id", libraryID, "error", err) + break + } + contentIDByGroup[groupOverrideLookupKey(version, groupKey)] = contentID + } + } + } + items := make([]libraryRootResponse, 0, len(groups)) for _, group := range groups { rootPath := strings.TrimSpace(group.SampleObservedRootPath) @@ -2337,6 +2363,7 @@ func (h *LibraryHandler) HandleListRoots(w http.ResponseWriter, r *http.Request) OverrideSource: group.OverrideSource, FirstSeenAt: group.FirstSeenAt, LastSeenAt: group.LastSeenAt, + ContentID: contentIDByGroup[groupOverrideLookupKey(group.GroupKeyVersion, group.ContentGroupKey)], } if override, ok := overrideByGroup[groupOverrideLookupKey(group.GroupKeyVersion, group.ContentGroupKey)]; ok { resp.ActiveOverride = &rootOverride{ @@ -2379,7 +2406,7 @@ func (h *LibraryHandler) HandleUpsertRootOverride(w http.ResponseWriter, r *http } if location == nil || location.PrimaryContentGroupKey == "" { if location != nil && location.ContentGroupCount > 1 { - writeError(w, http.StatusConflict, "ambiguous_root", "Root contains multiple logical groups; override the group after splitting or selecting a specific item") + writeError(w, http.StatusConflict, "ambiguous_root", "Root contains files from multiple items; resolve it with the item split flow (POST /admin/items/{id}/split)") return } writeError(w, http.StatusNotFound, "not_found", "Root not found") @@ -2437,7 +2464,7 @@ func (h *LibraryHandler) HandleDeleteRootOverride(w http.ResponseWriter, r *http } if location == nil || location.PrimaryContentGroupKey == "" { if location != nil && location.ContentGroupCount > 1 { - writeError(w, http.StatusConflict, "ambiguous_root", "Root contains multiple logical groups; delete the override from a specific group instead") + writeError(w, http.StatusConflict, "ambiguous_root", "Root contains files from multiple items; manage its identity overrides via the item split flow instead") return } writeError(w, http.StatusNotFound, "not_found", "Root not found") diff --git a/internal/api/router.go b/internal/api/router.go index 9a4ad3f0..aeb5857a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -981,6 +981,24 @@ func NewRouter(deps Dependencies) chi.Router { ) } + // Build admin split/merge handler for repairing wrong version groupings. + var adminSplitHandler *handlers.AdminSplitHandler + if itemRepo != nil && deps.DB != nil { + var merger handlers.ItemMerger + if m, ok := deps.MetadataService.(handlers.ItemMerger); ok { + merger = m + } + adminSplitHandler = handlers.NewAdminSplitHandler( + deps.DB, + itemRepo, + deps.MetadataService, + merger, + deps.Refresher, + deps.Scanner, + deps.FolderRepo, + ) + } + // Build admin image handler for poster/backdrop/logo selection. var adminImageHandler *handlers.AdminImageHandler if imageSvc, ok := deps.MetadataService.(handlers.ImageService); ok && itemRepo != nil && seasonRepo != nil && episodeRepo != nil && deps.DB != nil && detailSvc != nil { @@ -2367,6 +2385,11 @@ func NewRouter(deps Dependencies) chi.Router { r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates) r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch) } + if adminSplitHandler != nil { + r.Get("/items/{id}/files", adminSplitHandler.HandleListItemFiles) + r.Post("/items/{id}/split", adminSplitHandler.HandleSplitItem) + r.Post("/items/{id}/merge", adminSplitHandler.HandleMergeItem) + } if metadataAIHandler != nil { r.Post("/items/{id}/metadata-translation", metadataAIHandler.HandleTranslate) r.Get("/items/{id}/metadata-translation/jobs", metadataAIHandler.HandleListJobs) diff --git a/internal/catalog/reattribute/reattribute.go b/internal/catalog/reattribute/reattribute.go new file mode 100644 index 00000000..21aa08cb --- /dev/null +++ b/internal/catalog/reattribute/reattribute.go @@ -0,0 +1,536 @@ +// Package reattribute moves per-user state (watch progress, history, +// downloads, favorites, ...) between content ids when an item is split or +// merged. It is the shared engine behind the admin split/merge endpoints and +// the metadata merge path, so both directions apply identical rules: +// +// - Rows carrying a media_file_id are moved exactly: the file's plays belong +// to whichever item the file now belongs to. +// - user_watch_history has no file reference; rows are classified per +// (user, profile) against the per-session playback_history_admin log — +// moved when every recorded session for the source item is on moved files, +// kept when none are, ambiguous otherwise (including when no session +// evidence exists). HistoryMode controls what happens to ambiguous rows. +// - Item-level intent rows (favorites, watchlist, ratings, collections, +// dismissals) carry no signal about which title the user meant; they stay +// on the source item unless the whole item is moving (merge) or the +// operator chose HistoryModeMoveAll. +// +// All statements run on the caller's transaction: callers preview a split by +// running Run and rolling back. +package reattribute + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// HistoryMode selects how user_watch_history rows without decisive per-file +// evidence are handled during a file-subset move. +type HistoryMode string + +const ( + // HistoryModeEvidence moves rows whose session evidence is unanimous for + // the moved files and leaves ambiguous rows on the source item. + HistoryModeEvidence HistoryMode = "evidence" + // HistoryModeKeep leaves every history row on the source item (exact + // per-file tables still move). + HistoryModeKeep HistoryMode = "keep" + // HistoryModeMoveAll moves every history row and the item-level intent + // rows — for the "this item was always the other title" case. + HistoryModeMoveAll HistoryMode = "move_all" +) + +// ValidHistoryMode reports whether mode is a recognized HistoryMode. +func ValidHistoryMode(mode HistoryMode) bool { + switch mode { + case HistoryModeEvidence, HistoryModeKeep, HistoryModeMoveAll: + return true + } + return false +} + +// IDPair maps a source content id to its destination. +type IDPair struct { + From string + To string +} + +// Options describes one reattribution run. +type Options struct { + // FromContentID / ToContentID are the item-level ids (movie or series). + FromContentID string + ToContentID string + // MovedFileIDs are the media_files ids moving From→To. Ignored when + // WholeItem is set. + MovedFileIDs []int + // WholeItem moves all state regardless of files (the merge case). + WholeItem bool + // Mode controls history/intent handling in file-subset mode. + Mode HistoryMode + // EpisodePairs maps old→new episode content ids for series splits and + // merges. Episode-level state always moves wholesale per pair: an episode + // either moved or it did not. + EpisodePairs []IDPair +} + +// Report tallies what moved. Counts are rows updated per table family. +type Report struct { + PlaybackSessionLog int `json:"playback_session_log"` + Downloads int `json:"downloads"` + ProgressMoved int `json:"progress_moved"` + ProgressConflicts int `json:"progress_conflicts"` + HistoryMoved int `json:"history_moved"` + HistoryStayed int `json:"history_stayed"` + HistoryAmbiguous int `json:"history_ambiguous"` + IntentMoved int `json:"intent_moved"` + EpisodePairsMoved int `json:"episode_pairs_moved"` + + // AmbiguousHistory samples ambiguous rows (capped) so a dry-run UI can + // show the operator what the evidence could not decide. + AmbiguousHistory []AmbiguousHistoryRow `json:"ambiguous_history,omitempty"` +} + +// AmbiguousHistoryRow identifies one history row left behind for lack of +// decisive evidence. +type AmbiguousHistoryRow struct { + UserID int `json:"user_id"` + ProfileID string `json:"profile_id"` + WatchedAt string `json:"watched_at"` +} + +const ambiguousHistorySampleCap = 100 + +// Run applies the reattribution on the caller's transaction. +func Run(ctx context.Context, tx pgx.Tx, opts Options) (*Report, error) { + if opts.FromContentID == "" || opts.ToContentID == "" || opts.FromContentID == opts.ToContentID { + return nil, fmt.Errorf("reattribute: from/to content ids required and must differ") + } + if opts.Mode == "" { + opts.Mode = HistoryModeEvidence + } + if !ValidHistoryMode(opts.Mode) { + return nil, fmt.Errorf("reattribute: invalid history mode %q", opts.Mode) + } + + report := &Report{} + + if opts.WholeItem { + if err := movePairs(ctx, tx, []IDPair{{From: opts.FromContentID, To: opts.ToContentID}}, report); err != nil { + return nil, err + } + } else { + if err := moveFileSubset(ctx, tx, opts, report); err != nil { + return nil, err + } + } + + if len(opts.EpisodePairs) > 0 { + episodeReport := &Report{} + if err := movePairs(ctx, tx, opts.EpisodePairs, episodeReport); err != nil { + return nil, err + } + report.PlaybackSessionLog += episodeReport.PlaybackSessionLog + report.Downloads += episodeReport.Downloads + report.ProgressMoved += episodeReport.ProgressMoved + report.ProgressConflicts += episodeReport.ProgressConflicts + report.HistoryMoved += episodeReport.HistoryMoved + report.IntentMoved += episodeReport.IntentMoved + report.EpisodePairsMoved = len(opts.EpisodePairs) + } + + return report, nil +} + +// pairsCTE is the shared FROM clause exposing (from_id, to_id) rows. +const pairsCTE = `(SELECT unnest($1::text[]) AS from_id, unnest($2::text[]) AS to_id) p` + +// intentTables are the item-level rows with no per-file dimension. Each entry +// names the content-id column being remapped and the non-id PK columns used to +// detect collisions at the destination (destination wins; the duplicate source +// row is dropped). The series_id-keyed preference tables ride along so a +// series merge/split carries audio/subtitle/quality preferences with it +// (mirrors the provider-merge remap in internal/metadata/provider_id_integrity.go). +const ( + colMediaItemID = "media_item_id" + colSeriesID = "series_id" + colUserID = "user_id" + colProfileID = "profile_id" +) + +var intentTables = []struct { + table string + idColumn string + keyCols []string +}{ + {"user_favorites", colMediaItemID, []string{colUserID, colProfileID}}, + {"user_watchlist", colMediaItemID, []string{colUserID, colProfileID}}, + {"user_ratings", colMediaItemID, []string{colUserID, colProfileID}}, + {"user_personal_collection_items", colMediaItemID, []string{colUserID, "collection_id", "sub_item_id"}}, + {"library_collection_items", colMediaItemID, []string{"collection_id"}}, + {"user_home_item_dismissals", colMediaItemID, []string{colUserID, colProfileID, "surface"}}, + {"user_history_hidden_items", colMediaItemID, []string{colUserID, colProfileID}}, + {"user_audio_preferences", colSeriesID, []string{colUserID, colProfileID}}, + {"user_subtitle_preferences", colSeriesID, []string{colUserID, colProfileID}}, + {"user_series_playback_preferences", colSeriesID, []string{colUserID, colProfileID}}, +} + +// movePairs moves ALL state rows for each (from,to) pair: the whole-item path +// used for merges and for per-episode moves during series splits. +func movePairs(ctx context.Context, tx pgx.Tx, pairs []IDPair, report *Report) (err error) { + fromIDs := make([]string, 0, len(pairs)) + toIDs := make([]string, 0, len(pairs)) + for _, pair := range pairs { + if pair.From == "" || pair.To == "" || pair.From == pair.To { + return fmt.Errorf("reattribute: invalid id pair %q -> %q", pair.From, pair.To) + } + fromIDs = append(fromIDs, pair.From) + toIDs = append(toIDs, pair.To) + } + + // Exact tables: no cross-user PK on media_item_id, plain remap. + tag, err := tx.Exec(ctx, ` + UPDATE playback_history_admin t SET media_item_id = p.to_id + FROM `+pairsCTE+` WHERE t.media_item_id = p.from_id + `, fromIDs, toIDs) + if err != nil { + return fmt.Errorf("reattribute: playback_history_admin pairs: %w", err) + } + report.PlaybackSessionLog += int(tag.RowsAffected()) + + tag, err = tx.Exec(ctx, ` + UPDATE user_downloads t SET media_item_id = p.to_id + FROM `+pairsCTE+` WHERE t.media_item_id = p.from_id + `, fromIDs, toIDs) + if err != nil { + return fmt.Errorf("reattribute: user_downloads pairs: %w", err) + } + report.Downloads += int(tag.RowsAffected()) + + // Managed offline downloads (downloads-v2): soft content_id + episode_id. + // PK is the download id, so plain remaps — both columns swept because + // episode pairs land in episode_id while item pairs land in content_id. + tag, err = tx.Exec(ctx, ` + UPDATE downloads t SET content_id = p.to_id, updated_at = NOW() + FROM `+pairsCTE+` WHERE t.content_id = p.from_id + `, fromIDs, toIDs) + if err != nil { + return fmt.Errorf("reattribute: downloads content pairs: %w", err) + } + report.Downloads += int(tag.RowsAffected()) + if _, err := tx.Exec(ctx, ` + UPDATE downloads t SET episode_id = p.to_id, updated_at = NOW() + FROM `+pairsCTE+` WHERE t.episode_id = p.from_id + `, fromIDs, toIDs); err != nil { + return fmt.Errorf("reattribute: downloads episode pairs: %w", err) + } + + // series_id is denormalized onto dismissals (not part of their PK), so a + // plain sweep keeps continue-watching/next-up dismissals series-scoped. + if _, err := tx.Exec(ctx, ` + UPDATE user_home_item_dismissals t SET series_id = p.to_id + FROM `+pairsCTE+` WHERE t.series_id = p.from_id + `, fromIDs, toIDs); err != nil { + return fmt.Errorf("reattribute: dismissal series pairs: %w", err) + } + + tag, err = tx.Exec(ctx, ` + UPDATE user_watch_history t SET media_item_id = p.to_id + FROM `+pairsCTE+` WHERE t.media_item_id = p.from_id + `, fromIDs, toIDs) + if err != nil { + return fmt.Errorf("reattribute: user_watch_history pairs: %w", err) + } + report.HistoryMoved += int(tag.RowsAffected()) + + moved, conflicts, err := moveProgressPairs(ctx, tx, fromIDs, toIDs) + if err != nil { + return err + } + report.ProgressMoved += moved + report.ProgressConflicts += conflicts + + for _, intent := range intentTables { + movedRows, err := moveIntentPairs(ctx, tx, intent.table, intent.idColumn, intent.keyCols, fromIDs, toIDs) + if err != nil { + return err + } + report.IntentMoved += movedRows + } + return nil +} + +// moveProgressPairs moves user_watch_progress rows. PK is +// (user_id, profile_id, media_item_id); when both sides have a row for the +// same user+profile the row with the newer updated_at survives. +func moveProgressPairs(ctx context.Context, tx pgx.Tx, fromIDs, toIDs []string) (moved, conflicts int, err error) { + // Source rows that lose to a same-or-newer destination row. + tag, err := tx.Exec(ctx, ` + DELETE FROM user_watch_progress src + USING `+pairsCTE+`, user_watch_progress dest + WHERE src.media_item_id = p.from_id + AND dest.media_item_id = p.to_id + AND dest.user_id = src.user_id + AND dest.profile_id = src.profile_id + AND dest.updated_at >= src.updated_at + `, fromIDs, toIDs) + if err != nil { + return 0, 0, fmt.Errorf("reattribute: progress source-loses dedupe: %w", err) + } + conflicts += int(tag.RowsAffected()) + + // Destination rows that lose to a still-present newer source row. + tag, err = tx.Exec(ctx, ` + DELETE FROM user_watch_progress dest + USING `+pairsCTE+`, user_watch_progress src + WHERE dest.media_item_id = p.to_id + AND src.media_item_id = p.from_id + AND src.user_id = dest.user_id + AND src.profile_id = dest.profile_id + `, fromIDs, toIDs) + if err != nil { + return 0, 0, fmt.Errorf("reattribute: progress dest-loses dedupe: %w", err) + } + conflicts += int(tag.RowsAffected()) + + tag, err = tx.Exec(ctx, ` + UPDATE user_watch_progress t SET media_item_id = p.to_id + FROM `+pairsCTE+` WHERE t.media_item_id = p.from_id + `, fromIDs, toIDs) + if err != nil { + return 0, 0, fmt.Errorf("reattribute: progress move: %w", err) + } + return int(tag.RowsAffected()), conflicts, nil +} + +// moveIntentPairs moves one intent table for the pairs; on a destination +// collision the destination row wins and the source duplicate is dropped. +func moveIntentPairs(ctx context.Context, tx pgx.Tx, table, idColumn string, keyCols []string, fromIDs, toIDs []string) (int, error) { + join := "" + for _, col := range keyCols { + join += fmt.Sprintf(" AND dest.%s = src.%s", col, col) + } + if _, err := tx.Exec(ctx, ` + DELETE FROM `+table+` src + USING `+pairsCTE+`, `+table+` dest + WHERE src.`+idColumn+` = p.from_id + AND dest.`+idColumn+` = p.to_id`+join+` + `, fromIDs, toIDs); err != nil { + return 0, fmt.Errorf("reattribute: %s dedupe: %w", table, err) + } + tag, err := tx.Exec(ctx, ` + UPDATE `+table+` t SET `+idColumn+` = p.to_id + FROM `+pairsCTE+` WHERE t.`+idColumn+` = p.from_id + `, fromIDs, toIDs) + if err != nil { + return 0, fmt.Errorf("reattribute: %s move: %w", table, err) + } + return int(tag.RowsAffected()), nil +} + +// moveFileSubset is the split path: only the state attributable to +// MovedFileIDs follows the files to the destination item. +func moveFileSubset(ctx context.Context, tx pgx.Tx, opts Options, report *Report) error { + if len(opts.MovedFileIDs) == 0 { + return fmt.Errorf("reattribute: file-subset move requires moved file ids") + } + + // History classification MUST run before the session log is re-pointed: + // its evidence query reads playback_history_admin rows still keyed to the + // source item. Moving the log first would erase exactly the evidence that + // proves a profile's plays were all on moved files, leaving that profile's + // history behind as "ambiguous". + if err := moveHistorySubset(ctx, tx, opts, report); err != nil { + return err + } + + // Per-session log: exact. + tag, err := tx.Exec(ctx, ` + UPDATE playback_history_admin + SET media_item_id = $3 + WHERE media_item_id = $1 AND media_file_id = ANY($2::int[]) + `, opts.FromContentID, opts.MovedFileIDs, opts.ToContentID) + if err != nil { + return fmt.Errorf("reattribute: playback_history_admin subset: %w", err) + } + report.PlaybackSessionLog = int(tag.RowsAffected()) + + // Downloads: exact (history rows and managed offline downloads). + tag, err = tx.Exec(ctx, ` + UPDATE user_downloads + SET media_item_id = $3 + WHERE media_item_id = $1 AND media_file_id = ANY($2::int[]) + `, opts.FromContentID, opts.MovedFileIDs, opts.ToContentID) + if err != nil { + return fmt.Errorf("reattribute: user_downloads subset: %w", err) + } + report.Downloads = int(tag.RowsAffected()) + + // Managed offline downloads: soft content_id, remapped per file. The + // episode_id column re-derives via EpisodePairs in Run for series splits. + tag, err = tx.Exec(ctx, ` + UPDATE downloads + SET content_id = $3, updated_at = NOW() + WHERE content_id = $1 AND media_file_id = ANY($2::int[]) + `, opts.FromContentID, opts.MovedFileIDs, opts.ToContentID) + if err != nil { + return fmt.Errorf("reattribute: downloads subset: %w", err) + } + report.Downloads += int(tag.RowsAffected()) + + // Progress: rows whose resume point sits on a moved file follow it. + moved, conflicts, err := moveProgressSubset(ctx, tx, opts) + if err != nil { + return err + } + report.ProgressMoved = moved + report.ProgressConflicts = conflicts + + // Intent rows only move when the operator asserts the whole identity was + // wrong; evidence about individual files cannot attribute intent. + if opts.Mode == HistoryModeMoveAll { + for _, intent := range intentTables { + movedRows, err := moveIntentPairs(ctx, tx, intent.table, intent.idColumn, intent.keyCols, + []string{opts.FromContentID}, []string{opts.ToContentID}) + if err != nil { + return err + } + report.IntentMoved += movedRows + } + } + return nil +} + +func moveProgressSubset(ctx context.Context, tx pgx.Tx, opts Options) (moved, conflicts int, err error) { + tag, err := tx.Exec(ctx, ` + DELETE FROM user_watch_progress src + USING user_watch_progress dest + WHERE src.media_item_id = $1 + AND src.last_file_id = ANY($2::int[]) + AND dest.media_item_id = $3 + AND dest.user_id = src.user_id + AND dest.profile_id = src.profile_id + AND dest.updated_at >= src.updated_at + `, opts.FromContentID, opts.MovedFileIDs, opts.ToContentID) + if err != nil { + return 0, 0, fmt.Errorf("reattribute: progress subset source-loses dedupe: %w", err) + } + conflicts += int(tag.RowsAffected()) + + tag, err = tx.Exec(ctx, ` + DELETE FROM user_watch_progress dest + USING user_watch_progress src + WHERE dest.media_item_id = $3 + AND src.media_item_id = $1 + AND src.last_file_id = ANY($2::int[]) + AND src.user_id = dest.user_id + AND src.profile_id = dest.profile_id + `, opts.FromContentID, opts.MovedFileIDs, opts.ToContentID) + if err != nil { + return 0, 0, fmt.Errorf("reattribute: progress subset dest-loses dedupe: %w", err) + } + conflicts += int(tag.RowsAffected()) + + tag, err = tx.Exec(ctx, ` + UPDATE user_watch_progress + SET media_item_id = $3 + WHERE media_item_id = $1 AND last_file_id = ANY($2::int[]) + `, opts.FromContentID, opts.MovedFileIDs, opts.ToContentID) + if err != nil { + return 0, 0, fmt.Errorf("reattribute: progress subset move: %w", err) + } + return int(tag.RowsAffected()), conflicts, nil +} + +// moveHistorySubset classifies user_watch_history rows per (user, profile) +// against the playback_history_admin session log: +// +// unanimous moved sessions → the profile's history rows move +// unanimous stayed sessions → they stay +// mixed or no evidence → ambiguous (HistoryMode decides) +// +// Classification is per profile, not per row: individual history rows carry no +// file linkage, so a profile that played both halves of the split cannot have +// its rows divided truthfully — those stay behind (evidence mode) rather than +// being guessed. +func moveHistorySubset(ctx context.Context, tx pgx.Tx, opts Options, report *Report) error { + if opts.Mode == HistoryModeMoveAll { + tag, err := tx.Exec(ctx, ` + UPDATE user_watch_history SET media_item_id = $2 WHERE media_item_id = $1 + `, opts.FromContentID, opts.ToContentID) + if err != nil { + return fmt.Errorf("reattribute: history move_all: %w", err) + } + report.HistoryMoved = int(tag.RowsAffected()) + return nil + } + + const evidenceCTE = ` + WITH evidence AS ( + SELECT user_id, profile_id, + bool_or(media_file_id = ANY($2::int[])) AS any_moved, + bool_or(NOT (media_file_id = ANY($2::int[]))) AS any_stayed + FROM playback_history_admin + WHERE media_item_id = $1 + GROUP BY user_id, profile_id + )` + + if opts.Mode == HistoryModeEvidence { + tag, err := tx.Exec(ctx, evidenceCTE+` + UPDATE user_watch_history h + SET media_item_id = $3 + FROM evidence e + WHERE h.media_item_id = $1 + AND h.user_id = e.user_id + AND h.profile_id = e.profile_id + AND e.any_moved AND NOT e.any_stayed + `, opts.FromContentID, opts.MovedFileIDs, opts.ToContentID) + if err != nil { + return fmt.Errorf("reattribute: history evidence move: %w", err) + } + report.HistoryMoved = int(tag.RowsAffected()) + } + + // Tally what stayed and what was ambiguous (also reported in keep mode so + // dry-runs show the evidence split the operator is overriding). + rows, err := tx.Query(ctx, evidenceCTE+` + SELECT h.user_id, h.profile_id, h.watched_at::text, + COALESCE(e.any_moved, false) AS any_moved, + COALESCE(e.any_stayed, false) AS any_stayed + FROM user_watch_history h + LEFT JOIN evidence e ON e.user_id = h.user_id AND e.profile_id = h.profile_id + WHERE h.media_item_id = $1 + `, opts.FromContentID, opts.MovedFileIDs) + if err != nil { + return fmt.Errorf("reattribute: history classification tally: %w", err) + } + defer rows.Close() + + for rows.Next() { + var row AmbiguousHistoryRow + var anyMoved, anyStayed bool + if err := rows.Scan(&row.UserID, &row.ProfileID, &row.WatchedAt, &anyMoved, &anyStayed); err != nil { + return fmt.Errorf("reattribute: scanning history tally: %w", err) + } + switch { + case anyStayed && !anyMoved: + report.HistoryStayed++ + case anyMoved && !anyStayed: + // Unanimous-moved rows were already moved in evidence mode; in + // keep mode they are deliberate stay-behinds, count them stayed. + if opts.Mode == HistoryModeKeep { + report.HistoryStayed++ + } + default: + report.HistoryAmbiguous++ + if len(report.AmbiguousHistory) < ambiguousHistorySampleCap { + report.AmbiguousHistory = append(report.AmbiguousHistory, row) + } + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("reattribute: iterating history tally: %w", err) + } + return nil +} diff --git a/internal/catalog/reattribute/reattribute_db_test.go b/internal/catalog/reattribute/reattribute_db_test.go new file mode 100644 index 00000000..fdc9d42d --- /dev/null +++ b/internal/catalog/reattribute/reattribute_db_test.go @@ -0,0 +1,404 @@ +package reattribute + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type testEnv struct { + pool *pgxpool.Pool + userID int + profileID string + suffix int64 +} + +func newTestEnv(t *testing.T) *testEnv { + t.Helper() + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + t.Cleanup(pool.Close) + + suffix := time.Now().UnixNano() + var userID int + if err := pool.QueryRow(ctx, + `INSERT INTO users (username, role) VALUES ($1, 'user') RETURNING id`, + fmt.Sprintf("reattr-user-%d", suffix), + ).Scan(&userID); err != nil { + t.Fatalf("seed user: %v", err) + } + env := &testEnv{ + pool: pool, + userID: userID, + profileID: fmt.Sprintf("00000000-0000-4000-8000-%012d", suffix%1_000_000_000_000), + suffix: suffix, + } + t.Cleanup(func() { + for _, table := range []string{ + "playback_history_admin", "user_downloads", "downloads", "user_watch_history", + "user_watch_progress", "user_favorites", "user_watchlist", "user_ratings", + "user_audio_preferences", "user_subtitle_preferences", "user_series_playback_preferences", + "user_home_item_dismissals", + } { + _, _ = pool.Exec(ctx, `DELETE FROM `+table+` WHERE user_id = $1`, userID) + } + _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID) + }) + return env +} + +// seedMediaFile creates a media_folders + media_files pair so tables with a +// real FK on media_file_id (downloads) can be seeded. +func (e *testEnv) seedMediaFile(t *testing.T, contentID, path string) int { + t.Helper() + ctx := context.Background() + var folderID int + if err := e.pool.QueryRow(ctx, + `INSERT INTO media_folders (type, name, enabled) VALUES ('movies', $1, true) RETURNING id`, + fmt.Sprintf("reattr-folder-%d", e.suffix), + ).Scan(&folderID); err != nil { + t.Fatalf("seed folder: %v", err) + } + var fileID int + if err := e.pool.QueryRow(ctx, ` + INSERT INTO media_files (content_id, media_folder_id, file_path, file_size) + VALUES ($1, $2, $3, 1000) RETURNING id + `, contentID, folderID, path).Scan(&fileID); err != nil { + t.Fatalf("seed media file: %v", err) + } + t.Cleanup(func() { + _, _ = e.pool.Exec(ctx, `DELETE FROM media_files WHERE id = $1`, fileID) + _, _ = e.pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID) + }) + return fileID +} + +func (e *testEnv) run(t *testing.T, opts Options) *Report { + t.Helper() + ctx := context.Background() + tx, err := e.pool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + report, err := Run(ctx, tx, opts) + if err != nil { + _ = tx.Rollback(ctx) + t.Fatalf("Run: %v", err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatalf("commit: %v", err) + } + return report +} + +func (e *testEnv) itemIDOf(t *testing.T, table, where string, args ...any) string { + t.Helper() + var id string + err := e.pool.QueryRow(context.Background(), + `SELECT media_item_id FROM `+table+` WHERE `+where, args...).Scan(&id) + if err != nil { + t.Fatalf("lookup %s: %v", table, err) + } + return id +} + +func TestRun_FileSubsetExactAndProgress(t *testing.T) { + env := newTestEnv(t) + ctx := context.Background() + from := fmt.Sprintf("movie-src-%d", env.suffix) + to := fmt.Sprintf("movie-dst-%d", env.suffix) + movedFile, stayFile := 910001, 910002 + + // Managed offline download on a real media_files row (FK) keyed to the + // moved file: its content_id must follow the split. + managedFile := env.seedMediaFile(t, from, fmt.Sprintf("/reattr/%d/a.mkv", env.suffix)) + if _, err := env.pool.Exec(ctx, ` + INSERT INTO downloads (id, user_id, media_file_id, content_id, kind, status) + VALUES ($1, $2, $3, $4, 'queued', 'completed') + `, fmt.Sprintf("mdl-%d", env.suffix), env.userID, managedFile, from); err != nil { + t.Fatalf("seed managed download: %v", err) + } + + // Session log rows on both files; download on the moved file; progress + // resuming on the moved file. + for i, fileID := range []int{movedFile, stayFile} { + if _, err := env.pool.Exec(ctx, ` + INSERT INTO playback_history_admin (session_id, user_id, profile_id, media_item_id, media_file_id, play_method, started_at, ended_at) + VALUES ($1, $2, $3, $4, $5, 'direct', now(), now()) + `, fmt.Sprintf("sess-%d-%d", env.suffix, i), env.userID, env.profileID, from, fileID); err != nil { + t.Fatalf("seed session: %v", err) + } + } + if _, err := env.pool.Exec(ctx, ` + INSERT INTO user_downloads (id, user_id, profile_id, media_item_id, media_file_id) + VALUES ($1, $2, $3, $4, $5) + `, fmt.Sprintf("dl-%d", env.suffix), env.userID, env.profileID, from, movedFile); err != nil { + t.Fatalf("seed download: %v", err) + } + if _, err := env.pool.Exec(ctx, ` + INSERT INTO user_watch_progress (user_id, profile_id, media_item_id, position_seconds, duration_seconds, last_file_id) + VALUES ($1, $2, $3, 100, 7200, $4) + `, env.userID, env.profileID, from, movedFile); err != nil { + t.Fatalf("seed progress: %v", err) + } + + report := env.run(t, Options{ + FromContentID: from, + ToContentID: to, + MovedFileIDs: []int{movedFile, managedFile}, + Mode: HistoryModeEvidence, + }) + + if report.PlaybackSessionLog != 1 || report.Downloads != 2 || report.ProgressMoved != 1 { + t.Fatalf("report = %+v, want 1 session log, 2 downloads (user+managed), 1 progress moved", report) + } + if got := env.itemIDOf(t, "user_downloads", "user_id = $1", env.userID); got != to { + t.Fatalf("download item = %q, want %q", got, to) + } + var managedContentID string + if err := env.pool.QueryRow(ctx, + `SELECT content_id FROM downloads WHERE user_id = $1`, env.userID).Scan(&managedContentID); err != nil { + t.Fatalf("load managed download: %v", err) + } + if managedContentID != to { + t.Fatalf("managed download content_id = %q, want %q", managedContentID, to) + } + if got := env.itemIDOf(t, "user_watch_progress", "user_id = $1", env.userID); got != to { + t.Fatalf("progress item = %q, want %q", got, to) + } + // The session on the unmoved file stays. + if got := env.itemIDOf(t, "playback_history_admin", "user_id = $1 AND media_file_id = $2", env.userID, stayFile); got != from { + t.Fatalf("stayed session item = %q, want %q", got, from) + } +} + +func TestRun_HistoryEvidenceClassification(t *testing.T) { + env := newTestEnv(t) + ctx := context.Background() + from := fmt.Sprintf("movie-src-%d", env.suffix) + to := fmt.Sprintf("movie-dst-%d", env.suffix) + movedFile, stayFile := 920001, 920002 + + // Profile A: only played the moved file → unanimous, history moves. + // Profile B: played both files → mixed, history stays and is ambiguous. + profileA := env.profileID + profileB := fmt.Sprintf("00000000-0000-4000-8000-%012d", (env.suffix+1)%1_000_000_000_000) + + seedSession := func(id, profile string, fileID int) { + t.Helper() + if _, err := env.pool.Exec(ctx, ` + INSERT INTO playback_history_admin (session_id, user_id, profile_id, media_item_id, media_file_id, play_method, started_at, ended_at) + VALUES ($1, $2, $3, $4, $5, 'direct', now(), now()) + `, id, env.userID, profile, from, fileID); err != nil { + t.Fatalf("seed session %s: %v", id, err) + } + } + seedHistory := func(id, profile string) { + t.Helper() + if _, err := env.pool.Exec(ctx, ` + INSERT INTO user_watch_history (id, user_id, profile_id, media_item_id, watched_at, completed) + VALUES ($1, $2, $3, $4, now(), true) + `, id, env.userID, profile, from); err != nil { + t.Fatalf("seed history %s: %v", id, err) + } + } + seedSession(fmt.Sprintf("sa-%d", env.suffix), profileA, movedFile) + seedSession(fmt.Sprintf("sb1-%d", env.suffix), profileB, movedFile) + seedSession(fmt.Sprintf("sb2-%d", env.suffix), profileB, stayFile) + seedHistory(fmt.Sprintf("ha-%d", env.suffix), profileA) + seedHistory(fmt.Sprintf("hb-%d", env.suffix), profileB) + + report := env.run(t, Options{ + FromContentID: from, + ToContentID: to, + MovedFileIDs: []int{movedFile}, + Mode: HistoryModeEvidence, + }) + + if report.HistoryMoved != 1 || report.HistoryAmbiguous != 1 { + t.Fatalf("report = %+v, want 1 moved + 1 ambiguous history row", report) + } + if got := env.itemIDOf(t, "user_watch_history", "user_id = $1 AND profile_id = $2", env.userID, profileA); got != to { + t.Fatalf("profile A history = %q, want moved to %q", got, to) + } + if got := env.itemIDOf(t, "user_watch_history", "user_id = $1 AND profile_id = $2", env.userID, profileB); got != from { + t.Fatalf("profile B history = %q, want kept on %q", got, from) + } + if len(report.AmbiguousHistory) != 1 || report.AmbiguousHistory[0].ProfileID != profileB { + t.Fatalf("ambiguous sample = %+v, want profile B", report.AmbiguousHistory) + } +} + +func TestRun_MoveAllMovesIntent(t *testing.T) { + env := newTestEnv(t) + ctx := context.Background() + from := fmt.Sprintf("movie-src-%d", env.suffix) + to := fmt.Sprintf("movie-dst-%d", env.suffix) + movedFile := 930001 + + if _, err := env.pool.Exec(ctx, ` + INSERT INTO user_favorites (user_id, profile_id, media_item_id) VALUES ($1, $2, $3) + `, env.userID, env.profileID, from); err != nil { + t.Fatalf("seed favorite: %v", err) + } + if _, err := env.pool.Exec(ctx, ` + INSERT INTO user_watch_history (id, user_id, profile_id, media_item_id, watched_at) + VALUES ($1, $2, $3, $4, now()) + `, fmt.Sprintf("hma-%d", env.suffix), env.userID, env.profileID, from); err != nil { + t.Fatalf("seed history: %v", err) + } + + report := env.run(t, Options{ + FromContentID: from, + ToContentID: to, + MovedFileIDs: []int{movedFile}, + Mode: HistoryModeMoveAll, + }) + + if report.HistoryMoved != 1 || report.IntentMoved != 1 { + t.Fatalf("report = %+v, want 1 history + 1 intent moved", report) + } + if got := env.itemIDOf(t, "user_favorites", "user_id = $1", env.userID); got != to { + t.Fatalf("favorite item = %q, want %q", got, to) + } +} + +func TestRun_WholeItemPairsAndConflicts(t *testing.T) { + env := newTestEnv(t) + ctx := context.Background() + from := fmt.Sprintf("series-src-%d", env.suffix) + to := fmt.Sprintf("series-dst-%d", env.suffix) + epFrom := fmt.Sprintf("ep-src-%d", env.suffix) + epTo := fmt.Sprintf("ep-dst-%d", env.suffix) + + // Favorite on both source and destination: destination wins, source drops. + for _, item := range []string{from, to} { + if _, err := env.pool.Exec(ctx, ` + INSERT INTO user_favorites (user_id, profile_id, media_item_id) VALUES ($1, $2, $3) + `, env.userID, env.profileID, item); err != nil { + t.Fatalf("seed favorite %s: %v", item, err) + } + } + // Progress on both sides of an episode pair; source is newer and must win. + if _, err := env.pool.Exec(ctx, ` + INSERT INTO user_watch_progress (user_id, profile_id, media_item_id, position_seconds, duration_seconds, updated_at) + VALUES ($1, $2, $3, 500, 3600, now()), + ($1, $2, $4, 100, 3600, now() - interval '1 day') + `, env.userID, env.profileID, epFrom, epTo); err != nil { + t.Fatalf("seed progress: %v", err) + } + // Series-scoped subtitle preference and a dismissal denormalizing the + // series id: both must follow the merge. + if _, err := env.pool.Exec(ctx, ` + INSERT INTO user_subtitle_preferences (user_id, profile_id, series_id, subtitle_language) + VALUES ($1, $2, $3, 'en') + `, env.userID, env.profileID, from); err != nil { + t.Fatalf("seed subtitle preference: %v", err) + } + if _, err := env.pool.Exec(ctx, ` + INSERT INTO user_home_item_dismissals (user_id, profile_id, surface, media_item_id, series_id) + VALUES ($1, $2, 'next_up', $3, $4) + `, env.userID, env.profileID, epFrom, from); err != nil { + t.Fatalf("seed dismissal: %v", err) + } + + report := env.run(t, Options{ + FromContentID: from, + ToContentID: to, + WholeItem: true, + EpisodePairs: []IDPair{{From: epFrom, To: epTo}}, + }) + + var prefSeriesID string + if err := env.pool.QueryRow(ctx, + `SELECT series_id FROM user_subtitle_preferences WHERE user_id = $1`, env.userID).Scan(&prefSeriesID); err != nil { + t.Fatalf("load subtitle preference: %v", err) + } + if prefSeriesID != to { + t.Fatalf("subtitle preference series_id = %q, want %q", prefSeriesID, to) + } + var dismissItem, dismissSeries string + if err := env.pool.QueryRow(ctx, ` + SELECT media_item_id, series_id FROM user_home_item_dismissals WHERE user_id = $1 + `, env.userID).Scan(&dismissItem, &dismissSeries); err != nil { + t.Fatalf("load dismissal: %v", err) + } + if dismissItem != epTo || dismissSeries != to { + t.Fatalf("dismissal = (%q, %q), want (%q, %q)", dismissItem, dismissSeries, epTo, to) + } + + var favCount int + if err := env.pool.QueryRow(ctx, + `SELECT count(*) FROM user_favorites WHERE user_id = $1`, env.userID).Scan(&favCount); err != nil { + t.Fatalf("count favorites: %v", err) + } + if favCount != 1 { + t.Fatalf("favorites = %d, want 1 (deduped)", favCount) + } + if got := env.itemIDOf(t, "user_favorites", "user_id = $1", env.userID); got != to { + t.Fatalf("favorite item = %q, want %q", got, to) + } + + var position float64 + if err := env.pool.QueryRow(ctx, ` + SELECT position_seconds FROM user_watch_progress WHERE user_id = $1 AND media_item_id = $2 + `, env.userID, epTo).Scan(&position); err != nil { + t.Fatalf("load progress: %v", err) + } + if position != 500 { + t.Fatalf("progress position = %v, want newer source row (500)", position) + } + if report.ProgressConflicts == 0 { + t.Fatalf("report = %+v, want progress conflict recorded", report) + } +} + +func TestRun_PreviewRollsBack(t *testing.T) { + env := newTestEnv(t) + ctx := context.Background() + from := fmt.Sprintf("movie-src-%d", env.suffix) + to := fmt.Sprintf("movie-dst-%d", env.suffix) + + if _, err := env.pool.Exec(ctx, ` + INSERT INTO user_watch_progress (user_id, profile_id, media_item_id, position_seconds, duration_seconds, last_file_id) + VALUES ($1, $2, $3, 100, 7200, 940001) + `, env.userID, env.profileID, from); err != nil { + t.Fatalf("seed progress: %v", err) + } + + tx, err := env.pool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + report, err := Run(ctx, tx, Options{ + FromContentID: from, + ToContentID: to, + MovedFileIDs: []int{940001}, + Mode: HistoryModeEvidence, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) { + t.Fatalf("rollback: %v", err) + } + if report.ProgressMoved != 1 { + t.Fatalf("preview report = %+v, want 1 progress moved", report) + } + if got := env.itemIDOf(t, "user_watch_progress", "user_id = $1", env.userID); got != from { + t.Fatalf("after rollback progress item = %q, want untouched %q", got, from) + } +} diff --git a/internal/metadata/merge_items.go b/internal/metadata/merge_items.go new file mode 100644 index 00000000..8b17d7f5 --- /dev/null +++ b/internal/metadata/merge_items.go @@ -0,0 +1,43 @@ +package metadata + +import ( + "context" + "fmt" + "strings" +) + +// MergeItems merges the item at fromContentID into toContentID: files, library +// memberships, provider ids, and all per-user state (via the shared +// reattribution engine inside rebindItemToExistingItem) move to the target and +// the source row is deleted. Both items must exist and share a type. This is +// the admin-facing repair for a wrong split — two catalog items that are one +// logical title. +func (s *MetadataService) MergeItems(ctx context.Context, fromContentID, toContentID string) error { + if s == nil { + return fmt.Errorf("metadata service unavailable") + } + fromContentID = strings.TrimSpace(fromContentID) + toContentID = strings.TrimSpace(toContentID) + if fromContentID == "" || toContentID == "" { + return fmt.Errorf("merge requires source and target content ids") + } + if fromContentID == toContentID { + return fmt.Errorf("merge source and target are the same item") + } + + from, err := s.itemRepo.GetByID(ctx, fromContentID) + if err != nil { + return fmt.Errorf("loading merge source %s: %w", fromContentID, err) + } + to, err := s.itemRepo.GetByID(ctx, toContentID) + if err != nil { + return fmt.Errorf("loading merge target %s: %w", toContentID, err) + } + if from.Type != to.Type { + return fmt.Errorf("cannot merge %s item into %s item", from.Type, to.Type) + } + + // allowMatchedSource: an operator merging duplicates typically merges two + // fully matched items; the source row must still be deletable. + return s.rebindItemToExistingItem(ctx, fromContentID, toContentID, true) +} diff --git a/internal/metadata/service.go b/internal/metadata/service.go index 9f658401..e6034605 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -15,6 +15,7 @@ import ( "time" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/catalog/reattribute" "github.com/Silo-Server/silo-server/internal/contentid" "github.com/Silo-Server/silo-server/internal/idgen" "github.com/Silo-Server/silo-server/internal/lang" @@ -4900,6 +4901,24 @@ func (s *MetadataService) rebindItemToExistingItem(ctx context.Context, fromCont }, } + // Move user state (progress, history, favorites, ...) onto the surviving + // item before the source rows are touched: the source media_items row is + // deleted below, which would strand the soft-referenced watch state and + // cascade-delete FK children like collection memberships. Runs first so + // the episode S/E mapping still sees the source series' episode rows. + episodePairs, err := mergeEpisodeIDPairs(ctx, tx, fromContentID, toContentID) + if err != nil { + return err + } + if _, err := reattribute.Run(ctx, tx, reattribute.Options{ + FromContentID: fromContentID, + ToContentID: toContentID, + WholeItem: true, + EpisodePairs: episodePairs, + }); err != nil { + return fmt.Errorf("reattributing user state %s -> %s: %w", fromContentID, toContentID, err) + } + for _, step := range steps { if _, err := tx.Exec(ctx, step.sql, step.args...); err != nil { return fmt.Errorf("%s: %w", step.name, err) @@ -4919,6 +4938,38 @@ func (s *MetadataService) rebindItemToExistingItem(ctx context.Context, fromCont return nil } +// mergeEpisodeIDPairs maps the source series' episode content ids onto the +// target series' episodes by (season, episode) number, so episode-level user +// state survives a series merge. Episodes with no counterpart on the target +// are skipped: their state stays on ids that die with the source series, which +// is today's behavior, and the next scan recreates the episodes on the target. +func mergeEpisodeIDPairs(ctx context.Context, tx pgx.Tx, fromContentID, toContentID string) ([]reattribute.IDPair, error) { + rows, err := tx.Query(ctx, ` + SELECT src.content_id, dest.content_id + FROM episodes src + JOIN episodes dest + ON dest.series_id = $2 + AND dest.season_number = src.season_number + AND dest.episode_number = src.episode_number + WHERE src.series_id = $1 + AND src.content_id <> dest.content_id + `, fromContentID, toContentID) + if err != nil { + return nil, fmt.Errorf("mapping episode ids for merge %s -> %s: %w", fromContentID, toContentID, err) + } + defer rows.Close() + + var pairs []reattribute.IDPair + for rows.Next() { + var pair reattribute.IDPair + if err := rows.Scan(&pair.From, &pair.To); err != nil { + return nil, fmt.Errorf("scanning episode id pair: %w", err) + } + pairs = append(pairs, pair) + } + return pairs, rows.Err() +} + func rebindDeletableStatuses(allowMatchedSource bool) []string { statuses := []string{"pending", "unmatched", "ambiguous"} if allowMatchedSource { diff --git a/internal/models/media_group.go b/internal/models/media_group.go index 4aee3335..26cdca60 100644 --- a/internal/models/media_group.go +++ b/internal/models/media_group.go @@ -43,6 +43,36 @@ type MediaGroupOverride struct { UpdatedAt time.Time } +// Scopes for MediaIdentityOverride rows. +const ( + IdentityOverrideScopeRoot = "root" + IdentityOverrideScopeFile = "file" +) + +// MediaIdentityOverride stores an operator-provided identity forced onto all +// files under a root path (scope "root") or one file (scope "file"). Applied +// per file during group inference, before bucketing, so overridden files form +// their own content group — this is what makes a version split durable across +// rescans. File scope wins over root scope. +type MediaIdentityOverride struct { + ID int64 + MediaFolderID int + Scope string // "root" or "file" + RootPath string // scope="root" + FilePath string // scope="file" + ForcedType string + ForcedTitle string + ForcedYear int + ForcedTmdbID string + ForcedImdbID string + ForcedTvdbID string + Note string + CreatedByUserID *int + UpdatedByUserID *int + CreatedAt time.Time + UpdatedAt time.Time +} + // ObservedMediaLocation tracks one physical path-scoped media location. type ObservedMediaLocation struct { MediaFolderID int diff --git a/internal/naming/group_identity.go b/internal/naming/group_identity.go index 97320024..317a36aa 100644 --- a/internal/naming/group_identity.go +++ b/internal/naming/group_identity.go @@ -24,6 +24,9 @@ type GroupIdentity struct { State string EvidenceJSON []byte RepresentativePath string + // Overridden marks that an operator identity override was applied to this + // file (see ApplyIdentityOverride). + Overridden bool } // InferGroupIdentity derives a logical content-group identity from a media @@ -81,6 +84,17 @@ func InferGroupIdentity(filePath string, libraryType string, assignment RootAssi } } + // Structured provider IDs anchor the group KEY, not just the identity. + // Title+year keys merge distinct titles that normalize identically + // ("Passenger (2026)" vs "The Passenger (2026)" — the article is stripped), + // silently stacking two tagged movies as fake versions of one item. An + // explicit tag is the strongest identity evidence there is, so files tagged + // with the same provider ID always share a group and files tagged apart + // never merge, regardless of how their titles compare. + if anchored := anchoredGroupKey(group.GroupKeyVersion, group.BaseType, group.TmdbID, group.ImdbID, group.TvdbID); anchored != "" { + group.ContentGroupKey = anchored + } + if group.ContentGroupKey == "" { group.ContentGroupKey = isolatedGroupKey(group.BaseType, group.ObservedRootPath, cleanFilePath) } diff --git a/internal/naming/identity_override.go b/internal/naming/identity_override.go new file mode 100644 index 00000000..f319d169 --- /dev/null +++ b/internal/naming/identity_override.go @@ -0,0 +1,139 @@ +package naming + +import ( + "encoding/json" + "fmt" + "strings" +) + +// IdentityOverride carries operator-forced identity values applied to a single +// file's inferred group identity (see models.MediaIdentityOverride). Empty +// fields leave the inferred value in place. +type IdentityOverride struct { + ForcedType string + ForcedTitle string + ForcedYear int + ForcedTmdbID string + ForcedImdbID string + ForcedTvdbID string +} + +func (o IdentityOverride) isZero() bool { + return o.ForcedType == "" && o.ForcedTitle == "" && o.ForcedYear == 0 && + o.ForcedTmdbID == "" && o.ForcedImdbID == "" && o.ForcedTvdbID == "" +} + +// ApplyIdentityOverride forces operator-provided identity values onto an +// inferred group identity and re-derives its ContentGroupKey so the file lands +// in its own group even when its parsed title+year collides with a neighbor. +// A forced provider ID acts like a structured name tag: it anchors identity, +// clears ambiguity, and becomes part of the group key. It reports whether the +// identity was changed. +func ApplyIdentityOverride(identity *GroupIdentity, override IdentityOverride) bool { + if identity == nil || override.isZero() { + return false + } + + if forcedType := strings.TrimSpace(override.ForcedType); forcedType != "" { + identity.BaseType = forcedType + } + if forcedTitle := strings.TrimSpace(override.ForcedTitle); forcedTitle != "" { + identity.BaseTitle = forcedTitle + } + if override.ForcedYear > 0 { + identity.BaseYear = override.ForcedYear + } + if forced := strings.TrimSpace(override.ForcedTmdbID); forced != "" { + identity.TmdbID = forced + } + if forced := strings.TrimSpace(override.ForcedImdbID); forced != "" { + identity.ImdbID = forced + } + if forced := strings.TrimSpace(override.ForcedTvdbID); forced != "" { + identity.TvdbID = forced + } + + identity.Confidence = "high" + identity.State = "resolved" + identity.Overridden = true + identity.ContentGroupKey = overriddenGroupKey(identity, override) + identity.EvidenceJSON = mergeOverrideEvidence(identity.EvidenceJSON, override) + return true +} + +// overriddenGroupKey derives the group key for an overridden identity. A +// forced provider ID yields an anchored key so files forced to the same title +// always share a group and files forced apart never do. Without a forced +// provider ID the key falls back to the normal title+year derivation over the +// forced values. +func overriddenGroupKey(identity *GroupIdentity, override IdentityOverride) string { + if anchored := anchoredGroupKey( + identity.GroupKeyVersion, + identity.BaseType, + override.ForcedTmdbID, + override.ForcedImdbID, + override.ForcedTvdbID, + ); anchored != "" { + return anchored + } + return makeContentGroupKey(identity.BaseType, identity.BaseTitle, identity.BaseYear, identity.ObservedRootPath, identity.RepresentativePath) +} + +// anchoredGroupKey derives a provider-anchored content-group key, or "" when +// no usable provider ID is present. Provider precedence mirrors +// internal/contentid: movies tmdb→imdb→tvdb, series tvdb→tmdb→imdb, so two +// derivations that see the same ID set always pick the same anchor. +func anchoredGroupKey(version int, contentType, tmdbID, imdbID, tvdbID string) string { + const ( + providerTmdb = "tmdb" + providerImdb = "imdb" + providerTvdb = "tvdb" + ) + provider, id := "", "" + tmdb := strings.TrimSpace(tmdbID) + imdb := strings.TrimSpace(imdbID) + tvdb := strings.TrimSpace(tvdbID) + if contentType == "series" { + switch { + case tvdb != "": + provider, id = providerTvdb, tvdb + case tmdb != "": + provider, id = providerTmdb, tmdb + case imdb != "": + provider, id = providerImdb, imdb + } + } else { + switch { + case tmdb != "": + provider, id = providerTmdb, tmdb + case imdb != "": + provider, id = providerImdb, imdb + case tvdb != "": + provider, id = providerTvdb, tvdb + } + } + if provider == "" { + return "" + } + return fmt.Sprintf("v%d|%s|anchor|%s-%s", version, contentType, provider, strings.ToLower(id)) +} + +func mergeOverrideEvidence(evidence []byte, override IdentityOverride) []byte { + fields := map[string]any{} + if len(evidence) > 0 { + _ = json.Unmarshal(evidence, &fields) + } + fields["identity_override"] = map[string]any{ + "forced_type": override.ForcedType, + "forced_title": override.ForcedTitle, + "forced_year": override.ForcedYear, + "forced_tmdb_id": override.ForcedTmdbID, + "forced_imdb_id": override.ForcedImdbID, + "forced_tvdb_id": override.ForcedTvdbID, + } + merged, err := json.Marshal(fields) + if err != nil { + return evidence + } + return merged +} diff --git a/internal/scanner/group_inference.go b/internal/scanner/group_inference.go index 60e95078..5f3af7f3 100644 --- a/internal/scanner/group_inference.go +++ b/internal/scanner/group_inference.go @@ -13,6 +13,57 @@ import ( type fileGroupAssignment = naming.GroupIdentity +// identityOverrideSet indexes path-scoped identity overrides for per-file +// lookup during inference. File scope wins over root scope; among root +// overrides the deepest matching root wins. +type identityOverrideSet struct { + byFile map[string]*models.MediaIdentityOverride + byRoot map[string]*models.MediaIdentityOverride +} + +func newIdentityOverrideSet(overrides []models.MediaIdentityOverride) *identityOverrideSet { + if len(overrides) == 0 { + return nil + } + set := &identityOverrideSet{ + byFile: map[string]*models.MediaIdentityOverride{}, + byRoot: map[string]*models.MediaIdentityOverride{}, + } + for i := range overrides { + override := &overrides[i] + switch override.Scope { + case models.IdentityOverrideScopeFile: + if path := filepath.Clean(override.FilePath); path != "" && path != "." { + set.byFile[path] = override + } + case models.IdentityOverrideScopeRoot: + if path := filepath.Clean(override.RootPath); path != "" && path != "." { + set.byRoot[path] = override + } + } + } + return set +} + +func (s *identityOverrideSet) lookup(filePath string) *models.MediaIdentityOverride { + if s == nil { + return nil + } + if override, ok := s.byFile[filePath]; ok { + return override + } + // Walk ancestor directories so the deepest matching root override wins. + for dir := filepath.Dir(filePath); ; dir = filepath.Dir(dir) { + if override, ok := s.byRoot[dir]; ok { + return override + } + parent := filepath.Dir(dir) + if parent == dir { + return nil + } + } +} + type groupInferenceResult struct { Assignments map[string]fileGroupAssignment ScannedGroups []models.ScannedMediaGroup @@ -25,6 +76,7 @@ func inferGroupAssignments( libraryType string, folderID int, rootAssignments map[string]fileRootAssignment, + identityOverrides *identityOverrideSet, ) groupInferenceResult { assignments := make(map[string]fileGroupAssignment, len(filePaths)) groupBuckets := make(map[string][]fileGroupAssignment) @@ -41,6 +93,16 @@ func inferGroupAssignments( } } identity := naming.InferGroupIdentity(cleanPath, libraryType, rootAssignment) + if override := identityOverrides.lookup(cleanPath); override != nil { + naming.ApplyIdentityOverride(&identity, naming.IdentityOverride{ + ForcedType: override.ForcedType, + ForcedTitle: override.ForcedTitle, + ForcedYear: override.ForcedYear, + ForcedTmdbID: override.ForcedTmdbID, + ForcedImdbID: override.ForcedImdbID, + ForcedTvdbID: override.ForcedTvdbID, + }) + } assignments[cleanPath] = identity groupBuckets[groupBucketKey(identity)] = append(groupBuckets[groupBucketKey(identity)], identity) locationBuckets[identity.ObservedRootPath] = append(locationBuckets[identity.ObservedRootPath], identity) @@ -76,7 +138,7 @@ func inferGroupAssignments( SampleFilePath: first.RepresentativePath, SampleObservedRootPath: first.ObservedRootPath, EvidenceJSON: aggregateGroupEvidence(entries), - OverrideSource: "none", + OverrideSource: aggregateGroupOverrideSource(entries), }) seenLocations := map[string]bool{} @@ -160,18 +222,40 @@ func aggregateGroupState(entries []fileGroupAssignment) string { func aggregateLocationState(entries []fileGroupAssignment) string { state := "resolved" seenGroups := map[string]struct{}{} + nonOverriddenGroups := map[string]struct{}{} for _, entry := range entries { seenGroups[groupBucketKey(entry)] = struct{}{} + if !entry.Overridden { + nonOverriddenGroups[groupBucketKey(entry)] = struct{}{} + } if entry.State == "ambiguous" { state = "ambiguous" } } - if len(seenGroups) > 1 { + // A location split across groups is ambiguous only when the split was not + // operator-directed: identity overrides deliberately place files from one + // folder into different groups, and flagging that forever would make every + // resolved split look broken. + if len(seenGroups) > 1 && len(nonOverriddenGroups) > 1 { state = "ambiguous" } return state } +const ( + overrideSourceNone = "none" + overrideSourceManual = "manual" +) + +func aggregateGroupOverrideSource(entries []fileGroupAssignment) string { + for _, entry := range entries { + if entry.Overridden { + return overrideSourceManual + } + } + return overrideSourceNone +} + func aggregateGroupEvidence(entries []fileGroupAssignment) []byte { observedRoots := make([]string, 0, len(entries)) for _, entry := range entries { @@ -242,6 +326,6 @@ func applyGroupOverrideToSnapshot(group models.ScannedMediaGroup, override model } group.TypeConfidence = "high" group.State = "resolved" - group.OverrideSource = "manual" + group.OverrideSource = overrideSourceManual return group } diff --git a/internal/scanner/group_inference_test.go b/internal/scanner/group_inference_test.go index 6303febc..ec9b2825 100644 --- a/internal/scanner/group_inference_test.go +++ b/internal/scanner/group_inference_test.go @@ -15,7 +15,7 @@ func TestInferGroupAssignments_FlatLooseMovieEditionsCollapse(t *testing.T) { } rootInference := inferRootAssignments(filePaths, "movies", 1, nil) - groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments) + groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, nil) if got, want := len(groupInference.ScannedGroups), 1; got != want { t.Fatalf("len(ScannedGroups) = %d, want %d", got, want) @@ -48,7 +48,7 @@ func TestInferGroupAssignments_AnchormanEditionNoiseStaysResolved(t *testing.T) } rootInference := inferRootAssignments(filePaths, "movies", 1, nil) - groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments) + groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, nil) if got, want := len(groupInference.ScannedGroups), 1; got != want { t.Fatalf("len(ScannedGroups) = %d, want %d", got, want) @@ -70,7 +70,7 @@ func TestInferGroupAssignments_AVPAliasStaysResolved(t *testing.T) { } rootInference := inferRootAssignments(filePaths, "movies", 1, nil) - groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments) + groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, nil) group := groupInference.ScannedGroups[0] if got, want := group.State, "resolved"; got != want { @@ -89,7 +89,7 @@ func TestInferGroupAssignments_UnrelatedMovieTitleBecomesAmbiguous(t *testing.T) } rootInference := inferRootAssignments(filePaths, "movies", 1, nil) - groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments) + groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, nil) group := groupInference.ScannedGroups[0] if got, want := group.State, "ambiguous"; got != want { @@ -106,7 +106,7 @@ func TestInferGroupAssignments_SeriesSeasonDirsCollapse(t *testing.T) { } rootInference := inferRootAssignments(filePaths, "series", 1, nil) - groupInference := inferGroupAssignments(filePaths, "series", 1, rootInference.Assignments) + groupInference := inferGroupAssignments(filePaths, "series", 1, rootInference.Assignments, nil) if got, want := len(groupInference.ScannedGroups), 1; got != want { t.Fatalf("len(ScannedGroups) = %d, want %d", got, want) @@ -131,7 +131,7 @@ func TestInferGroupAssignments_SeriesEpisodeTitleNumberDoesNotBecomeTVDBID(t *te } rootInference := inferRootAssignments(filePaths, "mixed", 7, nil) - groupInference := inferGroupAssignments(filePaths, "mixed", 7, rootInference.Assignments) + groupInference := inferGroupAssignments(filePaths, "mixed", 7, rootInference.Assignments, nil) if got, want := len(groupInference.ScannedGroups), 1; got != want { t.Fatalf("len(ScannedGroups) = %d, want %d", got, want) @@ -153,7 +153,7 @@ func TestInferGroupAssignments_MovieEpisodeTokenFolderStaysResolved(t *testing.T } rootInference := inferRootAssignments(filePaths, "movies", 1, nil) - groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments) + groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, nil) if got, want := len(groupInference.ScannedGroups), 1; got != want { t.Fatalf("len(ScannedGroups) = %d, want %d", got, want) @@ -178,7 +178,7 @@ func TestApplyGroupOverrides_ForcesResolvedIdentity(t *testing.T) { } rootInference := inferRootAssignments(filePaths, "movies", 1, nil) - groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments) + groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, nil) group := groupInference.ScannedGroups[0] applyGroupOverrides(&groupInference, map[string]models.MediaGroupOverride{ @@ -214,3 +214,225 @@ func TestApplyGroupOverrides_ForcesResolvedIdentity(t *testing.T) { t.Fatalf("raw assignment TmdbID = %q, want %q", got, want) } } + +func TestInferGroupAssignments_FileIdentityOverrideSplitsCollidingKey(t *testing.T) { + t.Parallel() + + // Two different films whose names parse to the same title+year: without an + // override they merge into one group as fake "versions". + filePaths := []string{ + "/Media/Movies/The Grudge (2004)/The Grudge (2004).mkv", + "/Media/Movies/The Grudge (2004)/The Grudge (2004) JP Original.mkv", + } + + rootInference := inferRootAssignments(filePaths, "movies", 1, nil) + merged := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, nil) + if got, want := len(merged.ScannedGroups), 1; got != want { + t.Fatalf("pre-override len(ScannedGroups) = %d, want %d", got, want) + } + + overrides := newIdentityOverrideSet([]models.MediaIdentityOverride{{ + MediaFolderID: 1, + Scope: "file", + FilePath: filePaths[1], + ForcedTitle: "Ju-on: The Grudge", + ForcedTmdbID: "11838", + }}) + split := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, overrides) + + if got, want := len(split.ScannedGroups), 2; got != want { + t.Fatalf("post-override len(ScannedGroups) = %d, want %d", got, want) + } + kept := split.Assignments[filePaths[0]] + moved := split.Assignments[filePaths[1]] + if kept.ContentGroupKey == moved.ContentGroupKey { + t.Fatalf("override did not split group key: %q", kept.ContentGroupKey) + } + if moved.TmdbID != "11838" { + t.Fatalf("moved TmdbID = %q, want %q", moved.TmdbID, "11838") + } + if moved.State != "resolved" || moved.Confidence != "high" { + t.Fatalf("moved state/confidence = %q/%q, want resolved/high", moved.State, moved.Confidence) + } + if !moved.Overridden || kept.Overridden { + t.Fatalf("Overridden flags = moved:%v kept:%v, want true/false", moved.Overridden, kept.Overridden) + } + + // The shared folder now intentionally hosts two groups; the location must + // not stay flagged ambiguous forever. + if got, want := len(split.Locations), 1; got != want { + t.Fatalf("len(Locations) = %d, want %d", got, want) + } + if got, want := split.Locations[0].State, "resolved"; got != want { + t.Fatalf("location State = %q, want %q", got, want) + } + + // Overridden groups surface as manual for admin visibility. + overrideSources := map[string]bool{} + for _, group := range split.ScannedGroups { + overrideSources[group.OverrideSource] = true + } + if !overrideSources["manual"] || !overrideSources["none"] { + t.Fatalf("override sources = %v, want both manual and none", overrideSources) + } +} + +func TestInferGroupAssignments_RootIdentityOverrideRelinksFolder(t *testing.T) { + t.Parallel() + + // Two folders that parse to the same identity; a root-scope override on the + // second forces it to a different film. + filePaths := []string{ + "/Media/Movies/Crash (2004)/Crash (2004).mkv", + "/Media/Movies/Crash (2004) Cronenberg/Crash (2004).mkv", + } + + rootInference := inferRootAssignments(filePaths, "movies", 1, nil) + overrides := newIdentityOverrideSet([]models.MediaIdentityOverride{{ + MediaFolderID: 1, + Scope: "root", + RootPath: "/Media/Movies/Crash (2004) Cronenberg", + ForcedTmdbID: "10723", + }}) + result := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, overrides) + + kept := result.Assignments[filePaths[0]] + moved := result.Assignments[filePaths[1]] + if kept.ContentGroupKey == moved.ContentGroupKey { + t.Fatalf("root override did not split group key: %q", kept.ContentGroupKey) + } + if moved.TmdbID != "10723" { + t.Fatalf("moved TmdbID = %q, want %q", moved.TmdbID, "10723") + } + if kept.TmdbID != "" { + t.Fatalf("kept TmdbID = %q, want empty", kept.TmdbID) + } +} + +func TestInferGroupAssignments_FileOverrideBeatsRootOverride(t *testing.T) { + t.Parallel() + + filePath := "/Media/Movies/Pack (2001)/Pack (2001) Part2.mkv" + rootInference := inferRootAssignments([]string{filePath}, "movies", 1, nil) + overrides := newIdentityOverrideSet([]models.MediaIdentityOverride{ + {MediaFolderID: 1, Scope: "root", RootPath: "/Media/Movies/Pack (2001)", ForcedTmdbID: "100"}, + {MediaFolderID: 1, Scope: "file", FilePath: filePath, ForcedTmdbID: "200"}, + }) + result := inferGroupAssignments([]string{filePath}, "movies", 1, rootInference.Assignments, overrides) + + got := result.Assignments[filePath] + if got.TmdbID != "200" { + t.Fatalf("TmdbID = %q, want file-scope override %q", got.TmdbID, "200") + } +} + +func TestInferGroupAssignments_OverridesConvergeAcrossRescans(t *testing.T) { + t.Parallel() + + filePaths := []string{ + "/Media/Movies/The Thing (1982)/The Thing (1982).mkv", + "/Media/Movies/The Thing (1982)/The Thing (1982) Remaster.mkv", + } + overrides := []models.MediaIdentityOverride{{ + MediaFolderID: 1, + Scope: "file", + FilePath: filePaths[1], + ForcedTmdbID: "1091", + }} + + rootInference := inferRootAssignments(filePaths, "movies", 1, nil) + first := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, newIdentityOverrideSet(overrides)) + second := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, newIdentityOverrideSet(overrides)) + + for _, path := range filePaths { + if first.Assignments[path].ContentGroupKey != second.Assignments[path].ContentGroupKey { + t.Fatalf("group key for %s not stable across rescans: %q != %q", + path, first.Assignments[path].ContentGroupKey, second.Assignments[path].ContentGroupKey) + } + } +} + +func TestInferGroupAssignments_StructuredTagsAnchorGroupKeys(t *testing.T) { + t.Parallel() + + // Real-world wrong merge: two different 2026 films whose titles normalize + // identically once the leading article is stripped. Both folders carry + // explicit provider tags; those must anchor the group key so the films + // never merge as fake "versions" of one item. + filePaths := []string{ + "/media/movies/Passenger (2026) [imdb-tt33763941][tmdb-1368314]/Passenger (2026) {imdb-tt33763941} {tmdb-1368314} [WEBDL-2160p][EAC3 5.1][h265].mkv", + "/media/movies/Passenger (2026) [imdb-tt33763941][tmdb-1368314]/Passenger (2026) {imdb-tt33763941} {tmdb-1368314} [WEBDL-1080p][EAC3 5.1][h264].mkv", + "/media/movies/The Passenger (2026) [imdb-tt32298956][tmdb-1285959]/The Passenger (2026) {imdb-tt32298956} {tmdb-1285959} [WEBDL-2160p][EAC3 5.1][h265].mkv", + "/media/movies/The Passenger (2026) [imdb-tt32298956][tmdb-1285959]/The Passenger (2026) {imdb-tt32298956} {tmdb-1285959} [WEBDL-1080p][EAC3 5.1][h264].mkv", + } + + rootInference := inferRootAssignments(filePaths, "movies", 1, nil) + result := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, nil) + + if got, want := len(result.ScannedGroups), 2; got != want { + t.Fatalf("len(ScannedGroups) = %d, want %d (distinct tags must not merge)", got, want) + } + byTmdb := map[string]models.ScannedMediaGroup{} + for _, group := range result.ScannedGroups { + byTmdb[group.TmdbID] = group + } + passenger, ok1 := byTmdb["1368314"] + thePassenger, ok2 := byTmdb["1285959"] + if !ok1 || !ok2 { + t.Fatalf("groups carry wrong provider ids: %v", byTmdb) + } + if passenger.ObservedFileCount != 2 || thePassenger.ObservedFileCount != 2 { + t.Fatalf("file counts = %d/%d, want 2/2", passenger.ObservedFileCount, thePassenger.ObservedFileCount) + } + if passenger.BaseTitle != "Passenger" || thePassenger.BaseTitle != "The Passenger" { + t.Fatalf("titles = %q/%q", passenger.BaseTitle, thePassenger.BaseTitle) + } + if passenger.ImdbID != "tt33763941" || thePassenger.ImdbID != "tt32298956" { + t.Fatalf("imdb ids = %q/%q", passenger.ImdbID, thePassenger.ImdbID) + } +} + +func TestInferGroupAssignments_SameTagAcrossFoldersStillGroups(t *testing.T) { + t.Parallel() + + // Cross-folder versions of ONE film, both folders tagged with the same + // tmdb id, must keep sharing a group under the anchored key. + filePaths := []string{ + "/media/movies/Blade Runner (1982) {tmdb-78}/Blade Runner (1982) [WEBDL-2160p].mkv", + "/media/movies-4k/Blade Runner (1982) Final Cut {tmdb-78}/Blade Runner (1982) [Remux-2160p].mkv", + } + + rootInference := inferRootAssignments(filePaths, "movies", 1, nil) + result := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, nil) + + if got, want := len(result.ScannedGroups), 1; got != want { + t.Fatalf("len(ScannedGroups) = %d, want %d (same tag must group)", got, want) + } + if got, want := result.ScannedGroups[0].ObservedFileCount, 2; got != want { + t.Fatalf("ObservedFileCount = %d, want %d", got, want) + } + if got, want := len(result.GroupLocations), 2; got != want { + t.Fatalf("len(GroupLocations) = %d, want %d (two roots, one group)", got, want) + } +} + +func TestInferGroupAssignments_UntaggedFilesKeepTitleKeys(t *testing.T) { + t.Parallel() + + // No structured tags: grouping stays title+year based, so untagged + // libraries keep today's group keys (no churn). + filePaths := []string{ + "/media/movies/Heat (1995)/Heat (1995) 1080p.mkv", + "/media/movies/Heat (1995)/Heat (1995) 2160p.mkv", + } + + rootInference := inferRootAssignments(filePaths, "movies", 1, nil) + result := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, nil) + + if got, want := len(result.ScannedGroups), 1; got != want { + t.Fatalf("len(ScannedGroups) = %d, want %d", got, want) + } + if got, want := result.ScannedGroups[0].ContentGroupKey, "v1|movie|heat|1995"; got != want { + t.Fatalf("ContentGroupKey = %q, want %q", got, want) + } +} diff --git a/internal/scanner/identity_override_repo.go b/internal/scanner/identity_override_repo.go new file mode 100644 index 00000000..0875673a --- /dev/null +++ b/internal/scanner/identity_override_repo.go @@ -0,0 +1,162 @@ +package scanner + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/models" +) + +// MediaIdentityOverrideRepository persists operator-provided path-scoped +// identity overrides (the durable half of a version split). +type MediaIdentityOverrideRepository struct { + pool *pgxpool.Pool +} + +func NewMediaIdentityOverrideRepository(pool *pgxpool.Pool) *MediaIdentityOverrideRepository { + return &MediaIdentityOverrideRepository{pool: pool} +} + +const mediaIdentityOverrideColumns = `id, media_folder_id, scope, root_path, file_path, + forced_type, forced_title, forced_year, forced_tmdb_id, forced_imdb_id, forced_tvdb_id, + note, created_by_user_id, updated_by_user_id, created_at, updated_at` + +func scanMediaIdentityOverride(row pgx.Row) (*models.MediaIdentityOverride, error) { + var override models.MediaIdentityOverride + if err := row.Scan( + &override.ID, + &override.MediaFolderID, + &override.Scope, + &override.RootPath, + &override.FilePath, + &override.ForcedType, + &override.ForcedTitle, + &override.ForcedYear, + &override.ForcedTmdbID, + &override.ForcedImdbID, + &override.ForcedTvdbID, + &override.Note, + &override.CreatedByUserID, + &override.UpdatedByUserID, + &override.CreatedAt, + &override.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("scanning media identity override: %w", err) + } + return &override, nil +} + +func (r *MediaIdentityOverrideRepository) ListByFolder( + ctx context.Context, + folderID int, +) ([]models.MediaIdentityOverride, error) { + rows, err := r.pool.Query(ctx, ` + SELECT `+mediaIdentityOverrideColumns+` + FROM media_identity_overrides + WHERE media_folder_id = $1 + ORDER BY scope ASC, root_path ASC, file_path ASC + `, folderID) + if err != nil { + return nil, fmt.Errorf("listing media identity overrides: %w", err) + } + defer rows.Close() + + overrides := make([]models.MediaIdentityOverride, 0) + for rows.Next() { + override, err := scanMediaIdentityOverride(rows) + if err != nil { + return nil, err + } + overrides = append(overrides, *override) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating media identity overrides: %w", err) + } + return overrides, nil +} + +// Upsert persists one override keyed by (folder, scope, root_path, file_path). +func (r *MediaIdentityOverrideRepository) Upsert( + ctx context.Context, + override models.MediaIdentityOverride, +) error { + return upsertMediaIdentityOverride(ctx, r.pool, override) +} + +// UpsertTx is Upsert inside a caller-owned transaction (the split endpoint +// persists overrides atomically with the file re-point). +func (r *MediaIdentityOverrideRepository) UpsertTx( + ctx context.Context, + tx pgx.Tx, + override models.MediaIdentityOverride, +) error { + return upsertMediaIdentityOverride(ctx, tx, override) +} + +type identityOverrideExecutor interface { + Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) +} + +func upsertMediaIdentityOverride(ctx context.Context, exec identityOverrideExecutor, override models.MediaIdentityOverride) error { + _, err := exec.Exec(ctx, ` + INSERT INTO media_identity_overrides ( + media_folder_id, scope, root_path, file_path, + forced_type, forced_title, forced_year, forced_tmdb_id, forced_imdb_id, forced_tvdb_id, + note, created_by_user_id, updated_by_user_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + ON CONFLICT (media_folder_id, scope, root_path, file_path) DO UPDATE SET + forced_type = EXCLUDED.forced_type, + forced_title = EXCLUDED.forced_title, + forced_year = EXCLUDED.forced_year, + forced_tmdb_id = EXCLUDED.forced_tmdb_id, + forced_imdb_id = EXCLUDED.forced_imdb_id, + forced_tvdb_id = EXCLUDED.forced_tvdb_id, + note = EXCLUDED.note, + updated_by_user_id = EXCLUDED.updated_by_user_id, + updated_at = NOW() + `, + override.MediaFolderID, + override.Scope, + normalizeOverridePath(override.RootPath), + normalizeOverridePath(override.FilePath), + strings.TrimSpace(override.ForcedType), + strings.TrimSpace(override.ForcedTitle), + override.ForcedYear, + strings.TrimSpace(override.ForcedTmdbID), + strings.TrimSpace(override.ForcedImdbID), + strings.TrimSpace(override.ForcedTvdbID), + strings.TrimSpace(override.Note), + override.CreatedByUserID, + override.UpdatedByUserID, + ) + if err != nil { + return fmt.Errorf("upserting media identity override: %w", err) + } + return nil +} + +func (r *MediaIdentityOverrideRepository) Delete(ctx context.Context, folderID int, id int64) error { + _, err := r.pool.Exec(ctx, ` + DELETE FROM media_identity_overrides + WHERE media_folder_id = $1 AND id = $2 + `, folderID, id) + if err != nil { + return fmt.Errorf("deleting media identity override: %w", err) + } + return nil +} + +func normalizeOverridePath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + return filepath.Clean(path) +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 998b2b0c..e983cda1 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -106,22 +106,23 @@ type scannerImageCacher interface { } type Scanner struct { - fileRepo *FileRepository - rootSnapshotRepo *ScannedRootRepository - groupSnapshotRepo *ScannedGroupRepository - rootOverrideRepo *MediaRootOverrideRepository - groupOverrideRepo *MediaGroupOverrideRepository - locationRepo *ObservedLocationRepository - groupLocationRepo *GroupLocationRepository - folderRepo *catalog.FolderRepository - libraryRepo *catalog.LibraryItemRepository - episodeLibraryRepo *catalog.EpisodeLibraryRepository - itemRepo *catalog.ItemRepository - personRepo *catalog.PersonRepository - episodeRepo *catalog.EpisodeRepository - ffprobePath string - s3Client *s3client.Client // public assets bucket (may be nil) - imageCacher scannerImageCacher + fileRepo *FileRepository + rootSnapshotRepo *ScannedRootRepository + groupSnapshotRepo *ScannedGroupRepository + rootOverrideRepo *MediaRootOverrideRepository + groupOverrideRepo *MediaGroupOverrideRepository + identityOverrideRepo *MediaIdentityOverrideRepository + locationRepo *ObservedLocationRepository + groupLocationRepo *GroupLocationRepository + folderRepo *catalog.FolderRepository + libraryRepo *catalog.LibraryItemRepository + episodeLibraryRepo *catalog.EpisodeLibraryRepository + itemRepo *catalog.ItemRepository + personRepo *catalog.PersonRepository + episodeRepo *catalog.EpisodeRepository + ffprobePath string + s3Client *s3client.Client // public assets bucket (may be nil) + imageCacher scannerImageCacher // workers is atomic so admin settings changes can resize the per-scan // worker pool while a scan is running (applies to the next scan). workers atomic.Int32 @@ -202,24 +203,25 @@ func NewScanner(fileRepo *FileRepository, ffprobePath string, s3Client *s3client fileRemovalGrace = 0 } s := &Scanner{ - fileRepo: fileRepo, - rootSnapshotRepo: NewScannedRootRepository(fileRepo.Pool()), - groupSnapshotRepo: NewScannedGroupRepository(fileRepo.Pool()), - rootOverrideRepo: NewMediaRootOverrideRepository(fileRepo.Pool()), - groupOverrideRepo: NewMediaGroupOverrideRepository(fileRepo.Pool()), - locationRepo: NewObservedLocationRepository(fileRepo.Pool()), - groupLocationRepo: NewGroupLocationRepository(fileRepo.Pool()), - folderRepo: catalog.NewFolderRepository(fileRepo.Pool()), - libraryRepo: catalog.NewLibraryItemRepository(fileRepo.Pool()), - episodeLibraryRepo: catalog.NewEpisodeLibraryRepository(fileRepo.Pool()), - itemRepo: catalog.NewItemRepository(fileRepo.Pool()), - personRepo: catalog.NewPersonRepository(fileRepo.Pool()), - episodeRepo: catalog.NewEpisodeRepository(fileRepo.Pool()), - ffprobePath: ffprobePath, - s3Client: s3Client, - emptyTrashAfterScan: emptyTrashAfterScan, - fileRemovalGrace: fileRemovalGrace, - markerFetcher: nil, + fileRepo: fileRepo, + rootSnapshotRepo: NewScannedRootRepository(fileRepo.Pool()), + groupSnapshotRepo: NewScannedGroupRepository(fileRepo.Pool()), + rootOverrideRepo: NewMediaRootOverrideRepository(fileRepo.Pool()), + groupOverrideRepo: NewMediaGroupOverrideRepository(fileRepo.Pool()), + identityOverrideRepo: NewMediaIdentityOverrideRepository(fileRepo.Pool()), + locationRepo: NewObservedLocationRepository(fileRepo.Pool()), + groupLocationRepo: NewGroupLocationRepository(fileRepo.Pool()), + folderRepo: catalog.NewFolderRepository(fileRepo.Pool()), + libraryRepo: catalog.NewLibraryItemRepository(fileRepo.Pool()), + episodeLibraryRepo: catalog.NewEpisodeLibraryRepository(fileRepo.Pool()), + itemRepo: catalog.NewItemRepository(fileRepo.Pool()), + personRepo: catalog.NewPersonRepository(fileRepo.Pool()), + episodeRepo: catalog.NewEpisodeRepository(fileRepo.Pool()), + ffprobePath: ffprobePath, + s3Client: s3Client, + emptyTrashAfterScan: emptyTrashAfterScan, + fileRemovalGrace: fileRemovalGrace, + markerFetcher: nil, } s.SetWorkers(workers) return s @@ -665,7 +667,11 @@ func (s *Scanner) scanPaths( return nil, fmt.Errorf("loading root overrides: %w", err) } rootInference := inferRootAssignments(filePaths, folder.Type, folder.ID, rootOverrides) - groupInference := inferGroupAssignments(filePaths, folder.Type, folder.ID, rootInference.Assignments) + identityOverrides, err := s.loadIdentityOverrides(ctx, folder.ID) + if err != nil { + return nil, fmt.Errorf("loading identity overrides: %w", err) + } + groupInference := inferGroupAssignments(filePaths, folder.Type, folder.ID, rootInference.Assignments, identityOverrides) groupOverrides, err := s.loadGroupOverrides(ctx, folder.ID) if err != nil { return nil, fmt.Errorf("loading group overrides: %w", err) @@ -1172,7 +1178,11 @@ func (s *Scanner) scanScope( return nil, fmt.Errorf("loading root overrides: %w", err) } rootInference := inferRootAssignments(filePaths, folder.Type, folder.ID, rootOverrides) - groupInference := inferGroupAssignments(filePaths, folder.Type, folder.ID, rootInference.Assignments) + identityOverrides, err := s.loadIdentityOverrides(ctx, folder.ID) + if err != nil { + return nil, fmt.Errorf("loading identity overrides: %w", err) + } + groupInference := inferGroupAssignments(filePaths, folder.Type, folder.ID, rootInference.Assignments, identityOverrides) groupOverrides, err := s.loadGroupOverrides(ctx, folder.ID) if err != nil { return nil, fmt.Errorf("loading group overrides: %w", err) @@ -1626,7 +1636,11 @@ func (s *Scanner) ScanFile(ctx context.Context, filePath string, folder *models. rootInference := inferRootAssignments([]string{filePath}, folder.Type, folder.ID, rootOverrides) s.logRootInferenceDisagreements(rootInference.Assignments) - groupInference := inferGroupAssignments([]string{filePath}, folder.Type, folder.ID, rootInference.Assignments) + identityOverrides, err := s.loadIdentityOverrides(ctx, folder.ID) + if err != nil { + return fmt.Errorf("loading identity overrides for file: %w", err) + } + groupInference := inferGroupAssignments([]string{filePath}, folder.Type, folder.ID, rootInference.Assignments, identityOverrides) groupOverrides, err := s.loadGroupOverrides(ctx, folder.ID) if err != nil { return fmt.Errorf("loading group overrides for file: %w", err) @@ -2486,6 +2500,20 @@ func (s *Scanner) loadRootOverrides( return overridesByRoot, nil } +func (s *Scanner) loadIdentityOverrides( + ctx context.Context, + folderID int, +) (*identityOverrideSet, error) { + if s == nil || s.identityOverrideRepo == nil || folderID <= 0 { + return nil, nil + } + overrides, err := s.identityOverrideRepo.ListByFolder(ctx, folderID) + if err != nil { + return nil, err + } + return newIdentityOverrideSet(overrides), nil +} + func (s *Scanner) loadGroupOverrides( ctx context.Context, folderID int, diff --git a/migrations/sql/20260706144033_media_identity_overrides.sql b/migrations/sql/20260706144033_media_identity_overrides.sql new file mode 100644 index 00000000..b4fdd16f --- /dev/null +++ b/migrations/sql/20260706144033_media_identity_overrides.sql @@ -0,0 +1,36 @@ +-- Path-scoped identity overrides for the split-versions flow (see +-- docs/superpowers/specs/2026-07-06-split-versions-reassign-design.md). +-- +-- media_group_overrides forces an identity for an entire inferred group, which +-- cannot fix a wrong merge: the misgrouped files share one group key. These +-- overrides bind to *paths* instead — a root folder or a single file — and are +-- applied per file during group inference, before bucketing, so overridden +-- files form their own group and rescans converge on the corrected assignment. + +-- +goose Up +CREATE TABLE media_identity_overrides ( + id bigserial PRIMARY KEY, + media_folder_id integer NOT NULL REFERENCES media_folders(id) ON DELETE CASCADE, + scope text NOT NULL CHECK (scope IN ('root', 'file')), + root_path text NOT NULL DEFAULT '', + file_path text NOT NULL DEFAULT '', + forced_type text NOT NULL DEFAULT '', + forced_title text NOT NULL DEFAULT '', + forced_year integer NOT NULL DEFAULT 0, + forced_tmdb_id text NOT NULL DEFAULT '', + forced_imdb_id text NOT NULL DEFAULT '', + forced_tvdb_id text NOT NULL DEFAULT '', + note text NOT NULL DEFAULT '', + created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT media_identity_overrides_scope_path CHECK ( + (scope = 'root' AND root_path <> '' AND file_path = '') OR + (scope = 'file' AND file_path <> '' AND root_path = '') + ), + CONSTRAINT media_identity_overrides_unique UNIQUE (media_folder_id, scope, root_path, file_path) +); + +-- +goose Down +DROP TABLE media_identity_overrides; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 1b4b0c64..f1c1dc71 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; + 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; diff --git a/web/src/components/SplitItemDialog.tsx b/web/src/components/SplitItemDialog.tsx new file mode 100644 index 00000000..97c35c85 --- /dev/null +++ b/web/src/components/SplitItemDialog.tsx @@ -0,0 +1,440 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Folder, Search, Scissors } from "lucide-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { + ItemFile, + ItemMatchSearchRequest, + ItemSplitResponse, + MatchCandidate, + SplitHistoryMode, +} from "@/api/types"; +import { useItemFiles, useSearchItemMatchCandidates, useSplitItem } from "@/hooks/queries/items"; +import { cn } from "@/lib/utils"; + +interface SplittableItem { + content_id: string; + title: string; + year?: number; + type: string; + library_id?: number; +} + +interface SplitItemDialogProps { + item: SplittableItem; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const HISTORY_MODE_LABELS: Record = { + evidence: "Follow play evidence (recommended)", + keep: "Keep all history on this item", + move_all: "Move everything to the new item", +}; + +/** + * Repairs a wrong merge: moves selected files (usually one folder) of this + * item to a different — possibly new — item, previews the watch-state + * reattribution via a dry run, and persists identity overrides so rescans + * keep the corrected assignment. + */ +export default function SplitItemDialog({ item, open, onOpenChange }: SplitItemDialogProps) { + const { data: filesData, isLoading: filesLoading } = useItemFiles( + open ? item.content_id : undefined, + ); + const files = useMemo(() => filesData?.files ?? [], [filesData]); + + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [title, setTitle] = useState(""); + const [year, setYear] = useState(""); + const [imdbId, setImdbId] = useState(""); + const [tmdbId, setTmdbId] = useState(""); + const [tvdbId, setTvdbId] = useState(""); + const [selectedCandidate, setSelectedCandidate] = useState(null); + const [detachUnmatched, setDetachUnmatched] = useState(false); + const [historyMode, setHistoryMode] = useState("evidence"); + const [preview, setPreview] = useState(null); + + const searchMutation = useSearchItemMatchCandidates(item.content_id); + const splitMutation = useSplitItem(); + const candidates = searchMutation.data?.candidates ?? []; + + // Any change to the plan invalidates a previously fetched preview. + const resetPreview = useCallback(() => setPreview(null), []); + useEffect(() => { + resetPreview(); + }, [selectedIds, selectedCandidate, detachUnmatched, historyMode, resetPreview]); + + const filesByRoot = useMemo(() => { + const groups = new Map(); + for (const file of files) { + const group = groups.get(file.observed_root_path) ?? []; + group.push(file); + groups.set(file.observed_root_path, group); + } + return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)); + }, [files]); + + const toggleFile = useCallback((id: number) => { + setSelectedIds((current) => { + const next = new Set(current); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const toggleRoot = useCallback((rootFiles: ItemFile[]) => { + setSelectedIds((current) => { + const next = new Set(current); + const allSelected = rootFiles.every((file) => next.has(file.id)); + for (const file of rootFiles) { + if (allSelected) { + next.delete(file.id); + } else { + next.add(file.id); + } + } + return next; + }); + }, []); + + const handleSearch = useCallback(() => { + setSelectedCandidate(null); + setDetachUnmatched(false); + const parsedYear = Number.parseInt(year.trim(), 10); + const request: ItemMatchSearchRequest = { + title: title || undefined, + year: Number.isFinite(parsedYear) ? parsedYear : undefined, + imdb_id: imdbId || undefined, + tmdb_id: tmdbId || undefined, + tvdb_id: tvdbId || undefined, + library_id: item.library_id, + }; + searchMutation.mutate(request); + }, [title, year, imdbId, tmdbId, tvdbId, item.library_id, searchMutation]); + + const targetChosen = detachUnmatched || selectedCandidate !== null; + const selectionValid = selectedIds.size > 0 && selectedIds.size < files.length; + const canSubmit = selectionValid && targetChosen && !splitMutation.isPending; + + const submit = useCallback( + (dryRun: boolean) => { + splitMutation.mutate( + { + contentId: item.content_id, + request: { + file_ids: [...selectedIds], + target: detachUnmatched + ? { unmatched: true } + : { + provider_ids: selectedCandidate?.provider_ids, + title: selectedCandidate?.title, + year: selectedCandidate?.year || undefined, + }, + history_mode: historyMode, + dry_run: dryRun, + }, + }, + { + onSuccess: (result) => { + if (dryRun) { + setPreview(result); + } else { + onOpenChange(false); + } + }, + }, + ); + }, + [ + splitMutation, + item.content_id, + selectedIds, + detachUnmatched, + selectedCandidate, + historyMode, + onOpenChange, + ], + ); + + return ( + + + + Split Versions + + +
+
+ {item.title} + {item.year ? ({item.year}) : null} + + {item.type} + +
+ + {/* Step 1: pick the files that belong to a different title */} +
+ + {filesLoading ? ( +
+ Loading files… +
+ ) : files.length < 2 ? ( +
+ This item has only one file; splitting needs at least two. +
+ ) : ( +
+ {filesByRoot.map(([root, rootFiles]) => { + const allSelected = rootFiles.every((file) => selectedIds.has(file.id)); + return ( +
+ +
+ {rootFiles.map((file) => ( + + ))} +
+
+ ); + })} +
+ )} + {selectedIds.size > 0 && selectedIds.size === files.length && ( +

+ All files are selected — that is a re-match, not a split. Use “Match Item” instead, + or deselect the files that are correct. +

+ )} +
+ + {/* Step 2: what the moved files actually are */} +
+ +
+
+ setTitle(e.target.value)} + placeholder="Title" + aria-label="Search title" + /> +
+ setYear(e.target.value)} + placeholder="Year" + type="number" + aria-label="Search year" + /> + setTmdbId(e.target.value)} + placeholder="TMDB ID" + aria-label="TMDB ID" + /> + setImdbId(e.target.value)} + placeholder="IMDb ID (tt…)" + aria-label="IMDb ID" + /> + setTvdbId(e.target.value)} + placeholder="TVDB ID" + aria-label="TVDB ID" + /> +
+ + + {candidates.length > 0 && ( +
+ {candidates.map((candidate, index) => { + const key = Object.entries(candidate.provider_ids) + .map(([k, v]) => `${k}-${v}`) + .join("_"); + return ( + + ); + })} +
+ )} + {searchMutation.isSuccess && candidates.length === 0 && ( +

No candidates found.

+ )} + + +
+ + {/* Step 3: watch-state handling */} +
+ + +

+ Resume points and downloads tied to the moved files always follow them. This controls + history rows without per-file evidence. +

+
+ + {preview && ( +
+
+ Preview — {preview.files_moved} file{preview.files_moved === 1 ? "" : "s"} →{" "} + {preview.target_content_id} + {preview.target_created ? ( + + new item + + ) : null} +
+
    +
  • {preview.reattribution.progress_moved} resume points move
  • +
  • + {preview.reattribution.history_moved} history entries move,{" "} + {preview.reattribution.history_ambiguous} stay for lack of evidence +
  • +
  • {preview.reattribution.downloads} downloads move
  • + {preview.episode_pairs > 0 &&
  • {preview.episode_pairs} episodes re-anchored
  • } + {preview.root_overrides.length + preview.file_overrides.length > 0 && ( +
  • + {preview.root_overrides.length} folder / {preview.file_overrides.length} file + identity override{"(s)"} pinned for future scans +
  • + )} +
+
+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/web/src/hooks/queries/items.ts b/web/src/hooks/queries/items.ts index b0ab113c..ad19f56a 100644 --- a/web/src/hooks/queries/items.ts +++ b/web/src/hooks/queries/items.ts @@ -6,9 +6,12 @@ import type { ApplyItemImageRequest, ApplyItemImageResponse, ItemDetail, + ItemFilesResponse, ItemImagesResponse, ItemMatchSearchRequest, ItemMatchSearchResponse, + ItemSplitRequest, + ItemSplitResponse, WatchDetail, } from "@/api/types"; import { adminKeys, catalogKeys, episodeKeys, itemKeys, sectionKeys } from "./keys"; @@ -376,6 +379,45 @@ export function useApplyItemMatch() { }); } +// --- Split/merge hooks --- + +export function useItemFiles(contentId: string | undefined) { + return useQuery({ + queryKey: ["items", "files", contentId], + queryFn: () => api(`/admin/items/${itemPathID(contentId ?? "")}/files`), + enabled: Boolean(contentId), + staleTime: 30_000, + }); +} + +export function useSplitItem() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ contentId, request }: { contentId: string; request: ItemSplitRequest }) => + api(`/admin/items/${itemPathID(contentId)}/split`, { + method: "POST", + body: JSON.stringify(request), + }), + onSuccess: async (result, { contentId }) => { + if (result.dry_run) return; + toast.success( + `Moved ${result.files_moved} file${result.files_moved === 1 ? "" : "s"} to a separate item`, + ); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["items", "detail", contentId] }), + queryClient.invalidateQueries({ queryKey: ["catalog", "items", contentId, "detail"] }), + queryClient.invalidateQueries({ queryKey: ["items", "watchDetail", contentId] }), + queryClient.invalidateQueries({ queryKey: ["items", "files", contentId] }), + queryClient.invalidateQueries({ queryKey: adminKeys.unmatchedItems() }), + ]); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "Failed to split item"); + }, + }); +} + // --- Image selector hooks --- export function useItemImages(contentId: string | undefined, enabled = true) { diff --git a/web/src/pages/AdminLibraries.tsx b/web/src/pages/AdminLibraries.tsx index 441b1c9b..9fa38541 100644 --- a/web/src/pages/AdminLibraries.tsx +++ b/web/src/pages/AdminLibraries.tsx @@ -1369,14 +1369,27 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) { {root.observed_file_count} - +
+ + {root.content_id ? ( + + ) : null} +
)) diff --git a/web/src/pages/ItemDetail/MovieContent.tsx b/web/src/pages/ItemDetail/MovieContent.tsx index 2fe4d6d2..9188af5f 100644 --- a/web/src/pages/ItemDetail/MovieContent.tsx +++ b/web/src/pages/ItemDetail/MovieContent.tsx @@ -17,6 +17,7 @@ import DownloadVersionPicker from "@/components/DownloadVersionPicker"; import EditMetadataDialog from "@/components/EditMetadataDialog"; import MediaLocations from "@/components/MediaLocations"; import MatchItemDialog from "@/components/MatchItemDialog"; +import SplitItemDialog from "@/components/SplitItemDialog"; import PageBack from "@/components/PageBack"; import RecommendationGrid from "@/components/RecommendationGrid"; import DetailHero from "./DetailHero"; @@ -65,6 +66,7 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov const deleteRatingMutation = useDeleteRating(item.content_id); const [editOpen, setEditOpen] = useState(false); const [matchOpen, setMatchOpen] = useState(false); + const [splitOpen, setSplitOpen] = useState(false); const [downloadOpen, setDownloadOpen] = useState(false); const [subtitleSearchOpen, setSubtitleSearchOpen] = useState(false); @@ -268,6 +270,9 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov canEditMarkers={canEditMarkers} onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} onMatchItem={canCurateMetadata ? () => setMatchOpen(true) : undefined} + onSplitItem={ + canCurateMetadata && item.versions.length > 1 ? () => setSplitOpen(true) : undefined + } versions={item.versions} playbackVariants={item.playback_variants} selectedVersion={selectedVersion} @@ -337,6 +342,14 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov onOpenChange={setMatchOpen} /> )} + {canCurateMetadata && ( + + )} seasonsData?.seasons ?? [], [seasonsData?.seasons]); @@ -183,6 +185,7 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se canCurateMetadata={canCurateMetadata} onEditMetadata={canCurateMetadata ? () => setEditOpen(true) : undefined} onMatchItem={canCurateMetadata ? () => setMatchOpen(true) : undefined} + onSplitItem={canCurateMetadata ? () => setSplitOpen(true) : undefined} rating={item.user_rating ?? null} onRatingChange={handleRatingChange} /> @@ -240,6 +243,14 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se onOpenChange={setMatchOpen} /> )} + {canCurateMetadata && ( + + )} ); } diff --git a/web/src/pages/ItemDetail/components/ActionBar.tsx b/web/src/pages/ItemDetail/components/ActionBar.tsx index 2549b1f7..cdcf3a08 100644 --- a/web/src/pages/ItemDetail/components/ActionBar.tsx +++ b/web/src/pages/ItemDetail/components/ActionBar.tsx @@ -12,6 +12,7 @@ import { Play, RefreshCw, Pencil, + Scissors, Search, RotateCcw, Tags, @@ -74,6 +75,7 @@ interface ActionBarProps { isRedetectingIntro?: boolean; onEditMetadata?: () => void; onMatchItem?: () => void; + onSplitItem?: () => void; isAdmin?: boolean; canCurateMetadata?: boolean; /** Enables the "Edit Markers" action (playable items only: movies/episodes). */ @@ -125,6 +127,7 @@ export default function ActionBar({ isRedetectingIntro = false, onEditMetadata, onMatchItem, + onSplitItem, isAdmin = false, canCurateMetadata = false, canEditMarkers = false, @@ -439,6 +442,12 @@ export default function ActionBar({ Match Item )} + {canCurateMetadata && onSplitItem && ( + + + Split Versions + + )} )}