* 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>
265 lines
11 KiB
Go
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")
|
|
}
|
|
}
|