Files
silo-server/internal/models/media_group.go
T
0fb5afe479 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>
2026-07-06 12:23:32 -04:00

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
}