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:
committed by
GitHub
co-authored by
Claude Fable 5
parent
d08a4f232f
commit
0fb5afe479
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user