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

850 lines
29 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
)
// MarkerFileResolver loads a media file by id for the manual-marker API.
type MarkerFileResolver interface {
GetByID(ctx context.Context, id int) (*models.MediaFile, error)
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error)
GetByEpisodeID(ctx context.Context, episodeID string) ([]*models.MediaFile, error)
}
// ManualMarkerWriter persists/clears manual markers.
type ManualMarkerWriter interface {
UpsertMarkers(ctx context.Context, fileID int, update scanner.MarkerUpdate) (bool, error)
ClearMarkers(ctx context.Context, fileID int, segments []string) (bool, error)
UpsertAndClearMarkers(ctx context.Context, fileID int, update scanner.MarkerUpdate, clearSegments []string) (bool, error)
}
// MarkerContributor submits a file's eligible markers to enabled providers.
type MarkerContributor interface {
ContributeFile(ctx context.Context, file *models.MediaFile, opts markers.ContributeOptions) ([]markers.ContributionOutcome, error)
}
// MarkerContributionLister reads contribution history for a file.
type MarkerContributionLister interface {
ListByFile(ctx context.Context, fileID int) ([]markers.ContributionRow, error)
}
type MarkerAuditLister interface {
ListMarkerEditAudit(ctx context.Context, fileIDs []int, limit int) ([]scanner.MarkerEditAuditRow, error)
ListAllMarkerEditAudit(ctx context.Context, limit int) ([]scanner.MarkerEditAuditRow, error)
}
type MarkerPermissionUserLoader interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
// MarkersHandler serves the manual-marker + contribution API. The marker
// read/write/clear routes are mounted for any authenticated viewer (users fix
// and create markers from the player); the contribution + history routes stay
// admin-only. A successful manual write fires a background contribution run
// (see maybeContribute) so corrected markers reach enabled providers.
type MarkersHandler struct {
Files MarkerFileResolver
Writer ManualMarkerWriter
Contributor MarkerContributor
Contributions MarkerContributionLister
AuditHistory MarkerAuditLister
Notifier PlaybackMarkerUpdateNotifier
Users MarkerPermissionUserLoader
// Authorizer enforces per-item access on file lookups so a viewer can only
// edit markers for content they can actually watch. When nil (tests) the
// handler falls back to an unchecked lookup.
Authorizer *MediaFileAuthorizer
// BaseContext is the lifetime context for detached background work (set from
// the app context) so contribution goroutines cancel on shutdown.
BaseContext context.Context
logger *slog.Logger
}
// NewMarkersHandler constructs the handler.
func NewMarkersHandler(files MarkerFileResolver, writer ManualMarkerWriter, contributor MarkerContributor, contributions MarkerContributionLister, notifier PlaybackMarkerUpdateNotifier, logger *slog.Logger) *MarkersHandler {
if logger == nil {
logger = slog.Default()
}
return &MarkersHandler{Files: files, Writer: writer, Contributor: contributor, Contributions: contributions, Notifier: notifier, logger: logger}
}
var manualMarkerConfidence = 1.0
const manualMarkerAlgorithm = "manual:v1"
// markerContributeTimeout bounds the detached contribution run kicked off after
// a manual save so a slow or rate-limited provider can't leak goroutines.
const markerContributeTimeout = 30 * time.Second
var markerSegmentNames = []string{"intro", "credits", "recap", "preview"}
type segmentInput struct {
Start *float64 `json:"start"`
End *float64 `json:"end"`
}
type segmentMarker struct {
Start *float64 `json:"start"`
End *float64 `json:"end"`
Source *string `json:"source"`
Provider *string `json:"provider"`
Confidence *float64 `json:"confidence"`
Algorithm *string `json:"algorithm"`
DetectedAt *time.Time `json:"detected_at"`
}
type fileMarkersResponse struct {
FileID int `json:"file_id"`
Intro segmentMarker `json:"intro"`
Credits segmentMarker `json:"credits"`
Recap segmentMarker `json:"recap"`
Preview segmentMarker `json:"preview"`
}
type contributionOutcomeResponse struct {
Provider string `json:"provider"`
Segment string `json:"segment"`
Status string `json:"status"`
SubmissionID string `json:"submission_id,omitempty"`
Reason string `json:"reason,omitempty"`
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
}
type contributionRowResponse struct {
ID string `json:"id"`
MediaFileID int `json:"media_file_id"`
Provider string `json:"provider"`
Segment string `json:"segment"`
Source string `json:"source"`
SubmittedStartMS *int64 `json:"submitted_start_ms,omitempty"`
SubmittedEndMS *int64 `json:"submitted_end_ms,omitempty"`
VideoDurationMS *int64 `json:"video_duration_ms,omitempty"`
ContentHash string `json:"content_hash"`
SubmissionID *string `json:"submission_id,omitempty"`
Status string `json:"status"`
HTTPStatus *int `json:"http_status,omitempty"`
Error *string `json:"error,omitempty"`
SubmittedAt *time.Time `json:"submitted_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type markerEditAuditResponse struct {
ID int64 `json:"id"`
MediaFileID int `json:"media_file_id"`
ItemID *string `json:"item_id,omitempty"`
ItemType *string `json:"item_type,omitempty"`
MediaTitle *string `json:"media_title,omitempty"`
FilePath *string `json:"file_path,omitempty"`
Segment string `json:"segment"`
Action string `json:"action"`
Before *segmentMarker `json:"before"`
After *segmentMarker `json:"after"`
UserID *int `json:"user_id,omitempty"`
Username *string `json:"username,omitempty"`
ImpersonatorUserID *int `json:"impersonator_user_id,omitempty"`
ImpersonatorUsername *string `json:"impersonator_username,omitempty"`
APIKeyID *int64 `json:"api_key_id,omitempty"`
RequestID *string `json:"request_id,omitempty"`
ClientIP *string `json:"client_ip,omitempty"`
UserAgent *string `json:"user_agent,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func fileMarkers(file *models.MediaFile) fileMarkersResponse {
return fileMarkersResponse{
FileID: file.ID,
Intro: segmentMarker{file.IntroStart, file.IntroEnd, file.IntroMarkersSource, file.IntroMarkersProvider,
file.IntroMarkersConfidence, file.IntroMarkersAlgorithm, file.IntroMarkersDetectedAt},
Credits: segmentMarker{file.CreditsStart, file.CreditsEnd, file.CreditsMarkersSource, file.CreditsMarkersProvider,
file.CreditsMarkersConfidence, file.CreditsMarkersAlgorithm, file.CreditsMarkersDetectedAt},
Recap: segmentMarker{file.RecapStart, file.RecapEnd, file.RecapMarkersSource, file.RecapMarkersProvider,
file.RecapMarkersConfidence, file.RecapMarkersAlgorithm, file.RecapMarkersDetectedAt},
Preview: segmentMarker{file.PreviewStart, file.PreviewEnd, file.PreviewMarkersSource, file.PreviewMarkersProvider,
file.PreviewMarkersConfidence, file.PreviewMarkersAlgorithm, file.PreviewMarkersDetectedAt},
}
}
func (h *MarkersHandler) loadFile(w http.ResponseWriter, r *http.Request) (*models.MediaFile, bool) {
if h == nil || h.Files == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker editing is not configured")
return nil, false
}
fileID, err := strconv.Atoi(chi.URLParam(r, "fileId"))
if err != nil || fileID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "A valid file id is required")
return nil, false
}
return h.authorizeFile(w, r, fileID)
}
// authorizeFile loads a file, enforcing per-item access when an authorizer is
// configured. The 404-on-denial mirrors the playback/subtitle paths so marker
// editing can't be used to probe content outside a profile's library scope.
func (h *MarkersHandler) authorizeFile(w http.ResponseWriter, r *http.Request, fileID int) (*models.MediaFile, bool) {
if h.Authorizer != nil {
file, err := h.Authorizer.Authorize(r, fileID)
if err != nil {
switch {
case errors.Is(err, catalog.ErrItemNotFound), errors.Is(err, catalog.ErrEpisodeNotFound):
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
default:
h.logger.Error("markers: authorize failed", "file_id", fileID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file")
}
return nil, false
}
return file, true
}
file, err := h.Files.GetByID(r.Context(), fileID)
if err != nil || file == nil {
writeError(w, http.StatusNotFound, "not_found", "Media file not found")
return nil, false
}
return file, true
}
func (h *MarkersHandler) loadItemPrimaryFile(w http.ResponseWriter, r *http.Request) (*models.MediaFile, bool) {
if h == nil || h.Files == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker editing is not configured")
return nil, false
}
itemID := strings.TrimSpace(chi.URLParam(r, "id"))
if itemID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "A valid item id is required")
return nil, false
}
files, err := h.Files.GetByEpisodeID(r.Context(), itemID)
if err != nil {
h.logger.Error("markers: episode file lookup failed", "item_id", itemID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files")
return nil, false
}
if len(files) == 0 {
files, err = h.Files.GetByContentID(r.Context(), itemID)
if err != nil {
h.logger.Error("markers: content file lookup failed", "item_id", itemID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files")
return nil, false
}
}
if len(files) == 0 {
writeError(w, http.StatusNotFound, "not_found", "Media file not found for item")
return nil, false
}
// Re-validate the resolved primary file through the authorizer so item-id
// edits are access-checked the same way file-id edits are.
if h.Authorizer != nil {
for _, file := range files {
if file == nil {
continue
}
authorized, err := h.Authorizer.Authorize(r, file.ID)
if err == nil {
return authorized, true
}
if errors.Is(err, catalog.ErrItemNotFound) || errors.Is(err, catalog.ErrEpisodeNotFound) {
continue
}
h.logger.Error("markers: authorize item file failed", "item_id", itemID, "file_id", file.ID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize media file")
return nil, false
}
writeError(w, http.StatusNotFound, "not_found", "Media file not found for item")
return nil, false
}
for _, file := range files {
if file != nil {
return file, true
}
}
writeError(w, http.StatusNotFound, "not_found", "Media file not found for item")
return nil, false
}
func (h *MarkersHandler) requireMarkerEdit(w http.ResponseWriter, r *http.Request) bool {
claims := apimw.GetClaims(r.Context())
if claims == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return false
}
if claims.Role == "admin" {
return true
}
if h == nil || h.Users == nil {
writeError(w, http.StatusForbidden, "forbidden", "Marker editing permission required")
return false
}
user, err := h.Users.GetByID(r.Context(), claims.UserID)
if err != nil || user == nil || !auth.HasEffectivePermission(user, auth.PermissionMarkerEdit) {
writeError(w, http.StatusForbidden, "forbidden", "Marker editing permission required")
return false
}
return true
}
func (h *MarkersHandler) auditContext(r *http.Request) context.Context {
claims := apimw.GetClaims(r.Context())
if claims == nil {
return r.Context()
}
audit := scanner.MarkerAuditContext{
UserID: &claims.UserID,
ImpersonatorUserID: claims.ImpersonatorUserID,
RequestID: chimw.GetReqID(r.Context()),
ClientIP: clientip.FromContext(r.Context()),
UserAgent: r.UserAgent(),
}
if claims.APIKeyID > 0 {
apiKeyID := claims.APIKeyID
audit.APIKeyID = &apiKeyID
}
return scanner.WithMarkerAuditContext(r.Context(), audit)
}
// HandleGetFileMarkers returns the current markers + provenance for a file.
func (h *MarkersHandler) HandleGetFileMarkers(w http.ResponseWriter, r *http.Request) {
file, ok := h.loadFile(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, fileMarkers(file))
}
// HandleGetItemMarkers returns markers for the item's primary file.
func (h *MarkersHandler) HandleGetItemMarkers(w http.ResponseWriter, r *http.Request) {
file, ok := h.loadItemPrimaryFile(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, fileMarkers(file))
}
// HandleSetFileMarkers upserts the manual marker layer. Each segment key may be
// an object {start, end} to set, or null to clear; absent keys are unchanged.
func (h *MarkersHandler) HandleSetFileMarkers(w http.ResponseWriter, r *http.Request) {
file, ok := h.loadFile(w, r)
if !ok {
return
}
if !h.requireMarkerEdit(w, r) {
return
}
h.setMarkersForFile(w, r, file)
}
// HandleSetItemMarkers upserts manual markers on the item's primary file.
func (h *MarkersHandler) HandleSetItemMarkers(w http.ResponseWriter, r *http.Request) {
file, ok := h.loadItemPrimaryFile(w, r)
if !ok {
return
}
if !h.requireMarkerEdit(w, r) {
return
}
h.setMarkersForFile(w, r, file)
}
func (h *MarkersHandler) setMarkersForFile(w http.ResponseWriter, r *http.Request, file *models.MediaFile) {
if h.Writer == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker writing is not configured")
return
}
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
duration := float64(file.Duration)
update := scanner.MarkerUpdate{
MarkersSource: models.MarkerSourceManual,
MarkersConfidence: &manualMarkerConfidence,
MarkersAlgorithm: manualMarkerAlgorithm,
}
var clears []string
var setSegs []string
for _, seg := range markerSegmentNames {
val, present := raw[seg]
if !present {
continue
}
if isJSONNull(val) {
clears = append(clears, seg)
continue
}
var in segmentInput
if err := json.Unmarshal(val, &in); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+seg+" marker")
return
}
start, end, err := normalizeManualSegment(seg, in, duration)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
applyManualSegment(&update, seg, start, end)
setSegs = append(setSegs, seg)
}
if _, err := h.Writer.UpsertAndClearMarkers(h.auditContext(r), file.ID, update, clears); err != nil {
h.logger.Error("markers: save failed", "file_id", file.ID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save markers")
return
}
refreshed, err := h.reloadAndNotify(r.Context(), file.ID)
if err != nil {
h.logger.Error("markers: reload after save failed", "file_id", file.ID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Markers saved but failed to reload")
return
}
h.maybeContribute(refreshed, setSegs)
writeJSON(w, http.StatusOK, fileMarkers(refreshed))
}
// HandleClearFileSegment clears a single segment.
func (h *MarkersHandler) HandleClearFileSegment(w http.ResponseWriter, r *http.Request) {
file, ok := h.loadFile(w, r)
if !ok {
return
}
if h.Writer == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker writing is not configured")
return
}
if !h.requireMarkerEdit(w, r) {
return
}
segment := chi.URLParam(r, "segment")
if !isMarkerSegment(segment) {
writeError(w, http.StatusBadRequest, "bad_request", "Unknown marker segment")
return
}
if _, err := h.Writer.ClearMarkers(h.auditContext(r), file.ID, []string{segment}); err != nil {
h.logger.Error("markers: clear segment failed", "file_id", file.ID, "segment", segment, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear marker")
return
}
refreshed, err := h.reloadAndNotify(r.Context(), file.ID)
if err != nil {
h.logger.Error("markers: reload after clear failed", "file_id", file.ID, "segment", segment, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Marker cleared but failed to reload")
return
}
writeJSON(w, http.StatusOK, fileMarkers(refreshed))
}
// HandleContributeFile submits the file's eligible markers to enabled providers.
func (h *MarkersHandler) HandleContributeFile(w http.ResponseWriter, r *http.Request) {
file, ok := h.loadFile(w, r)
if !ok {
return
}
if h.Contributor == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Contribution is not configured")
return
}
var body struct {
Provider string `json:"provider"`
Segments []string `json:"segments"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
var kinds []markers.MarkerKind
for _, name := range body.Segments {
kind, ok := markerKindForName(name)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Unknown segment "+name)
return
}
kinds = append(kinds, kind)
}
outcomes, err := h.Contributor.ContributeFile(r.Context(), file, markers.ContributeOptions{Provider: body.Provider, Segments: kinds})
if err != nil {
h.logger.Error("markers: contribute failed", "file_id", file.ID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Contribution failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"outcomes": contributionOutcomeResponses(outcomes)})
}
// HandleListFileContributions returns the contribution history for a file.
func (h *MarkersHandler) HandleListFileContributions(w http.ResponseWriter, r *http.Request) {
file, ok := h.loadFile(w, r)
if !ok {
return
}
if h.Contributions == nil {
writeJSON(w, http.StatusOK, map[string]any{"contributions": []contributionRowResponse{}})
return
}
rows, err := h.Contributions.ListByFile(r.Context(), file.ID)
if err != nil {
h.logger.Error("markers: list contributions failed", "file_id", file.ID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load contributions")
return
}
writeJSON(w, http.StatusOK, map[string]any{"contributions": contributionRowResponses(rows)})
}
// HandleListFileMarkerHistory returns recent semantic marker edit audit rows for one file.
func (h *MarkersHandler) HandleListFileMarkerHistory(w http.ResponseWriter, r *http.Request) {
file, ok := h.loadFile(w, r)
if !ok {
return
}
h.listMarkerHistory(w, r, []int{file.ID})
}
// HandleListItemMarkerHistory returns recent marker edit audit rows for every file version on an item.
func (h *MarkersHandler) HandleListItemMarkerHistory(w http.ResponseWriter, r *http.Request) {
if h == nil || h.Files == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker history is not configured")
return
}
itemID := strings.TrimSpace(chi.URLParam(r, "id"))
if itemID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "A valid item id is required")
return
}
files, err := h.Files.GetByEpisodeID(r.Context(), itemID)
if err != nil {
h.logger.Error("markers: episode history file lookup failed", "item_id", itemID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files")
return
}
if len(files) == 0 {
files, err = h.Files.GetByContentID(r.Context(), itemID)
if err != nil {
h.logger.Error("markers: content history file lookup failed", "item_id", itemID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load item files")
return
}
}
fileIDs := make([]int, 0, len(files))
for _, file := range files {
if file != nil {
fileIDs = append(fileIDs, file.ID)
}
}
if len(fileIDs) == 0 {
writeError(w, http.StatusNotFound, "not_found", "Media file not found for item")
return
}
h.listMarkerHistory(w, r, fileIDs)
}
// HandleListMarkerHistory returns recent semantic marker edit audit rows across all files.
func (h *MarkersHandler) HandleListMarkerHistory(w http.ResponseWriter, r *http.Request) {
if h.AuditHistory == nil {
writeJSON(w, http.StatusOK, map[string]any{"history": []markerEditAuditResponse{}})
return
}
limit, ok := markerHistoryLimit(w, r)
if !ok {
return
}
rows, err := h.AuditHistory.ListAllMarkerEditAudit(r.Context(), limit)
if err != nil {
h.logger.Error("markers: list all history failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load marker history")
return
}
writeJSON(w, http.StatusOK, map[string]any{"history": markerEditAuditResponses(rows)})
}
func (h *MarkersHandler) listMarkerHistory(w http.ResponseWriter, r *http.Request, fileIDs []int) {
if h.AuditHistory == nil {
writeJSON(w, http.StatusOK, map[string]any{"history": []markerEditAuditResponse{}})
return
}
limit, ok := markerHistoryLimit(w, r)
if !ok {
return
}
rows, err := h.AuditHistory.ListMarkerEditAudit(r.Context(), fileIDs, limit)
if err != nil {
h.logger.Error("markers: list history failed", "file_ids", fileIDs, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load marker history")
return
}
writeJSON(w, http.StatusOK, map[string]any{"history": markerEditAuditResponses(rows)})
}
func markerHistoryLimit(w http.ResponseWriter, r *http.Request) (int, bool) {
limit := 25
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "A valid limit is required")
return 0, false
}
limit = parsed
}
return limit, true
}
func (h *MarkersHandler) reloadAndNotify(ctx context.Context, fileID int) (*models.MediaFile, error) {
refreshed, err := h.Files.GetByID(ctx, fileID)
if err != nil || refreshed == nil {
if err == nil {
err = scanner.ErrFileNotFound
}
return nil, err
}
if h.Notifier != nil {
h.Notifier.MarkersUpdated(ctx, refreshed)
}
return refreshed, nil
}
// maybeContribute fires a best-effort, detached contribution run for the
// segments just set by a manual save. ContributeFile self-gates on each
// provider's contribute_enabled flag, only submits scanner/manual-sourced
// segments, and is idempotent — so this is a no-op when contribution is off or
// the marker was already submitted. It runs in the background so the save
// responds immediately and is never blocked by provider rate limits.
func (h *MarkersHandler) maybeContribute(file *models.MediaFile, segments []string) {
if h == nil || h.Contributor == nil || file == nil || len(segments) == 0 {
return
}
kinds := make([]markers.MarkerKind, 0, len(segments))
for _, seg := range segments {
if kind, ok := markerKindForName(seg); ok {
kinds = append(kinds, kind)
}
}
if len(kinds) == 0 {
return
}
base := h.BaseContext
if base == nil {
base = context.Background()
}
go func() {
ctx, cancel := context.WithTimeout(base, markerContributeTimeout)
defer cancel()
if _, err := h.Contributor.ContributeFile(ctx, file, markers.ContributeOptions{Segments: kinds}); err != nil {
h.logger.Warn("markers: background contribution failed", "file_id", file.ID, "error", err)
}
}()
}
func isJSONNull(raw json.RawMessage) bool {
return string(bytes.TrimSpace(raw)) == "null"
}
func isMarkerSegment(seg string) bool {
_, ok := markerKindForName(seg)
return ok
}
func markerKindForName(name string) (markers.MarkerKind, bool) {
switch name {
case "intro":
return markers.MarkerKindIntro, true
case "credits":
return markers.MarkerKindCredits, true
case "recap":
return markers.MarkerKindRecap, true
case "preview":
return markers.MarkerKindPreview, true
default:
return 0, false
}
}
func markerNameForKind(kind markers.MarkerKind) string {
switch kind {
case markers.MarkerKindIntro:
return "intro"
case markers.MarkerKindCredits:
return "credits"
case markers.MarkerKindRecap:
return "recap"
case markers.MarkerKindPreview:
return "preview"
default:
return ""
}
}
func contributionOutcomeResponses(outcomes []markers.ContributionOutcome) []contributionOutcomeResponse {
resp := make([]contributionOutcomeResponse, 0, len(outcomes))
for _, o := range outcomes {
item := contributionOutcomeResponse{
Provider: o.Provider,
Segment: markerNameForKind(o.Segment),
Status: o.Status,
SubmissionID: o.SubmissionID,
Reason: o.Reason,
}
if o.RetryAfter > 0 {
item.RetryAfterSeconds = int(o.RetryAfter.Seconds())
}
resp = append(resp, item)
}
return resp
}
func contributionRowResponses(rows []markers.ContributionRow) []contributionRowResponse {
resp := make([]contributionRowResponse, 0, len(rows))
for _, row := range rows {
item := contributionRowResponse{
ID: row.ID,
MediaFileID: row.MediaFileID,
Provider: row.Provider,
Segment: row.SegmentKind,
Source: row.Source,
SubmittedStartMS: row.SubmittedStartMs,
SubmittedEndMS: row.SubmittedEndMs,
VideoDurationMS: row.VideoDurationMs,
ContentHash: row.ContentHash,
SubmissionID: row.SubmissionID,
Status: row.Status,
HTTPStatus: row.HTTPStatus,
Error: row.Error,
}
if !row.SubmittedAt.IsZero() {
submittedAt := row.SubmittedAt
item.SubmittedAt = &submittedAt
}
if !row.UpdatedAt.IsZero() {
updatedAt := row.UpdatedAt
item.UpdatedAt = &updatedAt
}
resp = append(resp, item)
}
return resp
}
func markerEditAuditResponses(rows []scanner.MarkerEditAuditRow) []markerEditAuditResponse {
resp := make([]markerEditAuditResponse, 0, len(rows))
for _, row := range rows {
resp = append(resp, markerEditAuditResponse{
ID: row.ID,
MediaFileID: row.MediaFileID,
ItemID: row.ItemID,
ItemType: row.ItemType,
MediaTitle: row.MediaTitle,
FilePath: row.FilePath,
Segment: row.SegmentKind,
Action: row.Action,
Before: markerAuditSegmentResponse(row.Before),
After: markerAuditSegmentResponse(row.After),
UserID: row.UserID,
Username: row.Username,
ImpersonatorUserID: row.ImpersonatorUserID,
ImpersonatorUsername: row.ImpersonatorUsername,
APIKeyID: row.APIKeyID,
RequestID: row.RequestID,
ClientIP: row.ClientIP,
UserAgent: row.UserAgent,
CreatedAt: row.CreatedAt,
})
}
return resp
}
func markerAuditSegmentResponse(segment *scanner.MarkerAuditSegment) *segmentMarker {
if segment == nil {
return nil
}
return &segmentMarker{
Start: segment.Start,
End: segment.End,
Source: segment.Source,
Provider: segment.Provider,
Confidence: segment.Confidence,
Algorithm: segment.Algorithm,
DetectedAt: segment.DetectedAt,
}
}
// normalizeManualSegment applies the start/end defaults and validation, mirroring
// the contribution rules: intro/recap may omit start (=0); credits/preview may
// omit end (=duration); end must exceed start and stay within the file.
func normalizeManualSegment(seg string, in segmentInput, duration float64) (start, end float64, err error) {
switch seg {
case "intro", "recap":
if in.Start != nil {
start = *in.Start
}
if in.End == nil {
return 0, 0, errSegment(seg, "end is required")
}
end = *in.End
default: // credits, preview
if in.Start == nil {
return 0, 0, errSegment(seg, "start is required")
}
start = *in.Start
if in.End != nil {
end = *in.End
} else if duration > 0 {
end = duration
} else {
return 0, 0, errSegment(seg, "end is required when duration is unknown")
}
}
if start < 0 || end <= start {
return 0, 0, errSegment(seg, "end must be greater than start")
}
if duration > 0 && end > duration+1 {
return 0, 0, errSegment(seg, "end exceeds the file duration")
}
return start, end, nil
}
func applyManualSegment(update *scanner.MarkerUpdate, seg string, start, end float64) {
s, e := start, end
switch seg {
case "intro":
update.IntroStart, update.IntroEnd = &s, &e
case "credits":
update.CreditsStart, update.CreditsEnd = &s, &e
case "recap":
update.RecapStart, update.RecapEnd = &s, &e
case "preview":
update.PreviewStart, update.PreviewEnd = &s, &e
}
}
func errSegment(seg, msg string) error {
return &markerValidationError{seg: seg, msg: msg}
}
type markerValidationError struct {
seg string
msg string
}
func (e *markerValidationError) Error() string { return e.seg + " marker: " + e.msg }