Files
silo-server/internal/intromarkers/analyzer_test.go
T

557 lines
20 KiB
Go

package intromarkers
import (
"context"
"fmt"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
type fakeIntroRepository struct {
enabledLibraries int
eligibleCandidates []Candidate
episodeCandidates map[string][]Candidate
groupCandidates map[string][]Candidate
backfillCandidates []Candidate
fingerprints map[int]*Fingerprint
seasonState *SeasonState
upsertedStates []SeasonState
patches []IntroMarkerPatch
}
func (f *fakeIntroRepository) CountEnabledLibraries(context.Context) (int, error) {
return f.enabledLibraries, nil
}
func (f *fakeIntroRepository) ListEligibleCandidates(context.Context) ([]Candidate, error) {
return append([]Candidate(nil), f.eligibleCandidates...), nil
}
func (f *fakeIntroRepository) ListCandidatesForEpisode(_ context.Context, episodeID string) ([]Candidate, error) {
return append([]Candidate(nil), f.episodeCandidates[episodeID]...), nil
}
func (f *fakeIntroRepository) ListCandidatesForGroup(_ context.Context, mediaFolderID int, seasonID, analysisGroupKey string) ([]Candidate, error) {
key := groupKey(mediaFolderID, seasonID, analysisGroupKey)
return append([]Candidate(nil), f.groupCandidates[key]...), nil
}
func (f *fakeIntroRepository) ListChapterSilenceBackfillCandidates(context.Context, int) ([]Candidate, error) {
return append([]Candidate(nil), f.backfillCandidates...), nil
}
func (f *fakeIntroRepository) PatchIntroMarker(_ context.Context, patch IntroMarkerPatch) (bool, error) {
f.patches = append(f.patches, patch)
return true, nil
}
func (f *fakeIntroRepository) LoadSeasonState(context.Context, SeasonState, Config) (*SeasonState, error) {
if f.seasonState == nil {
return nil, nil
}
state := *f.seasonState
return &state, nil
}
func (f *fakeIntroRepository) UpsertSeasonState(_ context.Context, state SeasonState, _ Config) error {
f.upsertedStates = append(f.upsertedStates, state)
return nil
}
func (f *fakeIntroRepository) LoadFingerprint(_ context.Context, candidate Candidate, _ Config) (*Fingerprint, error) {
fp := f.fingerprints[candidate.FileID]
if fp == nil {
return nil, nil
}
copied := *fp
copied.Points = append([]uint32(nil), fp.Points...)
return &copied, nil
}
func (f *fakeIntroRepository) UpsertFingerprint(context.Context, Fingerprint) error {
return nil
}
type fakeFingerprintExtractor struct {
preflightCalls int
extractCalls int
}
func (f *fakeFingerprintExtractor) Preflight(context.Context) error {
f.preflightCalls++
return nil
}
func (f *fakeFingerprintExtractor) Extract(context.Context, Candidate) (Fingerprint, bool, error) {
f.extractCalls++
return Fingerprint{}, false, nil
}
type fakeBoundaryRefiner struct {
calls int
segments map[int]Segment
}
func (f *fakeBoundaryRefiner) RefineChapterEnd(_ context.Context, candidate Candidate, segment Segment) (Segment, bool, error) {
f.calls++
refined, ok := f.segments[candidate.FileID]
if !ok {
return segment, false, nil
}
return refined, true, nil
}
func TestAnalyzeEpisodeNoCandidatesIsNoOp(t *testing.T) {
repo := &fakeIntroRepository{episodeCandidates: map[string][]Candidate{}}
extractor := &fakeFingerprintExtractor{}
analyzer := &Analyzer{repo: repo, extractor: extractor, config: DefaultConfig("ffmpeg")}
summary, err := analyzer.AnalyzeEpisode(context.Background(), "ep-disabled")
if err != nil {
t.Fatalf("AnalyzeEpisode returned error: %v", err)
}
if summary.FilesConsidered != 0 {
t.Fatalf("expected no files considered, got %d", summary.FilesConsidered)
}
if extractor.preflightCalls != 0 {
t.Fatalf("preflight should not run when no candidates exist")
}
}
func TestAnalyzeEpisodeWritesChapterMarker(t *testing.T) {
candidate := Candidate{
FileID: 10,
EpisodeID: "ep1",
Chapters: []models.MediaChapter{
{Index: 0, Title: "Cold Open", StartSeconds: 0, EndSeconds: 60},
{Index: 1, Title: "Opening", StartSeconds: 60, EndSeconds: 120},
{Index: 2, Title: "Part 1", StartSeconds: 95, EndSeconds: 900},
},
}
repo := &fakeIntroRepository{episodeCandidates: map[string][]Candidate{"ep1": []Candidate{candidate}}}
extractor := &fakeFingerprintExtractor{}
analyzer := &Analyzer{repo: repo, extractor: extractor, config: DefaultConfig("ffmpeg")}
summary, err := analyzer.AnalyzeEpisode(context.Background(), "ep1")
if err != nil {
t.Fatalf("AnalyzeEpisode returned error: %v", err)
}
if summary.ChapterMarkersWritten != 1 {
t.Fatalf("expected one chapter marker, got %d", summary.ChapterMarkersWritten)
}
if len(repo.patches) != 1 {
t.Fatalf("expected one patch, got %d", len(repo.patches))
}
if repo.patches[0].Algorithm != ChapterAlgorithm {
t.Fatalf("expected chapter algorithm, got %q", repo.patches[0].Algorithm)
}
if extractor.preflightCalls != 0 {
t.Fatalf("preflight should not run after chapter marker is applied")
}
}
func TestAnalyzeEpisodeWritesSilenceRefinedChapterMarker(t *testing.T) {
candidate := Candidate{
FileID: 10,
EpisodeID: "ep1",
DurationSeconds: 1200,
Chapters: []models.MediaChapter{
{Index: 1, Title: "Opening", StartSeconds: 60, EndSeconds: 120},
{Index: 2, Title: "Part 1", StartSeconds: 120, EndSeconds: 900},
},
}
repo := &fakeIntroRepository{episodeCandidates: map[string][]Candidate{"ep1": []Candidate{candidate}}}
extractor := &fakeFingerprintExtractor{}
refiner := &fakeBoundaryRefiner{segments: map[int]Segment{
10: {Start: 60, End: 132, Confidence: 0.95, Algorithm: ChapterSilenceAlgorithm},
}}
analyzer := &Analyzer{repo: repo, extractor: extractor, refiner: refiner, config: DefaultConfig("ffmpeg")}
summary, err := analyzer.AnalyzeEpisode(context.Background(), "ep1")
if err != nil {
t.Fatalf("AnalyzeEpisode returned error: %v", err)
}
if summary.SilenceRefinementsAttempted != 1 || summary.SilenceRefinementsApplied != 1 {
t.Fatalf("expected one applied silence refinement, got attempted=%d applied=%d", summary.SilenceRefinementsAttempted, summary.SilenceRefinementsApplied)
}
if len(repo.patches) != 1 {
t.Fatalf("expected one patch, got %d", len(repo.patches))
}
if repo.patches[0].Algorithm != ChapterSilenceAlgorithm || repo.patches[0].End != 132 {
t.Fatalf("expected silence-refined patch, got algorithm=%q end=%.3f", repo.patches[0].Algorithm, repo.patches[0].End)
}
if extractor.preflightCalls != 0 {
t.Fatalf("preflight should not run after silence-refined chapter marker is applied")
}
}
func TestAnalyzeEpisodeUpgradesExistingScannerChapterMarker(t *testing.T) {
source := models.MarkerSourceScanner
algorithm := ChapterAlgorithm
start := 60.0
end := 120.0
candidate := Candidate{
FileID: 10,
EpisodeID: "ep1",
DurationSeconds: 1200,
IntroStart: &start,
IntroEnd: &end,
IntroMarkersSource: &source,
IntroMarkersAlgorithm: &algorithm,
Chapters: []models.MediaChapter{
{Index: 1, Title: "Opening", StartSeconds: 60, EndSeconds: 120},
{Index: 2, Title: "Part 1", StartSeconds: 120, EndSeconds: 900},
},
}
repo := &fakeIntroRepository{episodeCandidates: map[string][]Candidate{"ep1": []Candidate{candidate}}}
refiner := &fakeBoundaryRefiner{segments: map[int]Segment{
10: {Start: 60, End: 132, Confidence: 0.95, Algorithm: ChapterSilenceAlgorithm},
}}
analyzer := &Analyzer{repo: repo, extractor: &fakeFingerprintExtractor{}, refiner: refiner, config: DefaultConfig("ffmpeg")}
_, err := analyzer.AnalyzeEpisode(context.Background(), "ep1")
if err != nil {
t.Fatalf("AnalyzeEpisode returned error: %v", err)
}
if len(repo.patches) != 1 {
t.Fatalf("expected one patch, got %d", len(repo.patches))
}
if repo.patches[0].Algorithm != ChapterSilenceAlgorithm {
t.Fatalf("expected upgrade to silence algorithm, got %q", repo.patches[0].Algorithm)
}
}
func TestAnalyzeEpisodeDoesNotOverwriteManualMarker(t *testing.T) {
source := models.MarkerSourceManual
start := 60.0
end := 120.0
candidate := Candidate{
FileID: 10,
EpisodeID: "ep1",
DurationSeconds: 1200,
IntroStart: &start,
IntroEnd: &end,
IntroMarkersSource: &source,
Chapters: []models.MediaChapter{
{Index: 1, Title: "Opening", StartSeconds: 60, EndSeconds: 120},
{Index: 2, Title: "Part 1", StartSeconds: 120, EndSeconds: 900},
},
}
repo := &fakeIntroRepository{episodeCandidates: map[string][]Candidate{"ep1": []Candidate{candidate}}}
analyzer := &Analyzer{repo: repo, extractor: &fakeFingerprintExtractor{}, refiner: &fakeBoundaryRefiner{}, config: DefaultConfig("ffmpeg")}
_, err := analyzer.AnalyzeEpisode(context.Background(), "ep1")
if err != nil {
t.Fatalf("AnalyzeEpisode returned error: %v", err)
}
if len(repo.patches) != 0 {
t.Fatalf("manual marker should not be overwritten, got %d patches", len(repo.patches))
}
}
func TestAnalyzeEpisodeCopiesMarkerToCompatibleEpisodeVersion(t *testing.T) {
source := Candidate{
FileID: 10,
EpisodeID: "ep1",
DurationSeconds: 1200,
Chapters: []models.MediaChapter{
{Index: 1, Title: "Opening", StartSeconds: 60, EndSeconds: 120},
{Index: 2, Title: "Part 1", StartSeconds: 120, EndSeconds: 900},
},
}
target := Candidate{FileID: 11, EpisodeID: "ep1", DurationSeconds: 1200.5}
repo := &fakeIntroRepository{episodeCandidates: map[string][]Candidate{"ep1": []Candidate{source, target}}}
analyzer := &Analyzer{repo: repo, extractor: &fakeFingerprintExtractor{}, config: DefaultConfig("ffmpeg")}
summary, err := analyzer.AnalyzeEpisode(context.Background(), "ep1")
if err != nil {
t.Fatalf("AnalyzeEpisode returned error: %v", err)
}
if summary.EpisodeVersionMarkersCopied != 1 {
t.Fatalf("expected one copied marker, got %d", summary.EpisodeVersionMarkersCopied)
}
if len(repo.patches) != 2 {
t.Fatalf("expected source and copy patches, got %d", len(repo.patches))
}
if repo.patches[1].Algorithm != EpisodeVersionCopyAlgorithm || repo.patches[1].Confidence != 0.85 {
t.Fatalf("expected copied marker patch, got algorithm=%q confidence=%.2f", repo.patches[1].Algorithm, repo.patches[1].Confidence)
}
}
func TestAnalyzeEpisodeSkipsCopyForIncompatibleDuration(t *testing.T) {
source := Candidate{
FileID: 10,
EpisodeID: "ep1",
DurationSeconds: 1200,
Chapters: []models.MediaChapter{
{Index: 1, Title: "Opening", StartSeconds: 60, EndSeconds: 120},
{Index: 2, Title: "Part 1", StartSeconds: 120, EndSeconds: 900},
},
}
target := Candidate{FileID: 11, EpisodeID: "ep1", DurationSeconds: 1205}
repo := &fakeIntroRepository{episodeCandidates: map[string][]Candidate{"ep1": []Candidate{source, target}}}
analyzer := &Analyzer{repo: repo, extractor: &fakeFingerprintExtractor{}, config: DefaultConfig("ffmpeg")}
summary, err := analyzer.AnalyzeEpisode(context.Background(), "ep1")
if err != nil {
t.Fatalf("AnalyzeEpisode returned error: %v", err)
}
if summary.EpisodeVersionMarkersCopied != 0 {
t.Fatalf("expected no copied markers, got %d", summary.EpisodeVersionMarkersCopied)
}
if len(repo.patches) != 1 {
t.Fatalf("expected only source patch, got %d", len(repo.patches))
}
}
func TestAnalyzeEpisodeRunsChromaprintAfterChapterMarker(t *testing.T) {
cfg := DefaultConfig("ffmpeg")
target := Candidate{
FileID: 1,
EpisodeID: "ep1",
SeasonID: "season1",
MediaFolderID: 7,
FileHash: "hash1",
FileSize: 100,
DurationSeconds: 1200,
Chapters: []models.MediaChapter{
{Index: 1, Title: "Opening", StartSeconds: 60, EndSeconds: 120},
{Index: 2, Title: "Part 1", StartSeconds: 120, EndSeconds: 900},
},
}
sibling := Candidate{
FileID: 2,
EpisodeID: "ep2",
SeasonID: "season1",
MediaFolderID: 7,
FileHash: "hash2",
FileSize: 200,
DurationSeconds: 1200,
}
groupCandidates := []Candidate{target, sibling}
group := groupKey(target.MediaFolderID, target.SeasonID, target.AnalysisGroupKey())
repo := &fakeIntroRepository{
episodeCandidates: map[string][]Candidate{"ep1": []Candidate{target}},
groupCandidates: map[string][]Candidate{group: groupCandidates},
fingerprints: map[int]*Fingerprint{
target.FileID: cachedFingerprint(target, cfg, sharedIntroPoints(1000)),
sibling.FileID: cachedFingerprint(sibling, cfg, sharedIntroPoints(5000)),
},
}
extractor := &fakeFingerprintExtractor{}
analyzer := &Analyzer{repo: repo, extractor: extractor, config: cfg}
summary, err := analyzer.AnalyzeEpisode(context.Background(), "ep1")
if err != nil {
t.Fatalf("AnalyzeEpisode returned error: %v", err)
}
if summary.ChapterMarkersWritten != 1 {
t.Fatalf("expected provisional chapter marker, got %d", summary.ChapterMarkersWritten)
}
if summary.ChromaprintMarkersWritten == 0 {
t.Fatal("expected chromaprint to run after chapter marker")
}
if extractor.preflightCalls != 1 {
t.Fatalf("expected chromaprint preflight, got %d", extractor.preflightCalls)
}
if len(repo.patches) < 2 {
t.Fatalf("expected chapter and chromaprint patches, got %d", len(repo.patches))
}
if repo.patches[0].Algorithm != ChapterAlgorithm {
t.Fatalf("expected first patch to be provisional chapter, got %q", repo.patches[0].Algorithm)
}
if repo.patches[1].Algorithm != ChromaprintAlgorithm {
t.Fatalf("expected chromaprint override patch, got %q", repo.patches[1].Algorithm)
}
}
func TestAnalyzeEpisodeChromaprintOnlyPatchesRequestedEpisode(t *testing.T) {
cfg := DefaultConfig("ffmpeg")
target := Candidate{
FileID: 1,
EpisodeID: "ep1",
SeasonID: "season1",
MediaFolderID: 7,
FileHash: "hash1",
FileSize: 100,
DurationSeconds: 1200,
}
sibling := Candidate{
FileID: 2,
EpisodeID: "ep2",
SeasonID: "season1",
MediaFolderID: 7,
FileHash: "hash2",
FileSize: 200,
DurationSeconds: 1200,
}
groupCandidates := []Candidate{target, sibling}
group := groupKey(target.MediaFolderID, target.SeasonID, target.AnalysisGroupKey())
repo := &fakeIntroRepository{
episodeCandidates: map[string][]Candidate{"ep1": []Candidate{target}},
groupCandidates: map[string][]Candidate{group: groupCandidates},
fingerprints: map[int]*Fingerprint{
target.FileID: cachedFingerprint(target, cfg, sharedIntroPoints(1000)),
sibling.FileID: cachedFingerprint(sibling, cfg, sharedIntroPoints(5000)),
},
}
extractor := &fakeFingerprintExtractor{}
analyzer := &Analyzer{repo: repo, extractor: extractor, config: cfg}
summary, err := analyzer.AnalyzeEpisode(context.Background(), "ep1")
if err != nil {
t.Fatalf("AnalyzeEpisode returned error: %v", err)
}
if summary.FingerprintCacheHits != 2 {
t.Fatalf("expected both group fingerprints to be used for comparison, got %d", summary.FingerprintCacheHits)
}
if summary.ChromaprintMarkersWritten != 1 {
t.Fatalf("expected one chromaprint marker for requested episode, got %d", summary.ChromaprintMarkersWritten)
}
if len(repo.patches) != 1 {
t.Fatalf("expected only requested episode to be patched, got %d patches", len(repo.patches))
}
if repo.patches[0].FileID != target.FileID {
t.Fatalf("expected requested file %d to be patched, got file %d", target.FileID, repo.patches[0].FileID)
}
if len(repo.upsertedStates) != 0 {
t.Fatalf("episode redetect should not persist season-wide state, got %d upserts", len(repo.upsertedStates))
}
}
func TestRunBackfillsExistingChapterMarkerWithSilenceBudget(t *testing.T) {
source := models.MarkerSourceScanner
algorithm := ChapterAlgorithm
start := 60.0
end := 120.0
candidate := Candidate{
FileID: 10,
EpisodeID: "ep1",
DurationSeconds: 1200,
IntroStart: &start,
IntroEnd: &end,
IntroMarkersSource: &source,
IntroMarkersAlgorithm: &algorithm,
Chapters: []models.MediaChapter{
{Index: 1, Title: "Opening", StartSeconds: 60, EndSeconds: 120},
{Index: 2, Title: "Part 1", StartSeconds: 120, EndSeconds: 900},
},
}
repo := &fakeIntroRepository{
enabledLibraries: 1,
eligibleCandidates: []Candidate{candidate},
backfillCandidates: []Candidate{candidate},
}
refiner := &fakeBoundaryRefiner{segments: map[int]Segment{
10: {Start: 60, End: 132, Confidence: 0.95, Algorithm: ChapterSilenceAlgorithm},
}}
analyzer := &Analyzer{repo: repo, extractor: &fakeFingerprintExtractor{}, refiner: refiner, config: DefaultConfig("ffmpeg")}
summary, err := analyzer.Run(context.Background(), nil)
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary.SilenceBackfillConsidered != 1 {
t.Fatalf("expected one backfill candidate, got %d", summary.SilenceBackfillConsidered)
}
if len(repo.patches) != 1 {
t.Fatalf("expected one backfill patch, got %d", len(repo.patches))
}
if repo.patches[0].Algorithm != ChapterSilenceAlgorithm {
t.Fatalf("expected silence algorithm, got %q", repo.patches[0].Algorithm)
}
}
func TestAnalyzeEpisodeForcesCachedSeasonGroup(t *testing.T) {
cfg := DefaultConfig("ffmpeg")
target := Candidate{
FileID: 1,
EpisodeID: "ep1",
SeasonID: "season1",
MediaFolderID: 7,
FileHash: "hash1",
FileSize: 100,
DurationSeconds: 1200,
}
sibling := Candidate{
FileID: 2,
EpisodeID: "ep2",
SeasonID: "season1",
MediaFolderID: 7,
FileHash: "hash2",
FileSize: 200,
DurationSeconds: 1200,
}
groupCandidates := []Candidate{target, sibling}
group := groupKey(target.MediaFolderID, target.SeasonID, target.AnalysisGroupKey())
repo := &fakeIntroRepository{
episodeCandidates: map[string][]Candidate{"ep1": []Candidate{target}},
groupCandidates: map[string][]Candidate{group: groupCandidates},
fingerprints: map[int]*Fingerprint{
target.FileID: cachedFingerprint(target, cfg, sharedIntroPoints(1000)),
sibling.FileID: cachedFingerprint(sibling, cfg, sharedIntroPoints(5000)),
},
seasonState: &SeasonState{
SeasonID: target.SeasonID,
MediaFolderID: target.MediaFolderID,
AnalysisGroupKey: target.AnalysisGroupKey(),
InputSignature: InputSignature(groupCandidates),
Status: "complete",
},
}
extractor := &fakeFingerprintExtractor{}
analyzer := &Analyzer{repo: repo, extractor: extractor, config: cfg}
summary, err := analyzer.AnalyzeEpisode(context.Background(), "ep1")
if err != nil {
t.Fatalf("AnalyzeEpisode returned error: %v", err)
}
if summary.SeasonGroupsConsidered != 1 {
t.Fatalf("expected one season group, got %d", summary.SeasonGroupsConsidered)
}
if summary.GroupsSkipped != 0 {
t.Fatalf("forced episode analysis should not skip same-signature group")
}
if summary.FingerprintCacheHits != 2 {
t.Fatalf("expected two cache hits, got %d", summary.FingerprintCacheHits)
}
if summary.FingerprintsComputed != 0 || extractor.extractCalls != 0 {
t.Fatalf("expected no ffmpeg extraction, computed=%d extract_calls=%d", summary.FingerprintsComputed, extractor.extractCalls)
}
if summary.ChromaprintMarkersWritten == 0 {
t.Fatal("expected chromaprint markers to be written from cached fingerprints")
}
}
func groupKey(mediaFolderID int, seasonID, analysisGroupKey string) string {
return fmt.Sprintf("%d:%s:%s", mediaFolderID, seasonID, analysisGroupKey)
}
func cachedFingerprint(candidate Candidate, cfg Config, points []uint32) *Fingerprint {
return &Fingerprint{
MediaFileID: candidate.FileID,
FileHash: candidate.FileHash,
FileSize: candidate.FileSize,
DurationSeconds: candidate.DurationSeconds,
WindowStartSeconds: 0,
WindowEndSeconds: analysisWindowEnd(candidate.DurationSeconds, cfg),
AlgorithmVersion: AlgorithmVersion,
ConfigHash: cfg.ConfigHash(),
FingerprintFormat: ChromaprintFormat,
SampleDurationSeconds: DefaultPointHopSeconds,
Points: points,
}
}
func sharedIntroPoints(offset uint32) []uint32 {
points := make([]uint32, 400)
for i := range points {
points[i] = uint32(i) + offset
}
for i := 40; i < 300; i++ {
points[i] = uint32(i)
}
return points
}