1006 lines
30 KiB
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)
|
|
}
|
|
}
|