Files
silo-server/internal/metadata/fallback_episode_test.go

1006 lines
30 KiB
Go

package metadata
import (
"context"
"fmt"
"sort"
"sync"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// ---------------------------------------------------------------------------
// Fake repositories for episode/season testing
// ---------------------------------------------------------------------------
// fakeEpisodeRepo implements metadataEpisodeRepo with an in-memory store keyed
// by (series_id, season_number, episode_number).
type fakeEpisodeRepo struct {
mu sync.Mutex
episodes map[string]*models.Episode // key: "seriesID:season:episode"
upserts int
}
func newFakeEpisodeRepo() *fakeEpisodeRepo {
return &fakeEpisodeRepo{episodes: make(map[string]*models.Episode)}
}
func episodeKey(seriesID string, season, episode int) string {
return fmt.Sprintf("%s:%d:%d", seriesID, season, episode)
}
func (r *fakeEpisodeRepo) GetBySeriesAndNumber(_ context.Context, seriesID string, season, episode int) (*models.Episode, error) {
r.mu.Lock()
defer r.mu.Unlock()
key := episodeKey(seriesID, season, episode)
if ep, ok := r.episodes[key]; ok {
cp := *ep
return &cp, nil
}
return nil, catalog.ErrEpisodeNotFound
}
func (r *fakeEpisodeRepo) GetByID(_ context.Context, contentID string) (*models.Episode, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, ep := range r.episodes {
if ep.ContentID == contentID {
cp := *ep
return &cp, nil
}
}
return nil, catalog.ErrEpisodeNotFound
}
func (r *fakeEpisodeRepo) ListBySeriesAndAirDates(_ context.Context, seriesID string, airDates []string) (map[string][]*models.Episode, error) {
r.mu.Lock()
defer r.mu.Unlock()
dateSet := make(map[string]struct{}, len(airDates))
for _, airDate := range airDates {
dateSet[airDate] = struct{}{}
}
result := make(map[string][]*models.Episode, len(dateSet))
for _, ep := range r.episodes {
if ep.SeriesID != seriesID || ep.AirDate == nil {
continue
}
key := ep.AirDate.Format("2006-01-02")
if _, ok := dateSet[key]; !ok {
continue
}
cp := *ep
result[key] = append(result[key], &cp)
}
for key := range result {
sort.Slice(result[key], func(i, j int) bool {
left := result[key][i]
right := result[key][j]
if left.SeasonNumber != right.SeasonNumber {
return left.SeasonNumber < right.SeasonNumber
}
return left.EpisodeNumber < right.EpisodeNumber
})
}
return result, nil
}
func (r *fakeEpisodeRepo) ListBySeries(_ context.Context, seriesID string) ([]*models.Episode, error) {
return r.listBySeries(seriesID), nil
}
func (r *fakeEpisodeRepo) ListBySeasonID(_ context.Context, seasonID string) ([]*models.Episode, error) {
r.mu.Lock()
defer r.mu.Unlock()
var result []*models.Episode
for _, ep := range r.episodes {
if ep.SeasonID == seasonID {
cp := *ep
result = append(result, &cp)
}
}
sort.Slice(result, func(i, j int) bool {
left := result[i]
right := result[j]
if left.SeasonNumber != right.SeasonNumber {
return left.SeasonNumber < right.SeasonNumber
}
return left.EpisodeNumber < right.EpisodeNumber
})
return result, nil
}
func (r *fakeEpisodeRepo) Upsert(_ context.Context, ep *models.Episode) error {
r.mu.Lock()
defer r.mu.Unlock()
r.upserts++
key := episodeKey(ep.SeriesID, ep.SeasonNumber, ep.EpisodeNumber)
if existing, ok := r.episodes[key]; ok {
// Preserve the existing content_id (mirrors the ON CONFLICT behavior).
ep.ContentID = existing.ContentID
// Update fields in place.
cp := *ep
r.episodes[key] = &cp
} else {
cp := *ep
r.episodes[key] = &cp
}
return nil
}
func (r *fakeEpisodeRepo) BulkUpsert(ctx context.Context, _ string, episodes []*models.Episode) error {
for _, ep := range episodes {
if err := r.Upsert(ctx, ep); err != nil {
return err
}
}
return nil
}
func (r *fakeEpisodeRepo) UpsertCalls() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.upserts
}
// listBySeries returns all episodes for a series, for test assertions.
func (r *fakeEpisodeRepo) listBySeries(seriesID string) []*models.Episode {
r.mu.Lock()
defer r.mu.Unlock()
var result []*models.Episode
for _, ep := range r.episodes {
if ep.SeriesID == seriesID {
cp := *ep
result = append(result, &cp)
}
}
return result
}
// fakeSeasonRepo implements metadataSeasonRepo with an in-memory store keyed
// by (series_id, season_number).
type fakeSeasonRepo struct {
mu sync.Mutex
seasons map[string]*models.Season // key: "seriesID:seasonNum"
upserts int
}
func newFakeSeasonRepo() *fakeSeasonRepo {
return &fakeSeasonRepo{seasons: make(map[string]*models.Season)}
}
func seasonKey(seriesID string, seasonNum int) string {
return fmt.Sprintf("%s:%d", seriesID, seasonNum)
}
func (r *fakeSeasonRepo) GetBySeriesAndNumber(_ context.Context, seriesID string, seasonNum int) (*models.Season, error) {
r.mu.Lock()
defer r.mu.Unlock()
key := seasonKey(seriesID, seasonNum)
if s, ok := r.seasons[key]; ok {
cp := *s
return &cp, nil
}
return nil, catalog.ErrSeasonNotFound
}
func (r *fakeSeasonRepo) GetByID(_ context.Context, contentID string) (*models.Season, error) {
r.mu.Lock()
defer r.mu.Unlock()
for _, season := range r.seasons {
if season.ContentID == contentID {
cp := *season
return &cp, nil
}
}
return nil, catalog.ErrSeasonNotFound
}
func (r *fakeSeasonRepo) Upsert(_ context.Context, s *models.Season) error {
r.mu.Lock()
defer r.mu.Unlock()
r.upserts++
key := seasonKey(s.SeriesID, s.SeasonNumber)
if existing, ok := r.seasons[key]; ok {
// Preserve existing content_id (mirrors ON CONFLICT behavior).
s.ContentID = existing.ContentID
cp := *s
r.seasons[key] = &cp
} else {
cp := *s
r.seasons[key] = &cp
}
return nil
}
func (r *fakeSeasonRepo) BulkUpsert(ctx context.Context, seasons []*models.Season) error {
for _, s := range seasons {
if err := r.Upsert(ctx, s); err != nil {
return err
}
}
return nil
}
func (r *fakeSeasonRepo) UpsertCalls() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.upserts
}
// fakeEpisodeLinkerFileRepo extends fakeFileRepo with EpisodeLinker methods,
// allowing the fallback synthesis to list unlinked files and link them.
type fakeEpisodeLinkerFileRepo struct {
fakeFileRepo
files map[int]*models.MediaFile // fileID -> file
episodeLinks map[int]string // fileID -> episodeID
}
func newFakeEpisodeLinkerFileRepo() *fakeEpisodeLinkerFileRepo {
return &fakeEpisodeLinkerFileRepo{
fakeFileRepo: *newFakeFileRepo(),
files: make(map[int]*models.MediaFile),
episodeLinks: make(map[int]string),
}
}
func (r *fakeEpisodeLinkerFileRepo) addFile(file *models.MediaFile) {
r.fakeFileRepo.mu.Lock()
defer r.fakeFileRepo.mu.Unlock()
cp := *file
r.files[file.ID] = &cp
}
func (r *fakeEpisodeLinkerFileRepo) UpdateEpisodeLink(_ context.Context, fileID int, episodeID string, seasonNum, episodeNum int) error {
r.fakeFileRepo.mu.Lock()
defer r.fakeFileRepo.mu.Unlock()
r.episodeLinks[fileID] = episodeID
if file, ok := r.files[fileID]; ok {
file.EpisodeID = episodeID
file.SeasonNumber = seasonNum
file.EpisodeNumber = episodeNum
}
return nil
}
func (r *fakeEpisodeLinkerFileRepo) ListBySeriesUnlinked(_ context.Context, seriesContentID string) ([]*models.MediaFile, error) {
r.fakeFileRepo.mu.Lock()
defer r.fakeFileRepo.mu.Unlock()
var result []*models.MediaFile
for _, file := range r.files {
cid := r.fakeFileRepo.contentIDs[file.ID]
if cid != seriesContentID {
continue
}
if _, linked := r.episodeLinks[file.ID]; linked {
continue
}
cp := *file
result = append(result, &cp)
}
return result, nil
}
type blockingEpisodeLinkerFileRepo struct {
*fakeEpisodeLinkerFileRepo
mu sync.Mutex
listCalls int
entered chan struct{}
release chan struct{}
enterOnce sync.Once
}
func newBlockingEpisodeLinkerFileRepo() *blockingEpisodeLinkerFileRepo {
return &blockingEpisodeLinkerFileRepo{
fakeEpisodeLinkerFileRepo: newFakeEpisodeLinkerFileRepo(),
entered: make(chan struct{}),
release: make(chan struct{}),
}
}
func (r *blockingEpisodeLinkerFileRepo) ListBySeriesUnlinked(ctx context.Context, seriesContentID string) ([]*models.MediaFile, error) {
r.mu.Lock()
r.listCalls++
callNum := r.listCalls
r.mu.Unlock()
if callNum == 1 {
r.enterOnce.Do(func() { close(r.entered) })
select {
case <-r.release:
case <-ctx.Done():
return nil, ctx.Err()
}
}
return r.fakeEpisodeLinkerFileRepo.ListBySeriesUnlinked(ctx, seriesContentID)
}
func (r *blockingEpisodeLinkerFileRepo) ListCalls() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.listCalls
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
type fallbackTestHarness struct {
service *MetadataService
itemRepo *fakeItemRepo
fileRepo *fakeEpisodeLinkerFileRepo
episodeRepo *fakeEpisodeRepo
seasonRepo *fakeSeasonRepo
libraryRepo *fakeLibraryRepo
}
func newFallbackTestHarness() *fallbackTestHarness {
h := &fallbackTestHarness{
itemRepo: newFakeItemRepo(),
fileRepo: newFakeEpisodeLinkerFileRepo(),
episodeRepo: newFakeEpisodeRepo(),
seasonRepo: newFakeSeasonRepo(),
libraryRepo: newFakeLibraryRepo(),
}
h.service = &MetadataService{
itemRepo: h.itemRepo,
fileRepo: h.fileRepo,
episodeRepo: h.episodeRepo,
seasonRepo: h.seasonRepo,
libraryRepo: h.libraryRepo,
}
return h
}
func mustDate(t *testing.T, value string) *time.Time {
t.Helper()
parsed, err := time.Parse("2006-01-02", value)
if err != nil {
t.Fatalf("parse date %q: %v", value, err)
}
return &parsed
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
// TestFallbackEpisode_UnmatchedSeriesGetsFallbackStructure verifies that an
// unmatched series with parseable S01E01-style files gets fallback season and
// episode rows immediately, without waiting for a provider match.
func TestFallbackEpisode_UnmatchedSeriesGetsFallbackStructure(t *testing.T) {
h := newFallbackTestHarness()
ctx := context.Background()
seriesID := "series-unmatched-1"
// Create the series item (status pending, no provider data).
h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: seriesID,
Title: "Niche Anime",
Type: "series",
Status: "pending",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
})
// Add files with parseable S01E01 patterns, linked to the series.
files := []*models.MediaFile{
{ID: 1, MediaFolderID: 10, FilePath: "/media/tv/Niche Anime/Season 01/Niche.Anime.S01E01.mkv", SeasonNumber: 1, EpisodeNumber: 1},
{ID: 2, MediaFolderID: 10, FilePath: "/media/tv/Niche Anime/Season 01/Niche.Anime.S01E02.mkv", SeasonNumber: 1, EpisodeNumber: 2},
{ID: 3, MediaFolderID: 10, FilePath: "/media/tv/Niche Anime/Season 02/Niche.Anime.S02E01.mkv", SeasonNumber: 2, EpisodeNumber: 1},
}
for _, f := range files {
h.fileRepo.addFile(f)
h.fileRepo.contentIDs[f.ID] = seriesID
}
// Call SynthesizeFallbackEpisodes — this is what the scanner/worker would
// call right after skeleton creation for an unmatched series.
if err := h.service.SynthesizeFallbackEpisodes(ctx, seriesID); err != nil {
t.Fatalf("SynthesizeFallbackEpisodes failed: %v", err)
}
// Verify: should have 2 seasons.
s1, err := h.seasonRepo.GetBySeriesAndNumber(ctx, seriesID, 1)
if err != nil {
t.Fatalf("season 1 not created: %v", err)
}
if s1.MetadataSource != "scanner_fallback" {
t.Errorf("season 1 metadata_source: want scanner_fallback, got %q", s1.MetadataSource)
}
s2, err := h.seasonRepo.GetBySeriesAndNumber(ctx, seriesID, 2)
if err != nil {
t.Fatalf("season 2 not created: %v", err)
}
if s2.MetadataSource != "scanner_fallback" {
t.Errorf("season 2 metadata_source: want scanner_fallback, got %q", s2.MetadataSource)
}
// Verify: should have 3 episodes.
episodes := h.episodeRepo.listBySeries(seriesID)
if len(episodes) != 3 {
t.Fatalf("expected 3 episodes, got %d", len(episodes))
}
// Check specific episodes exist with correct metadata source.
ep1, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 1, 1)
if err != nil {
t.Fatalf("S01E01 not created: %v", err)
}
if ep1.MetadataSource != "scanner_fallback" {
t.Errorf("S01E01 metadata_source: want scanner_fallback, got %q", ep1.MetadataSource)
}
if ep1.Title != "Episode 1" {
t.Errorf("S01E01 title: want %q, got %q", "Episode 1", ep1.Title)
}
ep2, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 1, 2)
if err != nil {
t.Fatalf("S01E02 not created: %v", err)
}
if ep2.MetadataSource != "scanner_fallback" {
t.Errorf("S01E02 metadata_source: want scanner_fallback, got %q", ep2.MetadataSource)
}
ep3, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 2, 1)
if err != nil {
t.Fatalf("S02E01 not created: %v", err)
}
if ep3.MetadataSource != "scanner_fallback" {
t.Errorf("S02E01 metadata_source: want scanner_fallback, got %q", ep3.MetadataSource)
}
// Verify: the series item should have EpisodeMetadataIncomplete = true.
item, err := h.itemRepo.GetByID(ctx, seriesID)
if err != nil {
t.Fatalf("series item not found: %v", err)
}
if !item.EpisodeMetadataIncomplete {
t.Error("expected EpisodeMetadataIncomplete=true after fallback synthesis")
}
}
func TestEnsureSeriesEpisodeLinks_LinksDateNamedFileByAirDate(t *testing.T) {
h := newFallbackTestHarness()
ctx := context.Background()
seriesID := "series-daily-1"
h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: seriesID,
Title: "Jeopardy!",
Type: "series",
Status: "matched",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
})
h.episodeRepo.Upsert(ctx, &models.Episode{
ContentID: "ep-jeopardy-2026-82",
SeriesID: seriesID,
SeasonID: "season-2026",
SeasonNumber: 2026,
EpisodeNumber: 82,
Title: "Fri, Apr 24, 2026",
AirDate: mustDate(t, "2026-04-24"),
MetadataSource: "provider",
})
file := &models.MediaFile{
ID: 100,
MediaFolderID: 10,
FilePath: "/media/tv/Jeopardy! (1984)/Season 2026/Jeopardy! (1984) - 2026-04-24 - Jamie Ding.mkv",
}
h.fileRepo.addFile(file)
h.fileRepo.contentIDs[file.ID] = seriesID
if err := h.service.ensureSeriesEpisodeLinks(ctx, seriesID); err != nil {
t.Fatalf("ensureSeriesEpisodeLinks failed: %v", err)
}
if got := h.fileRepo.episodeLinks[file.ID]; got != "ep-jeopardy-2026-82" {
t.Fatalf("episode link = %q, want ep-jeopardy-2026-82", got)
}
linked := h.fileRepo.files[file.ID]
if linked.SeasonNumber != 2026 || linked.EpisodeNumber != 82 {
t.Fatalf("linked season/episode = S%dE%d, want S2026E82", linked.SeasonNumber, linked.EpisodeNumber)
}
}
func TestEnsureSeriesEpisodeLinks_SkipsMissingAirDateMatch(t *testing.T) {
h := newFallbackTestHarness()
ctx := context.Background()
seriesID := "series-daily-missing"
h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: seriesID,
Title: "Daily Show",
Type: "series",
Status: "matched",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
})
file := &models.MediaFile{
ID: 101,
MediaFolderID: 10,
FilePath: "/media/tv/Daily Show/Season 2026/Daily Show - 2026-04-24.mkv",
}
h.fileRepo.addFile(file)
h.fileRepo.contentIDs[file.ID] = seriesID
if err := h.service.ensureSeriesEpisodeLinks(ctx, seriesID); err != nil {
t.Fatalf("ensureSeriesEpisodeLinks failed: %v", err)
}
if got := h.fileRepo.episodeLinks[file.ID]; got != "" {
t.Fatalf("unexpected episode link = %q", got)
}
if episodes := h.episodeRepo.listBySeries(seriesID); len(episodes) != 0 {
t.Fatalf("date-only file synthesized %d fallback episodes, want 0", len(episodes))
}
}
func TestEnsureSeriesEpisodeLinks_SkipsAmbiguousAirDateMatch(t *testing.T) {
h := newFallbackTestHarness()
ctx := context.Background()
seriesID := "series-daily-ambiguous"
h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: seriesID,
Title: "Two A Day",
Type: "series",
Status: "matched",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
})
h.episodeRepo.Upsert(ctx, &models.Episode{
ContentID: "ep-one",
SeriesID: seriesID,
SeasonID: "season-1",
SeasonNumber: 1,
EpisodeNumber: 10,
AirDate: mustDate(t, "2026-04-24"),
MetadataSource: "provider",
})
h.episodeRepo.Upsert(ctx, &models.Episode{
ContentID: "ep-two",
SeriesID: seriesID,
SeasonID: "season-1",
SeasonNumber: 1,
EpisodeNumber: 11,
AirDate: mustDate(t, "2026-04-24"),
MetadataSource: "provider",
})
file := &models.MediaFile{
ID: 102,
MediaFolderID: 10,
FilePath: "/media/tv/Two A Day/Season 01/Two A Day - 2026.04.24.mkv",
}
h.fileRepo.addFile(file)
h.fileRepo.contentIDs[file.ID] = seriesID
if err := h.service.ensureSeriesEpisodeLinks(ctx, seriesID); err != nil {
t.Fatalf("ensureSeriesEpisodeLinks failed: %v", err)
}
if got := h.fileRepo.episodeLinks[file.ID]; got != "" {
t.Fatalf("ambiguous date linked to %q, want no link", got)
}
}
func TestEnsureSeriesEpisodeLinks_PrefersSeriesProviderForAirDateMatch(t *testing.T) {
h := newFallbackTestHarness()
ctx := context.Background()
seriesID := "series-daily-provider-preference"
h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: seriesID,
Title: "Jeopardy!",
Type: "series",
Status: "matched",
TvdbID: "77075",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
})
h.episodeRepo.Upsert(ctx, &models.Episode{
ContentID: "ep-tmdb-season-42",
SeriesID: seriesID,
SeasonID: "season-42",
SeasonNumber: 42,
EpisodeNumber: 165,
Title: "Show #9550",
AirDate: mustDate(t, "2026-04-24"),
TmdbID: "7178079",
MetadataSource: "provider",
})
h.episodeRepo.Upsert(ctx, &models.Episode{
ContentID: "ep-tvdb-2026-82",
SeriesID: seriesID,
SeasonID: "season-2026",
SeasonNumber: 2026,
EpisodeNumber: 82,
Title: "Jamie Ding, Zach Pollock, Nicco Martinez",
AirDate: mustDate(t, "2026-04-24"),
TvdbID: "11733849",
MetadataSource: "provider",
})
file := &models.MediaFile{
ID: 103,
MediaFolderID: 10,
FilePath: "/media/tv/Jeopardy! (1984)/Season 2026/Jeopardy! (1984) - 2026-04-24 - Jamie Ding Zach Pollock Nicco Martinez.mkv",
}
h.fileRepo.addFile(file)
h.fileRepo.contentIDs[file.ID] = seriesID
if err := h.service.ensureSeriesEpisodeLinks(ctx, seriesID); err != nil {
t.Fatalf("ensureSeriesEpisodeLinks failed: %v", err)
}
if got := h.fileRepo.episodeLinks[file.ID]; got != "ep-tvdb-2026-82" {
t.Fatalf("episode link = %q, want ep-tvdb-2026-82", got)
}
linked := h.fileRepo.files[file.ID]
if linked.SeasonNumber != 2026 || linked.EpisodeNumber != 82 {
t.Fatalf("linked season/episode = S%dE%d, want S2026E82", linked.SeasonNumber, linked.EpisodeNumber)
}
}
// TestFallbackEpisode_PartialProviderCoverageKeepsScannerEpisodes verifies that
// when a provider supplies metadata for some episodes but not all, the
// scanner-derived fallback rows are preserved for the missing episodes.
func TestFallbackEpisode_PartialProviderCoverageKeepsScannerEpisodes(t *testing.T) {
h := newFallbackTestHarness()
ctx := context.Background()
seriesID := "series-partial-1"
// Create the series item (already matched).
h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: seriesID,
Title: "Partial Show",
Type: "series",
Status: "matched",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
})
// Provider supplied S01E01 only.
h.seasonRepo.Upsert(ctx, &models.Season{
ContentID: "season-provider-1",
SeriesID: seriesID,
SeasonNumber: 1,
Title: "Season 1",
MetadataSource: "provider",
})
h.episodeRepo.Upsert(ctx, &models.Episode{
ContentID: "ep-provider-s01e01",
SeriesID: seriesID,
SeasonID: "season-provider-1",
SeasonNumber: 1,
EpisodeNumber: 1,
Title: "Pilot",
MetadataSource: "provider",
})
// But there are also files for S01E02 and S01E03 that the provider didn't
// know about.
files := []*models.MediaFile{
{ID: 10, MediaFolderID: 10, FilePath: "/media/tv/Partial Show/Season 01/Partial.Show.S01E02.mkv", SeasonNumber: 1, EpisodeNumber: 2},
{ID: 11, MediaFolderID: 10, FilePath: "/media/tv/Partial Show/Season 01/Partial.Show.S01E03.mkv", SeasonNumber: 1, EpisodeNumber: 3},
}
for _, f := range files {
h.fileRepo.addFile(f)
h.fileRepo.contentIDs[f.ID] = seriesID
}
// Synthesize fallback episodes for the gaps.
if err := h.service.SynthesizeFallbackEpisodes(ctx, seriesID); err != nil {
t.Fatalf("SynthesizeFallbackEpisodes failed: %v", err)
}
// Provider episode S01E01 should be untouched.
ep1, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 1, 1)
if err != nil {
t.Fatalf("S01E01 missing: %v", err)
}
if ep1.ContentID != "ep-provider-s01e01" {
t.Errorf("S01E01 content_id changed: want ep-provider-s01e01, got %q", ep1.ContentID)
}
if ep1.Title != "Pilot" {
t.Errorf("S01E01 title changed: want Pilot, got %q", ep1.Title)
}
if ep1.MetadataSource != "provider" {
t.Errorf("S01E01 metadata_source changed: want provider, got %q", ep1.MetadataSource)
}
// Scanner-derived episodes should exist for the gaps.
ep2, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 1, 2)
if err != nil {
t.Fatalf("S01E02 not created: %v", err)
}
if ep2.MetadataSource != "scanner_fallback" {
t.Errorf("S01E02 metadata_source: want scanner_fallback, got %q", ep2.MetadataSource)
}
ep3, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 1, 3)
if err != nil {
t.Fatalf("S01E03 not created: %v", err)
}
if ep3.MetadataSource != "scanner_fallback" {
t.Errorf("S01E03 metadata_source: want scanner_fallback, got %q", ep3.MetadataSource)
}
// Total episodes should be 3.
allEpisodes := h.episodeRepo.listBySeries(seriesID)
if len(allEpisodes) != 3 {
t.Errorf("expected 3 total episodes, got %d", len(allEpisodes))
}
}
// TestFallbackEpisode_ProviderUpsertReusesExistingRow verifies that when a
// provider later supplies an episode that was previously created as
// scanner_fallback, the upsert reuses the same row (same content_id) and
// upgrades its metadata in place.
func TestFallbackEpisode_ProviderUpsertReusesExistingRow(t *testing.T) {
h := newFallbackTestHarness()
ctx := context.Background()
seriesID := "series-upgrade-1"
// Create the series item.
h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: seriesID,
Title: "Upgrade Show",
Type: "series",
Status: "pending",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
})
// Add a file for S01E01.
h.fileRepo.addFile(&models.MediaFile{
ID: 20, MediaFolderID: 10,
FilePath: "/media/tv/Upgrade Show/Season 01/Upgrade.Show.S01E01.mkv",
SeasonNumber: 1,
EpisodeNumber: 1,
})
h.fileRepo.contentIDs[20] = seriesID
// First: synthesize fallback (scanner-derived) episode.
if err := h.service.SynthesizeFallbackEpisodes(ctx, seriesID); err != nil {
t.Fatalf("initial SynthesizeFallbackEpisodes failed: %v", err)
}
// Record the scanner-created episode's content_id.
scannerEp, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 1, 1)
if err != nil {
t.Fatalf("scanner episode not found: %v", err)
}
originalContentID := scannerEp.ContentID
if scannerEp.MetadataSource != "scanner_fallback" {
t.Fatalf("expected scanner_fallback source, got %q", scannerEp.MetadataSource)
}
if scannerEp.Title != "Episode 1" {
t.Fatalf("expected fallback title 'Episode 1', got %q", scannerEp.Title)
}
// Second: provider supplies richer metadata for the same episode.
providerEp := &models.Episode{
ContentID: "ep-provider-new-id", // provider would use a new ID, but Upsert should preserve the original
SeriesID: seriesID,
SeasonID: scannerEp.SeasonID,
SeasonNumber: 1,
EpisodeNumber: 1,
Title: "The Real Pilot",
Overview: "An amazing first episode",
TmdbID: "tmdb-ep-999",
MetadataSource: "provider",
}
if err := h.episodeRepo.Upsert(ctx, providerEp); err != nil {
t.Fatalf("provider upsert failed: %v", err)
}
// The content_id should be preserved from the original scanner row.
if providerEp.ContentID != originalContentID {
t.Errorf("content_id changed: want %q (original), got %q", originalContentID, providerEp.ContentID)
}
// The metadata should be upgraded.
upgraded, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 1, 1)
if err != nil {
t.Fatalf("upgraded episode not found: %v", err)
}
if upgraded.Title != "The Real Pilot" {
t.Errorf("title not upgraded: want %q, got %q", "The Real Pilot", upgraded.Title)
}
if upgraded.MetadataSource != "provider" {
t.Errorf("metadata_source not upgraded: want provider, got %q", upgraded.MetadataSource)
}
if upgraded.TmdbID != "tmdb-ep-999" {
t.Errorf("tmdb_id not set: want tmdb-ep-999, got %q", upgraded.TmdbID)
}
// Should still be only 1 episode — no duplicates.
allEpisodes := h.episodeRepo.listBySeries(seriesID)
if len(allEpisodes) != 1 {
t.Errorf("expected 1 episode (no duplicates), got %d", len(allEpisodes))
}
}
// TestFallbackEpisode_WorkerSynthesizesFallbackOnEnrichmentFailure verifies the
// end-to-end worker path: when the enrichment pipeline fails for a series file,
// the worker still synthesizes fallback episodes so the content is browsable.
func TestFallbackEpisode_WorkerSynthesizesFallbackOnEnrichmentFailure(t *testing.T) {
h := newFallbackTestHarness()
ctx := context.Background()
file := &models.MediaFile{
ID: 30,
MediaFolderID: 10,
FilePath: "/media/tv/Obscure Show (2024)/Season 01/Obscure.Show.S01E01.mkv",
}
// Hook createOrFindSkeleton to return a series skeleton and set up
// the file linkage needed for ListBySeriesUnlinked to find this file.
seriesID := "series-worker-fallback"
h.service.hooks.createOrFindSkeleton = func(_ context.Context, f *models.MediaFile, _ int) (*skeletonResult, error) {
h.itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: seriesID,
Title: "Obscure Show",
Year: 2024,
Type: "series",
Status: "pending",
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
})
h.fileRepo.addFile(f)
h.fileRepo.contentIDs[f.ID] = seriesID
return &skeletonResult{
ContentID: seriesID,
IsNew: true,
Type: "series",
Title: "Obscure Show",
Year: 2024,
}, nil
}
// Simulate enrichment failure (no providers matched).
h.service.hooks.process = func(_ context.Context, _ ProcessRequest) (*ProcessResult, error) {
return &ProcessResult{Updated: false}, nil
}
worker := NewMatchWorker(h.service, h.fileRepo, 1, 10, 0)
worker.ProcessFile(ctx, file)
// The item should be marked "unmatched".
item, err := h.itemRepo.GetByID(ctx, seriesID)
if err != nil {
t.Fatalf("series item not found: %v", err)
}
if item.Status != "unmatched" {
t.Errorf("expected status=unmatched, got %q", item.Status)
}
// But a fallback episode should have been synthesized.
ep, err := h.episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 1, 1)
if err != nil {
t.Fatalf("expected fallback S01E01 to exist after worker enrichment failure: %v", err)
}
if ep.MetadataSource != "scanner_fallback" {
t.Errorf("expected scanner_fallback source, got %q", ep.MetadataSource)
}
// Season should also exist.
s, err := h.seasonRepo.GetBySeriesAndNumber(ctx, seriesID, 1)
if err != nil {
t.Fatalf("expected fallback Season 1 to exist: %v", err)
}
if s.MetadataSource != "scanner_fallback" {
t.Errorf("expected scanner_fallback source for season, got %q", s.MetadataSource)
}
}
func TestEnsureSeriesEpisodeLinks_CoalescesConcurrentFallbackWork(t *testing.T) {
ctx := context.Background()
itemRepo := newFakeItemRepo()
fileRepo := newBlockingEpisodeLinkerFileRepo()
episodeRepo := newFakeEpisodeRepo()
seasonRepo := newFakeSeasonRepo()
libraryRepo := newFakeLibraryRepo()
service := &MetadataService{
itemRepo: itemRepo,
fileRepo: fileRepo,
episodeRepo: episodeRepo,
seasonRepo: seasonRepo,
libraryRepo: libraryRepo,
}
seriesID := "series-coalesced-1"
if err := itemRepo.Upsert(ctx, &models.MediaItem{
ContentID: seriesID,
Title: "EastEnders",
Type: "series",
Status: "unmatched",
EpisodeMetadataIncomplete: true,
Studios: []string{},
Networks: []string{},
Countries: []string{},
Genres: []string{},
}); err != nil {
t.Fatalf("upserting series item: %v", err)
}
file := &models.MediaFile{
ID: 1,
MediaFolderID: 10,
FilePath: "/media/tv/EastEnders/Season 01/EastEnders.S01E01.mkv",
SeasonNumber: 1,
EpisodeNumber: 1,
}
fileRepo.addFile(file)
fileRepo.contentIDs[file.ID] = seriesID
const concurrentCalls = 16
errCh := make(chan error, concurrentCalls)
go func() {
errCh <- service.ensureSeriesEpisodeLinks(ctx, seriesID)
}()
<-fileRepo.entered
var wg sync.WaitGroup
for i := 1; i < concurrentCalls; i++ {
wg.Add(1)
go func() {
defer wg.Done()
errCh <- service.ensureSeriesEpisodeLinks(ctx, seriesID)
}()
}
close(fileRepo.release)
wg.Wait()
for i := 0; i < concurrentCalls; i++ {
if err := <-errCh; err != nil {
t.Fatalf("ensureSeriesEpisodeLinks returned error: %v", err)
}
}
if got := seasonRepo.UpsertCalls(); got != 1 {
t.Fatalf("expected 1 fallback season upsert, got %d", got)
}
if got := episodeRepo.UpsertCalls(); got != 1 {
t.Fatalf("expected 1 fallback episode upsert, got %d", got)
}
ep, err := episodeRepo.GetBySeriesAndNumber(ctx, seriesID, 1, 1)
if err != nil {
t.Fatalf("expected synthesized episode to exist: %v", err)
}
if ep.MetadataSource != "scanner_fallback" {
t.Fatalf("expected synthesized episode metadata source scanner_fallback, got %q", ep.MetadataSource)
}
}