Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion contracts/api/v2/fixtures/get_system_info_ok.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"server_version": "unavailable",
"api_major": 2,
"contract_digest": "1b83c215553d5b1bc2cc54037cd789ec0a1364fbb37e41d0dfdfd7cf135c9552",
"contract_digest": "a090db5a3b9114c65e340fd57ad7ca081b7452b01ab4b4d56d72f9ffc338b81b",
"links": {
"openapi": "/api/v2/openapi.json",
"capabilities": "/api/v2/capabilities",
Expand Down
88 changes: 88 additions & 0 deletions contracts/api/v2/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -31050,6 +31050,9 @@
},
"language": {
"type": "string"
},
"title": {
"type": "string"
}
},
"required": [
Expand Down Expand Up @@ -194093,6 +194096,9 @@
"input_path": {
"type": "string"
},
"prepared_tracks": {
"$ref": "#/x-silo-worker-protocols/schemas/playback_PreparedTracks"
},
"software_video_decode": {
"type": "boolean"
},
Expand Down Expand Up @@ -194164,6 +194170,9 @@
"total_duration": {
"format": "double",
"type": "number"
},
"track_recipe_version": {
"type": "string"
}
},
"required": [
Expand Down Expand Up @@ -194828,6 +194837,85 @@
],
"type": "object"
},
"playback_PreparedAudioTrack": {
"additionalProperties": false,
"properties": {
"codec": {
"type": "string"
},
"default": {
"type": "boolean"
},
"language": {
"type": "string"
},
"source_channels": {
"format": "int64",
"type": "integer"
},
"source_index": {
"format": "int64",
"type": "integer"
},
"title": {
"type": "string"
}
},
"required": [
"source_index",
"codec"
],
"type": "object"
},
"playback_PreparedSubtitleTrack": {
"additionalProperties": false,
"properties": {
"default": {
"type": "boolean"
},
"forced": {
"type": "boolean"
},
"hearing_impaired": {
"type": "boolean"
},
"language": {
"type": "string"
},
"source_index": {
"format": "int64",
"type": "integer"
},
"title": {
"type": "string"
}
},
"required": [
"source_index"
],
"type": "object"
},
"playback_PreparedTracks": {
"additionalProperties": false,
"properties": {
"audio": {
"items": {
"$ref": "#/x-silo-worker-protocols/schemas/playback_PreparedAudioTrack"
},
"type": "array"
},
"subtitles": {
"items": {
"$ref": "#/x-silo-worker-protocols/schemas/playback_PreparedSubtitleTrack"
},
"type": "array"
}
},
"required": [
"audio"
],
"type": "object"
},
"playback_RenderDeviceInfo": {
"additionalProperties": false,
"properties": {
Expand Down
9 changes: 8 additions & 1 deletion docs/architecture/playback-protocol-v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -1387,7 +1387,14 @@ session updates preserve codec, source/target channels, bitrate, and the
transcode decision as one recipe. A failed Jellyfin audio switch restores the
prior durable selection and executor facts so the same client report can retry.
Prepared downloads persist the audio recipe version and use `audio_v2_*` queue
states that pre-v2 API workers cannot claim or publish as ready.
states that pre-v2 API workers cannot claim or publish as ready. The multi-track
prepared layout (every audio track, plain-text subtitles as MP4 timed text,
ASS/SSA and PGS as manifest sidecars) is a
separate `track_recipe_version` with `tracks_v1_*` queue states that outrank the
audio and tone-map families. Its per-track plan travels in the prepare request
and execution fingerprint; only transcode nodes advertising the
`prepared_tracks_v1` transport feature receive it, and an older node's legacy
receipt is rejected.

They are advertised only if an eligible executor actually has the required
capability. The ordinary FFmpeg feature probe is cached; the more expensive
Expand Down
37 changes: 30 additions & 7 deletions docs/downloads-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,27 @@ Yes, manifests include metadata needed to make the offline item feel native:
- External and downloaded subtitle fetch URLs plus known subtitle file sizes.
- Container, codecs, resolution, HDR, duration, selected audio track, and audio
track inventory. For remux/transcode entries these describe the prepared
artifact the file endpoint actually delivers (single audio track, target
container/codecs), not the catalog source it was prepared from.
artifact the file endpoint actually delivers (target container/codecs), not
the catalog source it was prepared from.

Prepared remux/transcode files keep every source audio track in source order,
so `audio_tracks[].index` and `selected_audio_track_index` address positions
in the delivered MP4. Transcodes encode each track to stereo AAC; remuxes copy
tracks that share the primary track's codec (or are AAC/MP3) when MP4 can
store that codec (AAC, MP3, AC-3, E-AC-3, ALAC) and encode the rest to stereo
AAC. The server records the audio tracks when the file becomes ready, so a
later rescan of a replaced source does not change `audio_tracks`; a
`selected_audio_track_index` that no longer names the same-language track
falls back to the file's default track. Embedded plain-text subtitles (SRT, WebVTT) are carried
inside the MP4 as timed text, with their language, title, and forced flag.
MP4 timed text would drop ASS/SSA styling, drawing commands, and overlapping
events, and MP4 cannot store bitmap subtitles, so each embedded ASS/SSA track is
listed in `subtitles[]` as an `ass` sidecar and each PGS track as a `sup`
sidecar, with the track's `title` when it has one; DVD and DVB bitmap subtitles are not carried. MP4 marks the first
embedded subtitle track as default, so clients choose subtitles from forced
flags and viewer preference rather than that flag.
Files prepared before this layout contain only the first audio track and no
subtitles, and their manifests keep describing them that way.
- Stable provider identity and integrity metadata for local validation/rescan recovery.

The client still needs to fetch artwork/subtitle bytes once while online and cache
Expand Down Expand Up @@ -613,10 +632,14 @@ Other failures, such as `404 not_found`, won't succeed on a retry.
GET /api/v2/downloads/{id}/subtitles/{ref}
```

`ref` comes from `subtitles[].fetch_url` and encodes either `external:{index}` or
`downloaded:{id}`; `X-Silo-Device-Id` is required. Invalid refs return
`422 validation_failed`. Current content access is checked before asset delivery,
and downloaded-subtitle ownership must match the entry's media file.
`ref` comes from `subtitles[].fetch_url` and encodes `external:{index}`,
`embedded:{ordinal}`, or `downloaded:{id}`; `X-Silo-Device-Id` is required.
`embedded` refs name an embedded ASS/SSA or PGS track by subtitle ordinal and
return the complete track, as an ASS script or a `.sup` elementary stream,
extracted from the source file.
Invalid refs return `422 validation_failed`. Current content access is checked
before asset delivery, and downloaded-subtitle ownership must match the entry's
media file.

### 4.10 Direct download

Expand Down Expand Up @@ -1598,7 +1621,7 @@ unavailable or ineligible proxy targets fall back to existing local delivery.
`GET /api/v2/downloads/{id}/artwork/{kind}` and
`GET /api/v2/downloads/{id}/subtitles/{ref}` require the device header.
Artwork kinds are poster, backdrop and logo; subtitle references retain the
existing external:index and downloaded:id identity. Current content access is
external:index, embedded:ordinal, and downloaded:id identity. Current content access is
checked before asset delivery, and downloaded subtitle ownership must match the
entry's media file. These two asset routes preserve whole-object delivery and
private caching; they do not advertise byte ranges.
Expand Down
3 changes: 3 additions & 0 deletions internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -2005,6 +2005,9 @@ func newChiRouter(deps Dependencies) chi.Router {
}
downloadSvc.SetOfflineDeps(detailSvc, subtitleSource, nil)
}
if streamHandler != nil {
downloadSvc.SetSubtitleCache(streamHandler.SubtitleCache)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
Quick104 marked this conversation as resolved.
if deps.MarkerPopulation != nil {
downloadSvc.SetMarkerPopulation(deps.MarkerPopulation)
}
Expand Down
9 changes: 8 additions & 1 deletion internal/apiv2/download_delivery.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func (reg *Registry) serveDownloadDelivery(w http.ResponseWriter, r *http.Reques
// written rather than before the first read, so that failure still gets
// its problem response. Files keep ReadFrom for sendfile; ServeContent
// writes their header before copying anyway.
asset := struct{ http.ResponseWriter }{writer}
asset := assetResponseWriter{writer}
var err error
switch kind {
case "file":
Expand All @@ -125,3 +125,10 @@ func (reg *Registry) serveDownloadDelivery(w http.ResponseWriter, r *http.Reques
}
writeProblem(w, r, downloadProblem(err))
}

// assetResponseWriter hides ReadFrom from artwork and subtitle copies (see
// serveDownloadDelivery) while keeping Unwrap, so response controllers can
// still reach the connection for rolling write deadlines.
type assetResponseWriter struct{ http.ResponseWriter }

func (w assetResponseWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
28 changes: 25 additions & 3 deletions internal/downloadprepare/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,13 @@ type Request struct {
AudioRecipeVersion string `json:"audio_recipe_version,omitempty"`
// SourceAudioChannels freezes the selected input stream's probed channel
// count. Zero is the mixed-version-safe unknown value and never enables gain.
SourceAudioChannels int `json:"source_audio_channels,omitempty"`
TotalDuration float64 `json:"total_duration,omitempty"`
SourceAudioChannels int `json:"source_audio_channels,omitempty"`
// TrackRecipeVersion and PreparedTracks carry the multi-track stream
// layout. Older nodes ignore both, encode the legacy single-audio layout,
// and therefore cannot return the matching execution fingerprint.
TrackRecipeVersion string `json:"track_recipe_version,omitempty"`
PreparedTracks *playback.PreparedTracks `json:"prepared_tracks,omitempty"`
TotalDuration float64 `json:"total_duration,omitempty"`
}

// Result identifies a completed artifact without exposing the node's local
Expand Down Expand Up @@ -194,11 +199,23 @@ func (r Request) StereoDownmixBoostRequested() bool {
playback.IsAudioToAACStereoDownmixV3(r.SourceAudioChannels, r.TargetCodecAudio, r.TargetAudioChannels)
}

// PreparedTracksRequested includes incomplete layouts so the node can reject a
// partial recipe instead of encoding the legacy single-audio layout.
func (r Request) PreparedTracksRequested() bool {
return r.TrackRecipeVersion != "" || r.PreparedTracks != nil
}

// ValidPreparedTracks reports whether a requested layout is complete and uses
// the stream-layout version this build executes.
func (r Request) ValidPreparedTracks() bool {
return r.TrackRecipeVersion == playback.PreparedTracksRecipeVersion && r.PreparedTracks != nil
}

// ExecutionAttestationRequested reports whether accepting bytes requires a
// receipt from a node that understood all newly transported recipe fields.
// Explicit audio output settings affect bytes even when the v2 boost does not.
func (r Request) ExecutionAttestationRequested() bool {
return r.ToneMapRequested() || r.AudioRecipeRequested() ||
return r.ToneMapRequested() || r.AudioRecipeRequested() || r.PreparedTracksRequested() ||
r.TargetAudioChannels != 0 || r.TargetAudioBitrateKbps != 0
}

Expand Down Expand Up @@ -252,6 +269,10 @@ func NewRequest(artifactID string, opts playback.TranscodeOpts) Request {
AudioTrackIndex: opts.AudioTrackIndex,
TotalDuration: opts.TotalDuration,
}
if opts.PreparedTracks != nil {
request.TrackRecipeVersion = playback.PreparedTracksRecipeVersion
request.PreparedTracks = opts.PreparedTracks
}
if playback.IsAudioToAACStereoDownmixV3(opts.SourceAudioChannels, request.TargetCodecAudio, request.TargetAudioChannels) {
request.SourceAudioChannels = opts.SourceAudioChannels
request.AudioRecipeVersion = playback.TransformationAudioToAACRecipeVersionV3
Expand Down Expand Up @@ -287,6 +308,7 @@ func (r Request) TranscodeOpts(ffmpegPath, hwAccel, hwDevice string, sink playba
AudioTrackIndex: r.AudioTrackIndex,
SourceAudioChannels: r.SourceAudioChannels,
SubtitleTrackIndex: -1,
PreparedTracks: r.PreparedTracks,
FFmpegPath: ffmpegPath,
HWAccel: hwAccel,
HWDevice: hwDevice,
Expand Down
20 changes: 15 additions & 5 deletions internal/downloads/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,17 @@ const (
ArtifactAudioV2Queued = "audio_v2_queued"
ArtifactAudioV2Running = "audio_v2_running"
ArtifactAudioV2Ready = "audio_v2_ready"
ArtifactTracksQueued = "tracks_v1_queued"
ArtifactTracksRunning = "tracks_v1_running"
ArtifactTracksReady = "tracks_v1_ready"
ArtifactReady = "ready"
ArtifactFailed = "failed"
)

func queuedArtifactStatus(mode tonemap.Mode, audioRecipeVersion string) string {
func queuedArtifactStatus(mode tonemap.Mode, audioRecipeVersion, trackRecipeVersion string) string {
if trackRecipeVersion != "" {
return ArtifactTracksQueued
}
if audioRecipeVersion != "" {
return ArtifactAudioV2Queued
}
Expand All @@ -37,7 +43,8 @@ func queuedArtifactStatus(mode tonemap.Mode, audioRecipeVersion string) string {
}

func artifactReady(artifact *Artifact) bool {
return artifact != nil && (artifact.Status == ArtifactReady || artifact.Status == ArtifactToneMapReady || artifact.Status == ArtifactAudioV2Ready)
return artifact != nil && (artifact.Status == ArtifactReady || artifact.Status == ArtifactToneMapReady ||
artifact.Status == ArtifactAudioV2Ready || artifact.Status == ArtifactTracksReady)
}

// ErrNoArtifactJob is returned by the queue when no claimable job exists.
Expand All @@ -54,6 +61,8 @@ type Artifact struct {
CodecVideo string
CodecAudio string
AudioRecipeVersion string
TrackRecipeVersion string // playback.PreparedTracksRecipeVersion; empty = legacy single-audio layout
PreparedAudioTracks []OfflineAudioTrack // multi-track audio inventory, frozen when the file became ready
Resolution string
AudioTrackIndex int
TargetBitrateKbps int
Expand Down Expand Up @@ -129,13 +138,14 @@ func paramsHashWithToneMapRevision(params paramsHashParams) string {
}

// artifactUsesExecutionFingerprint distinguishes source-sensitive recipes from
// legacy parameter-only artifacts. AudioRecipeVersion is also the durable
// queue discriminator that keeps a pre-v2 worker from claiming these bytes.
// legacy parameter-only artifacts. AudioRecipeVersion and TrackRecipeVersion
// are also the durable queue discriminators that keep an older worker from
// claiming bytes it would encode differently.
func artifactUsesExecutionFingerprint(a *Artifact) bool {
if a == nil {
return false
}
return a.ToneMapMode != "" || a.AudioRecipeVersion != ""
return a.ToneMapMode != "" || a.AudioRecipeVersion != "" || a.TrackRecipeVersion != ""
}

// effectiveArtifactDir resolves where prepared artifacts are written: the
Expand Down
Loading
Loading