* 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>
44 lines
1.5 KiB
Go
44 lines
1.5 KiB
Go
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)
|
|
}
|