* 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>
162 lines
5.2 KiB
Go
162 lines
5.2 KiB
Go
package tasks
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/markers"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/taskmanager"
|
|
)
|
|
|
|
// ContributionRunner submits a file's eligible markers (satisfied by
|
|
// *markers.ContributionService).
|
|
type ContributionRunner interface {
|
|
ContributeFile(ctx context.Context, file *models.MediaFile, opts markers.ContributeOptions) ([]markers.ContributionOutcome, error)
|
|
}
|
|
|
|
// AutoContributeConfigReader exposes per-provider contribution config (satisfied
|
|
// by *markers.ProviderConfigStore).
|
|
type AutoContributeConfigReader interface {
|
|
List() []markers.ProviderConfig
|
|
}
|
|
|
|
// ContributionCandidateSource lists local-intro files eligible for auto
|
|
// contribution (satisfied by *markers.ContributionStore).
|
|
type ContributionCandidateSource interface {
|
|
CandidateLocalIntroFiles(ctx context.Context, minConfidence float64, afterID, limit int) ([]int, error)
|
|
}
|
|
|
|
// ContributionFileLoader loads files by id (satisfied by *scanner.FileRepository).
|
|
type ContributionFileLoader interface {
|
|
GetByIDs(ctx context.Context, ids []int) ([]*models.MediaFile, error)
|
|
}
|
|
|
|
// ContributeMarkersTask submits high-confidence local intro markers to providers
|
|
// that have auto-contribution enabled. It is a no-op when no provider opts in.
|
|
type ContributeMarkersTask struct {
|
|
service ContributionRunner
|
|
config AutoContributeConfigReader
|
|
candidates ContributionCandidateSource
|
|
files ContributionFileLoader
|
|
}
|
|
|
|
// NewContributeMarkersTask constructs the task.
|
|
func NewContributeMarkersTask(service ContributionRunner, config AutoContributeConfigReader, candidates ContributionCandidateSource, files ContributionFileLoader) *ContributeMarkersTask {
|
|
return &ContributeMarkersTask{service: service, config: config, candidates: candidates, files: files}
|
|
}
|
|
|
|
func (t *ContributeMarkersTask) Key() string { return "contribute_markers" }
|
|
func (t *ContributeMarkersTask) Name() string { return "Contribute Markers" }
|
|
func (t *ContributeMarkersTask) Description() string {
|
|
return "Submits high-confidence local intro markers to enabled contribution providers"
|
|
}
|
|
func (t *ContributeMarkersTask) Category() taskmanager.TaskCategory {
|
|
return taskmanager.TaskCategoryLibrary
|
|
}
|
|
func (t *ContributeMarkersTask) IsHidden() bool { return false }
|
|
|
|
func (t *ContributeMarkersTask) DefaultTriggers() []taskmanager.TriggerConfig {
|
|
// Runs after the 03:30 local-detection task so freshly detected markers are
|
|
// eligible the same night.
|
|
return []taskmanager.TriggerConfig{{Type: taskmanager.TriggerTypeDaily, TimeOfDay: "04:00"}}
|
|
}
|
|
|
|
func (t *ContributeMarkersTask) Execute(ctx context.Context, progress taskmanager.ProgressReporter) error {
|
|
if t.service == nil || t.config == nil || t.candidates == nil || t.files == nil {
|
|
progress.Report(100, "Contribution is not configured")
|
|
return nil
|
|
}
|
|
|
|
minConfidence := 0.0
|
|
autoEnabled := false
|
|
for _, c := range t.config.List() {
|
|
if !c.ContributeEnabled || !c.ContributeAutoLocal {
|
|
continue
|
|
}
|
|
if !autoEnabled || c.ContributeMinConfidence < minConfidence {
|
|
minConfidence = c.ContributeMinConfidence
|
|
}
|
|
autoEnabled = true
|
|
}
|
|
if !autoEnabled {
|
|
progress.Report(100, "No provider has auto-contribution enabled")
|
|
return nil
|
|
}
|
|
|
|
const batch = 100
|
|
afterID := 0
|
|
submitted, skipped, failed := 0, 0, 0
|
|
|
|
for {
|
|
ids, err := t.candidates.CandidateLocalIntroFiles(ctx, minConfidence, afterID, batch)
|
|
if err != nil {
|
|
return fmt.Errorf("load contribution candidates: %w", err)
|
|
}
|
|
if len(ids) == 0 {
|
|
break
|
|
}
|
|
files, err := t.files.GetByIDs(ctx, ids)
|
|
if err != nil {
|
|
return fmt.Errorf("load candidate files: %w", err)
|
|
}
|
|
byID := make(map[int]*models.MediaFile, len(files))
|
|
for _, f := range files {
|
|
byID[f.ID] = f
|
|
}
|
|
for _, id := range ids {
|
|
afterID = id
|
|
file := byID[id]
|
|
if file == nil {
|
|
continue
|
|
}
|
|
outcomes, err := t.service.ContributeFile(ctx, file, markers.ContributeOptions{Auto: true})
|
|
if err != nil {
|
|
failed++
|
|
continue
|
|
}
|
|
for _, o := range outcomes {
|
|
switch o.Status {
|
|
case markers.OutcomeStatusSkipped:
|
|
skipped++
|
|
case markers.OutcomeStatusRateLimited:
|
|
failed++
|
|
writeContributionTaskResult(progress, submitted, skipped, failed, o.RetryAfter)
|
|
progress.Report(100, fmt.Sprintf("Contribution usage-limited; retry after %s", formatRetryAfter(o.RetryAfter)))
|
|
return nil
|
|
case markers.OutcomeStatusError:
|
|
failed++
|
|
default:
|
|
submitted++
|
|
}
|
|
}
|
|
}
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
writeContributionTaskResult(progress, submitted, skipped, failed, 0)
|
|
progress.Report(100, fmt.Sprintf("Contributed %d, skipped %d, failed %d", submitted, skipped, failed))
|
|
return nil
|
|
}
|
|
|
|
func writeContributionTaskResult(progress taskmanager.ProgressReporter, submitted, skipped, failed int, retryAfter time.Duration) {
|
|
result := map[string]int{"submitted": submitted, "skipped": skipped, "failed": failed}
|
|
if retryAfter > 0 {
|
|
result["retry_after_seconds"] = int(retryAfter.Seconds())
|
|
}
|
|
if data, err := json.Marshal(result); err == nil {
|
|
progress.SetResultData(data)
|
|
}
|
|
}
|
|
|
|
func formatRetryAfter(d time.Duration) string {
|
|
if d <= 0 {
|
|
return "later"
|
|
}
|
|
return d.String()
|
|
}
|