Files
silo-server/internal/markers/write.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

199 lines
6.3 KiB
Go

package markers
import (
"strings"
"github.com/Silo-Server/silo-server/internal/models"
)
// CanWriteMarker reports whether a new marker write should be accepted given
// the existing source/confidence. A strictly higher-priority source always
// wins; an equal-priority source wins only if its confidence is strictly
// higher than what's already stored. Unknown/empty existing source is treated
// as priority zero so any defined source can replace it.
//
// A manual write is the exception: an explicit human edit is authoritative and
// always applies at equal-or-higher priority (last-writer-wins). Without this,
// correcting a previously hand-set marker — whose confidence is fixed at 1.0 —
// would be silently rejected by the strictly-higher-confidence rule, leaving
// users unable to fix their own (or each other's) edits.
func CanWriteMarker(existingSource *string, existingConfidence *float64, newSource string, newConfidence *float64) bool {
currentSource := ""
if existingSource != nil {
currentSource = *existingSource
}
existingPriority := models.MarkerSourcePriority(currentSource)
newPriority := models.MarkerSourcePriority(newSource)
if newPriority > existingPriority {
return true
}
if newPriority < existingPriority {
return false
}
if newSource == models.MarkerSourceManual {
return true
}
if existingConfidence != nil && newConfidence != nil {
return *newConfidence > *existingConfidence
}
return false
}
// SegmentPayload is the storage-agnostic per-segment marker write: its bounds
// plus the provenance (provider/confidence/algorithm) of the marker that
// produced it. A merged multi-provider result carries a different provider per
// segment; a single-source result repeats the same provider across segments.
type SegmentPayload struct {
Start, End *float64
Source string
Provider *string
Confidence *float64
Algorithm string
}
// Present reports whether the segment carries a bound to write.
func (s SegmentPayload) Present() bool { return s.Start != nil || s.End != nil }
// MarkerUpdatePayload is the storage-agnostic shape produced from a provider
// Result. Repositories convert it into their concrete write column set. Source
// is the shared source class (online/scanner/manual/...); each SegmentPayload
// carries its own provider/confidence/algorithm so a merged multi-provider
// result records correct per-segment provenance.
type MarkerUpdatePayload struct {
Intro SegmentPayload
Credits SegmentPayload
Recap SegmentPayload
Preview SegmentPayload
Source string
}
// HasAnySegment reports whether the payload carries at least one segment
// range. Callers can short-circuit empty writes without touching the DB.
func (p MarkerUpdatePayload) HasAnySegment() bool {
return p.Intro.Present() || p.Credits.Present() || p.Recap.Present() || p.Preview.Present()
}
// SummaryConfidence returns the highest per-segment confidence present, for the
// legacy shared markers_confidence column. Returns nil when no segment carries
// a confidence.
func (p MarkerUpdatePayload) SummaryConfidence() *float64 {
var max float64
found := false
for _, s := range []SegmentPayload{p.Intro, p.Credits, p.Recap, p.Preview} {
if s.Confidence != nil && (!found || *s.Confidence > max) {
max = *s.Confidence
found = true
}
}
if !found {
return nil
}
return &max
}
// SummarySource returns the highest-priority source class present in the
// per-segment payloads, using confidence as the tie-breaker. This keeps the
// legacy shared markers_source column coherent even when individual segment
// provenance differs.
func (p MarkerUpdatePayload) SummarySource() string {
source := strings.TrimSpace(p.Source)
confidence := p.SummaryConfidence()
found := source != ""
for _, s := range []SegmentPayload{p.Intro, p.Credits, p.Recap, p.Preview} {
if !s.Present() || strings.TrimSpace(s.Source) == "" {
continue
}
if !found ||
models.MarkerSourcePriority(s.Source) > models.MarkerSourcePriority(source) ||
(models.MarkerSourcePriority(s.Source) == models.MarkerSourcePriority(source) && confidenceGreater(s.Confidence, confidence)) {
source = s.Source
confidence = s.Confidence
found = true
}
}
return source
}
// BuildUpdatePayload converts a provider Result into the storage payload. Each
// marker maps to its segment with its own provenance: ProviderID/Algorithm fall
// back to the Result-level values (used for single-provider results), and the
// algorithm finally falls back to external:<source> so every write carries an
// algorithm tag.
func BuildUpdatePayload(result Result) MarkerUpdatePayload {
payload := MarkerUpdatePayload{Source: result.SourceClass}
for _, m := range result.Markers {
start := m.Start.Seconds()
end := m.End.Seconds()
if end <= start {
continue
}
startPtr, endPtr := start, end
source := markerSource(m, result)
seg := SegmentPayload{
Start: &startPtr,
End: &endPtr,
Source: source,
Provider: markerProvider(m, result),
Algorithm: markerAlgorithm(m, result.Algorithm, source),
}
if m.Confidence > 0 {
conf := m.Confidence
seg.Confidence = &conf
}
switch m.Kind {
case MarkerKindIntro:
payload.Intro = seg
case MarkerKindCredits:
payload.Credits = seg
case MarkerKindRecap:
payload.Recap = seg
case MarkerKindPreview:
payload.Preview = seg
}
}
return payload
}
func markerSource(m Marker, result Result) string {
source := strings.TrimSpace(m.SourceClass)
if source == "" {
source = strings.TrimSpace(result.SourceClass)
}
return source
}
// markerProvider returns the per-marker provider, falling back to the
// Result-level provider (single-provider results), or nil when neither is set.
func markerProvider(m Marker, result Result) *string {
provider := strings.TrimSpace(m.ProviderID)
if provider == "" {
provider = strings.TrimSpace(result.ProviderID)
}
if provider == "" {
return nil
}
return &provider
}
// markerAlgorithm returns the per-marker algorithm, falling back to the
// already-resolved Result-level algorithm.
func markerAlgorithm(m Marker, resultAlgorithm, source string) string {
if a := strings.TrimSpace(m.Algorithm); a != "" {
return a
}
if strings.TrimSpace(resultAlgorithm) == "" && strings.TrimSpace(source) != "" {
return "external:" + source
}
return resultAlgorithm
}
func confidenceGreater(a, b *float64) bool {
if a == nil {
return false
}
if b == nil {
return true
}
return *a > *b
}