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

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

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

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

Part of #318

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

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

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

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

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

Part of #318

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-06 12:23:32 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent d08a4f232f
commit 0fb5afe479
24 changed files with 3644 additions and 58 deletions
@@ -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 = <target>` 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-<provider>-<seriesId>-<s>-<e>` — 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": "<content_id>"}`) 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`.
+708
View File
@@ -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
}
+132
View File
@@ -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)
}
}
+29 -2
View File
@@ -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")
+23
View File
@@ -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)
+536
View File
@@ -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
}
@@ -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)
}
}
+43
View File
@@ -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)
}
+51
View File
@@ -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 {
+30
View File
@@ -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
+14
View File
@@ -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)
}
+139
View File
@@ -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
}
+87 -3
View File
@@ -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
}
+230 -8
View File
@@ -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)
}
}
+162
View File
@@ -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)
}
+65 -37
View File
@@ -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,
@@ -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;
+64
View File
@@ -2945,6 +2945,8 @@ export interface LibraryRoot {
first_seen_at: string;
last_seen_at: string;
active_override?: LibraryRootOverride;
/** Catalog item this group matched to, when known. */
content_id?: string;
}
export interface LibraryRootsResponse {
@@ -4135,6 +4137,68 @@ export interface ItemMatchApplyRequest {
library_id?: number;
}
// Split/merge (wrong version-grouping repair) types
export interface ItemFile {
id: number;
library_id: number;
file_path: string;
observed_root_path: string;
season_number?: number;
episode_number?: number;
}
export interface ItemFilesResponse {
files: ItemFile[];
}
export type SplitHistoryMode = "evidence" | "keep" | "move_all";
export interface ItemSplitTarget {
provider_ids?: Record<string, string>;
content_id?: string;
unmatched?: boolean;
title?: string;
year?: number;
}
export interface ItemSplitRequest {
file_ids: number[];
target: ItemSplitTarget;
history_mode?: SplitHistoryMode;
persist_override?: boolean;
dry_run?: boolean;
}
export interface ReattributionReport {
playback_session_log: number;
downloads: number;
progress_moved: number;
progress_conflicts: number;
history_moved: number;
history_stayed: number;
history_ambiguous: number;
intent_moved: number;
episode_pairs_moved: number;
ambiguous_history?: {
user_id: number;
profile_id: string;
watched_at: string;
}[];
}
export interface ItemSplitResponse {
dry_run: boolean;
source_content_id: string;
target_content_id: string;
target_created: boolean;
files_moved: number;
root_overrides: string[];
file_overrides: string[];
episode_pairs: number;
reattribution: ReattributionReport;
}
// Image selector types
export interface RemoteImage {
provider_id: string;
+440
View File
@@ -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<SplitHistoryMode, string> = {
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<Set<number>>(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<MatchCandidate | null>(null);
const [detachUnmatched, setDetachUnmatched] = useState(false);
const [historyMode, setHistoryMode] = useState<SplitHistoryMode>("evidence");
const [preview, setPreview] = useState<ItemSplitResponse | null>(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<string, ItemFile[]>();
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>Split Versions</DialogTitle>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto pr-1">
<div className="bg-muted/50 shrink-0 rounded-lg px-3 py-2 text-sm">
<span className="font-medium">{item.title}</span>
{item.year ? <span className="text-muted-foreground ml-2">({item.year})</span> : null}
<Badge variant="secondary" className="ml-2 text-[10px]">
{item.type}
</Badge>
</div>
{/* Step 1: pick the files that belong to a different title */}
<section className="space-y-2">
<Label>Files to move</Label>
{filesLoading ? (
<div className="text-muted-foreground bg-muted/30 rounded-lg border px-3 py-2 text-sm">
Loading files…
</div>
) : files.length < 2 ? (
<div className="text-muted-foreground bg-muted/30 rounded-lg border px-3 py-2 text-sm">
This item has only one file; splitting needs at least two.
</div>
) : (
<div className="bg-background/70 divide-border/50 divide-y rounded-lg border">
{filesByRoot.map(([root, rootFiles]) => {
const allSelected = rootFiles.every((file) => selectedIds.has(file.id));
return (
<div key={root} className="px-3 py-2">
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={allSelected}
onChange={() => toggleRoot(rootFiles)}
aria-label={`Select all files in ${root}`}
/>
<Folder className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={root}>
{root}
</span>
</label>
<div className="mt-1 space-y-1 pl-6">
{rootFiles.map((file) => (
<label
key={file.id}
className="flex cursor-pointer items-center gap-2 text-xs"
>
<input
type="checkbox"
checked={selectedIds.has(file.id)}
onChange={() => toggleFile(file.id)}
aria-label={`Select ${file.file_path}`}
/>
<span
className="text-muted-foreground min-w-0 flex-1 truncate font-mono"
title={file.file_path}
>
{file.file_path.split("/").pop()}
</span>
{file.season_number && file.episode_number ? (
<Badge variant="outline" className="text-[10px]">
S{file.season_number}E{file.episode_number}
</Badge>
) : null}
</label>
))}
</div>
</div>
);
})}
</div>
)}
{selectedIds.size > 0 && selectedIds.size === files.length && (
<p className="text-destructive text-xs">
All files are selected — that is a re-match, not a split. Use “Match Item” instead,
or deselect the files that are correct.
</p>
)}
</section>
{/* Step 2: what the moved files actually are */}
<section className="space-y-3">
<Label>Correct identity for the moved files</Label>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
aria-label="Search title"
/>
</div>
<Input
value={year}
onChange={(e) => setYear(e.target.value)}
placeholder="Year"
type="number"
aria-label="Search year"
/>
<Input
value={tmdbId}
onChange={(e) => setTmdbId(e.target.value)}
placeholder="TMDB ID"
aria-label="TMDB ID"
/>
<Input
value={imdbId}
onChange={(e) => setImdbId(e.target.value)}
placeholder="IMDb ID (tt…)"
aria-label="IMDb ID"
/>
<Input
value={tvdbId}
onChange={(e) => setTvdbId(e.target.value)}
placeholder="TVDB ID"
aria-label="TVDB ID"
/>
</div>
<Button
onClick={handleSearch}
disabled={searchMutation.isPending}
variant="secondary"
className="w-full gap-2"
>
<Search className={cn("h-4 w-4", searchMutation.isPending && "animate-spin")} />
Search
</Button>
{candidates.length > 0 && (
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
{candidates.map((candidate, index) => {
const key = Object.entries(candidate.provider_ids)
.map(([k, v]) => `${k}-${v}`)
.join("_");
return (
<button
key={`${key}-${index}`}
type="button"
className={cn(
"flex w-full min-w-0 items-center gap-3 rounded-lg border p-2 text-left transition-colors",
selectedCandidate === candidate
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/50",
)}
onClick={() => {
setSelectedCandidate(candidate);
setDetachUnmatched(false);
}}
data-testid="split-candidate"
>
{candidate.image_url ? (
<img
src={candidate.image_url}
alt=""
className="h-14 w-10 shrink-0 rounded object-cover"
/>
) : (
<div className="bg-muted h-14 w-10 shrink-0 rounded" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{candidate.title}</div>
<div className="text-muted-foreground text-xs">{candidate.year || ""}</div>
</div>
</button>
);
})}
</div>
)}
{searchMutation.isSuccess && candidates.length === 0 && (
<p className="text-muted-foreground text-center text-sm">No candidates found.</p>
)}
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={detachUnmatched}
onChange={(e) => {
setDetachUnmatched(e.target.checked);
if (e.target.checked) setSelectedCandidate(null);
}}
/>
Detach as unmatched (identify later)
</label>
</section>
{/* Step 3: watch-state handling */}
<section className="space-y-2">
<Label>Watch history handling</Label>
<Select
value={historyMode}
onValueChange={(value) => setHistoryMode(value as SplitHistoryMode)}
>
<SelectTrigger aria-label="Watch history handling">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(HISTORY_MODE_LABELS) as SplitHistoryMode[]).map((mode) => (
<SelectItem key={mode} value={mode}>
{HISTORY_MODE_LABELS[mode]}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Resume points and downloads tied to the moved files always follow them. This controls
history rows without per-file evidence.
</p>
</section>
{preview && (
<section className="bg-muted/30 space-y-1 rounded-lg border px-3 py-2 text-sm">
<div className="font-medium">
Preview — {preview.files_moved} file{preview.files_moved === 1 ? "" : "s"} →{" "}
<span className="font-mono text-xs">{preview.target_content_id}</span>
{preview.target_created ? (
<Badge variant="secondary" className="ml-2 text-[10px]">
new item
</Badge>
) : null}
</div>
<ul className="text-muted-foreground list-inside list-disc text-xs">
<li>{preview.reattribution.progress_moved} resume points move</li>
<li>
{preview.reattribution.history_moved} history entries move,{" "}
{preview.reattribution.history_ambiguous} stay for lack of evidence
</li>
<li>{preview.reattribution.downloads} downloads move</li>
{preview.episode_pairs > 0 && <li>{preview.episode_pairs} episodes re-anchored</li>}
{preview.root_overrides.length + preview.file_overrides.length > 0 && (
<li>
{preview.root_overrides.length} folder / {preview.file_overrides.length} file
identity override{"(s)"} pinned for future scans
</li>
)}
</ul>
</section>
)}
</div>
<div className="border-border/50 flex shrink-0 gap-2 border-t pt-4">
<Button
variant="secondary"
className="flex-1"
onClick={() => submit(true)}
disabled={!canSubmit}
data-testid="preview-split"
>
{splitMutation.isPending && !preview ? "Previewing…" : "Preview"}
</Button>
<Button
className="flex-1 gap-2"
onClick={() => submit(false)}
disabled={!canSubmit || !preview}
data-testid="confirm-split"
>
<Scissors className="h-4 w-4" />
{splitMutation.isPending && preview ? "Splitting…" : "Split"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
+42
View File
@@ -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<ItemFilesResponse>(`/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<ItemSplitResponse>(`/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) {
+21 -8
View File
@@ -1369,14 +1369,27 @@ function AmbiguousRootsSection({ libraries }: { libraries: Library[] }) {
{root.observed_file_count}
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setEditingRoot(root)}
>
Override
</Button>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setEditingRoot(root)}
>
Override
</Button>
{root.content_id ? (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
asChild
title="Open the matched item; use its Split Versions action to separate wrongly merged files"
>
<Link to={`/item/${encodeURIComponent(root.content_id)}`}>Resolve</Link>
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))
+13
View File
@@ -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 && (
<SplitItemDialog
key={`split-${item.content_id}`}
item={item}
open={splitOpen}
onOpenChange={setSplitOpen}
/>
)}
<DownloadVersionPicker
open={downloadOpen}
onOpenChange={setDownloadOpen}
@@ -16,6 +16,7 @@ import CastCarousel from "@/components/CastCarousel";
import CrewList from "@/components/CrewList";
import EditMetadataDialog from "@/components/EditMetadataDialog";
import MatchItemDialog from "@/components/MatchItemDialog";
import SplitItemDialog from "@/components/SplitItemDialog";
import PageBack from "@/components/PageBack";
import RecommendationGrid from "@/components/RecommendationGrid";
import DetailHero from "./DetailHero";
@@ -52,6 +53,7 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se
const [editOpen, setEditOpen] = useState(false);
const [matchOpen, setMatchOpen] = useState(false);
const [splitOpen, setSplitOpen] = useState(false);
const { data: seasonsData, isLoading: seasonsLoading } = useSeasons(item.content_id);
const { data: similarData, isLoading: similarLoading } = useSimilarItems(item.content_id);
const seasons = useMemo(() => 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 && (
<SplitItemDialog
key={`split-${item.content_id}`}
item={item}
open={splitOpen}
onOpenChange={setSplitOpen}
/>
)}
</div>
);
}
@@ -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
</DropdownMenuItem>
)}
{canCurateMetadata && onSplitItem && (
<DropdownMenuItem onSelect={onSplitItem}>
<Scissors className="size-4" />
Split Versions
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>