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:
parent
63be565227
commit
6a7a2e292e
10 changed files with 264 additions and 22 deletions
|
|
@ -13,7 +13,7 @@ import (
|
|||
type MirrorEntry struct {
|
||||
URL string `json:"url"`
|
||||
Label string `json:"label"`
|
||||
Kind string `json:"kind"` // "clearnet" | "tor"
|
||||
Kind string `json:"kind"` // "clearnet" | "tor"
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -426,6 +426,11 @@ type SyncRequest struct {
|
|||
Tasks []TaskState `json:"tasks"`
|
||||
CanDelete bool `json:"canDelete"` // library.allow_delete is enabled
|
||||
DeleteConfirmed []int `json:"deleteConfirmed,omitempty"` // library item IDs successfully deleted from disk
|
||||
// Subtitle-fetch job IDs the agent completed (sidecar written to disk).
|
||||
SubtitlesFetched []int `json:"subtitlesFetched,omitempty"`
|
||||
// Subtitle-fetch jobs that permanently failed (download/write error) — the web
|
||||
// marks them errored so the UI fails fast instead of waiting for a timeout.
|
||||
SubtitlesFailed []SubtitleFetchError `json:"subtitlesFailed,omitempty"`
|
||||
// Live managed-VPN split-tunnel state, sent every sync so the web sees the
|
||||
// WireGuard slot owner update in near-realtime (vs. register, once at startup).
|
||||
// VPNActive has no omitempty: false (tunnel down) must reach the server so it
|
||||
|
|
@ -512,14 +517,31 @@ type StreamSession struct {
|
|||
|
||||
// SyncResponse is returned by the server with all pending actions for the CLI.
|
||||
type SyncResponse struct {
|
||||
NewTasks []Task `json:"newTasks,omitempty"`
|
||||
Controls []ControlAction `json:"controls,omitempty"`
|
||||
StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
|
||||
StreamSessions []StreamSession `json:"streamSessions,omitempty"`
|
||||
Watching bool `json:"watching"`
|
||||
Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
|
||||
Scan bool `json:"scan,omitempty"`
|
||||
FilesToDelete []LibraryDeleteRequest `json:"filesToDelete,omitempty"`
|
||||
NewTasks []Task `json:"newTasks,omitempty"`
|
||||
Controls []ControlAction `json:"controls,omitempty"`
|
||||
StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
|
||||
StreamSessions []StreamSession `json:"streamSessions,omitempty"`
|
||||
Watching bool `json:"watching"`
|
||||
Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
|
||||
Scan bool `json:"scan,omitempty"`
|
||||
FilesToDelete []LibraryDeleteRequest `json:"filesToDelete,omitempty"`
|
||||
SubtitleFetches []SubtitleFetchRequest `json:"subtitleFetches,omitempty"`
|
||||
}
|
||||
|
||||
// SubtitleFetchRequest is a server-side request to download a subtitle (from our
|
||||
// proxy URL, already charset-fixed + VTT) and save it as a sidecar next to a
|
||||
// media file. URL is the absolute /api/internal/subtitles/proxy URL.
|
||||
type SubtitleFetchRequest struct {
|
||||
ID int `json:"id"`
|
||||
FilePath string `json:"filePath"`
|
||||
Lang string `json:"lang"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// SubtitleFetchError reports a permanently-failed subtitle fetch back to the web.
|
||||
type SubtitleFetchError struct {
|
||||
ID int `json:"id"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue