package intromarkers import ( "context" "fmt" "log/slog" "sort" "sync" "time" "github.com/Silo-Server/silo-server/internal/models" ) type Analyzer struct { repo introRepository extractor fingerprintExtractor refiner boundaryRefiner config Config logger *slog.Logger } type introRepository interface { CountEnabledLibraries(ctx context.Context) (int, error) ListEligibleCandidates(ctx context.Context) ([]Candidate, error) ListCandidatesForEpisode(ctx context.Context, episodeID string) ([]Candidate, error) ListCandidatesForGroup(ctx context.Context, mediaFolderID int, seasonID, analysisGroupKey string) ([]Candidate, error) ListChapterSilenceBackfillCandidates(ctx context.Context, limit int) ([]Candidate, error) PatchIntroMarker(ctx context.Context, patch IntroMarkerPatch) (bool, error) LoadSeasonState(ctx context.Context, state SeasonState, cfg Config) (*SeasonState, error) UpsertSeasonState(ctx context.Context, state SeasonState, cfg Config) error LoadFingerprint(ctx context.Context, candidate Candidate, cfg Config) (*Fingerprint, error) UpsertFingerprint(ctx context.Context, fp Fingerprint) error } type fingerprintExtractor interface { Preflight(ctx context.Context) error Extract(ctx context.Context, candidate Candidate) (Fingerprint, bool, error) } func NewAnalyzer(repo *Repository, config Config, logger *slog.Logger) *Analyzer { config = config.normalized() if logger == nil { logger = slog.Default() } return &Analyzer{ repo: repo, extractor: NewChromaprintExtractor(config), refiner: NewSilenceBoundaryRefiner(config), config: config, logger: logger, } } type ProgressFunc func(percent float64, message string) func (a *Analyzer) Run(ctx context.Context, progress ProgressFunc) (RunSummary, error) { report := func(percent float64, message string) { if progress != nil { progress(percent, message) } } summary := RunSummary{} libraries, err := a.repo.CountEnabledLibraries(ctx) if err != nil { return summary, err } summary.LibrariesScanned = libraries if libraries == 0 { report(100, "No intro-enabled series libraries") return summary, nil } candidates, err := a.repo.ListEligibleCandidates(ctx) if err != nil { return summary, err } summary.FilesConsidered = len(candidates) if len(candidates) == 0 { report(100, "No eligible episode files") return summary, nil } report(10, fmt.Sprintf("Checking embedded chapters for %d files", len(candidates))) _, chapterSummary := a.processChapterCandidates(ctx, candidates, chapterProcessingOptions{ allowEpisodeCopy: true, progress: func(i, total int) { if i%25 == 0 { report(10+float64(i)/float64(total)*20, fmt.Sprintf("Checked %d/%d files for chapter markers", i+1, total)) } }, }) mergeRunSummary(&summary, chapterSummary) if err := ctx.Err(); err != nil { return summary, err } remaining := ownDetectionCandidates(candidates) if len(remaining) == 0 { backfillSummary, err := a.runSilenceBackfill(ctx) mergeRunSummary(&summary, backfillSummary) if err != nil { return summary, err } report(100, "Intro chapter detection complete") return summary, nil } groups := groupCandidates(remaining) summary.SeasonGroupsConsidered = len(groups) if len(groups) == 0 { backfillSummary, err := a.runSilenceBackfill(ctx) mergeRunSummary(&summary, backfillSummary) if err != nil { return summary, err } report(100, "No season groups eligible for Chromaprint") return summary, nil } report(35, "Checking FFmpeg Chromaprint support") if err := a.extractor.Preflight(ctx); err != nil { summary.ChromaprintSupported = false summary.ChromaprintSupportMessage = err.Error() backfillSummary, backfillErr := a.runSilenceBackfill(ctx) mergeRunSummary(&summary, backfillSummary) if backfillErr != nil { return summary, backfillErr } report(100, "Chromaprint unsupported; chapter detection completed") return summary, nil } summary.ChromaprintSupported = true for idx, group := range groups { if err := ctx.Err(); err != nil { return summary, err } percent := 40 + float64(idx)/float64(len(groups))*55 report(percent, fmt.Sprintf("Analyzing intro group %d/%d", idx+1, len(groups))) groupSummary, err := a.analyzeGroup(ctx, group, analyzeGroupOptions{ persistState: true, }) summary.FingerprintsComputed += groupSummary.FingerprintsComputed summary.FingerprintCacheHits += groupSummary.FingerprintCacheHits summary.ChromaprintMarkersWritten += groupSummary.ChromaprintMarkersWritten summary.GroupsNotFound += groupSummary.GroupsNotFound summary.GroupsSkipped += groupSummary.GroupsSkipped summary.Errors = append(summary.Errors, groupSummary.Errors...) if err != nil { a.logger.Warn("intro marker group analysis failed", "season_id", group.SeasonID, "media_folder_id", group.MediaFolderID, "group_key", group.AnalysisGroupKey, "error", err) } } backfillSummary, err := a.runSilenceBackfill(ctx) mergeRunSummary(&summary, backfillSummary) if err != nil { return summary, err } report(100, "Intro marker detection completed") return summary, nil } func (a *Analyzer) AnalyzeEpisode(ctx context.Context, episodeID string) (RunSummary, error) { summary := RunSummary{} candidates, err := a.repo.ListCandidatesForEpisode(ctx, episodeID) if err != nil { return summary, err } summary.FilesConsidered = len(candidates) if len(candidates) == 0 { return summary, nil } _, chapterSummary := a.processChapterCandidates(ctx, candidates, chapterProcessingOptions{ forceExistingScanner: true, allowEpisodeCopy: true, }) mergeRunSummary(&summary, chapterSummary) if err := ctx.Err(); err != nil { return summary, err } remaining := ownDetectionCandidates(candidates) if len(remaining) == 0 { return summary, nil } targetFileIDs := candidateFileIDs(remaining) groupsByKey := map[string]candidateGroup{} for _, candidate := range remaining { key := fmt.Sprintf("%d:%s:%s", candidate.MediaFolderID, candidate.SeasonID, candidate.AnalysisGroupKey()) if _, ok := groupsByKey[key]; ok { continue } groupCandidates, err := a.repo.ListCandidatesForGroup(ctx, candidate.MediaFolderID, candidate.SeasonID, candidate.AnalysisGroupKey()) if err != nil { return summary, err } if distinctEpisodeCount(groupCandidates) < 2 { continue } groupsByKey[key] = candidateGroup{ SeasonID: candidate.SeasonID, MediaFolderID: candidate.MediaFolderID, AnalysisGroupKey: candidate.AnalysisGroupKey(), Candidates: groupCandidates, } } if len(groupsByKey) == 0 { return summary, nil } if err := a.extractor.Preflight(ctx); err != nil { summary.ChromaprintSupported = false summary.ChromaprintSupportMessage = err.Error() return summary, nil } summary.ChromaprintSupported = true groups := make([]candidateGroup, 0, len(groupsByKey)) for _, group := range groupsByKey { groups = append(groups, group) } sort.Slice(groups, func(i, j int) bool { if groups[i].MediaFolderID != groups[j].MediaFolderID { return groups[i].MediaFolderID < groups[j].MediaFolderID } if groups[i].SeasonID != groups[j].SeasonID { return groups[i].SeasonID < groups[j].SeasonID } return groups[i].AnalysisGroupKey < groups[j].AnalysisGroupKey }) summary.SeasonGroupsConsidered = len(groups) for _, group := range groups { if err := ctx.Err(); err != nil { return summary, err } groupSummary, err := a.analyzeGroup(ctx, group, analyzeGroupOptions{ force: true, patchFileIDs: targetFileIDs, }) summary.FingerprintsComputed += groupSummary.FingerprintsComputed summary.FingerprintCacheHits += groupSummary.FingerprintCacheHits summary.ChromaprintMarkersWritten += groupSummary.ChromaprintMarkersWritten summary.GroupsNotFound += groupSummary.GroupsNotFound summary.GroupsSkipped += groupSummary.GroupsSkipped summary.Errors = append(summary.Errors, groupSummary.Errors...) if err != nil { a.logger.Warn("intro marker episode group analysis failed", "episode_id", episodeID, "season_id", group.SeasonID, "media_folder_id", group.MediaFolderID, "group_key", group.AnalysisGroupKey, "error", err) } } return summary, nil } type chapterProcessingOptions struct { forceExistingScanner bool allowEpisodeCopy bool deadline time.Time progress func(index, total int) } type chapterSourceMarker struct { candidate Candidate segment Segment } func (a *Analyzer) processChapterCandidates(ctx context.Context, candidates []Candidate, opts chapterProcessingOptions) ([]Candidate, RunSummary) { summary := RunSummary{} remaining := make([]Candidate, 0, len(candidates)) directByEpisode := map[string]chapterSourceMarker{} directFileIDs := map[int]struct{}{} for i, candidate := range candidates { if opts.progress != nil { opts.progress(i, len(candidates)) } if err := ctx.Err(); err != nil { summary.Errors = append(summary.Errors, err.Error()) return remaining, summary } if !opts.deadline.IsZero() && time.Now().After(opts.deadline) { return remaining, summary } if candidate.HasHigherPriorityIntro(models.MarkerSourceScanner) { continue } hasIntro := candidate.IntroStart != nil && candidate.IntroEnd != nil effectiveSource := candidate.EffectiveIntroSource() if hasIntro && effectiveSource != "" && effectiveSource != models.MarkerSourceScanner { continue } if hasIntro && !opts.forceExistingScanner { continue } segment, ok := DetectChapterIntro(candidate.Chapters) if !ok { remaining = append(remaining, candidate) continue } segment = a.refineChapterSegment(ctx, candidate, segment, &summary) applied, patchErr := a.repo.PatchIntroMarker(ctx, IntroMarkerPatch{ FileID: candidate.FileID, Start: segment.Start, End: segment.End, Source: models.MarkerSourceScanner, Confidence: segment.Confidence, Algorithm: segment.Algorithm, DetectedAt: time.Now().UTC(), }) if patchErr != nil { summary.Errors = append(summary.Errors, patchErr.Error()) a.logger.Warn("intro marker chapter patch failed", "file_id", candidate.FileID, "error", patchErr) remaining = append(remaining, candidate) continue } if applied { summary.ChapterMarkersWritten++ } directFileIDs[candidate.FileID] = struct{}{} setBestChapterSource(directByEpisode, candidate, segment) } if !opts.allowEpisodeCopy || len(directByEpisode) == 0 || len(remaining) == 0 { return remaining, summary } unresolved := remaining[:0] for _, candidate := range remaining { if err := ctx.Err(); err != nil { summary.Errors = append(summary.Errors, err.Error()) unresolved = append(unresolved, candidate) continue } if !opts.deadline.IsZero() && time.Now().After(opts.deadline) { unresolved = append(unresolved, candidate) continue } if _, ok := directFileIDs[candidate.FileID]; ok { continue } source, ok := directByEpisode[candidate.EpisodeID] if !ok { unresolved = append(unresolved, candidate) continue } if candidate.HasHigherPriorityIntro(models.MarkerSourceScanner) || !compatibleEpisodeVersionDuration(source.candidate, candidate) { unresolved = append(unresolved, candidate) continue } if candidate.IntroStart != nil && candidate.IntroEnd != nil && candidate.EffectiveIntroSource() != models.MarkerSourceScanner { continue } confidence := 0.85 if source.segment.Algorithm == ChapterSilenceAlgorithm { confidence = 0.90 } applied, patchErr := a.repo.PatchIntroMarker(ctx, IntroMarkerPatch{ FileID: candidate.FileID, Start: source.segment.Start, End: source.segment.End, Source: models.MarkerSourceScanner, Confidence: confidence, Algorithm: EpisodeVersionCopyAlgorithm, DetectedAt: time.Now().UTC(), }) if patchErr != nil { msg := fmt.Sprintf("file %d: %v", candidate.FileID, patchErr) summary.Errors = append(summary.Errors, msg) a.logger.Warn("intro marker episode version copy failed", "file_id", candidate.FileID, "source_file_id", source.candidate.FileID, "error", patchErr) unresolved = append(unresolved, candidate) continue } if applied { summary.EpisodeVersionMarkersCopied++ } } return unresolved, summary } func (a *Analyzer) refineChapterSegment(ctx context.Context, candidate Candidate, segment Segment, summary *RunSummary) Segment { if a.refiner == nil || !a.config.normalized().SilenceRefinementEnabled { return segment } summary.SilenceRefinementsAttempted++ refined, ok, err := a.refiner.RefineChapterEnd(ctx, candidate, segment) if err != nil { summary.SilenceRefinementErrors++ a.logger.Warn("intro marker silence refinement failed", "file_id", candidate.FileID, "path", candidate.FilePath, "error", err) return segment } if ok { summary.SilenceRefinementsApplied++ return refined } return segment } func setBestChapterSource(sources map[string]chapterSourceMarker, candidate Candidate, segment Segment) { existing, ok := sources[candidate.EpisodeID] if !ok || chapterSourceRank(segment) > chapterSourceRank(existing.segment) { sources[candidate.EpisodeID] = chapterSourceMarker{candidate: candidate, segment: segment} } } func chapterSourceRank(segment Segment) int { if segment.Algorithm == ChapterSilenceAlgorithm { return 2 } if segment.Algorithm == ChapterAlgorithm { return 1 } return 0 } func compatibleEpisodeVersionDuration(source, target Candidate) bool { if source.DurationSeconds <= 0 || target.DurationSeconds <= 0 { return false } diff := source.DurationSeconds - target.DurationSeconds if diff < 0 { diff = -diff } return diff <= 1.0 } func ownDetectionCandidates(candidates []Candidate) []Candidate { remaining := make([]Candidate, 0, len(candidates)) for _, candidate := range candidates { if candidate.HasHigherPriorityIntro(models.MarkerSourceScanner) { continue } if candidate.IntroStart != nil && candidate.IntroEnd != nil && candidate.EffectiveIntroSource() != models.MarkerSourceScanner { continue } remaining = append(remaining, candidate) } return remaining } func (a *Analyzer) runSilenceBackfill(ctx context.Context) (RunSummary, error) { summary := RunSummary{} cfg := a.config.normalized() if !cfg.SilenceRefinementEnabled || cfg.SilenceBackfillLimit <= 0 { return summary, nil } candidates, err := a.repo.ListChapterSilenceBackfillCandidates(ctx, cfg.SilenceBackfillLimit) if err != nil { return summary, err } summary.SilenceBackfillConsidered = len(candidates) if len(candidates) == 0 { return summary, nil } _, backfillSummary := a.processChapterCandidates(ctx, candidates, chapterProcessingOptions{ forceExistingScanner: true, deadline: time.Now().Add(cfg.SilenceBackfillMaxDuration), }) mergeRunSummary(&summary, backfillSummary) return summary, nil } type candidateGroup struct { SeasonID string MediaFolderID int AnalysisGroupKey string Candidates []Candidate } type analyzeGroupOptions struct { force bool patchFileIDs map[int]struct{} persistState bool } func groupCandidates(candidates []Candidate) []candidateGroup { byKey := map[string]*candidateGroup{} for _, candidate := range candidates { key := fmt.Sprintf("%d:%s:%s", candidate.MediaFolderID, candidate.SeasonID, candidate.AnalysisGroupKey()) group := byKey[key] if group == nil { group = &candidateGroup{ SeasonID: candidate.SeasonID, MediaFolderID: candidate.MediaFolderID, AnalysisGroupKey: candidate.AnalysisGroupKey(), } byKey[key] = group } group.Candidates = append(group.Candidates, candidate) } groups := make([]candidateGroup, 0, len(byKey)) for _, group := range byKey { episodeIDs := map[string]struct{}{} for _, candidate := range group.Candidates { episodeIDs[candidate.EpisodeID] = struct{}{} } if len(episodeIDs) < 2 { continue } groups = append(groups, *group) } sort.Slice(groups, func(i, j int) bool { if groups[i].MediaFolderID != groups[j].MediaFolderID { return groups[i].MediaFolderID < groups[j].MediaFolderID } if groups[i].SeasonID != groups[j].SeasonID { return groups[i].SeasonID < groups[j].SeasonID } return groups[i].AnalysisGroupKey < groups[j].AnalysisGroupKey }) return groups } func (a *Analyzer) analyzeGroup(ctx context.Context, group candidateGroup, opts analyzeGroupOptions) (RunSummary, error) { summary := RunSummary{} state := SeasonState{ SeasonID: group.SeasonID, MediaFolderID: group.MediaFolderID, AnalysisGroupKey: group.AnalysisGroupKey, InputSignature: InputSignature(group.Candidates), EpisodeCount: distinctEpisodeCount(group.Candidates), FileCount: len(group.Candidates), } existing, err := a.repo.LoadSeasonState(ctx, state, a.config) if err != nil { return summary, err } if !opts.force && existing != nil && existing.InputSignature == state.InputSignature && (existing.Status == "complete" || existing.Status == "not_found") { summary.GroupsSkipped++ return summary, nil } inputs, hits, computed, err := a.ensureFingerprints(ctx, group.Candidates) summary.FingerprintCacheHits += hits summary.FingerprintsComputed += computed if err != nil { state.Status = "failed" state.LastError = err.Error() if opts.persistState { _ = a.repo.UpsertSeasonState(ctx, state, a.config) } summary.Errors = append(summary.Errors, err.Error()) return summary, err } if distinctFingerprintEpisodeCount(inputs) < 2 { state.Status = "not_found" state.LastError = "too few fingerprints" if opts.persistState { if err := a.repo.UpsertSeasonState(ctx, state, a.config); err != nil { return summary, err } } summary.GroupsNotFound++ return summary, nil } segments := CompareFingerprints(inputs, a.config) if len(segments) == 0 { state.Status = "not_found" if opts.persistState { if err := a.repo.UpsertSeasonState(ctx, state, a.config); err != nil { return summary, err } } summary.GroupsNotFound++ return summary, nil } byFileID := make(map[int]Candidate, len(group.Candidates)) for _, candidate := range group.Candidates { byFileID[candidate.FileID] = candidate } for fileID, segment := range segments { if !shouldPatchGroupFile(fileID, opts.patchFileIDs) { continue } candidate := byFileID[fileID] applied, patchErr := a.repo.PatchIntroMarker(ctx, IntroMarkerPatch{ FileID: fileID, Start: segment.Start, End: segment.End, Source: models.MarkerSourceScanner, Confidence: segment.Confidence, Algorithm: segment.Algorithm, DetectedAt: time.Now().UTC(), }) if patchErr != nil { msg := fmt.Sprintf("file %d: %v", fileID, patchErr) summary.Errors = append(summary.Errors, msg) a.logger.Warn("intro marker chromaprint patch failed", "file_id", fileID, "path", candidate.FilePath, "error", patchErr) continue } if applied { summary.ChromaprintMarkersWritten++ } } if opts.persistState { state.Status = "complete" state.MarkersWritten = summary.ChromaprintMarkersWritten if err := a.repo.UpsertSeasonState(ctx, state, a.config); err != nil { return summary, err } } return summary, nil } func candidateFileIDs(candidates []Candidate) map[int]struct{} { fileIDs := make(map[int]struct{}, len(candidates)) for _, candidate := range candidates { fileIDs[candidate.FileID] = struct{}{} } return fileIDs } func shouldPatchGroupFile(fileID int, allowed map[int]struct{}) bool { if allowed == nil { return true } _, ok := allowed[fileID] return ok } func (a *Analyzer) ensureFingerprints(ctx context.Context, candidates []Candidate) ([]fingerprintInput, int, int, error) { var ( mu sync.Mutex inputs []fingerprintInput hits int computed int firstErr error ) sem := make(chan struct{}, max(1, a.config.MaxParallelFFmpeg)) var wg sync.WaitGroup for _, candidate := range candidates { candidate := candidate wg.Add(1) go func() { defer wg.Done() if err := ctx.Err(); err != nil { mu.Lock() if firstErr == nil { firstErr = err } mu.Unlock() return } cached, err := a.repo.LoadFingerprint(ctx, candidate, a.config) if err != nil { mu.Lock() if firstErr == nil { firstErr = err } mu.Unlock() return } if cached != nil { mu.Lock() inputs = append(inputs, fingerprintInput{Candidate: candidate, Points: cached.Points}) hits++ mu.Unlock() return } select { case sem <- struct{}{}: case <-ctx.Done(): mu.Lock() if firstErr == nil { firstErr = ctx.Err() } mu.Unlock() return } fp, ok, err := a.extractor.Extract(ctx, candidate) <-sem if err != nil { a.logger.Warn("intro marker fingerprint extraction failed", "file_id", candidate.FileID, "path", candidate.FilePath, "error", err) return } if !ok { return } if err := a.repo.UpsertFingerprint(ctx, fp); err != nil { mu.Lock() if firstErr == nil { firstErr = err } mu.Unlock() return } mu.Lock() inputs = append(inputs, fingerprintInput{Candidate: candidate, Points: fp.Points}) computed++ mu.Unlock() }() } wg.Wait() sort.Slice(inputs, func(i, j int) bool { return inputs[i].Candidate.FileID < inputs[j].Candidate.FileID }) return inputs, hits, computed, firstErr } func distinctEpisodeCount(candidates []Candidate) int { seen := map[string]struct{}{} for _, candidate := range candidates { seen[candidate.EpisodeID] = struct{}{} } return len(seen) } func distinctFingerprintEpisodeCount(inputs []fingerprintInput) int { seen := map[string]struct{}{} for _, input := range inputs { seen[input.Candidate.EpisodeID] = struct{}{} } return len(seen) } func mergeRunSummary(dst *RunSummary, src RunSummary) { dst.LibrariesScanned += src.LibrariesScanned dst.FilesConsidered += src.FilesConsidered dst.SeasonGroupsConsidered += src.SeasonGroupsConsidered dst.FingerprintsComputed += src.FingerprintsComputed dst.FingerprintCacheHits += src.FingerprintCacheHits dst.ChapterMarkersWritten += src.ChapterMarkersWritten dst.ChromaprintMarkersWritten += src.ChromaprintMarkersWritten dst.GroupsNotFound += src.GroupsNotFound dst.GroupsSkipped += src.GroupsSkipped dst.Errors = append(dst.Errors, src.Errors...) if src.ChromaprintSupported { dst.ChromaprintSupported = true } if src.ChromaprintSupportMessage != "" { dst.ChromaprintSupportMessage = src.ChromaprintSupportMessage } dst.SilenceRefinementsAttempted += src.SilenceRefinementsAttempted dst.SilenceRefinementsApplied += src.SilenceRefinementsApplied dst.SilenceRefinementErrors += src.SilenceRefinementErrors dst.EpisodeVersionMarkersCopied += src.EpisodeVersionMarkersCopied dst.SilenceBackfillConsidered += src.SilenceBackfillConsidered }