* 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>
140 lines
4.4 KiB
Go
140 lines
4.4 KiB
Go
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
|
|
}
|