* 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>
164 lines
5.0 KiB
Go
164 lines
5.0 KiB
Go
package markers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// ProviderConfig is the per-provider behavior row from marker_provider_config.
|
|
// fetch_* control multi-source read dispatch; contribute_* gate submission and
|
|
// default off so contribution is opt-in per provider.
|
|
type ProviderConfig struct {
|
|
Provider string
|
|
FetchEnabled bool
|
|
FetchPriority int
|
|
ContributeEnabled bool
|
|
ContributeAutoLocal bool
|
|
ContributeMinConfidence float64
|
|
}
|
|
|
|
const providerConfigColumns = `provider, fetch_enabled, fetch_priority, contribute_enabled, contribute_auto_local, contribute_min_confidence`
|
|
|
|
// ProviderConfigStore is a cached read/write facade over marker_provider_config.
|
|
// Reads serve from an in-memory snapshot; call Reload at startup and after a
|
|
// settings-changed event. Update writes through and refreshes the snapshot.
|
|
type ProviderConfigStore struct {
|
|
pool *pgxpool.Pool
|
|
mu sync.RWMutex
|
|
cache map[string]ProviderConfig
|
|
}
|
|
|
|
// NewProviderConfigStore constructs a store backed by the supplied pool.
|
|
func NewProviderConfigStore(pool *pgxpool.Pool) *ProviderConfigStore {
|
|
return &ProviderConfigStore{pool: pool, cache: map[string]ProviderConfig{}}
|
|
}
|
|
|
|
// Reload replaces the in-memory snapshot from the database.
|
|
func (s *ProviderConfigStore) Reload(ctx context.Context) error {
|
|
if s == nil || s.pool == nil {
|
|
return nil
|
|
}
|
|
rows, err := s.pool.Query(ctx, `SELECT `+providerConfigColumns+` FROM marker_provider_config`)
|
|
if err != nil {
|
|
return fmt.Errorf("load marker provider config: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
next := make(map[string]ProviderConfig)
|
|
for rows.Next() {
|
|
var c ProviderConfig
|
|
if err := rows.Scan(
|
|
&c.Provider,
|
|
&c.FetchEnabled,
|
|
&c.FetchPriority,
|
|
&c.ContributeEnabled,
|
|
&c.ContributeAutoLocal,
|
|
&c.ContributeMinConfidence,
|
|
); err != nil {
|
|
return fmt.Errorf("scan marker provider config: %w", err)
|
|
}
|
|
next[c.Provider] = c
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("iterate marker provider config: %w", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.cache = next
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Get returns the config for a provider id from the snapshot.
|
|
func (s *ProviderConfigStore) Get(provider string) (ProviderConfig, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
c, ok := s.cache[provider]
|
|
return c, ok
|
|
}
|
|
|
|
// List returns all configs sorted by (fetch_priority asc, provider asc).
|
|
func (s *ProviderConfigStore) List() []ProviderConfig {
|
|
s.mu.RLock()
|
|
out := make([]ProviderConfig, 0, len(s.cache))
|
|
for _, c := range s.cache {
|
|
out = append(out, c)
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].FetchPriority != out[j].FetchPriority {
|
|
return out[i].FetchPriority < out[j].FetchPriority
|
|
}
|
|
return out[i].Provider < out[j].Provider
|
|
})
|
|
return out
|
|
}
|
|
|
|
// EnabledForFetch returns the fetch-enabled providers in priority order.
|
|
func (s *ProviderConfigStore) EnabledForFetch() []ProviderConfig {
|
|
all := s.List()
|
|
out := make([]ProviderConfig, 0, len(all))
|
|
for _, c := range all {
|
|
if c.FetchEnabled {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Update upserts a provider config row and refreshes the snapshot.
|
|
func (s *ProviderConfigStore) Update(ctx context.Context, c ProviderConfig) error {
|
|
if s == nil || s.pool == nil {
|
|
return fmt.Errorf("marker provider config store unavailable")
|
|
}
|
|
if _, err := s.pool.Exec(ctx, `
|
|
INSERT INTO marker_provider_config (
|
|
provider, fetch_enabled, fetch_priority,
|
|
contribute_enabled, contribute_auto_local, contribute_min_confidence, updated_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, now())
|
|
ON CONFLICT (provider) DO UPDATE SET
|
|
fetch_enabled = EXCLUDED.fetch_enabled,
|
|
fetch_priority = EXCLUDED.fetch_priority,
|
|
contribute_enabled = EXCLUDED.contribute_enabled,
|
|
contribute_auto_local = EXCLUDED.contribute_auto_local,
|
|
contribute_min_confidence = EXCLUDED.contribute_min_confidence,
|
|
updated_at = now()`,
|
|
c.Provider, c.FetchEnabled, c.FetchPriority,
|
|
c.ContributeEnabled, c.ContributeAutoLocal, c.ContributeMinConfidence,
|
|
); err != nil {
|
|
return fmt.Errorf("update marker provider config: %w", err)
|
|
}
|
|
return s.Reload(ctx)
|
|
}
|
|
|
|
// Ensure inserts a default provider config row if one does not already exist.
|
|
// Existing rows are left untouched so admin choices survive plugin restarts and
|
|
// upgrades.
|
|
func (s *ProviderConfigStore) Ensure(ctx context.Context, c ProviderConfig) error {
|
|
if s == nil || s.pool == nil {
|
|
return fmt.Errorf("marker provider config store unavailable")
|
|
}
|
|
if _, ok := s.Get(c.Provider); ok {
|
|
return nil
|
|
}
|
|
if _, err := s.pool.Exec(ctx, `
|
|
INSERT INTO marker_provider_config (
|
|
provider, fetch_enabled, fetch_priority,
|
|
contribute_enabled, contribute_auto_local, contribute_min_confidence, updated_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, now())
|
|
ON CONFLICT (provider) DO NOTHING`,
|
|
c.Provider, c.FetchEnabled, c.FetchPriority,
|
|
c.ContributeEnabled, c.ContributeAutoLocal, c.ContributeMinConfidence,
|
|
); err != nil {
|
|
return fmt.Errorf("ensure marker provider config: %w", err)
|
|
}
|
|
return s.Reload(ctx)
|
|
}
|