Files
silo-server/internal/markers/contribute_test.go
T
0163df3683 [codex] Add IntroDB marker integration and dialogue-aware Chromaprint refinement (#57)
* docs(markers): design + implementation plans for multi-source markers & TheIntroDB contribution

* fix(markers): TheIntroDB read-path correctness (TVDB, real confidence, best candidate)

Honor TVDB ids in /media lookups (previously dropped — anime/TheTVDB-first
libraries got no markers), decode and use the real per-segment confidence and
submission_count instead of a hardcoded 0.9, and pick the most-submitted /
highest-confidence candidate when several are returned. Adds httptest coverage
for the introdb client and provider.

Phase 1 of docs/superpowers/plans/2026-06-06-marker-sources-and-contribution-implementation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(markers): multi-source dispatch, per-provider config, per-segment provenance

Add marker_provider_config (per-provider fetch enable/priority + contribute
gates, contribution off by default) and a cached ProviderConfigStore. Add
Registry.FetchMerged: query all fetch-enabled providers concurrently and keep
the best candidate per segment (submission_count, then confidence, then fetch
priority), stamping each winning marker with its provider/algorithm. Thread
per-segment provenance through MarkerUpdatePayload and scanner.MarkerUpdate
(additive SegmentProvenance overrides) so a merged result writes correct
per-segment provider/confidence/algorithm; the legacy shared columns keep a
summary. The lazy-playback path now uses FetchMerged. With only TheIntroDB
enabled, behavior is unchanged.

Phase 2 of docs/superpowers/plans/2026-06-06-marker-sources-and-contribution-implementation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(markers): TheIntroDB submission client, contribution audit, service engine

Add a markers.Submitter capability and implement it on the introdb provider
(POST /v3/submit, GET /v3/user/stats; key required, usage-limit aware, applies
the null start/end conventions). Add the marker_contributions audit table and a
value-hash-keyed ContributionStore for idempotency. Add ContributionService:
resolves enabled submitter providers, gates eligibility (never re-submit
online-sourced markers; auto runs require contribute_auto_local + scanner-intro
above the per-provider confidence threshold), checks idempotency, submits, and
records. Wired in main.go; no trigger yet (admin API and task follow).

Phase 3 of docs/superpowers/plans/2026-06-06-marker-sources-and-contribution-implementation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): admin marker editing, contribution, and provider config endpoints

Add the RequireAdmin marker API: GET/PUT /admin/files/{id}/markers (read with
provenance; manual upsert where a segment object sets and null clears),
DELETE .../markers/{segment}, POST .../contribute and GET .../contributions,
plus GET/PUT /admin/markers/providers[/{provider}] and a
.../validate key-check returning user stats. Manual writes go through the
priority-gated UpsertMarkers (source=manual) and notify live sessions; a new
FileRepository.ClearMarkers nulls a segment's columns. Validation mirrors the
contribution rules.

Phase 4 of docs/superpowers/plans/2026-06-06-marker-sources-and-contribution-implementation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(markers): daily auto-contribution task for local intro markers

Add ContributeMarkersTask (daily 04:00, after local detection): when a provider
has contribute_enabled + contribute_auto_local, page through episode files with
a scanner intro marker at/above the provider's confidence threshold (new
ContributionStore.CandidateLocalIntroFiles keyset query) and run them through
ContributionService with Auto=true. No-op when no provider opts in; idempotent
and resumable across runs.

Phase 5 of docs/superpowers/plans/2026-06-06-marker-sources-and-contribution-implementation.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(intromarkers): refine chromaprint starts with dialogue cues

* feat(markers): finish marker management backend

* feat(web): add marker editing UI

* feat(markers): use plugin marker providers

* fix(markers): address PR review feedback

* feat(player): show marker labels on seek hover

* fix(markers): type nullable marker mutation params

* feat(markers): audit marker edits and add permission

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:29:26 -04:00

265 lines
11 KiB
Go

package markers
import (
"context"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
type fakeSubmitter struct {
id string
submitted []SubmissionRequest
result SubmissionResult
err error
required []string
}
func (f *fakeSubmitter) ID() string { return f.id }
func (f *fakeSubmitter) FetchMarkers(context.Context, Request) (Result, error) { return Result{}, nil }
func (f *fakeSubmitter) SubmitMarker(_ context.Context, req SubmissionRequest) (SubmissionResult, error) {
f.submitted = append(f.submitted, req)
if f.err != nil {
return SubmissionResult{}, f.err
}
if f.result.Status == "" {
return SubmissionResult{ID: "id1", Status: SubmissionStatusPending}, nil
}
return f.result, nil
}
func (f *fakeSubmitter) FetchUserStats(context.Context) (UserStats, error) { return UserStats{}, nil }
func (f *fakeSubmitter) SubmissionRequirements() SubmissionRequirements {
return SubmissionRequirements{RequiredExternalIDs: f.required}
}
type fakeResolver struct{ ids ExternalIDs }
func (f fakeResolver) ResolveForFile(context.Context, *models.MediaFile) (ExternalIDs, error) {
return f.ids, nil
}
type fakeConfig map[string]ProviderConfig
func (f fakeConfig) Get(p string) (ProviderConfig, bool) { c, ok := f[p]; return c, ok }
type fakeRecorder struct {
already bool
recorded []ContributionRow
}
func (f *fakeRecorder) AlreadySubmitted(context.Context, int, string, string, string) (bool, error) {
return f.already, nil
}
func (f *fakeRecorder) Record(_ context.Context, row ContributionRow) error {
f.recorded = append(f.recorded, row)
return nil
}
func floatPtr(v float64) *float64 { return &v }
func strPtr(v string) *string { return &v }
func newContribFile() *models.MediaFile {
return &models.MediaFile{ID: 7, EpisodeID: "ep1", SeasonNumber: 1, EpisodeNumber: 3, Duration: 1800}
}
func newContribService(sub *fakeSubmitter, cfg fakeConfig, rec *fakeRecorder) *ContributionService {
reg := NewRegistry(nil)
_ = reg.Register(sub)
resolver := fakeResolver{ids: ExternalIDs{Kind: ItemKindEpisode, TmdbID: "1234", SeasonNumber: 1, EpisodeNumber: 3}}
return NewContributionService(reg, resolver, cfg, rec, nil)
}
func TestContributeSkipsOnlineSourced(t *testing.T) {
sub := &fakeSubmitter{id: "introdb"}
file := newContribFile()
file.IntroStart, file.IntroEnd = floatPtr(0), floatPtr(60)
file.IntroMarkersSource = strPtr(models.MarkerSourceOnline) // came FROM introdb
svc := newContribService(sub, fakeConfig{"introdb": {Provider: "introdb", ContributeEnabled: true}}, &fakeRecorder{})
outcomes, err := svc.ContributeFile(context.Background(), file, ContributeOptions{})
if err != nil {
t.Fatalf("ContributeFile: %v", err)
}
if len(sub.submitted) != 0 {
t.Errorf("online-sourced marker must not be submitted, got %d", len(sub.submitted))
}
if len(outcomes) != 0 {
t.Errorf("expected no outcomes, got %+v", outcomes)
}
}
func TestContributeSubmitsManualOnDemand(t *testing.T) {
sub := &fakeSubmitter{id: "introdb"}
file := newContribFile()
file.CreditsStart, file.CreditsEnd = floatPtr(1500), floatPtr(1800)
file.CreditsMarkersSource = strPtr(models.MarkerSourceManual)
rec := &fakeRecorder{}
svc := newContribService(sub, fakeConfig{"introdb": {Provider: "introdb", ContributeEnabled: true}}, rec)
outcomes, err := svc.ContributeFile(context.Background(), file, ContributeOptions{})
if err != nil {
t.Fatalf("ContributeFile: %v", err)
}
if len(sub.submitted) != 1 || sub.submitted[0].Segment != MarkerKindCredits {
t.Fatalf("expected 1 credits submission, got %+v", sub.submitted)
}
if len(rec.recorded) != 1 || rec.recorded[0].Status != SubmissionStatusPending {
t.Errorf("expected recorded pending, got %+v", rec.recorded)
}
if len(outcomes) != 1 || outcomes[0].Status != SubmissionStatusPending {
t.Errorf("outcomes = %+v", outcomes)
}
}
func TestContributeAutoGatesOnThresholdAndKind(t *testing.T) {
cfg := fakeConfig{"introdb": {Provider: "introdb", ContributeEnabled: true, ContributeAutoLocal: true, ContributeMinConfidence: 0.9}}
subLow := &fakeSubmitter{id: "introdb"}
fileLow := newContribFile()
fileLow.IntroStart, fileLow.IntroEnd = floatPtr(0), floatPtr(60)
fileLow.IntroMarkersSource = strPtr(models.MarkerSourceScanner)
fileLow.IntroMarkersConfidence = floatPtr(0.8)
if _, err := newContribService(subLow, cfg, &fakeRecorder{}).ContributeFile(context.Background(), fileLow, ContributeOptions{Auto: true}); err != nil {
t.Fatalf("ContributeFile: %v", err)
}
if len(subLow.submitted) != 0 {
t.Errorf("below-threshold scanner intro must be skipped, got %d", len(subLow.submitted))
}
subHigh := &fakeSubmitter{id: "introdb"}
fileHigh := newContribFile()
fileHigh.IntroStart, fileHigh.IntroEnd = floatPtr(0), floatPtr(60)
fileHigh.IntroMarkersSource = strPtr(models.MarkerSourceScanner)
fileHigh.IntroMarkersConfidence = floatPtr(0.95)
if _, err := newContribService(subHigh, cfg, &fakeRecorder{}).ContributeFile(context.Background(), fileHigh, ContributeOptions{Auto: true}); err != nil {
t.Fatalf("ContributeFile: %v", err)
}
if len(subHigh.submitted) != 1 || subHigh.submitted[0].Segment != MarkerKindIntro {
t.Errorf("above-threshold scanner intro should submit, got %+v", subHigh.submitted)
}
}
func TestContributeSkipsDuplicate(t *testing.T) {
sub := &fakeSubmitter{id: "introdb"}
file := newContribFile()
file.IntroStart, file.IntroEnd = floatPtr(0), floatPtr(60)
file.IntroMarkersSource = strPtr(models.MarkerSourceManual)
svc := newContribService(sub, fakeConfig{"introdb": {Provider: "introdb", ContributeEnabled: true}}, &fakeRecorder{already: true})
outcomes, _ := svc.ContributeFile(context.Background(), file, ContributeOptions{})
if len(sub.submitted) != 0 {
t.Errorf("duplicate must not submit, got %d", len(sub.submitted))
}
if len(outcomes) != 1 || outcomes[0].Status != OutcomeStatusSkipped {
t.Errorf("expected skipped outcome, got %+v", outcomes)
}
}
func TestContributeSkipsWhenProviderRequiredIDMissing(t *testing.T) {
sub := &fakeSubmitter{id: "introdb", required: []string{ExternalIDKeyTMDB}}
file := newContribFile()
file.IntroStart, file.IntroEnd = floatPtr(0), floatPtr(60)
file.IntroMarkersSource = strPtr(models.MarkerSourceManual)
rec := &fakeRecorder{}
reg := NewRegistry(nil)
_ = reg.Register(sub)
resolver := fakeResolver{ids: ExternalIDs{Kind: ItemKindEpisode, TvdbID: "777", SeasonNumber: 1, EpisodeNumber: 3}}
svc := NewContributionService(reg, resolver, fakeConfig{"introdb": {Provider: "introdb", ContributeEnabled: true}}, rec, nil)
outcomes, err := svc.ContributeFile(context.Background(), file, ContributeOptions{})
if err != nil {
t.Fatalf("ContributeFile: %v", err)
}
if len(sub.submitted) != 0 || len(rec.recorded) != 0 {
t.Fatalf("tmdb-missing marker should be skipped before submit/record, submitted=%d recorded=%d", len(sub.submitted), len(rec.recorded))
}
if len(outcomes) != 1 || outcomes[0].Status != OutcomeStatusSkipped || outcomes[0].Reason != "tmdb id required" {
t.Fatalf("outcomes = %+v, want skipped tmdb id required", outcomes)
}
}
func TestContributeAllowsTVDBOnlyWhenProviderDoesNotRequireTMDB(t *testing.T) {
sub := &fakeSubmitter{id: "plugin:1:markers"}
file := newContribFile()
file.IntroStart, file.IntroEnd = floatPtr(0), floatPtr(60)
file.IntroMarkersSource = strPtr(models.MarkerSourceManual)
rec := &fakeRecorder{}
reg := NewRegistry(nil)
_ = reg.Register(sub)
resolver := fakeResolver{ids: ExternalIDs{Kind: ItemKindEpisode, TvdbID: "777", SeasonNumber: 1, EpisodeNumber: 3}}
svc := NewContributionService(reg, resolver, fakeConfig{"plugin:1:markers": {Provider: "plugin:1:markers", ContributeEnabled: true}}, rec, nil)
outcomes, err := svc.ContributeFile(context.Background(), file, ContributeOptions{})
if err != nil {
t.Fatalf("ContributeFile: %v", err)
}
if len(sub.submitted) != 1 || sub.submitted[0].ExternalIDs[ExternalIDKeyTVDB] != "777" {
t.Fatalf("tvdb-only marker should submit to provider without tmdb requirement, submitted=%+v", sub.submitted)
}
if len(outcomes) != 1 || outcomes[0].Status != SubmissionStatusPending {
t.Fatalf("outcomes = %+v, want pending", outcomes)
}
}
func TestContributeStopsOnRetryAfterError(t *testing.T) {
sub := &fakeSubmitter{
id: "introdb",
err: &RetryAfterError{Provider: "introdb", RetryAfter: 45 * time.Second, Message: "usage limited"},
}
file := newContribFile()
file.IntroStart, file.IntroEnd = floatPtr(0), floatPtr(60)
file.IntroMarkersSource = strPtr(models.MarkerSourceManual)
file.CreditsStart, file.CreditsEnd = floatPtr(1700), floatPtr(1800)
file.CreditsMarkersSource = strPtr(models.MarkerSourceManual)
rec := &fakeRecorder{}
svc := newContribService(sub, fakeConfig{"introdb": {Provider: "introdb", ContributeEnabled: true}}, rec)
outcomes, err := svc.ContributeFile(context.Background(), file, ContributeOptions{})
if err != nil {
t.Fatalf("ContributeFile: %v", err)
}
if len(sub.submitted) != 1 {
t.Fatalf("expected contribution loop to stop after first rate limit, submitted=%d", len(sub.submitted))
}
if len(outcomes) != 1 || outcomes[0].Status != OutcomeStatusRateLimited || outcomes[0].RetryAfter != 45*time.Second {
t.Fatalf("outcomes = %+v, want one rate_limited retry-after outcome", outcomes)
}
if len(rec.recorded) != 1 || rec.recorded[0].Status != OutcomeStatusError {
t.Fatalf("recorded = %+v, want one error audit row", rec.recorded)
}
}
func TestContributeDisabledProviderNoop(t *testing.T) {
sub := &fakeSubmitter{id: "introdb"}
file := newContribFile()
file.IntroStart, file.IntroEnd = floatPtr(0), floatPtr(60)
file.IntroMarkersSource = strPtr(models.MarkerSourceManual)
// contribute_enabled defaults false
svc := newContribService(sub, fakeConfig{"introdb": {Provider: "introdb"}}, &fakeRecorder{})
outcomes, _ := svc.ContributeFile(context.Background(), file, ContributeOptions{})
if len(sub.submitted) != 0 || len(outcomes) != 0 {
t.Errorf("disabled provider must be a no-op, submitted=%d outcomes=%d", len(sub.submitted), len(outcomes))
}
}
func TestContentHashStableAndSensitive(t *testing.T) {
s, e, d := int64(0), int64(60000), int64(1800000)
target := contributionTargetParts(ExternalIDs{Kind: ItemKindEpisode, TmdbID: "1234", SeasonNumber: 1, EpisodeNumber: 3})
h1 := ContentHash("intro", &s, &e, &d, target...)
if h1 != ContentHash("intro", &s, &e, &d, target...) {
t.Error("hash not stable for identical input")
}
e2 := int64(61000)
if ContentHash("intro", &s, &e2, &d, target...) == h1 {
t.Error("changed end should change hash")
}
rematched := contributionTargetParts(ExternalIDs{Kind: ItemKindEpisode, TmdbID: "9999", SeasonNumber: 1, EpisodeNumber: 3})
if ContentHash("intro", &s, &e, &d, rematched...) == h1 {
t.Error("changed resolved target should change hash")
}
}