Files
silo-server/internal/scanner/group_inference_test.go
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

439 lines
17 KiB
Go

package scanner
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestInferGroupAssignments_FlatLooseMovieEditionsCollapse(t *testing.T) {
t.Parallel()
filePaths := []string{
"/Media/Movies/Blade Runner (1982) {edition-Director's Cut}.mp4",
"/Media/Movies/Blade Runner (1982) {edition-Final Cut}.mp4",
}
rootInference := inferRootAssignments(filePaths, "movies", 1, nil)
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)
}
first := groupInference.Assignments[filePaths[0]]
second := groupInference.Assignments[filePaths[1]]
if first.ContentGroupKey == "" || second.ContentGroupKey == "" {
t.Fatal("content group key should not be empty")
}
if first.ContentGroupKey != second.ContentGroupKey {
t.Fatalf("group keys differ: %q != %q", first.ContentGroupKey, second.ContentGroupKey)
}
if got, want := groupInference.ScannedGroups[0].BaseTitle, "Blade Runner"; got != want {
t.Fatalf("BaseTitle = %q, want %q", got, want)
}
if got, want := groupInference.ScannedGroups[0].BaseYear, 1982; got != want {
t.Fatalf("BaseYear = %d, want %d", got, want)
}
if got, want := groupInference.ScannedGroups[0].State, "resolved"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
}
func TestInferGroupAssignments_AnchormanEditionNoiseStaysResolved(t *testing.T) {
t.Parallel()
filePaths := []string{
"/srv/media/movies/alt-cuts/1080p/Anchorman 2 The Legend Continues (2013)/Anchorman.2.The.Legend.Continues.Super-Sized.R-Rated.Version.2013.Bluray.Remux.1080p.AVC.DTS-HD.MA.5.1-HiFi.mkv",
}
rootInference := inferRootAssignments(filePaths, "movies", 1, nil)
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)
}
group := groupInference.ScannedGroups[0]
if got, want := group.State, "resolved"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
if got, want := group.BaseTitle, "Anchorman 2 The Legend Continues"; got != want {
t.Fatalf("BaseTitle = %q, want %q", got, want)
}
}
func TestInferGroupAssignments_AVPAliasStaysResolved(t *testing.T) {
t.Parallel()
filePaths := []string{
"/srv/media/movies/alt-cuts/1080p/AVP Alien vs. Predator (2004)/Alien.vs.Predator.2004.Unrated.Bluray.Remux.1080p.AVC.DTS-HD.MA.5.1-HiFi.mkv",
}
rootInference := inferRootAssignments(filePaths, "movies", 1, nil)
groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, nil)
group := groupInference.ScannedGroups[0]
if got, want := group.State, "resolved"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
if got, want := group.BaseYear, 2004; got != want {
t.Fatalf("BaseYear = %d, want %d", got, want)
}
}
func TestInferGroupAssignments_UnrelatedMovieTitleBecomesAmbiguous(t *testing.T) {
t.Parallel()
filePaths := []string{
"/srv/media/movies/alt-cuts/1080p/Green Lantern (2011)/Green.Lantern.Emerald.Knights.2011.1080p.BluRay.x264.mkv",
}
rootInference := inferRootAssignments(filePaths, "movies", 1, nil)
groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, nil)
group := groupInference.ScannedGroups[0]
if got, want := group.State, "ambiguous"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
}
func TestInferGroupAssignments_SeriesSeasonDirsCollapse(t *testing.T) {
t.Parallel()
filePaths := []string{
"/tv/Breaking Bad (2008)/Season 01/Breaking.Bad.S01E01.mkv",
"/tv/Breaking Bad (2008)/Season 02/Breaking.Bad.S02E01.mkv",
}
rootInference := inferRootAssignments(filePaths, "series", 1, nil)
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)
}
group := groupInference.ScannedGroups[0]
if got, want := group.InferredType, "series"; got != want {
t.Fatalf("InferredType = %q, want %q", got, want)
}
if got, want := group.BaseTitle, "Breaking Bad"; got != want {
t.Fatalf("BaseTitle = %q, want %q", got, want)
}
if got, want := group.BaseYear, 2008; got != want {
t.Fatalf("BaseYear = %d, want %d", got, want)
}
}
func TestInferGroupAssignments_SeriesEpisodeTitleNumberDoesNotBecomeTVDBID(t *testing.T) {
t.Parallel()
filePaths := []string{
"/sports/television/WWE SmackDown (1999)/Season 01/WWE SmackDown (1999) - S01E01 - SmackDown 01.mkv",
}
rootInference := inferRootAssignments(filePaths, "mixed", 7, nil)
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)
}
group := groupInference.ScannedGroups[0]
if got, want := group.InferredType, "series"; got != want {
t.Fatalf("InferredType = %q, want %q", got, want)
}
if group.TvdbID != "" {
t.Fatalf("TvdbID = %q, want empty", group.TvdbID)
}
}
func TestInferGroupAssignments_MovieEpisodeTokenFolderStaysResolved(t *testing.T) {
t.Parallel()
filePaths := []string{
"/movies/s01e03 (2020) {imdb-tt12261772} {tmdb-588077}/s01e03 (2020).mkv",
}
rootInference := inferRootAssignments(filePaths, "movies", 1, nil)
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)
}
group := groupInference.ScannedGroups[0]
if got, want := group.State, "resolved"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
if got, want := group.BaseTitle, "s01e03"; got != want {
t.Fatalf("BaseTitle = %q, want %q", got, want)
}
if got, want := group.InferredType, "movie"; got != want {
t.Fatalf("InferredType = %q, want %q", got, want)
}
}
func TestApplyGroupOverrides_ForcesResolvedIdentity(t *testing.T) {
t.Parallel()
filePaths := []string{
"/srv/media/movies/alt-cuts/1080p/AVP Alien vs. Predator (2004)/Alien.vs.Predator.2004.Unrated.Bluray.Remux.1080p.AVC.DTS-HD.MA.5.1-HiFi.mkv",
}
rootInference := inferRootAssignments(filePaths, "movies", 1, nil)
groupInference := inferGroupAssignments(filePaths, "movies", 1, rootInference.Assignments, nil)
group := groupInference.ScannedGroups[0]
applyGroupOverrides(&groupInference, map[string]models.MediaGroupOverride{
groupOverrideKey(group.GroupKeyVersion, group.ContentGroupKey): {
MediaFolderID: 1,
GroupKeyVersion: group.GroupKeyVersion,
ContentGroupKey: group.ContentGroupKey,
ForcedTitle: "Alien vs. Predator",
ForcedYear: 2004,
ForcedTmdbID: "395",
},
})
updated := groupInference.ScannedGroups[0]
if got, want := updated.State, "resolved"; got != want {
t.Fatalf("State = %q, want %q", got, want)
}
if got, want := updated.BaseTitle, "Alien vs. Predator"; got != want {
t.Fatalf("BaseTitle = %q, want %q", got, want)
}
if got, want := updated.TmdbID, "395"; got != want {
t.Fatalf("TmdbID = %q, want %q", got, want)
}
if got, want := updated.OverrideSource, "manual"; got != want {
t.Fatalf("OverrideSource = %q, want %q", got, want)
}
assignment := groupInference.Assignments[filePaths[0]]
if got, want := assignment.BaseTitle, "AVP Alien vs. Predator"; got != want {
t.Fatalf("raw assignment BaseTitle = %q, want %q", got, want)
}
if got, want := assignment.TmdbID, ""; got != want {
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)
}
}