feat(library): detección de intro/créditos post-scan (skip segments)
Some checks failed
CI / Test (push) Failing after 6m18s
CI / Build (push) Successful in 1m32s
CI / Build-1 (push) Successful in 1m55s
CI / Build-2 (push) Successful in 1m33s
CI / Build-3 (push) Successful in 1m32s
CI / Build-4 (push) Successful in 1m35s
CI / Build-5 (push) Successful in 1m33s
CI / Lint (push) Failing after 2m50s
CI / Coverage (push) Successful in 2m58s
CI / Vet (push) Successful in 2m7s

Tras cada scan, localiza la intro (OP) y los créditos (ED) comparando
fingerprints chromaprint entre episodios de la misma temporada —
reimplementación limpia del enfoque de Intro Skipper: índice invertido
de uint32, alineamiento por shifts, Hamming ≤6/32, región contigua más
larga (15-120s intro / 15-450s créditos). Películas: inicio de créditos
por rachas de blackframe (solo keyframes, -skip_frame nokey) que llegan
al final del fichero.

- fpcalc se auto-descarga de las releases estáticas de acoustid
  (linux/macos/windows, ~2MB) con el mismo patrón que ffmpeg/ffprobe.
- Resultados cacheados como sidecar .skipseg.json (mtime + versión de
  algoritmo); solo los ficheros nuevos trabajan.
- Submit a /api/internal/agent/skip-segments DESPUÉS del library-sync,
  en dos fases (episodios primero, películas después) para que la
  fase rápida no espere a los blackframe lentos sobre NAS.
- Agrupación por (dir + título-pre-SxxEyy + season): los títulos
  parseados arrastran nombre de episodio y tags de release.
- Gotcha cazado en vivo: fpcalc -length sale sin drenar el pipe; hay
  que cerrar nuestro read-end o ffmpeg queda bloqueado para siempre.
- config: library.skip_detect (default true, backfill) y scan_interval
  default 24h → 1h (estilo Plex).
This commit is contained in:
Deivid Soto 2026-06-12 19:46:07 +02:00
parent 59da949a53
commit a710bc1626
11 changed files with 1223 additions and 5 deletions

View file

@ -512,3 +512,14 @@ func (c *Client) handleResponse(resp *http.Response, dst any) error {
return nil
}
// SubmitSkipSegments uploads detected intro/credits segments after a library
// scan. Must run AFTER SyncLibrary — the server resolves file paths against
// the freshly-synced library_item rows.
func (c *Client) SubmitSkipSegments(ctx context.Context, req SkipSegmentsRequest) (*SkipSegmentsResponse, error) {
var resp SkipSegmentsResponse
if err := c.doPostWith(ctx, c.librarySyncClient, "/api/internal/agent/skip-segments", req, &resp); err != nil {
return nil, fmt.Errorf("skip segments: %w", err)
}
return &resp, nil
}

View file

@ -592,3 +592,38 @@ type WatchProgressUpdate struct {
type WatchProgressResponse struct {
Success bool `json:"success"`
}
// ---------------------------------------------------------------------------
// Skip-segment types (intro/credits detection — see library/skipdetect.go)
// ---------------------------------------------------------------------------
// SkipSegmentRange is one detected skippable range inside a media file.
type SkipSegmentRange struct {
Category string `json:"category"` // "intro" | "credits"
StartSec float64 `json:"startSec"`
EndSec float64 `json:"endSec"`
}
// SkipSegmentItem carries the detected segments of one library file. The
// server resolves FilePath against the user's library_item rows (synced just
// before) to attach the segments to a content identity.
type SkipSegmentItem struct {
FilePath string `json:"filePath"`
Title string `json:"title,omitempty"`
Season int `json:"season,omitempty"`
Episode int `json:"episode,omitempty"`
DurationSec float64 `json:"durationSec"`
Segments []SkipSegmentRange `json:"segments"`
}
// SkipSegmentsRequest submits detected skip segments after a library scan.
type SkipSegmentsRequest struct {
AgentID string `json:"agentId,omitempty"`
Items []SkipSegmentItem `json:"items"`
}
// SkipSegmentsResponse reports how many segments the server stored.
type SkipSegmentsResponse struct {
Stored int `json:"stored"`
Unmatched int `json:"unmatched"`
}