feat(subtitles): subtitle-fetch jobs vía sync + auto-fetch opcional en scan

El web empuja SubtitleFetchRequest por el sync (URL del proxy, ya
charset-fixed a WebVTT); el daemon lo descarga y lo escribe como sidecar
<base>.<lang>.vtt junto al medio (contención en scan paths con
EvalSymlinks, cap 10 MiB) y reporta done/failed en el siguiente sync
para que el web marque el job. Config nueva library.subtitles
(auto_fetch + languages) para el auto-fetch en scan, off por defecto.
This commit is contained in:
Deivid Soto 2026-06-10 14:48:35 +02:00
parent 63be565227
commit 6a7a2e292e
10 changed files with 264 additions and 22 deletions

View file

@ -66,6 +66,12 @@ type SyncClient struct {
// It should delete the files and return the IDs of successfully deleted items.
OnDeleteFiles func(items []LibraryDeleteRequest) []int
// OnSubtitleFetch is called when the server requests on-demand subtitle
// downloads. It should download each (from req.URL, already VTT), write a
// sidecar next to req.FilePath, and return the IDs successfully fetched plus
// the ones that failed (so the web can mark them errored).
OnSubtitleFetch func(reqs []SubtitleFetchRequest) ([]int, []SubtitleFetchError)
// OnRevoked is called when a sync is rejected because this agent's credential
// was revoked (the user deleted the agent from the dashboard). The daemon
// wires this to wipe the stored key + stop — it must NOT keep retrying or the
@ -89,6 +95,11 @@ type SyncClient struct {
// deleteInFlight tracks item IDs currently being processed or awaiting confirmation.
// Prevents the same file from being passed to OnDeleteFiles multiple times.
deleteInFlight map[int]struct{}
// Subtitle-fetch jobs awaiting confirmation + dedup (guarded by pendingDeleteMu).
pendingSubtitlesFetched []int
pendingSubtitlesFailed []SubtitleFetchError
subtitleInFlight map[int]struct{}
}
// NewSyncClient creates a sync client.
@ -218,6 +229,20 @@ func (sc *SyncClient) buildRequest() SyncRequest {
}
sc.pendingDeleteConfirmed = nil
}
if len(sc.pendingSubtitlesFetched) > 0 {
req.SubtitlesFetched = sc.pendingSubtitlesFetched
for _, id := range sc.pendingSubtitlesFetched {
delete(sc.subtitleInFlight, id)
}
sc.pendingSubtitlesFetched = nil
}
if len(sc.pendingSubtitlesFailed) > 0 {
req.SubtitlesFailed = sc.pendingSubtitlesFailed
for _, f := range sc.pendingSubtitlesFailed {
delete(sc.subtitleInFlight, f.ID)
}
sc.pendingSubtitlesFailed = nil
}
sc.pendingDeleteMu.Unlock()
return req
}
@ -289,6 +314,37 @@ func (sc *SyncClient) processResponse(resp *SyncResponse) {
}(newItems)
}
}
// On-demand subtitle fetches — dedup against in-flight, run off the sync
// goroutine (network + disk I/O), confirm on the next cycle.
if len(resp.SubtitleFetches) > 0 && sc.OnSubtitleFetch != nil {
sc.pendingDeleteMu.Lock()
if sc.subtitleInFlight == nil {
sc.subtitleInFlight = make(map[int]struct{})
}
var newReqs []SubtitleFetchRequest
for _, r := range resp.SubtitleFetches {
if _, inFlight := sc.subtitleInFlight[r.ID]; !inFlight {
newReqs = append(newReqs, r)
sc.subtitleInFlight[r.ID] = struct{}{}
}
}
sc.pendingDeleteMu.Unlock()
if len(newReqs) > 0 {
go func(reqs []SubtitleFetchRequest) {
done, failed := sc.OnSubtitleFetch(reqs)
// Both done and failed are reported on the next uplink; buildRequest
// clears them from subtitleInFlight when it flushes them. A failure
// becomes status='error' on the web (no silent infinite retry — the
// user re-requests, which creates a fresh row).
sc.pendingDeleteMu.Lock()
sc.pendingSubtitlesFetched = append(sc.pendingSubtitlesFetched, done...)
sc.pendingSubtitlesFailed = append(sc.pendingSubtitlesFailed, failed...)
sc.pendingDeleteMu.Unlock()
}(newReqs)
}
}
}
// runWakeListener holds a long-poll connection to /api/internal/agent/wake.