* 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>
135 lines
3.7 KiB
Go
135 lines
3.7 KiB
Go
package models
|
|
|
|
import "time"
|
|
|
|
// ScannedMediaGroup is the persisted group-inference snapshot for one logical
|
|
// content group inside a library folder.
|
|
type ScannedMediaGroup struct {
|
|
MediaFolderID int
|
|
GroupKeyVersion int
|
|
ContentGroupKey string
|
|
State string
|
|
InferredType string
|
|
TypeConfidence string
|
|
BaseTitle string
|
|
BaseYear int
|
|
TmdbID string
|
|
ImdbID string
|
|
TvdbID string
|
|
ObservedFileCount int
|
|
SampleFilePath string
|
|
SampleObservedRootPath string
|
|
EvidenceJSON []byte
|
|
OverrideSource string
|
|
FirstSeenAt time.Time
|
|
LastSeenAt time.Time
|
|
}
|
|
|
|
// MediaGroupOverride stores an operator-provided override for a scanned group.
|
|
type MediaGroupOverride struct {
|
|
MediaFolderID int
|
|
GroupKeyVersion int
|
|
ContentGroupKey string
|
|
ForcedType string
|
|
ForcedTitle string
|
|
ForcedYear int
|
|
ForcedTmdbID string
|
|
ForcedImdbID string
|
|
ForcedTvdbID string
|
|
Note string
|
|
CreatedByUserID *int
|
|
UpdatedByUserID *int
|
|
CreatedAt time.Time
|
|
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
|
|
ObservedRootPath string
|
|
LocationType string
|
|
SampleFilePath string
|
|
ObservedFileCount int
|
|
ContentGroupCount int
|
|
PrimaryGroupKeyVersion int
|
|
PrimaryContentGroupKey string
|
|
State string
|
|
EvidenceJSON []byte
|
|
FirstSeenAt time.Time
|
|
LastSeenAt time.Time
|
|
}
|
|
|
|
// MediaGroupLocation links a logical content group to a physical location.
|
|
type MediaGroupLocation struct {
|
|
MediaFolderID int
|
|
GroupKeyVersion int
|
|
ContentGroupKey string
|
|
ObservedRootPath string
|
|
IsPrimary bool
|
|
FirstSeenAt time.Time
|
|
LastSeenAt time.Time
|
|
}
|
|
|
|
// SeriesRootMatchQueueEntry represents one pending initial series-root job.
|
|
type SeriesRootMatchQueueEntry struct {
|
|
MediaFolderID int
|
|
ObservedRootPath string
|
|
FirstQueuedAt time.Time
|
|
AvailableAt time.Time
|
|
LastAttemptedAt *time.Time
|
|
AttemptCount int
|
|
LastError string
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// SeriesRootMatchJob is the claimed work payload returned to the matcher.
|
|
type SeriesRootMatchJob struct {
|
|
MediaFolderID int
|
|
ObservedRootPath string
|
|
SampleFilePath string
|
|
ObservedFileCount int
|
|
}
|
|
|
|
// MovieMatchQueueEntry represents one pending initial movie-file job.
|
|
type MovieMatchQueueEntry struct {
|
|
MediaFileID int
|
|
MediaFolderID int
|
|
FilePath string
|
|
FirstQueuedAt time.Time
|
|
AvailableAt time.Time
|
|
LastAttemptedAt *time.Time
|
|
AttemptCount int
|
|
LastError string
|
|
UpdatedAt time.Time
|
|
}
|