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

762 lines
22 KiB
Go

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
}