Compare commits

..
Author SHA1 Message Date
Quick104 fd3d737a58 fix(playback): trust completed segment writes 2026-08-11 15:02:38 -04:00
Quick104 86a02a6f61 fix(playback): bound sparse seek cleanup 2026-08-11 14:55:27 -04:00
Quick104 1cbb59d0fa fix(playback): finish segment acknowledgements 2026-08-11 14:45:33 -04:00
Quick104 874a26de0f fix(playback): confirm proxy downstream delivery 2026-08-11 14:30:55 -04:00
Quick104 0544213730 fix(playback): verify segment completion before pruning 2026-08-11 14:21:33 -04:00
Quick104 e05c30c637 fix(playback): keep restart buffers prunable 2026-08-11 13:42:43 -04:00
Quick104 7f6225bb11 fix(playback): remove redundant retention floor assignment 2026-08-11 13:21:53 -04:00
Quick104 977104eac5 Merge remote-tracking branch 'origin/main' into t3code/pr565-review 2026-08-11 13:14:44 -04:00
Quick104 dffa6ae34e Merge origin/main into codex/bound-transcode-segments 2026-08-11 13:05:12 -04:00
Quick104 e2899ff619 fix(playback): preserve retained segments across restarts 2026-08-11 12:59:25 -04:00
Quick104 f524c48d19 fix(playback): close retention gaps across ranges and seeks 2026-08-08 18:49:27 -04:00
Quick104 c26ab22809 test(playback): strengthen retention regression coverage 2026-08-08 16:08:31 -04:00
Quick104 0a3adf6552 fix(playback): harden segment retention lifecycle 2026-08-08 15:59:56 -04:00
Quick104 35de68b589 fix(playback): synchronize prune option snapshot 2026-08-08 15:29:02 -04:00
Quick104 6a3fcef165 fix(playback): prune downloaded transcode segments 2026-08-08 15:23:16 -04:00
131 changed files with 2947 additions and 10139 deletions
+1 -17
View File
@@ -135,22 +135,6 @@ transcoding.
Multi-host operators can use those examples as a starting point for a dedicated worker Compose
file connected to the deployment's shared PostgreSQL and Redis services.
Proxy nodes serve source downloads from the same absolute media paths used by direct playback.
Prepared-download work can also run on transcode nodes. Each selected transcode node retains its
result on node-local storage and exposes it only through Silo's authenticated internal artifact API;
the paired proxy relays those bytes, so no shared artifact mount is required. Dedicated transcode
nodes default to retaining prepared downloads in a protected directory inside the transcode volume
captured at process startup. `download.artifact_dir` overrides that location for both dedicated
transcode nodes and the integrated/API-local fallback, so mount the configured path on every process
that prepares downloads. Changing either artifact-path setting requires a restart. Downloads with a
configured server-wide or per-user bandwidth limit remain API-local so those aggregate limits stay exact.
Clients discover distributed delivery through `proxy_delivery` on the download capability response.
When it is true, they may opt into `GET` or `HEAD /api/v1/downloads/{id}/file-proxy` and
`/api/v1/direct-download-proxy`; those routes may return a temporary redirect to a proxy node. The
established `/file` and `/direct-download` routes keep serving bytes directly with their existing
status-code contract; for a node-local prepared artifact, the API itself performs the authenticated
relay on that fallback route.
### Deployment Notes
The default compose stack intentionally bundles PostgreSQL and Redis for ease of setup and assumes a fresh install without those services already available. If you already operate PostgreSQL and Redis, omit those examples from compose and point Silo at your existing infrastructure instead. For serious installs, PostgreSQL is better on a separate VM or a managed service so upgrades, tuning, and backups are isolated from the app host. Redis can stay local for many installs, but externalizing it is also reasonable if you already operate shared infrastructure.
@@ -174,7 +158,7 @@ through the admin UI after first launch.
| `integrated` | Full server: API + frontend + scanner + transcode (default) |
| `api` | API server only, no local transcoding |
| `proxy` | Stream proxy node that connects to the shared deployment database and Redis |
| `transcode` | HLS and prepared-download worker node that connects to the shared deployment database and Redis |
| `transcode` | HLS transcode worker node that connects to the shared deployment database and Redis |
### PostgreSQL Auto-Tuning
-5
View File
@@ -662,10 +662,6 @@ func goldenConformanceMatrix() playback.ConformanceMatrixV3 {
qualityChange.ReplanRequestID = "replan-quality-change-0001"
qualityChange.Failure = playback.FailureV3{}
qualityChange.QualityPreference = resolutionHD
outputChange := goldenReplanRequest()
outputChange.Operation = playback.ReplanOperationOutputChangeV3
outputChange.ReplanRequestID = "replan-output-change-0001"
outputChange.Failure = playback.FailureV3{}
seekReanchor := goldenReplanRequest()
seekReanchor.Operation = playback.ReplanOperationSeekReanchorV3
seekReanchor.ReplanRequestID = "replan-seek-reanchor-0001"
@@ -689,7 +685,6 @@ func goldenConformanceMatrix() playback.ConformanceMatrixV3 {
replans := []playback.ReplanScenarioV3{
{Name: "track_change", Category: "track_change_replan", Request: trackChange, Expected: playback.ReplanExpectationV3{HTTPStatus: http.StatusOK, PreserveUnmodifiedTracks: true}},
{Name: "quality_change", Category: "quality_change_replan", Request: qualityChange, Expected: playback.ReplanExpectationV3{HTTPStatus: http.StatusOK, SelectedQuality: resolutionHD}},
{Name: "output_change", Category: "output_change_replan", Request: outputChange, Expected: playback.ReplanExpectationV3{HTTPStatus: http.StatusOK, PreserveUnmodifiedTracks: true}},
{Name: "track_change_idempotent_duplicate", Category: "idempotent_replan", Request: trackDuplicate, Expected: playback.ReplanExpectationV3{SameRequestAndBodyStatus: http.StatusOK, ResponseReplayedVerbatim: true, ChangedBodyStatus: http.StatusConflict, ChangedBodyError: "idempotency_key_reused"}},
{Name: "quality_change_idempotent_duplicate", Category: "idempotent_replan", Request: qualityDuplicate, Expected: playback.ReplanExpectationV3{SameRequestAndBodyStatus: http.StatusOK, ResponseReplayedVerbatim: true, ChangedBodyStatus: http.StatusConflict, ChangedBodyError: "idempotency_key_reused"}},
{Name: "track_change_concurrent_duplicate", Category: "concurrent_replan", Request: trackConcurrent, Expected: playback.ReplanExpectationV3{WhileFirstLeaseActiveStatus: http.StatusConflict, ConcurrentError: "replan_in_progress", AfterCompletionStatus: http.StatusOK, ResponseReplayedVerbatim: true}},
+11 -36
View File
@@ -718,19 +718,9 @@ func main() {
var handler http.Handler
if mode == "proxy" {
srv := proxy.NewServer(watcher, tracker)
srv.SetRemoteArtifactMissReporter(downloads.NewArtifactManager(
downloads.NewArtifactRepository(pool),
downloads.NewRepository(pool),
nil,
downloads.NewPlaybackPreparer(),
nodeID,
watcher.Config,
nil,
))
handler = srv.Handler()
} else {
srv := transcodenode.NewServer(watcher, tracker)
srv.SetInputPathAuthorizer(transcodenode.NewMediaRootAuthorizer(catalog.NewFolderRepository(pool)))
srv.SetFFmpegLogSink(playback.NewSlogFFmpegLogSink(slog.Default(), nodeID))
// Read jellycompat reconstruction recipes central wrote at transcode
// start, so this node can rebuild a Jellyfin transcode after its own
@@ -930,14 +920,8 @@ func main() {
proxyPool := nodepool.NewProxyPool()
transcodePool := nodepool.NewTranscodePool()
proxyNodes, err := nodeRepo.ListEnabled(appCtx, nodepool.NodeTypeProxy)
if err != nil {
log.Fatalf("load enabled proxy nodes: %v", err)
}
transcodeNodes, err := nodeRepo.ListEnabled(appCtx, nodepool.NodeTypeTranscode)
if err != nil {
log.Fatalf("load enabled transcode nodes: %v", err)
}
proxyNodes, _ := nodeRepo.ListEnabled(context.Background(), nodepool.NodeTypeProxy)
transcodeNodes, _ := nodeRepo.ListEnabled(context.Background(), nodepool.NodeTypeTranscode)
proxyPool.SetNodes(proxyNodes)
transcodePool.SetNodes(transcodeNodes)
@@ -2076,29 +2060,20 @@ func main() {
// Download prepare-to-file pipeline (Phase 3): a durable, leased encode
// queue hosted on the task manager. Built here (before Start) and shared
// with the API via deps so the download service can enqueue jobs.
liveDownloadConfig := func() *config.Config {
if deps.LiveConfig != nil {
if c := deps.LiveConfig(); c != nil {
return c
}
}
return deps.Config
}
var downloadWorkPlanner nodepool.TranscodeWorkPlanner
if deps.NodePlanner != nil {
downloadWorkPlanner = deps.NodePlanner
}
preparer := downloads.NewNodeAwarePreparer(downloads.NewPlaybackPreparer(), downloadWorkPlanner, liveDownloadConfig)
if deps.NodeRepo != nil {
preparer.SetOriginLookup(deps.NodeRepo)
}
artifactMgr := downloads.NewArtifactManager(
downloads.NewArtifactRepository(deps.DB),
downloads.NewRepository(deps.DB),
deps.FileRepo,
preparer,
downloads.NewPlaybackPreparer(),
deps.NodeID,
liveDownloadConfig,
func() *config.Config {
if deps.LiveConfig != nil {
if c := deps.LiveConfig(); c != nil {
return c
}
}
return deps.Config
},
func(ctx context.Context, d *downloads.Download) {
if deps.EventsHub == nil {
return
+30 -58
View File
@@ -97,24 +97,23 @@ the document is always the full one:
"enabled": true,
"protocol_versions": [3],
"features": ["playback_plan_v3", "neutral_playback_v3_contract_v1", "layout_aware_passthrough", "playback_route_diagnostics",
"device_quirks_v1", "seek_reanchor_v1", "output_change_v1", "direct_stream_resume_v1",
"device_quirks_v1", "seek_reanchor_v1", "direct_stream_resume_v1",
"plan_source_duration_v1"],
"deliveries": ["original_http", "server_remux_progressive", "server_remux_hls", "server_transcode_hls"],
"transformations": [{"name": "audio_to_aac", "executor": "server", "recipe_version": "1", "validated_claims": ["audio_decode"]}]
}
```
The nine feature strings above are the full set this server version advertises:
The eight feature strings above are the full set this server version advertises:
| Feature | What it promises |
| --- | --- |
| `playback_plan_v3` | The three plan endpoints exist and behave as specified here |
| `neutral_playback_v3_contract_v1` | The server mints opaque `plan_attempt_key` values that clients only echo, and exposes track/quality intent replans distinct from failure recovery |
| `neutral_playback_v3_contract_v1` | The server mints opaque `plan_attempt_key` values that clients only echo, and exposes `track_change` / `quality_change` as intent replans distinct from failure recovery |
| `layout_aware_passthrough` | Audio passthrough is decided from channel *layouts*, not just channel counts (§3) |
| `playback_route_diagnostics` | `POST /playback/route-events` is accepted |
| `device_quirks_v1` | Plans may carry `applied_quirks` and `runtime_corrections` (§9) |
| `seek_reanchor_v1` | The `seek_reanchor` replan operation is available (§6) |
| `output_change_v1` | The `output_change` intent replan is available; clients must keep the active route when this feature is absent |
| `direct_stream_resume_v1` | A direct route may resume mid-file rather than restarting |
| `plan_source_duration_v1` | `source.duration_seconds` is populated when known, so its absence means *unknown* rather than *unsupported* (§5) |
@@ -381,25 +380,6 @@ HDR10, and the plan carries the `hdr_range_assumed_hdr10` degradation warning.
Refusing to play those outright would be worse than an assumption the client is
told about.
The web client does not promote the generic high-dynamic-range media query to a
format claim, and it does not gate format claims on it either. Decoder capability
and active-output HDR are separate facts — browsers tone-map HDR content onto SDR
outputs, and Safari 26 reports `dynamic-range: standard` even on an XDR display —
so the media query survives only as the best-effort `hdr` output boolean. Format
claims come from exact shape probes matched to the bytes the remux delivers:
HDR10 requires Media Capabilities support for Silo's progressive 2160p HEVC
Main10, Rec. 2020, PQ, SMPTE ST 2086 shape, probed under exactly the `hvc1`
sample entry because the explicit v3 HDR10 strip remux labels its output `hvc1`
(legacy and automatic strip paths retain FFmpeg's default `hev1`). Dolby Vision
requires a definitive media-element answer for exactly `dvh1.05.06` or
`dvh1.08.06`, because the preserve remux tags its output `dvh1`. An answer only
for the other spelling (`hev1`/`dvhe`) is evidence for a file Silo never sends
and earns no claim. Both claims are scoped to `progressive`: they
are cleared from `original_http` and `hls` because those delivery paths were not
tested by the same probe. An HDR10 claim can carry `hdr10_max_width`, `hdr10_max_height`,
`hdr10_max_frame_rate`, and `hdr10_max_bitrate_kbps`; these ceilings keep a
successful format probe from admitting an untested stream class.
---
## 4. Deliveries
@@ -471,19 +451,17 @@ position, `can_seek_anywhere` is true when the runtime is known, and
`seek_restoration` is `player_position` — the client seeks locally.
**Copy remux over HLS** is served from FFmpeg's live, still-growing playlist,
which starts at the preceding keyframe selected by FFmpeg's input seek.
`stream_origin` and `timeline_offset` both equal that resolved source position,
while `player_start` is the requested position minus the resolved origin so the
client advances past copied pre-roll. `seek_window_start_seconds` is the resolved
origin, and **`seek_window_end_seconds` is deliberately absent**. An open end
marks the window as incomplete; combined with `can_seek_anywhere: false` it
routes every seek back through the server as a reanchor (§6), which is correct
because the playlist has no bytes for positions FFmpeg has not reached yet.
`seek_restoration` is `source_position`.
which starts at the requested position. So `player_start` is `0`,
`stream_origin` and `timeline_offset` both equal the seek position,
`seek_window_start_seconds` is that position, and **`seek_window_end_seconds` is
deliberately absent**. An open end marks the window as incomplete; combined with
`can_seek_anywhere: false` it routes every seek back through the server as a
reanchor (§6), which is correct because the playlist has no bytes for positions
FFmpeg has not reached yet. `seek_restoration` is `source_position`.
**Progressive remux** is a freshly generated chunked response with no byte-range
support, so it uses the same resolved keyframe origin and player pre-roll offset;
the window is open-ended and seeks go through the server.
support, so it behaves the same way: player-zero is stream-zero is the seek
position, the window is open-ended, and seeks go through the server.
### `source.duration_seconds`
@@ -505,7 +483,7 @@ The media's full runtime, and nothing else. Specifically:
Replan is the only way a plan changes. It covers both "that didn't work" and
"the user asked for something else," and the distinction matters to the server.
`operation` is one of six:
`operation` is one of five:
| Operation | Meaning | Requires |
| --- | --- | --- |
@@ -514,7 +492,6 @@ Replan is the only way a plan changes. It covers both "that didn't work" and
| `seek_reanchor` | Move a server-anchored timeline to a new position | — |
| `track_change` | The user picked a different audio or subtitle track | — |
| `quality_change` | The user picked a rung from `available_qualities` | non-empty `quality_preference` |
| `output_change` | The active display/output capabilities changed | — |
The asymmetry is intentional. A seek reanchor is a timeline operation, not a
failed recipe — a classification is still accepted from older callers but never
@@ -525,25 +502,23 @@ normalizes to `auto`, which is a different user intent than the menu selection
the operation models, so the server rejects it rather than silently doing
something else.
**Intent operations behave differently from failure recovery.**
`track_change`, `quality_change`, and `output_change` keep the previous route
eligible because nothing established that its recipe failed: neither attempted-
key history nor the failed-plan exclusion applies. The first two replace what
were separate v2 endpoints (an audio PATCH and a client-posted transcode start).
A client may therefore be handed back a plan it has already tried — that is
correct here, and a client must not treat a repeated `plan_attempt_key` as a
loop.
**User-intent operations behave differently from failure recovery.**
`track_change` and `quality_change` replace what were separate v2 endpoints (an
audio PATCH and a client-posted transcode start). Nothing failed, so the previous
route stays eligible: neither the attempted-key history nor the failed-plan
exclusion applies. A client may therefore be handed back a plan it has already
tried — that is correct here, and a client must not treat a repeated
`plan_attempt_key` as a loop.
When such an operation actually changes something — the request's tracks,
quality, or output capabilities differ from what the session currently has —
the server also tries to return to the *requested* edition rather than staying
on whatever alternate version a previous fallback landed on, since the newly
requested intent may fit the original file again. That is a preference, not a
guarantee: if the requested edition no longer resolves or fails its preflight,
the healthy active alternate is kept. Track identities are remapped only when
the edition really changes, because remapping within one file would degrade an
exact selection to a best-match lookup and could silently move a listener off a
commentary track.
When such an operation actually changes something — the request's tracks or
quality differ from what the session currently has — the server also tries to
return to the *requested* edition rather than staying on whatever alternate
version a previous fallback landed on, since a user switching tracks may well
want the original file back. That is a preference, not a guarantee: if the
requested edition no longer resolves or fails its preflight, the healthy active
alternate is kept. Track identities are remapped only when the edition really
changes, because remapping within one file would degrade an exact selection to a
best-match lookup and could silently move a listener off a commentary track.
Omitting `quality_preference` on any replan preserves the session's current
preference; sending it replaces that preference. A track change therefore does
@@ -620,10 +595,7 @@ help. Delivered inside a `201` (start) or `200` (replan), never a 4xx.
`source_metadata_incomplete`, `source_unavailable`,
`audio_conversion_unsupported`, `video_conversion_unsupported`,
`dv_conversion_unsupported`, `transcoding_disabled`,
`subtitle_conversion_unsupported`. When a video adaptation is forced solely by a
subtitle burn-in requirement and cannot execute, the terminal is
`subtitle_conversion_unsupported` naming the subtitle rather than the underlying
HDR, 4K, or transcode-policy reason — deselecting the subtitle restores playback.
`subtitle_conversion_unsupported`.
*Subtitle policy:* `subtitle_burn_in_source_unsupported`,
`subtitle_codec_unsupported`, `subtitle_track_invalid`,
@@ -761,11 +761,6 @@
"dolby_vision_profile": {
"type": "integer"
},
"dolby_vision_level": {
"type": "integer",
"minimum": 1,
"maximum": 13
},
"dv_bl_compat_id": {
"type": "integer"
},
@@ -31,8 +31,7 @@
"seek_reanchor",
"seek_failure_recovery",
"track_change",
"quality_change",
"output_change"
"quality_change"
],
"maxLength": 32
},
@@ -153,7 +152,7 @@
},
"failure": {
"type": "object",
"description": "Required for failure_recovery and seek_failure_recovery: both need a non-empty classification. Intent operations carry no failure.",
"description": "Required for failure_recovery and seek_failure_recovery: both need a non-empty classification. A track_change or quality_change carries no failure.",
"properties": {
"classification": {
"type": "string",
@@ -226,79 +225,14 @@
"hlg": {
"type": "boolean"
},
"hdr10_max_width": {
"type": "integer",
"minimum": 0
},
"hdr10_max_height": {
"type": "integer",
"minimum": 0
},
"hdr10_max_frame_rate": {
"type": "number",
"minimum": 0
},
"hdr10_max_bitrate_kbps": {
"type": "integer",
"minimum": 0
},
"dolby_vision_profiles": {
"type": "array",
"maxItems": 16,
"items": {
"type": "integer"
}
},
"dolby_vision_profile_levels": {
"type": "array",
"maxItems": 16,
"items": {
"type": "object",
"required": [
"profile",
"max_level"
],
"properties": {
"profile": {
"type": "integer",
"minimum": 1
},
"max_level": {
"type": "integer",
"minimum": 1,
"maximum": 13
},
"bl_compatibility_ids": {
"type": "array",
"maxItems": 16,
"uniqueItems": true,
"items": {
"type": "integer",
"minimum": 0,
"maximum": 15
}
}
},
"additionalProperties": true
}
}
},
"allOf": [
{
"if": {
"anyOf": [
{ "required": ["hdr10_max_width"] },
{ "required": ["hdr10_max_height"] },
{ "required": ["hdr10_max_frame_rate"] },
{ "required": ["hdr10_max_bitrate_kbps"] }
]
},
"then": {
"required": ["hdr10"],
"properties": { "hdr10": { "const": true } }
}
}
],
"additionalProperties": true
},
"audio_passthrough": {
@@ -723,30 +657,6 @@
}
}
}
},
{
"$comment": "Intent-only replans describe a user or output change and must not carry route-failure evidence.",
"if": {
"properties": {
"operation": {
"enum": [
"track_change",
"quality_change",
"output_change"
]
}
},
"required": [
"operation"
]
},
"then": {
"not": {
"required": [
"failure"
]
}
}
}
],
"additionalProperties": true
@@ -177,79 +177,14 @@
"hlg": {
"type": "boolean"
},
"hdr10_max_width": {
"type": "integer",
"minimum": 0
},
"hdr10_max_height": {
"type": "integer",
"minimum": 0
},
"hdr10_max_frame_rate": {
"type": "number",
"minimum": 0
},
"hdr10_max_bitrate_kbps": {
"type": "integer",
"minimum": 0
},
"dolby_vision_profiles": {
"type": "array",
"maxItems": 16,
"items": {
"type": "integer"
}
},
"dolby_vision_profile_levels": {
"type": "array",
"maxItems": 16,
"items": {
"type": "object",
"required": [
"profile",
"max_level"
],
"properties": {
"profile": {
"type": "integer",
"minimum": 1
},
"max_level": {
"type": "integer",
"minimum": 1,
"maximum": 13
},
"bl_compatibility_ids": {
"type": "array",
"maxItems": 16,
"uniqueItems": true,
"items": {
"type": "integer",
"minimum": 0,
"maximum": 15
}
}
},
"additionalProperties": true
}
}
},
"allOf": [
{
"if": {
"anyOf": [
{ "required": ["hdr10_max_width"] },
{ "required": ["hdr10_max_height"] },
{ "required": ["hdr10_max_frame_rate"] },
{ "required": ["hdr10_max_bitrate_kbps"] }
]
},
"then": {
"required": ["hdr10"],
"properties": { "hdr10": { "const": true } }
}
}
],
"additionalProperties": true
},
"audio_passthrough": {
@@ -235,9 +235,7 @@ DROP TABLE IF EXISTS public.download_artifacts;
`params_hash = sha256(format | container | codec_video | codec_audio | resolution | audio_track_index | subtitle_burn_in)`.
Two devices (or a web user and a phone) requesting the same 1080p H.264/AAC of the same source share one
artifact. `last_used_at` drives LRU cleanup. `output_path` remains the deterministic API-local
fallback target; distributed preparation additionally stores an opaque artifact id plus its
transcode-node origin and group, allowing an authenticated proxy relay without a shared filesystem. The
artifact. `last_used_at` drives LRU cleanup; `output_path` is on a configured artifact volume. The
`attempts`/`lease_*`/`next_retry_at` columns make the table a **durable, recoverable job queue** (see the
"Durable artifact queue" subsection) so a crash mid-encode cannot strand a download in `preparing`.
@@ -246,8 +244,7 @@ transcode-node origin and group, allowing an authenticated proxy relay without a
`server_settings` is key/value, so new keys need no migration — written when an admin saves them, parsed
in `internal/config/db_loader.go`:
- `download.transcode_enabled` (bool, default **false**) — server gate for transcode-to-file.
- `download.artifact_dir` (string) — local artifact output volume; dedicated transcode nodes default
to a protected directory inside their existing transcode volume.
- `download.artifact_dir` (string) — artifact output volume; defaults under the existing transcode/temp root.
- `download.max_concurrent_prepares` (int, default 2) — encode/remux worker-pool size.
- `download.artifact_max_bytes` (int64, default 0 = unlimited) — LRU eviction budget for artifacts.
-3
View File
@@ -9,7 +9,6 @@ import (
)
func requestAccessFilter(r *http.Request) catalog.AccessFilter {
deviceID := deviceMetadataFromRequest(r).DeviceID
if scope, ok := access.GetScope(r.Context()); ok {
return catalog.AccessFilter{
AllowedLibraryIDs: scope.AllowedLibraryIDs,
@@ -18,12 +17,10 @@ func requestAccessFilter(r *http.Request) catalog.AccessFilter {
MaxPlaybackQuality: scope.MaxPlaybackQuality,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
return catalog.AccessFilter{
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
@@ -1,18 +0,0 @@
package handlers
import (
"net/http/httptest"
"testing"
)
func TestCatalogAccessFiltersCarryExplicitDeviceIdentity(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v1/items/movie-1", nil)
req.Header.Set(deviceIDHeader, " apple-tv ")
if got := requestAccessFilter(req).DeviceID; got != "apple-tv" {
t.Fatalf("requestAccessFilter().DeviceID = %q, want apple-tv", got)
}
if got := (&ItemsHandler{}).accessFilter(req).DeviceID; got != "apple-tv" {
t.Fatalf("ItemsHandler.accessFilter().DeviceID = %q, want apple-tv", got)
}
}
+17 -248
View File
@@ -4,15 +4,9 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
@@ -20,9 +14,7 @@ import (
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/downloads"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/streamtoken"
)
// DownloadService is the interface that the download handler depends on. A
@@ -52,51 +44,14 @@ type DownloadService interface {
// DownloadHandler handles download endpoints.
type DownloadHandler struct {
svc DownloadService
nodePlanner nodepool.DownloadPlanner
jwtSecret func() string
proxyHTTPClient *http.Client
preflightMu sync.Mutex
preflightCache map[string]proxyPreflightResult
}
type proxyPreflightResult struct {
reachable bool
expiresAt time.Time
svc DownloadService
}
// NewDownloadHandler creates a new DownloadHandler.
func NewDownloadHandler(svc DownloadService) *DownloadHandler {
return &DownloadHandler{
svc: svc,
preflightCache: make(map[string]proxyPreflightResult),
proxyHTTPClient: &http.Client{
Timeout: 3 * time.Second,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
return &DownloadHandler{svc: svc}
}
// SetProxyDelivery enables stateless proxy-node egress after the API service
// has authorized and resolved a download target. It is optional; integrated
// deployments continue to serve bytes locally.
func (h *DownloadHandler) SetProxyDelivery(planner nodepool.DownloadPlanner, jwtSecret func() string) {
h.nodePlanner = planner
h.jwtSecret = jwtSecret
}
type downloadFileResolver interface {
ResolveDirectFile(ctx context.Context, userID, fileID int, format string, filter catalog.AccessFilter) (*downloads.FileTarget, error)
ResolveManagedFile(ctx context.Context, userID int, profileID, deviceID, downloadID string, filter catalog.AccessFilter) (*downloads.FileTarget, error)
}
const (
proxyDownloadTokenTTL = 15 * time.Minute
proxyPreflightCacheTTL = 5 * time.Second
)
// downloadRequest represents the JSON body for POST /downloads.
type downloadRequest struct {
ContentID string `json:"content_id"`
@@ -169,7 +124,6 @@ type downloadCapabilityResponse struct {
SeasonDownload bool `json:"season_download"`
SeriesMonitoring bool `json:"series_monitoring"`
MonitoringModes []string `json:"monitoring_modes,omitempty"`
ProxyDelivery bool `json:"proxy_delivery"`
}
func toDownloadResponse(d *downloads.Download) downloadResponse {
@@ -225,7 +179,6 @@ func (h *DownloadHandler) HandleCapability(w http.ResponseWriter, r *http.Reques
return
}
_, _, proxyDelivery := h.proxyTarget()
writeJSON(w, http.StatusOK, downloadCapabilityResponse{
Enabled: capInfo.Enabled,
DownloadAllowed: capInfo.DownloadAllowed,
@@ -235,7 +188,6 @@ func (h *DownloadHandler) HandleCapability(w http.ResponseWriter, r *http.Reques
SeasonDownload: capInfo.SeasonDownload,
SeriesMonitoring: capInfo.SeriesMonitoring,
MonitoringModes: capInfo.MonitoringModes,
ProxyDelivery: proxyDelivery,
})
}
@@ -430,17 +382,6 @@ func (h *DownloadHandler) HandlePatchDownload(w http.ResponseWriter, r *http.Req
// HandleDownloadFile handles GET /downloads/{id}/file.
func (h *DownloadHandler) HandleDownloadFile(w http.ResponseWriter, r *http.Request) {
h.handleDownloadFile(w, r, false)
}
// HandleDownloadFileViaProxy serves the additive proxy-aware download route.
// Clients opt into its 307 response only after discovering proxy_delivery in
// the download capability; the established file route retains 200/206.
func (h *DownloadHandler) HandleDownloadFileViaProxy(w http.ResponseWriter, r *http.Request) {
h.handleDownloadFile(w, r, true)
}
func (h *DownloadHandler) handleDownloadFile(w http.ResponseWriter, r *http.Request, delegate bool) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
@@ -458,56 +399,35 @@ func (h *DownloadHandler) handleDownloadFile(w http.ResponseWriter, r *http.Requ
profileID, deviceID, _, _ := managedIdentity(r)
filter := requestAccessFilter(r)
if delegate && deviceID != "" {
handled, err := h.redirectManagedDownload(r.Context(), w, r, userID, profileID, deviceID, id, filter)
if err != nil {
h.writeDownloadFileError(w, r, id, err)
return
}
if handled {
return
}
}
// Full media downloads outlive the server's absolute WriteTimeout; roll
// the write deadline with progress instead.
sw := httpstream.NewRollingDeadlineWriter(w)
if err := h.svc.ServeFile(r.Context(), sw, r, userID, profileID, deviceID, id, filter); err != nil {
if errors.Is(err, downloads.ErrResponseCommitted) {
if errors.Is(err, downloads.ErrNotFound) || errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Download not found")
return
}
if errors.Is(err, downloads.ErrDownloadNotActive) {
writeError(w, http.StatusConflict, "download_inactive", "This download is no longer active")
return
}
if errors.Is(err, downloads.ErrProfileRequired) {
writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads")
return
}
if errors.Is(err, downloads.ErrFeatureDisabled) || errors.Is(err, downloads.ErrDownloadNotAllowed) {
writeError(w, http.StatusForbidden, "forbidden", "You are not allowed to download")
return
}
h.writeDownloadFileError(w, r, id, err)
}
}
func (h *DownloadHandler) writeDownloadFileError(w http.ResponseWriter, r *http.Request, id string, err error) {
switch {
case errors.Is(err, downloads.ErrNotFound), errors.Is(err, catalog.ErrItemNotFound):
writeError(w, http.StatusNotFound, "not_found", "Download not found")
case errors.Is(err, downloads.ErrDownloadNotActive):
writeError(w, http.StatusConflict, "download_inactive", "This download is no longer active")
case errors.Is(err, downloads.ErrProfileRequired):
writeError(w, http.StatusBadRequest, "profile_required", "A profile is required for managed downloads")
case errors.Is(err, downloads.ErrFeatureDisabled), errors.Is(err, downloads.ErrDownloadNotAllowed):
writeError(w, http.StatusForbidden, "forbidden", "You are not allowed to download")
default:
slog.ErrorContext(r.Context(), "failed to serve download file", "component", "api", "download_id", id, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serve download")
return
}
}
// HandleDirectDownload handles GET /direct-download?file_id=N. A legacy
// format=original query is accepted, but direct downloads remain original-only.
func (h *DownloadHandler) HandleDirectDownload(w http.ResponseWriter, r *http.Request) {
h.handleDirectDownload(w, r, false)
}
// HandleDirectDownloadViaProxy serves the additive proxy-aware direct-download
// route while the established endpoint retains its 200/206 response contract.
func (h *DownloadHandler) HandleDirectDownloadViaProxy(w http.ResponseWriter, r *http.Request) {
h.handleDirectDownload(w, r, true)
}
func (h *DownloadHandler) handleDirectDownload(w http.ResponseWriter, r *http.Request, delegate bool) {
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
@@ -526,163 +446,12 @@ func (h *DownloadHandler) handleDirectDownload(w http.ResponseWriter, r *http.Re
}
filter := requestAccessFilter(r)
if delegate {
if handled, redirectErr := h.redirectDirectDownload(r.Context(), w, r, userID, fileID, r.URL.Query().Get("format"), filter); redirectErr != nil {
h.writeDownloadError(w, redirectErr)
return
} else if handled {
return
}
}
if err := h.svc.ServeDirect(r.Context(), w, r, userID, fileID, r.URL.Query().Get("format"), filter); err != nil {
h.writeDownloadError(w, err)
return
}
}
func (h *DownloadHandler) redirectDirectDownload(ctx context.Context, w http.ResponseWriter, r *http.Request, userID, fileID int, format string, filter catalog.AccessFilter) (bool, error) {
resolver, secret, ok := h.proxyTarget()
if !ok {
return false, nil
}
target, err := resolver.ResolveDirectFile(ctx, userID, fileID, format, filter)
if err != nil {
return false, err
}
return h.redirectToProxy(w, r, secret, target, userID, "")
}
func (h *DownloadHandler) redirectManagedDownload(ctx context.Context, w http.ResponseWriter, r *http.Request, userID int, profileID, deviceID, downloadID string, filter catalog.AccessFilter) (bool, error) {
resolver, secret, ok := h.proxyTarget()
if !ok {
return false, nil
}
target, err := resolver.ResolveManagedFile(ctx, userID, profileID, deviceID, downloadID, filter)
if err != nil {
return false, err
}
return h.redirectToProxy(w, r, secret, target, userID, profileID)
}
func (h *DownloadHandler) proxyTarget() (downloadFileResolver, string, bool) {
resolver, ok := h.svc.(downloadFileResolver)
if !ok || h.nodePlanner == nil || h.jwtSecret == nil {
return nil, "", false
}
secret := strings.TrimSpace(h.jwtSecret())
if secret == "" {
return nil, "", false
}
return resolver, secret, true
}
func (h *DownloadHandler) redirectToProxy(w http.ResponseWriter, r *http.Request, secret string, target *downloads.FileTarget, userID int, profileID string) (bool, error) {
if target == nil || (strings.TrimSpace(target.Path) == "" && target.OriginArtifactID == "") {
return false, fmt.Errorf("download target is empty")
}
if target.OriginArtifactID != "" && strings.TrimSpace(target.OriginNodeURL) == "" {
return false, nil
}
if !target.ProxyEligible {
return false, nil
}
reservationKey := target.DownloadID
if reservationKey == "" {
reservationKey = fmt.Sprintf("direct-%d-%d", userID, target.MediaFileID)
}
sessionID := fmt.Sprintf("download-%s-%d", reservationKey, time.Now().UnixNano())
plan := h.nodePlanner.PlanDownload(sessionID, target.OriginNodeGroup)
if plan.ProxyNode == nil {
return false, nil
}
releaseReservation := func() { h.nodePlanner.ReleaseSession(sessionID) }
mediaPath := target.Path
downloadFilename := ""
if target.OriginArtifactID != "" {
mediaPath = ""
if strings.TrimSpace(target.Path) != "" {
downloadFilename = filepath.Base(target.Path)
}
}
token, err := streamtoken.Sign(streamtoken.Claims{
SessionID: sessionID,
MediaPath: mediaPath,
PlayMethod: streamtoken.PlayMethodDownload,
TranscodeNode: target.OriginNodeURL,
DownloadArtifactID: target.OriginArtifactID,
DownloadArtifactRowID: target.ArtifactID,
DownloadFilename: downloadFilename,
UserID: userID,
ProfileID: profileID,
MediaFileID: target.MediaFileID,
}, secret, proxyDownloadTokenTTL)
if err != nil {
releaseReservation()
return false, fmt.Errorf("sign proxy download token: %w", err)
}
location := strings.TrimRight(plan.ProxyNode.URL, "/") + "/downloads/file/" + url.PathEscape(token)
targetKey := target.Path
if target.OriginArtifactID != "" {
targetKey = target.OriginNodeURL + "\x00" + target.OriginArtifactID
}
cacheKey := strings.TrimRight(plan.ProxyNode.URL, "/") + "\x00" + targetKey
if !h.proxyCanServe(r.Context(), cacheKey, location) {
releaseReservation()
return false, nil
}
// A HEAD request only proves that the proxy can read the selected path; the
// proxy deliberately does not count it as an active transfer. Release the
// provisional slot before redirecting so a probe cannot hold max_jobs
// capacity until the planner's reservation bridge expires.
if r.Method == http.MethodHead {
releaseReservation()
}
http.Redirect(w, r, location, http.StatusTemporaryRedirect)
return true, nil
}
func (h *DownloadHandler) proxyCanServe(ctx context.Context, cacheKey, location string) bool {
now := time.Now()
h.preflightMu.Lock()
for key, cached := range h.preflightCache {
if !now.Before(cached.expiresAt) {
delete(h.preflightCache, key)
}
}
if cached, ok := h.preflightCache[cacheKey]; ok && now.Before(cached.expiresAt) {
h.preflightMu.Unlock()
return cached.reachable
}
h.preflightMu.Unlock()
req, err := http.NewRequestWithContext(ctx, http.MethodHead, location, nil)
if err != nil {
return false
}
client := h.proxyHTTPClient
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
reachable := false
if err != nil {
slog.DebugContext(ctx, "download proxy preflight failed; serving locally", "component", "api", "error", err)
} else {
_ = resp.Body.Close()
reachable = resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices
}
if err == nil && !reachable {
slog.DebugContext(ctx, "download proxy preflight unavailable; serving locally", "component", "api", "status", resp.StatusCode)
}
h.preflightMu.Lock()
if h.preflightCache == nil {
h.preflightCache = make(map[string]proxyPreflightResult)
}
h.preflightCache[cacheKey] = proxyPreflightResult{reachable: reachable, expiresAt: now.Add(proxyPreflightCacheTTL)}
h.preflightMu.Unlock()
return reachable
}
// requireManaged validates a managed (device-scoped) request: authentication, a
// configured service, and the device + profile identity (device_id from the
// X-Silo-Device-Id header only, never the body). On failure it writes the error
-330
View File
@@ -4,10 +4,8 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
@@ -16,8 +14,6 @@ import (
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/downloads"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/streamtoken"
)
// fakeDownloadService is a programmable DownloadService for handler tests. It
@@ -35,7 +31,6 @@ type fakeDownloadService struct {
deleteErr error
patchErr error
serveErr error
serveBody string
directErr error
manifest *downloads.OfflineManifest
batchManifests []*downloads.OfflineManifest
@@ -86,21 +81,6 @@ type identityCall struct {
downloadID string
}
type proxyDownloadService struct {
*fakeDownloadService
directTarget *downloads.FileTarget
managedTarget *downloads.FileTarget
resolveErr error
}
func (s *proxyDownloadService) ResolveDirectFile(context.Context, int, int, string, catalog.AccessFilter) (*downloads.FileTarget, error) {
return s.directTarget, s.resolveErr
}
func (s *proxyDownloadService) ResolveManagedFile(context.Context, int, string, string, string, catalog.AccessFilter) (*downloads.FileTarget, error) {
return s.managedTarget, s.resolveErr
}
func (f *fakeDownloadService) Capability(context.Context, int) (downloads.Capability, error) {
return f.capability, nil
}
@@ -186,9 +166,6 @@ func (f *fakeDownloadService) ServeDirect(_ context.Context, w http.ResponseWrit
func (f *fakeDownloadService) ServeFile(_ context.Context, w http.ResponseWriter, _ *http.Request, userID int, profileID, deviceID, downloadID string, _ catalog.AccessFilter) error {
f.gotServe = identityCall{userID, profileID, deviceID, downloadID}
if f.serveBody != "" {
_, _ = w.Write([]byte(f.serveBody))
}
if f.serveErr != nil {
return f.serveErr
}
@@ -306,25 +283,6 @@ func TestHandleCapability(t *testing.T) {
if len(resp.QualityPresets) != 1 || resp.QualityPresets[0] != downloads.QualityOriginal {
t.Fatalf("quality presets = %v, want [original]", resp.QualityPresets)
}
if resp.ProxyDelivery {
t.Fatal("proxy delivery advertised without a configured planner")
}
}
func TestHandleCapabilityAdvertisesAdditiveProxyDelivery(t *testing.T) {
svc := &proxyDownloadService{fakeDownloadService: &fakeDownloadService{}}
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(nodepool.NewProxyPool(), nodepool.NewTranscodePool()), func() string { return "secret" })
rec := httptest.NewRecorder()
h.HandleCapability(rec, downloadTestRequest(http.MethodGet, "/downloads/capability", nil, 7, "", ""))
var resp downloadCapabilityResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if !resp.ProxyDelivery {
t.Fatalf("capability = %+v", resp)
}
}
func TestHandleCapabilityUnauthorized(t *testing.T) {
@@ -471,139 +429,6 @@ func TestManagedFileThreadsIdentity(t *testing.T) {
}
}
func TestManagedFileDoesNotAppendJSONAfterRelayResponseCommitted(t *testing.T) {
svc := &fakeDownloadService{
serveBody: "partial",
serveErr: fmt.Errorf("remote relay failed: %w", downloads.ErrResponseCommitted),
}
h := NewDownloadHandler(svc)
req := withChiID(downloadTestRequest(http.MethodGet, "/downloads/dl1/file", nil, 7, "pA", "devA"), "dl1")
rec := httptest.NewRecorder()
h.HandleDownloadFile(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != "partial" {
t.Fatalf("response status=%d body=%q", rec.Code, rec.Body.String())
}
}
func TestManagedFileRedirectsAuthorizedArtifactToProxy(t *testing.T) {
const secret = "download-proxy-secret"
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodHead {
t.Errorf("preflight method = %s", r.Method)
}
w.WriteHeader(http.StatusOK)
}))
defer proxy.Close()
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
managedTarget: &downloads.FileTarget{Path: "/artifacts/movie.mp4", DownloadID: "dl1", MediaFileID: 42, ProxyEligible: true},
}
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{{URL: proxy.URL, Enabled: true, Healthy: true}})
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(proxies, nodepool.NewTranscodePool()), func() string { return secret })
req := withChiID(downloadTestRequest(http.MethodGet, "/downloads/dl1/file-proxy", nil, 7, "pA", "devA"), "dl1")
rec := httptest.NewRecorder()
h.HandleDownloadFileViaProxy(rec, req)
if rec.Code != http.StatusTemporaryRedirect {
t.Fatalf("status = %d, want 307 (body: %s)", rec.Code, rec.Body.String())
}
location := rec.Header().Get("Location")
prefix := proxy.URL + "/downloads/file/"
if !strings.HasPrefix(location, prefix) {
t.Fatalf("Location = %q", location)
}
claims, err := streamtoken.Verify(strings.TrimPrefix(location, prefix), secret)
if err != nil {
t.Fatal(err)
}
if claims.PlayMethod != streamtoken.PlayMethodDownload || claims.MediaPath != "/artifacts/movie.mp4" || claims.UserID != 7 || claims.ProfileID != "pA" {
t.Fatalf("claims = %+v", claims)
}
if svc.gotServe != (identityCall{}) {
t.Fatalf("local ServeFile was called: %+v", svc.gotServe)
}
}
func TestManagedFileRedirectsNodeLocalArtifactThroughSameGroupProxy(t *testing.T) {
const secret = "download-proxy-secret"
proxyA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("wrong-group proxy was selected")
w.WriteHeader(http.StatusOK)
}))
defer proxyA.Close()
proxyB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodHead {
t.Errorf("preflight method = %s", r.Method)
}
w.WriteHeader(http.StatusOK)
}))
defer proxyB.Close()
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
managedTarget: &downloads.FileTarget{
DownloadID: "dl-remote",
ArtifactID: "artifact-row",
Path: "/prepared/Movie Final.mp4",
MediaFileID: 42,
OriginNodeURL: "http://transcode-b.internal:8096",
OriginNodeGroup: "host-b",
OriginArtifactID: "artifact-remote",
ProxyEligible: true,
},
}
groupA, groupB := "host-a", "host-b"
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{
{URL: proxyA.URL, Group: &groupA, Enabled: true, Healthy: true},
{URL: proxyB.URL, Group: &groupB, Enabled: true, Healthy: true},
})
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(proxies, nodepool.NewTranscodePool()), func() string { return secret })
req := withChiID(downloadTestRequest(http.MethodGet, "/downloads/dl-remote/file-proxy", nil, 7, "pA", "devA"), "dl-remote")
rec := httptest.NewRecorder()
h.HandleDownloadFileViaProxy(rec, req)
if rec.Code != http.StatusTemporaryRedirect {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
location := rec.Header().Get("Location")
prefix := proxyB.URL + "/downloads/file/"
if !strings.HasPrefix(location, prefix) {
t.Fatalf("Location = %q, want proxy-b", location)
}
claims, err := streamtoken.Verify(strings.TrimPrefix(location, prefix), secret)
if err != nil {
t.Fatal(err)
}
if claims.MediaPath != "" || claims.DownloadFilename != "Movie Final.mp4" || claims.TranscodeNode != "http://transcode-b.internal:8096" ||
claims.DownloadArtifactID != "artifact-remote" || claims.DownloadArtifactRowID != "artifact-row" {
t.Fatalf("claims = %+v", claims)
}
}
func TestManagedFileFallsBackWhenRemoteLocatorHasNoOriginURL(t *testing.T) {
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
managedTarget: &downloads.FileTarget{
DownloadID: "dl-incomplete",
OriginArtifactID: "artifact-without-origin",
ProxyEligible: true,
},
}
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{{URL: "http://proxy.invalid", Enabled: true, Healthy: true}})
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(proxies, nodepool.NewTranscodePool()), func() string { return "secret" })
req := withChiID(downloadTestRequest(http.MethodGet, "/downloads/dl-incomplete/file-proxy", nil, 7, "pA", "devA"), "dl-incomplete")
rec := httptest.NewRecorder()
h.HandleDownloadFileViaProxy(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != "served" {
t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String())
}
}
func TestManagedListThreadsIdentity(t *testing.T) {
svc := &fakeDownloadService{}
h := NewDownloadHandler(svc)
@@ -689,161 +514,6 @@ func TestHandleDirectDownloadThreadsOriginalFormat(t *testing.T) {
}
}
func TestHandleDirectDownloadViaProxyRedirectsToProxy(t *testing.T) {
const secret = "download-proxy-secret"
maxJobs := 1
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer proxy.Close()
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
directTarget: &downloads.FileTarget{Path: "/media/movie.mkv", MediaFileID: 42, ProxyEligible: true},
}
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{{URL: proxy.URL + "/", Enabled: true, Healthy: true, MaxJobs: &maxJobs}})
planner := nodepool.NewPlanner(proxies, nodepool.NewTranscodePool())
h := NewDownloadHandler(svc)
h.SetProxyDelivery(planner, func() string { return secret })
rec := httptest.NewRecorder()
h.HandleDirectDownloadViaProxy(rec, downloadTestRequest(http.MethodHead, "/direct-download-proxy?file_id=42", nil, 7, "", ""))
if rec.Code != http.StatusTemporaryRedirect {
t.Fatalf("status = %d, want 307 (body: %s)", rec.Code, rec.Body.String())
}
location := rec.Header().Get("Location")
claims, err := streamtoken.Verify(strings.TrimPrefix(location, proxy.URL+"/downloads/file/"), secret)
if err != nil {
t.Fatal(err)
}
if claims.MediaPath != "/media/movie.mkv" || claims.MediaFileID != 42 {
t.Fatalf("claims = %+v", claims)
}
if svc.gotDirectFileID != 0 {
t.Fatalf("local ServeDirect called for file %d", svc.gotDirectFileID)
}
// HEAD never becomes an active proxy transfer, so it must return the only
// available slot immediately rather than waiting for reservation expiry.
if plan := planner.PlanDownload("after-head"); plan.ProxyNode == nil {
t.Fatal("HEAD request retained the proxy's only job slot")
}
planner.ReleaseSession("after-head")
}
func TestHandleDirectDownloadFallsBackWithoutHealthyProxy(t *testing.T) {
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
directTarget: &downloads.FileTarget{Path: "/media/movie.mkv", MediaFileID: 42, ProxyEligible: true},
}
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{{URL: "https://proxy.example", Enabled: true, Healthy: false}})
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(proxies, nodepool.NewTranscodePool()), func() string { return "secret" })
rec := httptest.NewRecorder()
h.HandleDirectDownloadViaProxy(rec, downloadTestRequest(http.MethodGet, "/direct-download-proxy?file_id=42", nil, 7, "", ""))
if rec.Code != http.StatusOK || svc.gotDirectFileID != 42 {
t.Fatalf("status = %d file = %d body = %s", rec.Code, svc.gotDirectFileID, rec.Body.String())
}
}
func TestHandleDirectDownloadFallsBackWhenProxyCannotServePath(t *testing.T) {
proxy := httptest.NewServer(http.NotFoundHandler())
defer proxy.Close()
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
directTarget: &downloads.FileTarget{Path: "/media/movie.mkv", MediaFileID: 42, ProxyEligible: true},
}
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{{URL: proxy.URL, Enabled: true, Healthy: true}})
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(proxies, nodepool.NewTranscodePool()), func() string { return "secret" })
rec := httptest.NewRecorder()
h.HandleDirectDownloadViaProxy(rec, downloadTestRequest(http.MethodGet, "/direct-download-proxy?file_id=42", nil, 7, "", ""))
if rec.Code != http.StatusOK || svc.gotDirectFileID != 42 {
t.Fatalf("status = %d file = %d body = %s", rec.Code, svc.gotDirectFileID, rec.Body.String())
}
}
func TestHandleDirectDownloadEstablishedRouteKeepsLocalSuccessStatus(t *testing.T) {
proxyRequests := 0
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
proxyRequests++
w.WriteHeader(http.StatusOK)
}))
defer proxy.Close()
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
directTarget: &downloads.FileTarget{Path: "/media/movie.mkv", MediaFileID: 42, ProxyEligible: true},
}
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{{URL: proxy.URL, Enabled: true, Healthy: true}})
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(proxies, nodepool.NewTranscodePool()), func() string { return "secret" })
rec := httptest.NewRecorder()
h.HandleDirectDownload(rec, downloadTestRequest(http.MethodGet, "/direct-download?file_id=42", nil, 7, "", ""))
if rec.Code != http.StatusOK || svc.gotDirectFileID != 42 || proxyRequests != 0 {
t.Fatalf("status = %d file = %d proxy requests = %d", rec.Code, svc.gotDirectFileID, proxyRequests)
}
}
func TestHandleDirectDownloadViaProxyStaysLocalWhenTargetIneligible(t *testing.T) {
proxyRequests := 0
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
proxyRequests++
w.WriteHeader(http.StatusOK)
}))
defer proxy.Close()
svc := &proxyDownloadService{
fakeDownloadService: &fakeDownloadService{},
directTarget: &downloads.FileTarget{Path: "/media/movie.mkv", MediaFileID: 42, ProxyEligible: false},
}
proxies := nodepool.NewProxyPool()
proxies.SetNodes([]*nodepool.Node{{URL: proxy.URL, Enabled: true, Healthy: true}})
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(proxies, nodepool.NewTranscodePool()), func() string { return "secret" })
rec := httptest.NewRecorder()
h.HandleDirectDownloadViaProxy(rec, downloadTestRequest(http.MethodGet, "/direct-download-proxy?file_id=42", nil, 7, "", ""))
if rec.Code != http.StatusOK || svc.gotDirectFileID != 42 || proxyRequests != 0 {
t.Fatalf("status = %d file = %d proxy requests = %d", rec.Code, svc.gotDirectFileID, proxyRequests)
}
}
func TestHandleDirectDownloadViaProxyMapsResolverError(t *testing.T) {
svc := &proxyDownloadService{fakeDownloadService: &fakeDownloadService{}, resolveErr: catalog.ErrItemNotFound}
h := NewDownloadHandler(svc)
h.SetProxyDelivery(nodepool.NewPlanner(nodepool.NewProxyPool(), nodepool.NewTranscodePool()), func() string { return "secret" })
rec := httptest.NewRecorder()
h.HandleDirectDownloadViaProxy(rec, downloadTestRequest(http.MethodGet, "/direct-download-proxy?file_id=42", nil, 7, "", ""))
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
}
func TestProxyPreflightCachesReachabilityByNodeAndPath(t *testing.T) {
requests := 0
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests++
w.WriteHeader(http.StatusOK)
}))
defer proxy.Close()
h := NewDownloadHandler(&fakeDownloadService{})
location := proxy.URL + "/downloads/file/token"
if !h.proxyCanServe(context.Background(), proxy.URL+"\x00/media/movie.mkv", location) {
t.Fatal("first preflight failed")
}
if !h.proxyCanServe(context.Background(), proxy.URL+"\x00/media/movie.mkv", location) {
t.Fatal("cached preflight failed")
}
if requests != 1 {
t.Fatalf("preflight requests = %d, want 1", requests)
}
}
func TestHandleDirectDownloadMissingFileID(t *testing.T) {
h := NewDownloadHandler(&fakeDownloadService{})
rec := httptest.NewRecorder()
-3
View File
@@ -2115,7 +2115,6 @@ func filterSortClause(sort, order string) string {
}
func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
deviceID := deviceMetadataFromRequest(r).DeviceID
selectedFileID := 0
if fileIDRaw := strings.TrimSpace(r.URL.Query().Get("fileId")); fileIDRaw != "" {
if fileID, err := strconv.Atoi(fileIDRaw); err == nil && fileID > 0 {
@@ -2142,7 +2141,6 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
@@ -2170,7 +2168,6 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
+64 -50
View File
@@ -33,6 +33,7 @@ import (
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/streamtoken"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/transcodeproxy"
"github.com/Silo-Server/silo-server/internal/userstore"
"github.com/Silo-Server/silo-server/internal/watchstate"
"github.com/Silo-Server/silo-server/internal/watchsync"
@@ -133,14 +134,6 @@ type PlaybackOriginalLanguageLookup interface {
GetOriginalLanguage(ctx context.Context, contentID string) (string, error)
}
type copySeekAnchorResolver func(
ctx context.Context,
ffmpegPath string,
inputPath string,
requestedSeekSeconds float64,
segmentDuration int,
) (float64, int, error)
// PlaybackHandler handles playback session HTTP endpoints.
type PlaybackHandler struct {
sessionMgr SessionManagerInterface
@@ -183,7 +176,6 @@ type PlaybackHandler struct {
// playbackConfig(), which falls back to defaults when unset.
PlaybackConfig func() config.PlaybackConfig
FFmpegLogSink playback.FFmpegLogSink
copySeekAnchor copySeekAnchorResolver
realtimeCommandMu sync.Mutex
realtimeCommands map[string]playbackCommandRecord
// tm owns the transcode-session lifecycle (live map, recipe cards, and
@@ -238,10 +230,11 @@ func NewPlaybackHandler(sessionMgr SessionManagerInterface, opts ...FilePathReso
h.tm.Config = func() playback.TranscodeRuntimeConfig {
c := h.playbackConfig()
return playback.TranscodeRuntimeConfig{
TranscodeDir: c.TranscodeDir,
FFmpegPath: c.FFmpegPath,
HWAccel: c.HWAccel,
HWDevice: c.HWDevice,
TranscodeDir: c.TranscodeDir,
FFmpegPath: c.FFmpegPath,
HWAccel: c.HWAccel,
HWDevice: c.HWDevice,
SegmentRetentionSeconds: c.SegmentRetentionSeconds,
}
}
h.tm.StartThrottler = func(ctx context.Context, ts *playback.TranscodeSession) {
@@ -624,33 +617,32 @@ func (h *PlaybackHandler) resolveOriginalLanguage(ctx context.Context, file *mod
return lang
}
// resolvedPlaybackAudioLanguage returns the effective playback.audio_language
// for one canonical settings context. It may return
// playback.OriginalLanguageSentinel, which the caller resolves to a concrete
// language. Returns "" when nothing is stored: the contract default is null,
// "no preference". Resolution and decoding failures are returned so playback
// does not silently substitute a different track.
func resolvedPlaybackAudioLanguage(ctx context.Context, store userstore.UserStore, rc settingsresolve.Context) (string, error) {
if store == nil || rc.ProfileID == "" {
return "", nil
// resolvedProfileAudioLanguage returns the effective playback.audio_language
// for the profile with no content context, resolved through the settings
// contract — the canonical replacement for reading the legacy
// user_profiles.language column, matching catalog's detail resolution. It may
// return playback.OriginalLanguageSentinel, which the caller resolves to a
// concrete language. Returns "" when nothing is stored: the contract default
// is null, "no preference".
func resolvedProfileAudioLanguage(ctx context.Context, store userstore.UserStore, profileID string) string {
if store == nil || profileID == "" {
return ""
}
contract, err := settingscontract.Load()
if err != nil {
return "", fmt.Errorf("loading settings contract: %w", err)
return ""
}
resolved, err := settingsresolve.New(contract).Resolve(ctx, store, rc,
resolved, err := settingsresolve.New(contract).Resolve(ctx, store,
settingsresolve.Context{ProfileID: profileID},
[]string{settingskeys.PlaybackAudioLanguage}, nil)
if err != nil {
return "", fmt.Errorf("resolving playback audio language: %w", err)
}
if len(resolved) == 0 {
return "", nil
if err != nil || len(resolved) == 0 {
return ""
}
var language string
if err := json.Unmarshal(resolved[0].Value, &language); err != nil {
return "", fmt.Errorf("decoding playback audio language: %w", err)
if json.Unmarshal(resolved[0].Value, &language) != nil {
return ""
}
return strings.TrimSpace(language), nil
return strings.TrimSpace(language)
}
// --- Persistence helpers ---
@@ -1384,7 +1376,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
h.touchSessionActivity(sessionID)
segmentName := chi.URLParam(r, "name")
segmentPath, err := transcodeSession.GetSegment(segmentName)
segmentLease, err := transcodeSession.OpenSegment(segmentName)
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) {
segNum, parseErr := playback.ParseSegmentNumber(segmentName)
if parseErr == nil {
@@ -1421,7 +1413,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
"session", sessionID,
"playback_session_id", sessionID,
)
segmentPath, err = transcodeSession.WaitForSegment(segmentName, decision.WaitTimeout)
segmentLease, err = transcodeSession.WaitForOpenSegment(segmentName, decision.WaitTimeout)
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) {
slog.InfoContext(r.Context(), "transcode segment wait timeout", "component", "api",
"segment", segmentName,
@@ -1483,7 +1475,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
); restartErr == nil {
// Throttler + exit monitor re-arm via the session's
// restart hook.
segmentPath, err = transcodeSession.WaitForSegment(segmentName, 30*time.Second)
segmentLease, err = transcodeSession.WaitForOpenSegment(segmentName, 30*time.Second)
if err == nil && strings.EqualFold(transcodeSession.Opts().TargetCodecVideo, "copy") {
// Copy-mode seeks can resume as soon as the target segment
// exists, but that sometimes leaves the player one segment
@@ -1491,7 +1483,9 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
// for a single lookahead fragment when available so the
// first resumed playback window is less brittle.
nextSegmentName := fmt.Sprintf("seg_%05d.m4s", segNum+1)
_, _ = transcodeSession.WaitForSegment(nextSegmentName, 1200*time.Millisecond)
if nextSegment, nextErr := transcodeSession.WaitForOpenSegment(nextSegmentName, 1200*time.Millisecond); nextErr == nil {
_ = nextSegment.Close()
}
}
}
}
@@ -1499,7 +1493,7 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
} else if transcodeSession.IsRunning() {
// Non-numbered segment (e.g., init.mp4 for fMP4 HLS).
// Wait briefly — the init segment is written almost immediately.
segmentPath, err = transcodeSession.WaitForSegment(segmentName, 10*time.Second)
segmentLease, err = transcodeSession.WaitForOpenSegment(segmentName, 10*time.Second)
}
}
if err != nil {
@@ -1511,14 +1505,17 @@ func (h *PlaybackHandler) HandleGetTranscodeSegment(w http.ResponseWriter, r *ht
return
}
// Report segment download for throttle tracking.
if segNum, parseErr := playback.ParseSegmentNumber(segmentName); parseErr == nil {
transcodeSession.ReportSegmentDownloaded(segNum)
}
w.Header().Set("Cache-Control", "no-store, max-age=0")
w.Header().Set("Pragma", "no-cache")
http.ServeFile(w, r, segmentPath)
defer func() { _ = segmentLease.Close() }()
sw := httpstream.NewRollingDeadlineWriter(w)
http.ServeContent(sw, r, segmentLease.Info.Name(), segmentLease.Info.ModTime(), segmentLease.File)
if r.Method == http.MethodGet &&
sw.CompletedFullResponse(segmentLease.Info.Size()) {
if segNum, parseErr := playback.ParseSegmentNumber(segmentName); parseErr == nil {
transcodeSession.ReportSegmentDownloadedForGeneration(segNum, segmentLease.Generation)
}
}
}
// buildProxyManifestURL signs a stream token carrying the session's full
@@ -1541,6 +1538,9 @@ func (h *PlaybackHandler) buildProxyManifestURL(card playback.RecipeCard, proxyN
func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, transcodeNodeURL, path string) {
sessionID := chi.URLParam(r, "session_id")
targetURL := transcodeNodeURL + path
isSegmentRoute := strings.Contains(path, "/segment/")
_, segmentParseErr := playback.ParseSegmentNumber(filepath.Base(path))
isMediaSegment := segmentParseErr == nil
// Capture the signed stream token ("st") before stripping it from the URL.
// We forward it out-of-band as a header so the node can reconstruct after a
// self-restart, while keeping it out of the forwarded/logged URL.
@@ -1560,6 +1560,14 @@ func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Re
return
}
req.Header.Set("Authorization", "Bearer "+h.JWTSecret)
if isSegmentRoute {
// The node's immediate transport peer is this API process, so receiving a
// complete response there does not prove that the browser received it.
// Suppress node-local accounting and acknowledge only after the downstream
// writer completes. Forward range validators so both hops serve the same
// representation.
transcodeproxy.PrepareRequest(req, r)
}
// Best-effort forward of the stream token as a header so the node's
// reconstruct path (X-Silo-Stream-Token) can rebuild after a self-restart.
// Verify at the API boundary and confirm it belongs to this session; an
@@ -1583,7 +1591,7 @@ func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Re
http.Error(w, "transcode node unavailable", http.StatusBadGateway)
return
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
// The node strips "st" from the request query (kept out of node URLs/logs),
// so the segment/init URIs in the manifest it builds carry no token. Without
@@ -1613,16 +1621,22 @@ func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Re
return
}
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
generation := resp.Header.Get(transcodeproxy.GenerationHeader)
transcodeproxy.CopyResponseHeaders(w.Header(), resp.Header)
// Proxied transcode output can stream past the server's absolute
// WriteTimeout; roll the write deadline with progress instead.
sw := httpstream.NewRollingDeadlineWriter(w)
sw.WriteHeader(resp.StatusCode)
io.Copy(sw, resp.Body)
if _, copyErr := io.Copy(sw, resp.Body); copyErr != nil {
return
}
fullSize := transcodeproxy.FullRepresentationSize(resp)
if isMediaSegment && generation != "" && r.Method == http.MethodGet &&
sw.CompletedFullResponse(fullSize) {
if ackErr := transcodeproxy.Acknowledge(r.Context(), http.DefaultClient, transcodeNodeURL+path, h.JWTSecret, generation); ackErr != nil {
slog.WarnContext(r.Context(), "acknowledge transcode segment completion", "component", "api", "error", ackErr, "playback_session_id", sessionID)
}
}
}
// maybeStartThrottler reads throttle settings and starts the throttler if enabled.
@@ -0,0 +1,19 @@
package handlers
import (
"testing"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/playback"
)
func TestPlaybackHandlerTranscodeRuntimeConfigIncludesSegmentRetention(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.PlaybackConfig = func() config.PlaybackConfig {
return config.PlaybackConfig{SegmentRetentionSeconds: 300}
}
if got := handler.tm.Config().SegmentRetentionSeconds; got != 300 {
t.Fatalf("configured segment retention = %d, want 300", got)
}
}
@@ -0,0 +1,127 @@
package handlers
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/transcodeproxy"
)
func TestProxyToTranscodeNodeAcknowledgesOnlyFullDownstreamResponse(t *testing.T) {
const (
body = "complete segment"
generation = "17"
)
tests := []struct {
name string
rangeHeader string
wantStatus int
wantAck int32
}{
{name: "ordinary get", wantStatus: http.StatusOK, wantAck: 1},
{name: "whole-file range", rangeHeader: "bytes=0-", wantStatus: http.StatusPartialContent, wantAck: 1},
{name: "partial range", rangeHeader: "bytes=1-", wantStatus: http.StatusPartialContent},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var acknowledgements atomic.Int32
seenRange := make(chan string, 1)
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if r.Header.Get(transcodeproxy.RequestHeader) != "1" {
t.Error("segment request omitted proxy marker")
}
seenRange <- r.Header.Get("Range")
w.Header().Set(transcodeproxy.GenerationHeader, generation)
http.ServeContent(w, r, "seg_00007.ts", time.Time{}, strings.NewReader(body))
case http.MethodPost:
if r.Header.Get(transcodeproxy.GenerationHeader) != generation {
t.Errorf("ack generation = %q, want %q", r.Header.Get(transcodeproxy.GenerationHeader), generation)
}
acknowledgements.Add(1)
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
}
}))
defer node.Close()
handler := &PlaybackHandler{JWTSecret: "proxy-test-secret"}
req := withPlaybackRouteParam(
httptest.NewRequest(http.MethodGet, "/playback/transcode/public/segment/seg_00007.ts", nil),
"session_id",
"public",
)
req.Header.Set("Range", tt.rangeHeader)
rr := httptest.NewRecorder()
handler.proxyToTranscodeNode(rr, req, node.URL, "/transcode/remote/segment/seg_00007.ts")
if rr.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d; body = %q", rr.Code, tt.wantStatus, rr.Body.String())
}
if got := <-seenRange; got != tt.rangeHeader {
t.Fatalf("forwarded Range = %q, want %q", got, tt.rangeHeader)
}
if got := rr.Header().Get(transcodeproxy.GenerationHeader); got != "" {
t.Fatalf("internal generation leaked downstream: %q", got)
}
if got := acknowledgements.Load(); got != tt.wantAck {
t.Fatalf("acknowledgements = %d, want %d", got, tt.wantAck)
}
})
}
}
func TestProxyToTranscodeNodeDoesNotAcknowledgeFailedDownstreamWrite(t *testing.T) {
const body = "complete segment"
var acknowledgements atomic.Int32
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
acknowledgements.Add(1)
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set(transcodeproxy.GenerationHeader, "21")
http.ServeContent(w, r, "seg_00007.ts", time.Time{}, strings.NewReader(body))
}))
defer node.Close()
handler := &PlaybackHandler{JWTSecret: "proxy-test-secret"}
req := withPlaybackRouteParam(
httptest.NewRequest(http.MethodGet, "/playback/transcode/public/segment/seg_00007.ts", nil),
"session_id",
"public",
)
w := &failingProxyResponseWriter{header: make(http.Header), remaining: 5}
handler.proxyToTranscodeNode(w, req, node.URL, "/transcode/remote/segment/seg_00007.ts")
if got := acknowledgements.Load(); got != 0 {
t.Fatalf("failed downstream transfer produced %d acknowledgement(s)", got)
}
}
type failingProxyResponseWriter struct {
header http.Header
status int
remaining int
}
func (w *failingProxyResponseWriter) Header() http.Header { return w.header }
func (w *failingProxyResponseWriter) WriteHeader(status int) { w.status = status }
func (w *failingProxyResponseWriter) Write(p []byte) (int, error) {
if w.remaining <= 0 {
return 0, io.ErrClosedPipe
}
n := min(len(p), w.remaining)
w.remaining -= n
return n, io.ErrClosedPipe
}
+10 -104
View File
@@ -551,8 +551,6 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
file.AudioTracks = []models.AudioTrack{
{Language: "eng", Codec: "aac", Channels: 2, Layout: "stereo", Default: true},
{Language: "jpn", Codec: "aac", Channels: 2, Layout: "stereo"},
{Language: "spa", Codec: "aac", Channels: 2, Layout: "stereo"},
{Language: "fra", Codec: "aac", Channels: 2, Layout: "stereo"},
}
return file
}
@@ -566,49 +564,18 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
}
}
setCanonicalLanguage := func(t *testing.T, store userstore.UserStore, identity userstore.SettingIdentity, language string) {
t.Helper()
identity.Key = settingskeys.PlaybackAudioLanguage
identity.ProfileID = "profile-1"
encoded, err := json.Marshal(language)
if err != nil {
t.Fatalf("encode canonical audio language: %v", err)
}
if _, err := store.UpsertSettingValue(context.Background(), identity, encoded); err != nil {
t.Fatalf("seed canonical audio language: %v", err)
}
}
startPlayback := func(
t *testing.T,
store userstore.UserStore,
file *models.MediaFile,
deviceID string,
mutate func(*playback.StartRequestV3),
) int {
startPlayback := func(t *testing.T, store userstore.UserStore, file *models.MediaFile) int {
t.Helper()
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
handler.StoreProvider = testUserStoreProvider{store: store}
handler.ItemAccess = allowAllPlaybackItemAccess{}
if file.EpisodeID != "" {
handler.EpisodeLookup = testEpisodeLookup{episode: &models.Episode{ContentID: file.EpisodeID, SeriesID: "series-1"}}
}
startRequest := v3HandlerStartRequest()
startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassProgressiveV3] = playback.DeliveryCapabilityV3{Enabled: true, SupportedOnDevice: true}
if mutate != nil {
mutate(&startRequest)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start",
strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
// A client name or user agent is not a stable settings identity. Only
// the explicit device header may select a profile_device row.
req.Header.Set("User-Agent", "SiloTV/apple-tv")
if deviceID != "" {
req.Header.Set(deviceIDHeader, deviceID)
}
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != http.StatusCreated {
@@ -631,11 +598,15 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
t.Run("canonical value wins over legacy column", func(t *testing.T) {
store := newPlaybackTestStore(t)
setLegacyLanguage(t, store, "eng")
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfile,
}, "ja")
if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.PlaybackAudioLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
}, json.RawMessage(`"ja"`)); err != nil {
t.Fatalf("seed canonical audio language: %v", err)
}
if index := startPlayback(t, store, newFile(t), "", nil); index != 1 {
if index := startPlayback(t, store, newFile(t)); index != 1 {
t.Fatalf("AudioTrackIndex = %d, want 1 (canonical \"ja\" track)", index)
}
})
@@ -646,75 +617,10 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
// No canonical value stored: the contract default is "no preference",
// so selection falls to the file's default track, not the column's.
if index := startPlayback(t, store, newFile(t), "", nil); index != 0 {
if index := startPlayback(t, store, newFile(t)); index != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (file default track)", index)
}
})
t.Run("device value applies only to the identified device", func(t *testing.T) {
store := newPlaybackTestStore(t)
setCanonicalLanguage(t, store, userstore.SettingIdentity{Scope: settingscontract.ScopeProfile}, "en")
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileDevice,
DeviceID: "apple-tv",
}, "ja")
if index := startPlayback(t, store, newFile(t), "apple-tv", nil); index != 1 {
t.Fatalf("AudioTrackIndex = %d, want 1 (device \"ja\" track)", index)
}
if index := startPlayback(t, store, newFile(t), "", nil); index != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (profile \"en\" track without device identity)", index)
}
if index := startPlayback(t, store, newFile(t), "iphone", nil); index != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (profile \"en\" track on another device)", index)
}
})
t.Run("series and library values outrank device value", func(t *testing.T) {
store := newPlaybackTestStore(t)
setCanonicalLanguage(t, store, userstore.SettingIdentity{Scope: settingscontract.ScopeProfile}, "en")
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileDevice,
DeviceID: "apple-tv",
}, "ja")
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileLibrary,
LibraryID: 12,
}, "es")
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileSeries,
SeriesID: "series-1",
}, "fr")
movie := newFile(t)
movie.MediaFolderID = 12
if index := startPlayback(t, store, movie, "apple-tv", nil); index != 2 {
t.Fatalf("AudioTrackIndex = %d, want 2 (library \"es\" track)", index)
}
episode := newFile(t)
episode.MediaFolderID = 12
episode.EpisodeID = "episode-1"
if index := startPlayback(t, store, episode, "apple-tv", nil); index != 3 {
t.Fatalf("AudioTrackIndex = %d, want 3 (series \"fr\" track)", index)
}
})
t.Run("explicit track selection outranks saved settings", func(t *testing.T) {
store := newPlaybackTestStore(t)
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileDevice,
DeviceID: "apple-tv",
}, "ja")
file := newFile(t)
if index := startPlayback(t, store, file, "apple-tv", func(request *playback.StartRequestV3) {
explicit := 0
request.AudioTrackIndex = &explicit
request.AudioTrackID = playback.TrackIDV3(file.ID, "audio", explicit)
}); index != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (explicit \"en\" track)", index)
}
})
}
// An omitted start_position asks the server for its resume policy; an explicit
+106 -244
View File
@@ -26,23 +26,21 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/transcodenode"
)
const (
maxPlaybackV3BodyBytes = 256 << 10
maxPlaybackV3EventBodyBytes = 32 << 10
replanLeaseDurationV3 = 15 * time.Second
replanReleaseTimeoutV3 = 3 * time.Second
v3NodeCapabilityTTL = time.Minute
playbackNodeIntegratedV3 = "integrated"
subtitleFormatVTTV3 = "vtt"
subtitleMIMEVTTV3 = "text/vtt"
subtitleUnavailableReasonV3 = "subtitle_artifact_unavailable"
transcodeStartFailedReasonV3 = "transcode_start_failed"
seekRestorationPlayerV3 = "player_position"
maxPlaybackV3BodyBytes = 256 << 10
maxPlaybackV3EventBodyBytes = 32 << 10
replanLeaseDurationV3 = 15 * time.Second
replanReleaseTimeoutV3 = 3 * time.Second
v3NodeCapabilityTTL = time.Minute
playbackNodeIntegratedV3 = "integrated"
subtitleFormatVTTV3 = "vtt"
subtitleMIMEVTTV3 = "text/vtt"
subtitleUnavailableReasonV3 = "subtitle_artifact_unavailable"
seekRestorationPlayerV3 = "player_position"
// Failed capability fetches are memoized briefly so an unreachable node
// costs one timeout per window instead of one per planning request.
v3NodeCapabilityErrorTTL = 15 * time.Second
@@ -70,13 +68,6 @@ type preparedTransportV3 struct {
afterDurableCommit func()
}
type preparedTimelineV3 struct {
seekSeconds float64
streamOriginSeconds float64
startSegmentNumber int
copySeekAnchorResolved bool
}
type transportErrorV3 struct {
reason string
message string
@@ -363,11 +354,11 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
return
}
userID := apimw.GetUserID(r.Context())
deviceID := deviceMetadataFromRequest(r).DeviceID
requestDigests := newPlaybackStartRequestDigestsV3(body, deviceID)
digestBytes := sha256.Sum256(body)
requestDigest := hex.EncodeToString(digestBytes[:])
if existing, lookupErr := h.PlanStoreV3.GetAttemptByPlaybackAttemptID(r.Context(), req.PlaybackAttemptID); lookupErr == nil {
if existing.UserID != userID || existing.ProfileID != profileID || existing.RequestedMediaFileID != req.FileID ||
!requestDigests.matches(existing.RequestDigest) {
(existing.RequestDigest != "" && existing.RequestDigest != requestDigest) {
writeError(w, http.StatusConflict, "playback_attempt_reused", "The playback attempt ID belongs to a different request")
return
}
@@ -405,7 +396,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
return
}
if req.AudioTrackID == "" && req.AudioTrackIndex == nil {
audioIndex, err = h.preferredAudioTrackIndexV3(r.Context(), userID, profileID, deviceID, requestedFile)
audioIndex, err = h.preferredAudioTrackIndexV3(r.Context(), userID, profileID, requestedFile)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load the saved audio preference")
return
@@ -430,12 +421,12 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(),
AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile),
})
if terminalAllowsAlternateFileV3(result.Terminal) && shouldTryAlternateFileV3(req.QualityPreference) {
if result.Terminal != nil && result.Terminal.Reason == "no_alternate_version" && shouldTryAlternateFileV3(req.QualityPreference) {
if alternate, alternateErr := h.findAlternateFile(r.Context(), requestedFile); alternateErr == nil && alternate != nil {
effectiveFile = h.ensurePlaybackProbe(r.Context(), alternate)
audioIndex = remapAudioIndexV3(requestedFile, effectiveFile, audioIndex)
if err := h.remapSubtitleSelectionV3(r.Context(), requestedFile, effectiveFile, &req); err != nil {
response, persistErr := h.persistTerminalStartDecisionV3(r.Context(), userID, profileID, req, requestDigests, requestedFile.ID, effectiveFile.ID, playback.NewTerminalResponseV3("subtitle_unavailable_in_version", err.Error(), false))
response, persistErr := h.persistTerminalStartDecisionV3(r.Context(), userID, profileID, req, requestDigest, requestedFile.ID, effectiveFile.ID, playback.NewTerminalResponseV3("subtitle_unavailable_in_version", err.Error(), false))
if persistErr != nil {
writeStartAttemptPersistenceErrorV3(w, persistErr)
return
@@ -458,7 +449,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
"file_id", effectiveFile.ID,
"quality_preference", req.QualityPreference,
)
response, persistErr := h.persistTerminalStartDecisionV3(r.Context(), userID, profileID, req, requestDigests, requestedFile.ID, effectiveFile.ID, playback.NewTerminalResponseV3(result.Terminal.Reason, result.Terminal.Message, result.Terminal.Retryable))
response, persistErr := h.persistTerminalStartDecisionV3(r.Context(), userID, profileID, req, requestDigest, requestedFile.ID, effectiveFile.ID, playback.NewTerminalResponseV3(result.Terminal.Reason, result.Terminal.Message, result.Terminal.Retryable))
if persistErr != nil {
writeStartAttemptPersistenceErrorV3(w, persistErr)
return
@@ -487,7 +478,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
"bandwidth_estimate_kbps", intOrZeroHandlerV3(req.BandwidthEstimateKbps),
)
result.Plan.DegradationWarnings = append(result.Plan.DegradationWarnings, warnings...)
response, statusErr := h.startPlannedPlaybackV3(r, userID, profileID, req, requestDigests, requestedFile, effectiveFile, audioIndex, result)
response, statusErr := h.startPlannedPlaybackV3(r, userID, profileID, req, requestDigest, requestedFile, effectiveFile, audioIndex, result)
if statusErr != nil {
if statusErr.reason == "playback_attempt_reused" {
writeError(w, http.StatusConflict, "playback_attempt_reused", statusErr.message)
@@ -496,7 +487,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
if statusErr.reason == "internal_error" {
slog.ErrorContext(r.Context(), "protocol v3 start failed", "component", "api", "reason", statusErr.reason, "error", statusErr.cause)
}
persistedResponse, persistErr := h.startFailureDecisionV3(r.Context(), userID, profileID, req, requestDigests, requestedFile.ID, effectiveFile.ID, statusErr)
persistedResponse, persistErr := h.startFailureDecisionV3(r.Context(), userID, profileID, req, requestDigest, requestedFile.ID, effectiveFile.ID, statusErr)
if persistErr != nil {
writeStartAttemptPersistenceErrorV3(w, persistErr)
return
@@ -507,32 +498,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
writeJSON(w, http.StatusCreated, response)
}
type playbackStartRequestDigestsV3 struct {
current string
legacy string
}
// newPlaybackStartRequestDigestsV3 fingerprints both the body and normalized
// device identity because either can change the selected playback plan. It
// also retains the pre-device digest while attempts written by an older
// server can still be replayed during a rolling deployment.
func newPlaybackStartRequestDigestsV3(body []byte, deviceID string) playbackStartRequestDigestsV3 {
hasher := sha256.New()
_, _ = fmt.Fprintf(hasher, "%d:", len(body))
_, _ = hasher.Write(body)
_, _ = hasher.Write([]byte(deviceID))
legacy := sha256.Sum256(body)
return playbackStartRequestDigestsV3{
current: hex.EncodeToString(hasher.Sum(nil)),
legacy: hex.EncodeToString(legacy[:]),
}
}
func (d playbackStartRequestDigestsV3) matches(stored string) bool {
return stored == "" || stored == d.current || stored == d.legacy
}
func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, profileID string, req playback.StartRequestV3, requestDigests playbackStartRequestDigestsV3, requestedFile, effectiveFile *models.MediaFile, audioIndex int, result playback.PlannerResultV3) (playback.DecisionResponseV3, *transportErrorV3) {
func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, profileID string, req playback.StartRequestV3, requestDigest string, requestedFile, effectiveFile *models.MediaFile, audioIndex int, result playback.PlannerResultV3) (playback.DecisionResponseV3, *transportErrorV3) {
if result.Plan == nil {
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "The server produced no playback plan."}
}
@@ -600,7 +566,7 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err)
}
response := playback.DecisionResponseV3{ProtocolVersion: playback.ProtocolV3, ServerFeatures: playback.ServerFeaturesV3(), Outcome: playback.OutcomePlayableV3, SessionID: session.ID, PlaybackPlan: result.Plan}
record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, FrozenRecipe: frozenRecipe, NormalizedRequest: req, StartResponse: response, RequestDigest: requestDigests.current, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)}
record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, FrozenRecipe: frozenRecipe, NormalizedRequest: req, StartResponse: response, RequestDigest: requestDigest, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)}
if err := h.updateV3SessionState(r.Context(), session, effectiveFile, result, transport); err != nil {
transport.rollback()
abort()
@@ -609,9 +575,12 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
if err := h.PlanStoreV3.SaveAttempt(r.Context(), record); err != nil {
transport.rollback()
abort()
if errors.Is(err, playback.ErrPlaybackAttemptExistsV3) || errors.Is(err, playback.ErrIdempotencyKeyReusedV3) {
if errors.Is(err, playback.ErrIdempotencyKeyReusedV3) {
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "playback_attempt_reused", message: "The playback attempt ID was reused with different input."}
}
if errors.Is(err, playback.ErrPlaybackAttemptExistsV3) {
existing, lookupErr := h.PlanStoreV3.GetAttemptByPlaybackAttemptID(r.Context(), req.PlaybackAttemptID)
if lookupErr == nil && existing.UserID == userID && existing.ProfileID == profileID && existing.RequestedMediaFileID == req.FileID && requestDigests.matches(existing.RequestDigest) {
if lookupErr == nil && existing.UserID == userID && existing.ProfileID == profileID && existing.RequestedMediaFileID == req.FileID {
// Replaying a concurrent duplicate is only valid while its
// session is alive; otherwise tell the client to mint a new
// attempt rather than hand it a plan it can never stream.
@@ -620,9 +589,6 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
}
return decisionResponseFromAttemptV3(existing), nil
}
if errors.Is(err, playback.ErrIdempotencyKeyReusedV3) {
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "playback_attempt_reused", message: "The playback attempt ID was reused with different input."}
}
}
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to persist the playback plan.", cause: err}
}
@@ -667,12 +633,8 @@ func (h *PlaybackHandler) persistSeriesSelectionsV3(ctx context.Context, userID
}
func (h *PlaybackHandler) prepareTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3) (preparedTransportV3, *transportErrorV3) {
timeline, timelineErr := h.prepareTransportTimelineV3(r.Context(), session, file, result)
if timelineErr != nil {
return preparedTransportV3{}, timelineErr
}
if result.Plan.Delivery != playback.DeliveryTranscodeHLSV3 && result.Plan.Delivery != playback.DeliveryRemuxHLSV3 {
return h.prepareIdentityTransportV3(session, result, timeline), nil
return h.prepareIdentityTransportV3(session, result), nil
}
if h.NodePlanner != nil {
plan := h.planNodeSessionV3(r.Context(), session, result)
@@ -682,7 +644,7 @@ func (h *PlaybackHandler) prepareTransportV3(r *http.Request, session *playback.
err = validateAdvertisedTransformationsV3(result.Plan, transformations)
}
if err == nil {
transport, transportErr := h.prepareRemoteTransportV3(r, session, file, result, plan, timeline)
transport, transportErr := h.prepareRemoteTransportV3(r, session, file, result, plan)
if transportErr != nil {
if releaser, ok := h.NodePlanner.(sessionReservationReleaserV3); ok {
releaser.ReleaseSession(session.ID)
@@ -712,54 +674,7 @@ func (h *PlaybackHandler) prepareTransportV3(r *http.Request, session *playback.
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_node_capability_unavailable", message: "No available transcode executor can run the selected playback recipe.", retryable: true, cause: err}
}
}
return h.prepareLocalTransportV3(r, session, file, result, timeline)
}
func (h *PlaybackHandler) prepareTransportTimelineV3(ctx context.Context, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3) (preparedTimelineV3, *transportErrorV3) {
if result.Plan == nil {
return preparedTimelineV3{}, nil
}
requested := result.Plan.Timeline.SourceStartSeconds
switch result.Plan.Delivery {
case playback.DeliveryRemuxProgressiveV3, playback.DeliveryRemuxHLSV3:
// Audio-only remuxes have no copied video keyframe to resolve. Keep the
// requested position as their exact stream origin: the chunked output
// clock restarts at zero and later seeks require another server reanchor.
if file != nil && file.IsAudioOnly() {
configureCopyRemuxTimelineV3(result.Plan, requested)
return preparedTimelineV3{seekSeconds: requested, streamOriginSeconds: requested}, nil
}
origin, startSegment := 0.0, 0
if requested > 0 {
if file == nil || strings.TrimSpace(file.FilePath) == "" {
return preparedTimelineV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "Failed to resolve remux seek position.", retryable: true, cause: errors.New("copy seek anchor requires a media file path")}
}
resolver := h.copySeekAnchor
if resolver == nil {
resolver = playback.ResolveCopySeekAnchor
}
var err error
origin, startSegment, err = resolver(ctx, h.playbackConfig().FFmpegPath, file.FilePath, requested, 2)
if err != nil {
slog.ErrorContext(ctx, "failed to resolve protocol v3 copy-video seek anchor",
"component", "api",
"playback_session_id", session.ID,
"requested_seek_seconds", requested,
"error", err,
)
return preparedTimelineV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "Failed to resolve remux seek position.", retryable: true, cause: err}
}
}
configureCopyRemuxTimelineV3(result.Plan, origin)
return preparedTimelineV3{seekSeconds: requested, streamOriginSeconds: origin, startSegmentNumber: startSegment, copySeekAnchorResolved: true}, nil
case playback.DeliveryTranscodeHLSV3:
sourceMetadata := sourceExecutionMetadataV3(file, result)
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, result.TargetVideoCodec, 2, sourceMetadata.DurationSeconds)
return preparedTimelineV3{seekSeconds: seekSeconds, streamOriginSeconds: result.Plan.Timeline.StreamOriginSeconds, startSegmentNumber: startSegment}, nil
default:
return preparedTimelineV3{}, nil
}
return h.prepareLocalTransportV3(r, session, file, result)
}
// planRequiresServerTransformationsV3 reports whether the plan carries any
@@ -777,7 +692,7 @@ func planRequiresServerTransformationsV3(plan *playback.PlanV3) bool {
return false
}
func (h *PlaybackHandler) prepareIdentityTransportV3(session *playback.Session, result playback.PlannerResultV3, timeline preparedTimelineV3) preparedTransportV3 {
func (h *PlaybackHandler) prepareIdentityTransportV3(session *playback.Session, result playback.PlannerResultV3) preparedTransportV3 {
routeSession := *session
routeSession.PlayMethod = result.PlayMethod
routeSession.BasePlayMethod = result.PlayMethod
@@ -794,7 +709,8 @@ func (h *PlaybackHandler) prepareIdentityTransportV3(session *playback.Session,
committed := false
streamURL := h.playbackStreamURL(&routeSession)
if result.Plan != nil && result.Plan.Delivery == playback.DeliveryRemuxProgressiveV3 {
if seek := timeline.seekSeconds; seek > 0 {
configureProgressiveRemuxTimelineV3(result.Plan)
if seek := result.Plan.Timeline.StreamOriginSeconds; seek > 0 {
streamURL = appendPlaybackQueryV3(streamURL, "seek", strconv.FormatFloat(seek, 'f', -1, 64))
}
}
@@ -840,14 +756,14 @@ func sessionOwnsResumeTimelineV3(file *models.MediaFile) bool {
}
// preferredAudioTrackIndexV3 answers what an omitted audio track means: the
// language this profile has settled on for this series, this library, this
// device, or generally — the same resolution the catalog performs when it
// publishes `effective_audio_track_index`, so the track a client sees on the
// detail page is the track that plays when it does not ask for one.
// language this profile has settled on for this series, this library, or
// generally — the same resolution the catalog performs when it publishes
// `effective_audio_track_index`, so the track a client sees on the detail page
// is the track that plays when it does not ask for one.
//
// The client sends a track identity only when the viewer picked one. Defaulting
// to ordinal zero instead would silently play the first track on the reel.
func (h *PlaybackHandler) preferredAudioTrackIndexV3(ctx context.Context, userID int, profileID, deviceID string, file *models.MediaFile) (int, error) {
func (h *PlaybackHandler) preferredAudioTrackIndexV3(ctx context.Context, userID int, profileID string, file *models.MediaFile) (int, error) {
if file == nil || len(file.AudioTracks) == 0 || h.StoreProvider == nil {
return 0, nil
}
@@ -856,9 +772,8 @@ func (h *PlaybackHandler) preferredAudioTrackIndexV3(ctx context.Context, userID
slog.ErrorContext(ctx, "protocol v3 start: audio preference store lookup failed", "component", "api", "user_id", userID, "error", err)
return 0, err
}
seriesID := h.resolveSeriesID(ctx, file)
var seriesPref *playback.AudioTrackPreference
if seriesID != "" {
if seriesID := h.resolveSeriesID(ctx, file); seriesID != "" {
stored, prefErr := store.GetAudioPreference(ctx, profileID, seriesID)
if prefErr != nil {
slog.ErrorContext(ctx, "protocol v3 start: series audio preference lookup failed", "component", "api", "profile_id", profileID, "series_id", seriesID, "error", prefErr)
@@ -866,38 +781,36 @@ func (h *PlaybackHandler) preferredAudioTrackIndexV3(ctx context.Context, userID
}
if stored != nil {
seriesPref = &playback.AudioTrackPreference{AudioTrackIndex: stored.AudioTrackIndex, AudioLanguage: stored.AudioLanguage, TrackSignature: stored.TrackSignature}
}
}
rc := settingsresolve.Context{
ProfileID: profileID,
DeviceID: deviceID,
LibraryIDs: []int{file.MediaFolderID},
}
if seriesID != "" {
rc.SeriesIDs = []string{seriesID}
}
preferredLang, err := resolvedPlaybackAudioLanguage(ctx, store, rc)
if err != nil {
slog.ErrorContext(ctx, "protocol v3 start: canonical audio preference lookup failed", "component", "api", "profile_id", profileID, "device_id", deviceID, "error", err)
return 0, err
}
if preferredLang == playback.OriginalLanguageSentinel {
preferredLang = h.resolveOriginalLanguage(ctx, file)
if preferredLang == "" {
preferredLang, err = resolvedPlaybackAudioLanguage(ctx, store, settingsresolve.Context{ProfileID: profileID})
if err != nil {
slog.ErrorContext(ctx, "protocol v3 start: profile audio preference fallback failed", "component", "api", "profile_id", profileID, "error", err)
return 0, err
}
if preferredLang == playback.OriginalLanguageSentinel {
preferredLang = h.resolveOriginalLanguage(ctx, file)
if seriesPref.AudioLanguage == playback.OriginalLanguageSentinel {
seriesPref.AudioLanguage = h.resolveOriginalLanguage(ctx, file)
}
}
}
if seriesPref != nil {
// The specialized row supplies concrete track identity; canonical
// settings own the language and its scope precedence.
seriesPref.AudioLanguage = preferredLang
preferredLang := resolvedProfileAudioLanguage(ctx, store, profileID)
// A library override applies only where no series-sticky choice exists;
// having watched this series in a language outranks the library default.
libraryLang := ""
if seriesPref == nil {
stored, prefErr := store.GetLibraryPlaybackPreference(ctx, profileID, file.MediaFolderID)
if prefErr != nil {
slog.ErrorContext(ctx, "protocol v3 start: library audio preference lookup failed", "component", "api", "profile_id", profileID, "library_id", file.MediaFolderID, "error", prefErr)
return 0, prefErr
}
if stored != nil {
libraryLang = stored.AudioLanguage
}
}
if preferredLang == playback.OriginalLanguageSentinel || libraryLang == playback.OriginalLanguageSentinel {
originalLang := h.resolveOriginalLanguage(ctx, file)
if preferredLang == playback.OriginalLanguageSentinel {
preferredLang = originalLang
}
if libraryLang == playback.OriginalLanguageSentinel {
libraryLang = originalLang
}
}
if libraryLang != "" {
preferredLang = libraryLang
}
return normalizeAudioTrackIndex(file, playback.SelectAudioTrack(file.AudioTracks, preferredLang, seriesPref)), nil
}
@@ -936,16 +849,16 @@ func (h *PlaybackHandler) resumePositionV3(ctx context.Context, userID int, prof
return &position, nil
}
// A copy remux starts at the preceding keyframe selected by the demuxer, not
// necessarily at the requested source position. Its player clock therefore
// begins at the resolved stream origin and advances through the copied pre-roll
// before reaching the requested position. Neither progressive nor growing HLS
// copy transports can seek arbitrarily inside their current response.
func configureCopyRemuxTimelineV3(plan *playback.PlanV3, origin float64) {
// A progressive remux is a freshly generated, chunked MP4 response and does
// not implement byte ranges. Its player clock therefore begins at zero at the
// requested source origin, and arbitrary seeks must request another server
// reanchor rather than issuing a Range request against the remux pipe.
func configureProgressiveRemuxTimelineV3(plan *playback.PlanV3) {
if plan == nil {
return
}
plan.Timeline.PlayerStartSeconds = max(0, plan.Timeline.SourceStartSeconds-origin)
origin := plan.Timeline.SourceStartSeconds
plan.Timeline.PlayerStartSeconds = 0
plan.Timeline.StreamOriginSeconds = origin
plan.Timeline.TimelineOffsetSeconds = origin
plan.Timeline.SeekWindowStartSeconds = &origin
@@ -962,7 +875,7 @@ func appendPlaybackQueryV3(rawURL, key, value string) string {
return rawURL + separator + key + "=" + value
}
func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, timeline preparedTimelineV3) (preparedTransportV3, *transportErrorV3) {
func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3) (preparedTransportV3, *transportErrorV3) {
cfg := h.playbackConfig()
if err := os.MkdirAll(cfg.TranscodeDir, 0o755); err != nil {
return preparedTransportV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to prepare the transcode directory.", cause: err}
@@ -975,12 +888,13 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play
}
sourceMetadata := sourceExecutionMetadataV3(file, result)
sourceProfile, sourceBitDepth := sourceVideoTranscodeFactsV3(file, result)
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceMetadata.DurationSeconds)
unlock := h.tm.LockSessionLifecycle(session.ID)
opts := playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: timeline.seekSeconds, StreamOriginSeconds: timeline.streamOriginSeconds, CopySeekAnchorResolved: timeline.copySeekAnchorResolved, StartSegmentNumber: timeline.startSegmentNumber, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, FastStart: true, NodeType: playbackNodeIntegratedV3, ExecutionMode: playbackNodeIntegratedV3, FFmpegLogSink: h.FFmpegLogSink}
opts := playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, SegmentRetentionSeconds: cfg.SegmentRetentionSeconds, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, FastStart: true, NodeType: playbackNodeIntegratedV3, ExecutionMode: playbackNodeIntegratedV3, FFmpegLogSink: h.FFmpegLogSink}
ts, err := h.startLocalPlaybackTransport(r.Context(), opts)
if err != nil {
unlock()
return preparedTransportV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "Failed to start the playback transport.", retryable: true, cause: err}
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "Failed to start the playback transport.", retryable: true, cause: err}
}
if _, readyErr := ts.WaitForManifest(playback.ManifestStartupTimeout); readyErr != nil {
wasRunning := ts.IsRunning()
@@ -1007,7 +921,7 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play
ts, err = h.startLocalPlaybackTransport(r.Context(), retryOpts)
if err != nil {
unlock()
return preparedTransportV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "Failed to start the playback transport.", retryable: true, cause: err}
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "Failed to start the playback transport.", retryable: true, cause: err}
}
if _, retryReadyErr := ts.WaitForManifest(playback.ManifestStartupTimeout); retryReadyErr != nil {
transportErr = manifestStartupTransportErrorV3(ts.IsRunning(), retryReadyErr)
@@ -1059,10 +973,10 @@ func manifestStartupTransportErrorV3(running bool, cause error) *transportErrorV
if running {
message = "The playback transport did not become ready in time."
}
return &transportErrorV3{reason: transcodeStartFailedReasonV3, message: message, retryable: running, cause: cause}
return &transportErrorV3{reason: "transcode_start_failed", message: message, retryable: running, cause: cause}
}
func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, nodePlan nodepool.Plan, timeline preparedTimelineV3) (preparedTransportV3, *transportErrorV3) {
func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, nodePlan nodepool.Plan) (preparedTransportV3, *transportErrorV3) {
node := nodePlan.TranscodeNode
transportID := transportGenerationV3(session.ID, result.Plan.PlanID)
videoCodec := result.TargetVideoCodec
@@ -1071,7 +985,8 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
}
sourceMetadata := sourceExecutionMetadataV3(file, result)
sourceProfile, sourceBitDepth := sourceVideoTranscodeFactsV3(file, result)
req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: timeline.seekSeconds, StreamOriginSeconds: timeline.streamOriginSeconds, CopySeekAnchorResolved: timeline.copySeekAnchorResolved, StartSegmentNumber: timeline.startSegmentNumber, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, RequireReady: true}
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceMetadata.DurationSeconds)
req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, RequireReady: true}
nodeResp, status, err := h.startRemotePlaybackTransport(r.Context(), node.URL, req)
if err != nil {
// A timeout can fire after the node actually started the job; the
@@ -1082,10 +997,10 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
}
if status != http.StatusAccepted {
h.tm.StopRemoteTranscode(transportID, node.URL)
return preparedTransportV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "The selected transcode node rejected the playback transport.", retryable: true}
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "The selected transcode node rejected the playback transport.", retryable: true}
}
hw := firstNonEmptyHandlerV3(strings.TrimSpace(nodeResp.HWAccel), strings.TrimSpace(req.HWAccel))
card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, node.URL, playback.TranscodeOpts{InputPath: req.InputPath, SessionID: session.ID, TranscodeTransportID: transportID, SourceVideoCodec: req.SourceVideoCodec, SourceVideoProfile: req.SourceVideoProfile, SourceVideoBitDepth: req.SourceVideoBitDepth, SoftwareVideoDecode: req.SoftwareVideoDecode, VideoBitstreamFilter: req.VideoBitstreamFilter, SeekSeconds: req.SeekSeconds, StreamOriginSeconds: req.StreamOriginSeconds, CopySeekAnchorResolved: req.CopySeekAnchorResolved, StartSegmentNumber: req.StartSegmentNumber, TargetResolution: req.TargetResolution, TargetCodecVideo: req.TargetCodecVideo, TargetCodecAudio: req.TargetCodecAudio, TargetAudioChannels: req.TargetAudioChannels, TargetAudioBitrateKbps: req.TargetAudioBitrateKbps, TargetBitrateKbps: req.TargetBitrateKbps, SegmentDuration: req.SegmentDuration, HWAccel: hw, AudioTrackIndex: req.AudioTrackIndex, SubtitleTrackIndex: req.SubtitleTrackIndex, SubtitleBurnIn: req.SubtitleBurnIn, SubtitleCodec: req.SubtitleCodec, TotalDuration: req.TotalDuration})
card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, node.URL, playback.TranscodeOpts{InputPath: req.InputPath, SessionID: session.ID, TranscodeTransportID: transportID, SourceVideoCodec: req.SourceVideoCodec, SourceVideoProfile: req.SourceVideoProfile, SourceVideoBitDepth: req.SourceVideoBitDepth, SoftwareVideoDecode: req.SoftwareVideoDecode, VideoBitstreamFilter: req.VideoBitstreamFilter, SeekSeconds: req.SeekSeconds, StartSegmentNumber: req.StartSegmentNumber, TargetResolution: req.TargetResolution, TargetCodecVideo: req.TargetCodecVideo, TargetCodecAudio: req.TargetCodecAudio, TargetAudioChannels: req.TargetAudioChannels, TargetAudioBitrateKbps: req.TargetAudioBitrateKbps, TargetBitrateKbps: req.TargetBitrateKbps, SegmentDuration: req.SegmentDuration, HWAccel: hw, AudioTrackIndex: req.AudioTrackIndex, SubtitleTrackIndex: req.SubtitleTrackIndex, SubtitleBurnIn: req.SubtitleBurnIn, SubtitleCodec: req.SubtitleCodec, TotalDuration: req.TotalDuration})
url := h.buildProxyManifestURL(card, nodePlan.ProxyNode)
committed := false
previousNodeURL := session.TranscodeNodeURL
@@ -1521,11 +1436,10 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
seekScopedRecovery := seekReanchor || seekFailureRecovery
trackChange := operation == playback.ReplanOperationTrackChangeV3
qualityChange := operation == playback.ReplanOperationQualityChangeV3
outputChange := operation == playback.ReplanOperationOutputChangeV3
// User-intent operations replace the legacy audio PATCH and client-recipe
// transcode start. Nothing failed, so their previous route stays eligible:
// neither attempted-key history nor the failed-plan exclusion applies.
userIntentOperation := trackChange || qualityChange || outputChange
userIntentOperation := trackChange || qualityChange
intentChange := false
if seekScopedRecovery {
if err := validateSeekRecoveryRequestV3(record, req); err != nil {
@@ -1560,8 +1474,6 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
case qualityChange:
nextQuality, _ := playback.NormalizeQualityV3(req.QualityPreference)
intentChange = nextQuality != start.QualityPreference
case outputChange:
intentChange = true
default:
switch req.Failure.Classification {
case "quality_changed":
@@ -1641,7 +1553,6 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "source_unavailable", message: "The effective media source is unavailable."}
}
effectiveFile := currentEffectiveFile
currentEffectiveStart := start
if intentChange && !trackChange {
// Prefer returning to the requested edition, but a quality/output/track
// change must not abandon a healthy active alternate merely because the
@@ -1655,20 +1566,13 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
// from an eng/ac3 commentary track to the identically-shaped main
// track on a quality change.
if currentEffectiveFile.ID != effectiveFile.ID {
candidateStart := start
remapErr := remapAudioSelectionV3(currentEffectiveFile, effectiveFile, &candidateStart)
if remapErr == nil && (candidateStart.SubtitleTrackIndex != nil || candidateStart.SubtitleTrackID != "") {
remapErr = h.remapSubtitleSelectionV3(r.Context(), currentEffectiveFile, effectiveFile, &candidateStart)
if err := remapAudioSelectionV3(currentEffectiveFile, effectiveFile, &start); err != nil {
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()}
}
if remapErr != nil && outputChange {
// An output refresh may make the requested edition viable again,
// but it must not retire a healthy active alternate merely because
// the viewer selected a track unique to that alternate.
effectiveFile = currentEffectiveFile
} else if remapErr != nil {
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: remapErr.Error()}
} else {
start = candidateStart
if start.SubtitleTrackIndex != nil || start.SubtitleTrackID != "" {
if err := h.remapSubtitleSelectionV3(r.Context(), currentEffectiveFile, effectiveFile, &start); err != nil {
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()}
}
}
}
}
@@ -1743,21 +1647,7 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
} else {
result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
}
if outputChange && result.Terminal != nil && effectiveFile.ID != currentEffectiveFile.ID {
// Returning to the requested edition is speculative during an output
// refresh. Any terminal from that probe must fall back to the edition
// already playing, not only HDR/alternate-selection terminals: its audio,
// subtitle, or delivery constraints may still differ from the active file.
start = currentEffectiveStart
start.FileID = currentEffectiveFile.ID
effectiveFile = currentEffectiveFile
audioIndex, err = resolveV3AudioIndex(effectiveFile, start.AudioTrackID, start.AudioTrackIndex)
if err != nil {
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()}
}
result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
}
if terminalAllowsAlternateFileV3(result.Terminal) && replanAllowsAlternateFileV3(operation, start.QualityPreference) {
if result.Terminal != nil && result.Terminal.Reason == "no_alternate_version" && replanAllowsAlternateFileV3(operation, start.QualityPreference) {
if alternate, alternateErr := h.findAlternateFile(r.Context(), requestedFile); alternateErr == nil && alternate != nil {
alternate = h.ensurePlaybackProbe(r.Context(), alternate)
remappedAudio := remapAudioIndexV3(effectiveFile, alternate, audioIndex)
@@ -2359,40 +2249,12 @@ func shouldTryAlternateFileV3(qualityPreference string) bool {
return !strings.EqualFold(strings.TrimSpace(qualityPreference), "original")
}
const (
terminalNoAlternateVersionV3 = "no_alternate_version"
terminalHDRTranscodeUnsupportedV3 = "hdr_transcode_unsupported"
terminalSubtitleConversionUnsupportedV3 = "subtitle_conversion_unsupported"
)
// terminalAllowsAlternateFileV3 reports whether a planner terminal should
// trigger another media-file attempt before the response is finalized.
//
// subtitle_conversion_unsupported is included because a burn-in-only
// adaptation can rename an HDR or 4K blocker to that reason (see
// planVideoTranscodeV3's subtitleForcedAdaptation path). The underlying
// incompatibility is still version-shaped: an SDR / lower-resolution
// alternate may burn the same subtitle successfully.
func terminalAllowsAlternateFileV3(terminal *playback.TerminalV3) bool {
if terminal == nil {
return false
}
switch terminal.Reason {
case terminalNoAlternateVersionV3, terminalHDRTranscodeUnsupportedV3, terminalSubtitleConversionUnsupportedV3:
return true
default:
return false
}
}
func replanAllowsAlternateFileV3(operation playback.ReplanOperationV3, qualityPreference string) bool {
switch operation {
case playback.ReplanOperationFailureRecoveryV3, playback.ReplanOperationQualityChangeV3, playback.ReplanOperationOutputChangeV3, playback.ReplanOperationTrackChangeV3:
// Quality, output, and track changes can make another version the only
// viable route. In particular, a bitmap subtitle can require video burn-in
// that an HDR source cannot support while an SDR alternate can. The
// subtitle identity is remapped before the alternate is adopted; seek-only
// operations remain pinned to the mounted source.
case playback.ReplanOperationFailureRecoveryV3, playback.ReplanOperationQualityChangeV3:
// A quality change carries the same "another version may fit better"
// semantics its legacy quality_changed failure classification had; seek
// operations and track changes stay pinned to the mounted source.
return shouldTryAlternateFileV3(qualityPreference)
default:
return false
@@ -2403,11 +2265,11 @@ func replanAlternateFilePinnedByOriginalQualityV3(operation playback.ReplanOpera
if shouldTryAlternateFileV3(qualityPreference) {
return false
}
return operation == playback.ReplanOperationFailureRecoveryV3 || operation == playback.ReplanOperationQualityChangeV3 || operation == playback.ReplanOperationOutputChangeV3
return operation == playback.ReplanOperationFailureRecoveryV3 || operation == playback.ReplanOperationQualityChangeV3
}
func (h *PlaybackHandler) clarifyOriginalQuality4KTerminalV3(ctx context.Context, terminal *playback.TerminalV3, requestedFile *models.MediaFile, alternateFilePinned bool) {
if !alternateFilePinned || terminal == nil || terminal.Reason != terminalNoAlternateVersionV3 || terminal.Message != playback.TerminalMessage4KTranscodeDisabledV3 {
if !alternateFilePinned || terminal == nil || terminal.Reason != "no_alternate_version" || terminal.Message != playback.TerminalMessage4KTranscodeDisabledV3 {
return
}
if alternate, err := h.findAlternateFile(ctx, requestedFile); err == nil && alternate != nil {
@@ -2759,7 +2621,7 @@ func sessionStartErrorV3(err error) *transportErrorV3 {
}
}
func (h *PlaybackHandler) persistTerminalStartDecisionV3(ctx context.Context, userID int, profileID string, req playback.StartRequestV3, requestDigests playbackStartRequestDigestsV3, requestedFileID, effectiveFileID int, response playback.DecisionResponseV3) (playback.DecisionResponseV3, error) {
func (h *PlaybackHandler) persistTerminalStartDecisionV3(ctx context.Context, userID int, profileID string, req playback.StartRequestV3, requestDigest string, requestedFileID, effectiveFileID int, response playback.DecisionResponseV3) (playback.DecisionResponseV3, error) {
record := playback.AttemptRecordV3{
PlaybackAttemptID: req.PlaybackAttemptID,
UserID: userID,
@@ -2768,12 +2630,12 @@ func (h *PlaybackHandler) persistTerminalStartDecisionV3(ctx context.Context, us
EffectiveMediaFileID: effectiveFileID,
NormalizedRequest: req,
StartResponse: response,
RequestDigest: requestDigests.current,
RequestDigest: requestDigest,
ExpiresAt: time.Now().Add(playback.MaxTokenTTL),
}
if err := h.PlanStoreV3.SaveAttempt(ctx, record); err == nil {
return response, nil
} else if !errors.Is(err, playback.ErrPlaybackAttemptExistsV3) && !errors.Is(err, playback.ErrIdempotencyKeyReusedV3) {
} else if !errors.Is(err, playback.ErrPlaybackAttemptExistsV3) {
return playback.DecisionResponseV3{}, err
}
@@ -2782,15 +2644,15 @@ func (h *PlaybackHandler) persistTerminalStartDecisionV3(ctx context.Context, us
return playback.DecisionResponseV3{}, err
}
if existing.UserID != userID || existing.ProfileID != profileID ||
existing.RequestedMediaFileID != requestedFileID || !requestDigests.matches(existing.RequestDigest) {
existing.RequestedMediaFileID != requestedFileID || existing.RequestDigest != requestDigest {
return playback.DecisionResponseV3{}, playback.ErrIdempotencyKeyReusedV3
}
return decisionResponseFromAttemptV3(existing), nil
}
func (h *PlaybackHandler) startFailureDecisionV3(ctx context.Context, userID int, profileID string, req playback.StartRequestV3, requestDigests playbackStartRequestDigestsV3, requestedFileID, effectiveFileID int, failure *transportErrorV3) (playback.DecisionResponseV3, error) {
func (h *PlaybackHandler) startFailureDecisionV3(ctx context.Context, userID int, profileID string, req playback.StartRequestV3, requestDigest string, requestedFileID, effectiveFileID int, failure *transportErrorV3) (playback.DecisionResponseV3, error) {
response := playback.NewTerminalResponseV3(failure.reason, failure.message, failure.retryable)
return h.persistTerminalStartDecisionV3(ctx, userID, profileID, req, requestDigests, requestedFileID, effectiveFileID, response)
return h.persistTerminalStartDecisionV3(ctx, userID, profileID, req, requestDigest, requestedFileID, effectiveFileID, response)
}
func writeStartAttemptPersistenceErrorV3(w http.ResponseWriter, err error) {
@@ -3022,12 +2884,12 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur
seek := alignedSeekSeconds(requested, segmentDuration, videoCodec)
startSegment := computeStartSegment(seek, segmentDuration)
plan.Timeline.SourceStartSeconds = requested
usesGrowingManifest := !playback.CanGenerateSyntheticManifest(durationSeconds, segmentDuration)
usesGrowingManifest := strings.EqualFold(videoCodec, "copy") ||
!playback.CanGenerateSyntheticManifest(durationSeconds, segmentDuration)
if usesGrowingManifest {
// Encoded streams seek to the preceding segment boundary. Preserve the
// requested sub-segment offset so playback still begins at the exact
// requested source position. Copy remuxes are configured separately with
// their probed keyframe origin.
// requested source position. Copy seeks are already exact, making this 0.
plan.Timeline.PlayerStartSeconds = max(0, requested-seek)
plan.Timeline.StreamOriginSeconds = seek
plan.Timeline.TimelineOffsetSeconds = seek
+24 -518
View File
@@ -3,8 +3,6 @@ package handlers
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -51,12 +49,12 @@ func (s failingProgressStoreV3) GetProgress(context.Context, string, string) (*u
return nil, s.err
}
type failingSettingsResolutionStoreV3 struct {
type failingLibraryPlaybackPreferenceStoreV3 struct {
userstore.UserStore
err error
}
func (s failingSettingsResolutionStoreV3) ListSettingValuesForResolution(context.Context, userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
func (s failingLibraryPlaybackPreferenceStoreV3) GetLibraryPlaybackPreference(context.Context, string, int) (*userstore.LibraryPlaybackPreference, error) {
return nil, s.err
}
@@ -116,17 +114,6 @@ func TestShouldTryAlternateFileV3PinsOriginalQuality(t *testing.T) {
}
}
func TestTerminalAllowsAlternateFileV3IncludesHDRIncompatibility(t *testing.T) {
for _, reason := range []string{"no_alternate_version", "hdr_transcode_unsupported", "subtitle_conversion_unsupported"} {
if !terminalAllowsAlternateFileV3(&playback.TerminalV3{Reason: reason}) {
t.Fatalf("terminal reason %q should permit alternate selection", reason)
}
}
if terminalAllowsAlternateFileV3(&playback.TerminalV3{Reason: "client_hls_unsupported"}) {
t.Fatal("unrelated terminal reason should not permit alternate selection")
}
}
func TestValidateAdvertisedTransformationsV3RejectsOldVideoRecipe(t *testing.T) {
plan := &playback.PlanV3{Transformations: []playback.TransformationV3{{
Name: playback.TransformationVideoToH264V3,
@@ -206,53 +193,6 @@ func TestHandleStartPlaybackV3ExplainsOriginalQuality4KPinWhenAlternateExists(t
}
}
func TestHandleStartPlaybackV3TriesAlternateAfterHDRTerminal(t *testing.T) {
source := v3HandlerFixtureFile(t)
source.CodecVideo = "hevc"
source.Resolution = "2160p"
source.Bitrate = 32_000
source.VideoTracks[0] = models.VideoTrack{
Codec: "hevc", Profile: "main 10", Level: 150, Width: 3840, Height: 2160,
FrameRate: "24000/1001", Bitrate: 32_000, BitDepth: 10,
VideoRange: "DolbyVision", VideoRangeType: "DOVIWithHDR10", DVProfile: 8, DVBLCompatID: 1,
}
alternateValue := *source
alternate := &alternateValue
alternate.ID = 84
alternate.CodecVideo = "h264"
alternate.Resolution = "1080p"
alternate.Bitrate = 8_000
alternate.VideoTracks = []models.VideoTrack{{
Codec: "h264", Profile: "high", Level: 41, Width: 1920, Height: 1080,
FrameRate: "24000/1001", Bitrate: 8_000, BitDepth: 8, VideoRange: "SDR", VideoRangeType: "SDR",
}}
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: source})
handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{
source.ContentID: {source, alternate},
}}
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"allow_4k_transcode": "false"}}
handler.PlaybackConfig = playbackTestConfig("", "")
handler.ItemAccess = allowAllPlaybackItemAccess{}
start := v3HandlerStartRequest()
start.QualityPreference = "auto"
start.ClientPlaybackContext.Deliveries[playback.DeliveryClassHLSV3] = playback.DeliveryCapabilityV3{
Enabled: true, SupportedOnDevice: true, Containers: []string{"hls"},
VideoCodecs: []string{"h264"}, AudioDecodeCodecs: []string{"aac"},
}
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, start))).WithContext(newAuthorizedPlaybackContext()))
var response playback.DecisionResponseV3
if rr.Code != http.StatusCreated || json.Unmarshal(rr.Body.Bytes(), &response) != nil || response.PlaybackPlan == nil {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
if response.PlaybackPlan.EffectiveMediaFileID != alternate.ID {
t.Fatalf("effective file = %d, want alternate %d", response.PlaybackPlan.EffectiveMediaFileID, alternate.ID)
}
}
func TestReplanAllowsAlternateFileV3PinsSeekOperations(t *testing.T) {
tests := []struct {
name string
@@ -261,8 +201,6 @@ func TestReplanAllowsAlternateFileV3PinsSeekOperations(t *testing.T) {
want bool
}{
{name: "ordinary failure may use another version", operation: playback.ReplanOperationFailureRecoveryV3, quality: "auto", want: true},
{name: "output change may use another version", operation: playback.ReplanOperationOutputChangeV3, quality: "auto", want: true},
{name: "track change may use another version", operation: playback.ReplanOperationTrackChangeV3, quality: "auto", want: true},
{name: "original quality remains pinned", operation: playback.ReplanOperationFailureRecoveryV3, quality: "original", want: false},
{name: "exact seek reanchor pins current version", operation: playback.ReplanOperationSeekReanchorV3, quality: "auto", want: false},
{name: "failed seek recovery pins current version", operation: playback.ReplanOperationSeekFailureRecoveryV3, quality: "auto", want: false},
@@ -373,86 +311,6 @@ func TestHandleStartPlaybackV3CommitsStartSideEffectsOnce(t *testing.T) {
}
}
func TestHandleStartPlaybackV3RejectsAttemptReplayFromAnotherDevice(t *testing.T) {
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: v3HandlerFixtureFile(t)})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
body := marshalV3StartRequest(t, v3HandlerStartRequest())
start := func(deviceID string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(body)).WithContext(newAuthorizedPlaybackContext())
req.Header.Set(deviceIDHeader, deviceID)
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
return rr
}
first := start(" living-room ")
sameDevice := start("living-room")
otherDevice := start("bedroom")
if first.Code != http.StatusCreated || sameDevice.Code != http.StatusCreated {
t.Fatalf("same-device statuses = %d, %d; first=%s replay=%s", first.Code, sameDevice.Code, first.Body.String(), sameDevice.Body.String())
}
if first.Body.String() != sameDevice.Body.String() {
t.Fatalf("normalized same-device replay changed response:\nfirst=%s\nreplay=%s", first.Body.String(), sameDevice.Body.String())
}
if otherDevice.Code != http.StatusConflict || !strings.Contains(otherDevice.Body.String(), "playback_attempt_reused") {
t.Fatalf("other-device status = %d, body = %s", otherDevice.Code, otherDevice.Body.String())
}
if got := len(manager.AllSessions()); got != 1 {
t.Fatalf("sessions = %d, want 1", got)
}
}
func TestHandleStartPlaybackV3AcceptsPreDeviceDigestReplay(t *testing.T) {
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: v3HandlerFixtureFile(t)})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
body := marshalV3StartRequest(t, startRequest)
start := func(requestBody, deviceID string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(requestBody)).WithContext(newAuthorizedPlaybackContext())
req.Header.Set(deviceIDHeader, deviceID)
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
return rr
}
first := start(body, "living-room")
if first.Code != http.StatusCreated {
t.Fatalf("first status = %d, body = %s", first.Code, first.Body.String())
}
var response playback.DecisionResponseV3
if err := json.Unmarshal(first.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), response.SessionID)
if err != nil {
t.Fatal(err)
}
legacyDigest := sha256.Sum256([]byte(body))
record.RequestDigest = hex.EncodeToString(legacyDigest[:])
handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record)
replay := start(body, "living-room")
if replay.Code != http.StatusCreated || replay.Body.String() != first.Body.String() {
t.Fatalf("legacy replay status = %d, body = %s; first = %s", replay.Code, replay.Body.String(), first.Body.String())
}
changedRequest := startRequest
position := 42.0
changedRequest.StartPosition = &position
conflict := start(marshalV3StartRequest(t, changedRequest), "living-room")
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "playback_attempt_reused") {
t.Fatalf("changed legacy replay status = %d, body = %s", conflict.Code, conflict.Body.String())
}
}
func TestHandlePlaybackCapabilityV3AdvertisesTheFinalizedContract(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
@@ -672,21 +530,22 @@ func TestPreferredAudioTrackIndexV3PropagatesSeriesPreferenceReadFailure(t *test
AudioTracks: []models.AudioTrack{{Codec: "aac", Language: "eng"}, {Codec: "aac", Language: "spa"}},
}
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", "", file); !errors.Is(err, wantErr) {
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", file); !errors.Is(err, wantErr) {
t.Fatalf("preferredAudioTrackIndexV3 error = %v, want %v", err, wantErr)
}
}
func TestPreferredAudioTrackIndexV3PropagatesCanonicalPreferenceReadFailure(t *testing.T) {
wantErr := errors.New("settings resolution unavailable")
store := failingSettingsResolutionStoreV3{UserStore: newPlaybackTestStore(t), err: wantErr}
func TestPreferredAudioTrackIndexV3PropagatesLibraryPreferenceReadFailure(t *testing.T) {
wantErr := errors.New("library playback preference store unavailable")
store := failingLibraryPlaybackPreferenceStoreV3{UserStore: newPlaybackTestStore(t), err: wantErr}
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.StoreProvider = testUserStoreProvider{store: store}
file := &models.MediaFile{
AudioTracks: []models.AudioTrack{{Codec: "aac", Language: "eng"}, {Codec: "aac", Language: "spa"}},
MediaFolderID: 9,
AudioTracks: []models.AudioTrack{{Codec: "aac", Language: "eng"}, {Codec: "aac", Language: "spa"}},
}
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", "living-room", file); !errors.Is(err, wantErr) {
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", file); !errors.Is(err, wantErr) {
t.Fatalf("preferredAudioTrackIndexV3 error = %v, want %v", err, wantErr)
}
}
@@ -707,7 +566,6 @@ func TestHandleReplanPlaybackV3UpdatesSelectedAudioAndReplaysIdempotently(t *tes
file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo", Language: "spa"})
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
stubCopySeekAnchorV3(handler)
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"allow_4k_transcode": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
@@ -1328,7 +1186,6 @@ func TestHandleReplanPlaybackV3SeekReanchorPreservesFallbackRecipe(t *testing.T)
file := v3HandlerFixtureFile(t)
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
stubCopySeekAnchorV3(handler)
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
{Name: "audio_to_aac", RecipeVersion: "1", Available: true},
@@ -1412,7 +1269,6 @@ func TestHandleReplanPlaybackV3SeekReanchorWithoutFrozenRecipeFailsRetryably(t *
file := v3HandlerFixtureFile(t)
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
stubCopySeekAnchorV3(handler)
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
@@ -1512,7 +1368,6 @@ func TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion(t *te
source.ID: source, alternate.ID: alternate,
}
handler := NewPlaybackHandler(manager, mapPlaybackFileResolver{files: files})
stubCopySeekAnchorV3(handler)
handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{
source.ContentID: {source, alternate},
}}
@@ -1739,10 +1594,10 @@ func TestHandleReplanPlaybackV3SeekUsesEffectiveEditionWhenRequestedEditionIsGon
t.Fatal(err)
}
outputContext := current.NormalizedRequest.ClientPlaybackContext
outputContext.Output.OutputContextID = "route-2"
currentKey = playback.PlanAttemptKeyV3(current.CurrentPlan, current.NormalizedRequest.ClientPlaybackContext.Output.OutputContextID, nil)
ordinary := playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3,
Operation: playback.ReplanOperationOutputChangeV3,
PlaybackAttemptID: current.PlaybackAttemptID,
ReplanRequestID: "ordinary-missing-requested-0001",
FailedPlanID: current.CurrentPlanID,
@@ -1753,7 +1608,7 @@ func TestHandleReplanPlaybackV3SeekUsesEffectiveEditionWhenRequestedEditionIsGon
QualityPreference: current.NormalizedRequest.QualityPreference,
PositionSeconds: 320,
SelectedTracks: current.CurrentPlan.SelectedTracks,
Failure: playback.FailureV3{},
Failure: playback.FailureV3{Classification: "output_route_changed"},
Capabilities: current.NormalizedRequest.Capabilities,
ClientPlaybackContext: outputContext,
}
@@ -2199,40 +2054,29 @@ func TestFrozenSubtitleIdentityV3RejectsExternalAndEmbeddedInventoryDrift(t *tes
}
}
func TestPrepareTransportV3ProgressiveRemuxUsesResolvedCopyAnchor(t *testing.T) {
func TestPrepareIdentityTransportV3ProgressiveRemuxNeverAdvertisesNativeSeek(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
handler.copySeekAnchor = func(_ context.Context, _ string, inputPath string, requested float64, segmentDuration int) (float64, int, error) {
if inputPath != "/media/movie.mkv" || segmentDuration != 2 {
t.Fatalf("copy seek probe input=%q segment=%d", inputPath, segmentDuration)
}
return requested - 0.75, int((requested - 0.75) / float64(segmentDuration)), nil
}
session := &playback.Session{ID: "session-progressive", UserID: 7, ProfileID: "profile-1", MediaFileID: 42, PlayMethod: playback.PlayRemux, BasePlayMethod: playback.PlayRemux, AudioTrackIndex: 0}
file := &models.MediaFile{ID: 42, FilePath: "/media/movie.mkv"}
for index, requested := range []float64{321.25, 654.5} {
for index, origin := range []float64{321.25, 654.5} {
plan := &playback.PlanV3{
PlanID: "plan:progressive",
Delivery: playback.DeliveryRemuxProgressiveV3,
EffectiveMediaFileID: 42,
Timeline: playback.TimelineV3{SourceStartSeconds: requested, PlayerStartSeconds: requested, CanSeekAnywhere: true, SeekRestoration: "player_position"},
}
transport, transportErr := handler.prepareTransportV3(httptest.NewRequest(http.MethodPost, "/", nil), session, file, playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux})
if transportErr != nil {
t.Fatalf("prepare progressive transport: %v", transportErr)
Timeline: playback.TimelineV3{SourceStartSeconds: origin, PlayerStartSeconds: origin, CanSeekAnywhere: true, SeekRestoration: "player_position"},
}
transport := handler.prepareIdentityTransportV3(session, playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux})
parsed, err := url.Parse(transport.url)
if err != nil {
transport.rollback()
t.Fatal(err)
}
if parsed.Query().Get("st") == "" || parsed.Query().Get("seek") != strconv.FormatFloat(requested, 'f', -1, 64) {
if parsed.Query().Get("st") == "" || parsed.Query().Get("seek") != strconv.FormatFloat(origin, 'f', -1, 64) {
transport.rollback()
t.Fatalf("progressive reanchor URL %d = %q", index, transport.url)
}
origin := requested - 0.75
if plan.Timeline.PlayerStartSeconds != 0.75 || plan.Timeline.StreamOriginSeconds != origin ||
if plan.Timeline.PlayerStartSeconds != 0 || plan.Timeline.StreamOriginSeconds != origin ||
plan.Timeline.TimelineOffsetSeconds != origin || plan.Timeline.CanSeekAnywhere ||
plan.Timeline.SeekWindowStartSeconds == nil || *plan.Timeline.SeekWindowStartSeconds != origin ||
plan.Timeline.SeekWindowEndSeconds != nil || plan.Timeline.SeekRestoration != "source_position" {
@@ -2243,77 +2087,6 @@ func TestPrepareTransportV3ProgressiveRemuxUsesResolvedCopyAnchor(t *testing.T)
}
}
func TestPrepareTransportV3AudioOnlyRemuxSkipsVideoCopyAnchor(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
probeCalls := 0
handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) {
probeCalls++
return 0, 0, errors.New("audio-only remux must not probe a video keyframe")
}
requested := 321.25
plan := &playback.PlanV3{
PlanID: "plan:audio-only-progressive",
Delivery: playback.DeliveryRemuxProgressiveV3,
Timeline: playback.TimelineV3{
SourceStartSeconds: requested,
PlayerStartSeconds: requested,
CanSeekAnywhere: true,
SeekRestoration: "player_position",
},
}
file := &models.MediaFile{
ID: 42,
BaseType: "audiobook",
FilePath: "/media/book.m4b",
CodecAudio: "aac",
AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2}},
}
transport, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-audio-only", MediaFileID: 42},
file,
playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux, TargetAudioCodec: "aac"},
)
if transportErr != nil {
t.Fatalf("prepare audio-only transport: %v", transportErr)
}
defer transport.rollback()
if probeCalls != 0 {
t.Fatalf("audio-only copy anchor probes = %d, want 0", probeCalls)
}
parsed, err := url.Parse(transport.url)
if err != nil {
t.Fatal(err)
}
if parsed.Query().Get("seek") != strconv.FormatFloat(requested, 'f', -1, 64) {
t.Fatalf("audio-only seek URL = %q", transport.url)
}
if plan.Timeline.PlayerStartSeconds != 0 || plan.Timeline.CanSeekAnywhere ||
plan.Timeline.SeekRestoration != "source_position" || plan.Timeline.StreamOriginSeconds != requested ||
plan.Timeline.TimelineOffsetSeconds != requested || plan.Timeline.SeekWindowStartSeconds == nil ||
*plan.Timeline.SeekWindowStartSeconds != requested || plan.Timeline.SeekWindowEndSeconds != nil {
t.Fatalf("audio-only timeline changed = %#v", plan.Timeline)
}
}
func TestPrepareTransportV3CopyAnchorFailureIsRetryable(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) {
return 0, 0, errors.New("probe failed")
}
plan := &playback.PlanV3{PlanID: "plan:copy-failure", Delivery: playback.DeliveryRemuxHLSV3, Timeline: playback.TimelineV3{SourceStartSeconds: 120}}
_, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-copy-failure"},
&models.MediaFile{ID: 42, FilePath: "/media/movie.mkv"},
playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux},
)
if transportErr == nil || transportErr.reason != "transcode_start_failed" || !transportErr.retryable || transportErr.cause == nil || transportErr.cause.Error() != "probe failed" {
t.Fatalf("transport error = %#v, want retryable copy anchor failure", transportErr)
}
}
func TestPrepareTransportV3RejectsNodeMissingRequiredTransformation(t *testing.T) {
startHits := 0
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -2394,52 +2167,6 @@ func TestPrepareTransportV3RequiresRemoteManifestReadiness(t *testing.T) {
}
}
func TestPrepareTransportV3SendsResolvedCopyAnchorToRemoteExecutor(t *testing.T) {
var startRequest transcodenode.TranscodeStartRequest
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/hw-capabilities":
writeJSON(w, http.StatusOK, playback.HWAccelInfo{})
case r.Method == http.MethodPost && r.URL.Path == "/transcode/start":
if err := json.NewDecoder(r.Body).Decode(&startRequest); err != nil {
t.Errorf("decode remote start: %v", err)
}
writeJSON(w, http.StatusAccepted, transcodenode.TranscodeStartResponse{SessionID: startRequest.SessionID, Status: "started"})
case r.Method == http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer remote.Close()
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
handler.NodePlanner = staticNodePlannerV3{plan: nodepool.Plan{TranscodeNode: &nodepool.Node{URL: remote.URL}}}
handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) {
return 1085.501, 542, nil
}
plan := &playback.PlanV3{PlanID: "plan:remote-copy-anchor", Delivery: playback.DeliveryRemuxHLSV3, Timeline: playback.TimelineV3{SourceStartSeconds: 1086.2}}
transport, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-remote-copy-anchor", UserID: 7, ProfileID: "profile-1"},
&models.MediaFile{ID: 42, FilePath: "/media/movie.mkv", CodecVideo: "h264"},
playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux, TargetAudioCodec: "aac"},
)
if transportErr != nil {
t.Fatalf("prepare remote copy transport: %v", transportErr)
}
defer transport.rollback()
if startRequest.SeekSeconds != 1086.2 || startRequest.StreamOriginSeconds != 1085.501 ||
!startRequest.CopySeekAnchorResolved || startRequest.StartSegmentNumber != 542 {
t.Fatalf("remote copy timeline = %#v", startRequest)
}
if plan.Timeline.StreamOriginSeconds != 1085.501 || plan.Timeline.TimelineOffsetSeconds != 1085.501 ||
math.Abs(plan.Timeline.PlayerStartSeconds-0.699) > 0.0001 {
t.Fatalf("advertised copy timeline = %#v", plan.Timeline)
}
}
func TestPrepareTransportV3UsesFrozenSourceMetadataAfterProbeDrift(t *testing.T) {
var startRequest transcodenode.TranscodeStartRequest
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -2534,11 +2261,7 @@ func TestPrepareLocalTransportV3ReturnsStableTerminalWhenFFmpegExitsBeforeReady(
SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1,
}
request := httptest.NewRequest(http.MethodPost, "/", nil)
timeline, timelineErr := handler.prepareTransportTimelineV3(request.Context(), &playback.Session{ID: "session-startup-failure"}, file, result)
if timelineErr != nil {
t.Fatalf("prepare timeline: %v", timelineErr)
}
transport, transportErr := handler.prepareLocalTransportV3(request, &playback.Session{ID: "session-startup-failure", UserID: 7, ProfileID: "profile-1"}, file, result, timeline)
transport, transportErr := handler.prepareLocalTransportV3(request, &playback.Session{ID: "session-startup-failure", UserID: 7, ProfileID: "profile-1"}, file, result)
if transportErr == nil {
transport.rollback()
t.Fatal("failed ffmpeg startup returned a playable transport")
@@ -2578,8 +2301,7 @@ func TestManifestStartupTimeoutWhileRunningIsPersistedIdempotently(t *testing.T)
failure := manifestStartupTransportErrorV3(session.IsRunning(), waitErr)
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
request := v3HandlerStartRequest()
requestDigests := playbackStartRequestDigestsV3{current: "digest"}
response, err := handler.startFailureDecisionV3(context.Background(), 1, request.ProfileID, request, requestDigests, request.FileID, request.FileID, failure)
response, err := handler.startFailureDecisionV3(context.Background(), 1, request.ProfileID, request, "digest", request.FileID, request.FileID, failure)
if err != nil {
t.Fatalf("start failure decision: %v", err)
}
@@ -2590,7 +2312,7 @@ func TestManifestStartupTimeoutWhileRunningIsPersistedIdempotently(t *testing.T)
if err != nil || record.StartResponse.Terminal == nil || !record.StartResponse.Terminal.Retryable {
t.Fatalf("retryable startup timeout attempt = %#v, err=%v", record, err)
}
replayed, err := handler.startFailureDecisionV3(context.Background(), 1, request.ProfileID, request, requestDigests, request.FileID, request.FileID, failure)
replayed, err := handler.startFailureDecisionV3(context.Background(), 1, request.ProfileID, request, "digest", request.FileID, request.FileID, failure)
if err != nil || replayed.Terminal == nil || replayed.Terminal.Reason != response.Terminal.Reason {
t.Fatalf("retryable timeout replay = %#v, err=%v", replayed, err)
}
@@ -2603,12 +2325,12 @@ func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) {
// locally seekable — sending them past the produced head instead of back
// to the server. The runtime belongs on source.duration_seconds.
copyPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
configureCopyRemuxTimelineV3(copyPlan, 16)
if copyPlan.Timeline.StreamOriginSeconds != 16 || copyPlan.Timeline.TimelineOffsetSeconds != 16 || math.Abs(copyPlan.Timeline.PlayerStartSeconds-1.3) > 0.0001 || copyPlan.Timeline.CanSeekAnywhere ||
copyPlan.Timeline.SeekWindowStartSeconds == nil || *copyPlan.Timeline.SeekWindowStartSeconds != 16 ||
copySeek, copySegment := configureHLSTimelineV3(copyPlan, "copy", 2, 600)
if copySeek != 17.3 || copySegment != 8 || copyPlan.Timeline.StreamOriginSeconds != 17.3 || copyPlan.Timeline.TimelineOffsetSeconds != 17.3 || copyPlan.Timeline.PlayerStartSeconds != 0 || copyPlan.Timeline.CanSeekAnywhere ||
copyPlan.Timeline.SeekWindowStartSeconds == nil || *copyPlan.Timeline.SeekWindowStartSeconds != 17.3 ||
copyPlan.Timeline.SeekWindowEndSeconds != nil ||
copyPlan.Timeline.SeekRestoration != "source_position" {
t.Fatalf("copy timeline=%#v", copyPlan.Timeline)
t.Fatalf("copy timeline=%#v seek=%v segment=%d", copyPlan.Timeline, copySeek, copySegment)
}
encodePlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
@@ -2810,12 +2532,6 @@ func v3HandlerFixtureFile(t *testing.T) *models.MediaFile {
return &models.MediaFile{ID: 42, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie.mp4"), Container: "mp4", CodecVideo: "h264", CodecAudio: "aac", Resolution: "1080p", Bitrate: 8_000, AudioChannels: 2, Duration: 3600, VideoTracks: []models.VideoTrack{{Codec: "h264", Profile: "high", Level: 41, Width: 1920, Height: 1080, FrameRate: "24000/1001", Bitrate: 8_000, BitDepth: 8, VideoRange: "SDR", VideoRangeType: "SDR"}}, AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2, Layout: "stereo", Default: true}}}
}
func stubCopySeekAnchorV3(handler *PlaybackHandler) {
handler.copySeekAnchor = func(_ context.Context, _ string, _ string, requested float64, segmentDuration int) (float64, int, error) {
return requested, computeStartSegment(requested, segmentDuration), nil
}
}
func v3HandlerStartRequest() playback.StartRequestV3 {
return playback.StartRequestV3{ProtocolVersion: playback.ProtocolV3, ClientFeatures: []string{playback.FeaturePlaybackPlanV3}, FileID: 42, ProfileID: "profile-1", PlaybackAttemptID: "attempt-handler-0001", QualityPreference: "original", SubtitleFidelityPreference: playback.SubtitleFidelityCompatibleV3, Capabilities: playback.ClientCodecCapabilitiesV3{VideoEvidence: playback.EvidenceExactV3, AudioEvidence: playback.EvidenceExactV3, CodecsVideo: []string{"h264"}, CodecsVideoHardware: []string{"h264"}, CodecsAudio: []string{"aac"}, Containers: []string{"mp4"}, MaxResolution: "1080p", VideoDecode: []playback.VideoDecodeCapabilityV3{{Codec: "h264", Profiles: []string{"high"}, Levels: []int{41}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 20_000, Hardware: true}}}, ClientPlaybackContext: playback.ClientPlaybackContextV3{ProtocolVersion: playback.ProtocolV3, FormFactor: "tv", AppVersion: "test", Device: playback.DeviceContextV3{Platform: "android"}, Output: playback.OutputContextV3{OutputContextID: "route-1"}, Deliveries: map[string]playback.DeliveryCapabilityV3{playback.DeliveryClassOriginalHTTPV3: {Enabled: true, SupportedOnDevice: true, Subtitles: playback.DeliverySubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}}}}}
}
@@ -2903,108 +2619,6 @@ func TestHandleReplanPlaybackV3PreservesOmittedSubtitleAndReportsUnavailableInFa
}
}
func TestHandleReplanPlaybackV3BitmapSubtitleFallsBackFromHDRToSDRVersion(t *testing.T) {
source := v3HandlerFixtureFile(t)
source.Container = "mkv"
source.FilePath = writePlaybackTestMediaFile(t, "movie-4k.mkv")
source.CodecVideo = "hevc"
source.Resolution = "2160p"
source.Bitrate = 64_000
source.VideoTracks = []models.VideoTrack{{
Codec: "hevc", Profile: "main 10", Level: 153, Width: 3840, Height: 2160,
FrameRate: "24000/1001", Bitrate: 64_000, BitDepth: 10,
VideoRange: "HDR10", VideoRangeType: "HDR10",
}}
source.SubtitleTracks = []models.SubtitleTrack{{Index: 4, Codec: "hdmv_pgs_subtitle", Language: "eng", Title: "English"}}
alternateValue := *source
alternate := &alternateValue
alternate.ID = 84
alternate.FilePath = writePlaybackTestMediaFile(t, "movie-1080p.mkv")
alternate.CodecVideo = "h264"
alternate.Resolution = "1080p"
alternate.Bitrate = 8_000
alternate.VideoTracks = []models.VideoTrack{{
Codec: "h264", Profile: "high", Level: 41, Width: 1920, Height: 1080,
FrameRate: "24000/1001", Bitrate: 8_000, BitDepth: 8,
VideoRange: "SDR", VideoRangeType: "SDR",
}}
files := map[int]*models.MediaFile{source.ID: source, alternate.ID: alternate}
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, mapPlaybackFileResolver{files: files})
handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{
source.ContentID: {source, alternate},
}}
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"transcode_enabled": "true", "allow_4k_transcode": "true"}}
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
{Name: playback.TransformationAudioToAACV3, RecipeVersion: "1", Available: true},
{Name: playback.TransformationVideoToH264V3, RecipeVersion: "2", Available: true},
}))
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
startRequest.QualityPreference = "auto"
startRequest.Capabilities.CodecsVideo = []string{"hevc", "h264"}
startRequest.Capabilities.CodecsVideoHardware = []string{"hevc", "h264"}
startRequest.Capabilities.Containers = []string{"mkv", "hls"}
startRequest.Capabilities.MaxResolution = "2160p"
startRequest.Capabilities.VideoDecode = []playback.VideoDecodeCapabilityV3{{
Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10},
MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true,
}}
hdr := &playback.HDRCapabilitiesV3{HDR10: true}
startRequest.Capabilities.HDRDetails = hdr
startRequest.ClientPlaybackContext.Output.HDRDetails = hdr
startRequest.ClientPlaybackContext.Deliveries = map[string]playback.DeliveryCapabilityV3{
playback.DeliveryClassOriginalHTTPV3: {
Enabled: true, SupportedOnDevice: true, Containers: []string{"mkv"},
VideoCodecs: []string{"hevc", "h264"}, AudioDecodeCodecs: []string{"aac"},
},
playback.DeliveryClassHLSV3: {
Enabled: true, SupportedOnDevice: true, Containers: []string{"hls"},
VideoCodecs: []string{"h264"}, AudioDecodeCodecs: []string{"aac"},
},
}
startRR := httptest.NewRecorder()
handler.HandleStartPlayback(startRR, httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext()))
var started playback.DecisionResponseV3
if startRR.Code != http.StatusCreated || json.Unmarshal(startRR.Body.Bytes(), &started) != nil || started.PlaybackPlan == nil {
t.Fatalf("start status=%d body=%s", startRR.Code, startRR.Body.String())
}
if started.PlaybackPlan.EffectiveMediaFileID != source.ID {
t.Fatalf("start effective file = %d, want HDR source %d", started.PlaybackPlan.EffectiveMediaFileID, source.ID)
}
subtitleIndex := 0
replanned := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationTrackChangeV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID, ReplanRequestID: "hdr-subtitle-fallback-0001",
FailedPlanID: started.PlaybackPlan.PlanID, PlanAttemptID: "hdr-subtitle-attempt-0001",
PlanAttemptKey: started.PlaybackPlan.PlanAttemptKey, AttemptCount: 1, PositionSeconds: 30,
QualityPreference: "auto",
SelectedTracks: playback.SelectedTracksV3{
Audio: started.PlaybackPlan.SelectedTracks.Audio,
Subtitle: &playback.TrackIdentityV3{
ID: playback.TrackIDV3(source.ID, "subtitle", subtitleIndex), Index: &subtitleIndex,
},
},
Capabilities: startRequest.Capabilities, ClientPlaybackContext: startRequest.ClientPlaybackContext,
})
if replanned.PlaybackPlan == nil || replanned.Terminal != nil {
t.Fatalf("subtitle replan = %#v", replanned)
}
if replanned.PlaybackPlan.EffectiveMediaFileID != alternate.ID {
t.Fatalf("subtitle effective file = %d, want SDR alternate %d", replanned.PlaybackPlan.EffectiveMediaFileID, alternate.ID)
}
if replanned.PlaybackPlan.Subtitle.Mode != playback.SubtitleBurnInV3 || replanned.PlaybackPlan.SelectedTracks.Subtitle == nil {
t.Fatalf("subtitle plan = %#v, want burn-in selection", replanned.PlaybackPlan)
}
t.Cleanup(func() { handler.tm.CloseTranscodeSession(started.SessionID, "") })
}
func TestHandleReplanPlaybackV3TrackChangeStaysOnEffectiveAlternate(t *testing.T) {
source := v3HandlerFixtureFile(t)
source.Resolution = "2160p"
@@ -3019,12 +2633,10 @@ func TestHandleReplanPlaybackV3TrackChangeStaysOnEffectiveAlternate(t *testing.T
alternate.VideoTracks[0].Level, alternate.VideoTracks[0].Bitrate = 41, 8_000
alternate.AudioTracks = append([]models.AudioTrack(nil), source.AudioTracks...)
alternate.AudioTracks = append(alternate.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo", Language: "spa"})
alternate.ExternalSubtitles = []models.ExternalSubtitle{{Path: writePlaybackTestMediaFile(t, "alternate.eng.ass"), Language: "eng", Format: "ass"}}
files := map[int]*models.MediaFile{source.ID: source, alternate.ID: alternate}
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, mapPlaybackFileResolver{files: files})
stubCopySeekAnchorV3(handler)
handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{source.ContentID: {source, alternate}}}
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"allow_4k_transcode": "false"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
@@ -3053,111 +2665,6 @@ func TestHandleReplanPlaybackV3TrackChangeStaysOnEffectiveAlternate(t *testing.T
if response.PlaybackPlan.SelectedTracks.Audio == nil || response.PlaybackPlan.SelectedTracks.Audio.Index == nil || *response.PlaybackPlan.SelectedTracks.Audio.Index != audioIndex {
t.Fatalf("alternate audio selection = %#v", response.PlaybackPlan.SelectedTracks.Audio)
}
subtitleIndex := 0
withSubtitle := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationTrackChangeV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID, ReplanRequestID: "alternate-subtitle-change-0001",
FailedPlanID: response.PlaybackPlan.PlanID, PlanAttemptID: "alternate-subtitle-attempt-0001",
PlanAttemptKey: response.PlaybackPlan.PlanAttemptKey, AttemptCount: 1, PositionSeconds: 31,
SelectedTracks: playback.SelectedTracksV3{
Audio: response.PlaybackPlan.SelectedTracks.Audio,
Subtitle: &playback.TrackIdentityV3{Index: &subtitleIndex},
},
Capabilities: startRequest.Capabilities, ClientPlaybackContext: startRequest.ClientPlaybackContext,
})
if withSubtitle.PlaybackPlan == nil || withSubtitle.PlaybackPlan.EffectiveMediaFileID != alternate.ID || withSubtitle.PlaybackPlan.SelectedTracks.Subtitle == nil {
t.Fatalf("alternate subtitle selection failed: %#v", withSubtitle)
}
upgradedCapabilities := startRequest.Capabilities
upgradedCapabilities.MaxResolution = "2160p"
upgradedCapabilities.VideoDecode = append([]playback.VideoDecodeCapabilityV3(nil), startRequest.Capabilities.VideoDecode...)
upgradedCapabilities.VideoDecode[0].Levels = []int{51}
upgradedCapabilities.VideoDecode[0].MaxWidth = 3840
upgradedCapabilities.VideoDecode[0].MaxHeight = 2160
upgradedCapabilities.VideoDecode[0].MaxBitrateKbps = 50_000
outputResponse := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationOutputChangeV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID, ReplanRequestID: "alternate-output-change-0001",
FailedPlanID: withSubtitle.PlaybackPlan.PlanID, PlanAttemptID: "alternate-output-attempt-0001",
PlanAttemptKey: withSubtitle.PlaybackPlan.PlanAttemptKey, AttemptCount: 1, PositionSeconds: 32,
SelectedTracks: withSubtitle.PlaybackPlan.SelectedTracks,
Capabilities: upgradedCapabilities,
ClientPlaybackContext: startRequest.ClientPlaybackContext,
})
if outputResponse.PlaybackPlan == nil || outputResponse.PlaybackPlan.EffectiveMediaFileID != alternate.ID {
t.Fatalf("output change abandoned alternate-only subtitle: %#v", outputResponse)
}
if outputResponse.PlaybackPlan.SelectedTracks.Subtitle == nil || !strings.HasPrefix(outputResponse.PlaybackPlan.SelectedTracks.Subtitle.ID, "file:84:subtitle:") {
t.Fatalf("output change lost alternate subtitle: %#v", outputResponse.PlaybackPlan.SelectedTracks.Subtitle)
}
}
func TestHandleReplanPlaybackV3OutputChangeRetriesActiveAlternateAfterRequestedTerminal(t *testing.T) {
source := v3HandlerFixtureFile(t)
source.Resolution, source.Bitrate = "2160p", 32_000
source.VideoTracks = append([]models.VideoTrack(nil), source.VideoTracks...)
source.VideoTracks[0].Width, source.VideoTracks[0].Height = 3840, 2160
source.VideoTracks[0].Level, source.VideoTracks[0].Bitrate = 51, 32_000
alternateValue := *source
alternate := &alternateValue
alternate.ID, alternate.Container, alternate.Resolution, alternate.Bitrate = 84, "mp4", "1080p", 8_000
alternate.VideoTracks = append([]models.VideoTrack(nil), source.VideoTracks...)
alternate.VideoTracks[0].Width, alternate.VideoTracks[0].Height = 1920, 1080
alternate.VideoTracks[0].Level, alternate.VideoTracks[0].Bitrate = 41, 8_000
files := map[int]*models.MediaFile{source.ID: source, alternate.ID: alternate}
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), mapPlaybackFileResolver{files: files})
stubCopySeekAnchorV3(handler)
handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{source.ContentID: {source, alternate}}}
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"allow_4k_transcode": "false"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
startRequest.QualityPreference = "auto"
startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassProgressiveV3] = playback.DeliveryCapabilityV3{
Enabled: true, SupportedOnDevice: true, Containers: []string{"mp4"},
VideoCodecs: []string{"h264"}, AudioDecodeCodecs: []string{"aac"},
}
startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassHLSV3] = playback.DeliveryCapabilityV3{
Enabled: true, SupportedOnDevice: true, Containers: []string{"hls"},
VideoCodecs: []string{"h264"}, AudioDecodeCodecs: []string{"aac"},
}
startRR := httptest.NewRecorder()
handler.HandleStartPlayback(startRR, httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext()))
var started playback.DecisionResponseV3
if startRR.Code != http.StatusCreated || json.Unmarshal(startRR.Body.Bytes(), &started) != nil || started.PlaybackPlan == nil || started.PlaybackPlan.EffectiveMediaFileID != alternate.ID {
t.Fatalf("alternate start status=%d body=%s", startRR.Code, startRR.Body.String())
}
// The inactive requested edition can acquire a different constraint before
// an output refresh (for example after a rescan). Its speculative terminal
// must not displace the already-playing alternate.
source.Container = "mkv"
source.CodecVideo = "av1"
source.VideoTracks[0].Codec = "av1"
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"transcode_enabled": "false", "allow_4k_transcode": "false"}}
upgradedCapabilities := startRequest.Capabilities
upgradedCapabilities.MaxResolution = "2160p"
upgradedCapabilities.VideoDecode = append([]playback.VideoDecodeCapabilityV3(nil), startRequest.Capabilities.VideoDecode...)
upgradedCapabilities.VideoDecode[0].Levels = []int{51}
upgradedCapabilities.VideoDecode[0].MaxWidth = 3840
upgradedCapabilities.VideoDecode[0].MaxHeight = 2160
upgradedCapabilities.VideoDecode[0].MaxBitrateKbps = 50_000
response := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationOutputChangeV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID, ReplanRequestID: "alternate-terminal-output-0001",
FailedPlanID: started.PlaybackPlan.PlanID, PlanAttemptID: "alternate-terminal-attempt-0001",
PlanAttemptKey: started.PlaybackPlan.PlanAttemptKey, AttemptCount: 1, PositionSeconds: 33,
SelectedTracks: started.PlaybackPlan.SelectedTracks,
Capabilities: upgradedCapabilities,
ClientPlaybackContext: startRequest.ClientPlaybackContext,
})
if response.Terminal != nil || response.PlaybackPlan == nil || response.PlaybackPlan.EffectiveMediaFileID != alternate.ID {
t.Fatalf("requested-edition terminal abandoned active alternate: %#v", response)
}
}
func marshalV3StartRequest(t *testing.T, request playback.StartRequestV3) string {
@@ -3176,7 +2683,6 @@ func TestHandleReplanPlaybackV3TrackChangeOperation(t *testing.T) {
file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo", Language: "spa"})
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
stubCopySeekAnchorV3(handler)
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"allow_4k_transcode": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
-56
View File
@@ -98,62 +98,6 @@ func (h *SubtitleSearchHandler) authorizeMediaFile(w http.ResponseWriter, r *htt
return authorizeMediaFileAccess(w, r, h.FileAuthorizer, fileID)
}
// subtitleProviderStatusResponse tells a client whether this deployment can
// search external subtitle providers at all, following the per-subsystem
// capability convention (/subtitles/ai/status, /items/trailers/capability).
//
// Without it a search on a server with no providers configured answers exactly
// like a search that ran and matched nothing, so a player has no way to tell
// "this server cannot do that" from "nothing found for this file" and ends up
// offering an entry point that can never succeed. A client that finds enabled
// false should disable the search action and say why rather than let the user
// run a query that is guaranteed to come back empty.
//
// Provider names are safe to return to any authenticated viewer: they already
// travel in every SubtitleResult.provider and DownloadedSubtitle.provider. The
// credentials behind them stay in the admin-only provider config.
type subtitleProviderStatusResponse struct {
SchemaVersion int `json:"schema_version"`
// Enabled reports that at least one provider is registered, so
// POST /subtitles/search can actually reach an upstream.
Enabled bool `json:"enabled"`
// Providers is the registered provider identifiers, sorted. Always a
// list — empty rather than null — so clients can iterate it unguarded.
Providers []string `json:"providers"`
}
// HandleProviderStatus reports whether external subtitle search is available
// here, so the player can show or hide the entry point.
// GET /api/v1/subtitles/providers/status
//
// It answers even when the subtitle subsystem is unwired, because enabled:false
// is the answer in that case; the router registers a fallback so a client never
// has to interpret a 404 on the probe itself.
func (h *SubtitleSearchHandler) HandleProviderStatus(w http.ResponseWriter, _ *http.Request) {
providers := []string{}
if h != nil && h.manager != nil {
providers = h.manager.ProviderNames()
}
writeJSON(w, http.StatusOK, subtitleProviderStatusResponse{
SchemaVersion: 1,
Enabled: len(providers) > 0,
Providers: providers,
})
}
// WriteSubtitleProvidersDisabledStatus answers the subtitle provider capability
// probe with a 200 {"enabled": false, "providers": []} when no subtitle handler
// is wired, so the client gets a clean negative instead of a 404 (the 2-segment
// /providers/status path is not shadowed by the 1-segment /{media_file_id}
// route — they never compete in chi's router).
func WriteSubtitleProvidersDisabledStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, subtitleProviderStatusResponse{
SchemaVersion: 1,
Enabled: false,
Providers: []string{},
})
}
// HandleSearch handles POST /api/v1/subtitles/search
func (h *SubtitleSearchHandler) HandleSearch(w http.ResponseWriter, r *http.Request) {
var req searchSubtitlesRequest
@@ -235,109 +235,6 @@ func TestHandleDeleteRequiresAccessToMediaFile(t *testing.T) {
}
}
// stubSubtitleProvider is a registerable no-op provider; only its name matters
// to the capability probe.
type stubSubtitleProvider struct {
name string
}
func (s stubSubtitleProvider) Name() string { return s.name }
func (s stubSubtitleProvider) Search(context.Context, subtitles.SearchRequest) ([]subtitles.SubtitleResult, error) {
return nil, nil
}
func (s stubSubtitleProvider) Download(context.Context, string) ([]byte, subtitles.SubtitleFormat, error) {
return nil, subtitles.FormatSRT, nil
}
func decodeProviderStatus(t *testing.T, rr *httptest.ResponseRecorder) struct {
SchemaVersion int `json:"schema_version"`
Enabled bool `json:"enabled"`
Providers []string `json:"providers"`
} {
t.Helper()
var resp struct {
SchemaVersion int `json:"schema_version"`
Enabled bool `json:"enabled"`
Providers []string `json:"providers"`
}
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.SchemaVersion != 1 {
t.Fatalf("schema_version = %d, want 1", resp.SchemaVersion)
}
return resp
}
func TestHandleProviderStatusWithoutProviders(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
rr := httptest.NewRecorder()
handler.HandleProviderStatus(rr, newSubtitleAuthRequest(http.MethodGet, "/subtitles/providers/status", nil))
resp := decodeProviderStatus(t, rr)
if resp.Enabled {
t.Fatal("enabled = true, want false with no providers registered")
}
if len(resp.Providers) != 0 {
t.Fatalf("providers = %v, want empty", resp.Providers)
}
// The empty list must reach the wire as [] — a null would read to a
// client as a missing field rather than "no providers here".
if !bytes.Contains(rr.Body.Bytes(), []byte(`"providers":[]`)) {
t.Fatalf("body = %s, want providers serialized as []", rr.Body.String())
}
}
func TestHandleProviderStatusWithProviders(t *testing.T) {
repo := newMockSubtitleRepoForHandler()
manager := subtitles.NewManager(repo, newMockS3ClientForHandler(), "test-bucket")
manager.RegisterProvider(stubSubtitleProvider{name: "subdl"})
manager.RegisterProvider(stubSubtitleProvider{name: "opensubtitles"})
handler := NewSubtitleSearchHandler(manager, repo, stubSubtitleMediaResolver{})
rr := httptest.NewRecorder()
handler.HandleProviderStatus(rr, newSubtitleAuthRequest(http.MethodGet, "/subtitles/providers/status", nil))
resp := decodeProviderStatus(t, rr)
if !resp.Enabled {
t.Fatal("enabled = false, want true with providers registered")
}
want := []string{"opensubtitles", "subdl"}
if len(resp.Providers) != len(want) {
t.Fatalf("providers = %v, want %v", resp.Providers, want)
}
for i, name := range want {
if resp.Providers[i] != name {
t.Fatalf("providers = %v, want %v (sorted)", resp.Providers, want)
}
}
}
func TestWriteSubtitleProvidersDisabledStatus(t *testing.T) {
rr := httptest.NewRecorder()
WriteSubtitleProvidersDisabledStatus(rr, newSubtitleAuthRequest(http.MethodGet, "/subtitles/providers/status", nil))
resp := decodeProviderStatus(t, rr)
if resp.Enabled {
t.Fatal("enabled = true, want false from the disabled fallback")
}
if len(resp.Providers) != 0 {
t.Fatalf("providers = %v, want empty", resp.Providers)
}
if !bytes.Contains(rr.Body.Bytes(), []byte(`"providers":[]`)) {
t.Fatalf("body = %s, want providers serialized as []", rr.Body.String())
}
}
type handlerMockSubtitleRepo struct {
subtitles map[int]*subtitles.DownloadedSubtitle
nextID int
-29
View File
@@ -1671,15 +1671,6 @@ func NewRouter(deps Dependencies) chi.Router {
// background worker.
downloadSvc.SetSubscriptions(downloads.NewSubscriptionRepository(deps.DB))
downloadHandler = handlers.NewDownloadHandler(downloadSvc)
if deps.NodePlanner != nil {
downloadHandler.SetProxyDelivery(deps.NodePlanner, func() string {
cfg := deps.CurrentConfig()
if cfg == nil {
return ""
}
return cfg.Auth.JWTSecret
})
}
if profileHandler != nil {
// Profiles may live outside Postgres (sqlite userdb backend), so
// deleting one cannot FK-cascade the shared user_devices table;
@@ -2575,11 +2566,6 @@ func NewRouter(deps Dependencies) chi.Router {
}
}
r.Route("/subtitles", func(r chi.Router) {
// Capability probe for external subtitle search. Two
// segments on purpose: a bare /providers would shadow
// the /{media_file_id} route below, while
// /providers/status never competes with it in chi.
r.Get("/providers/status", subtitleSearchHandler.HandleProviderStatus)
r.Post("/search", subtitleSearchHandler.HandleSearch)
r.Post("/download", subtitleSearchHandler.HandleDownload)
r.Post("/upload", subtitleSearchHandler.HandleUpload)
@@ -2600,17 +2586,6 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/{media_file_id}", subtitleSearchHandler.HandleList)
r.Delete("/{id}", subtitleSearchHandler.HandleDelete)
})
} else {
// The whole group above is conditional (it needs the DB,
// S3 and the subtitle repo), so on a storage-less
// deployment the capability probe would 404 — leaving a
// client to interpret the same ambiguous status the probe
// exists to replace. Mount the probe alone, answering
// enabled:false, so feature detection always gets a real
// answer.
r.Route("/subtitles", func(r chi.Router) {
r.Get("/providers/status", handlers.WriteSubtitleProvidersDisabledStatus)
})
}
// Playback routes.
@@ -2697,16 +2672,12 @@ func NewRouter(deps Dependencies) chi.Router {
// HEAD natively.
r.Get("/{id}/file", downloadHandler.HandleDownloadFile)
r.Head("/{id}/file", downloadHandler.HandleDownloadFile)
r.Get("/{id}/file-proxy", downloadHandler.HandleDownloadFileViaProxy)
r.Head("/{id}/file-proxy", downloadHandler.HandleDownloadFileViaProxy)
r.Get("/{id}/manifest", downloadHandler.HandleManifest)
r.Get("/{id}/artwork/{kind}", downloadHandler.HandleArtwork)
r.Get("/{id}/subtitles/{ref}", downloadHandler.HandleSubtitle)
})
r.Get("/direct-download", downloadHandler.HandleDirectDownload)
r.Head("/direct-download", downloadHandler.HandleDirectDownload)
r.Get("/direct-download-proxy", downloadHandler.HandleDirectDownloadViaProxy)
r.Head("/direct-download-proxy", downloadHandler.HandleDirectDownloadViaProxy)
// Recipe gallery catalog (no profile required — purely static metadata).
recipeHandler := &handlers.RecipeHandler{}
-3
View File
@@ -30,9 +30,6 @@ type AccessFilter struct {
SelectedFileID int
UserID int
ProfileID string
// DeviceID identifies the requesting client for device-scoped setting
// resolution. It does not participate in catalog access control.
DeviceID string
// NamePrefix, when non-empty, restricts results to items whose
// LOWER(COALESCE(NULLIF(BTRIM(sort_title),''), title)) starts with the
// given (case-insensitive) prefix. Pushed into the SQL WHERE clause so
+3 -6
View File
@@ -3105,7 +3105,6 @@ type audioPrefResolver struct {
store userstore.UserStore
valid bool
profileID string
deviceID string
contentID string
profileDone bool
@@ -3137,7 +3136,6 @@ func (s *DetailService) newAudioPrefResolver(ctx context.Context, filter AccessF
r := &audioPrefResolver{
svc: s,
profileID: filter.ProfileID,
deviceID: filter.DeviceID,
contentID: audioPreferenceContentID,
resolvedLang: map[int]string{},
}
@@ -3178,16 +3176,15 @@ func (r *audioPrefResolver) audioPreference(ctx context.Context) *playback.Audio
// audioLanguage resolves with every content identity in context. The language
// preference lives in the canonical table at profile_series/profile_library/
// profile_device/profile scopes; the specialized audio row supplies only
// concrete track identity. Caching by library keeps a multi-file item at one
// canonical read per distinct folder rather than one read per file.
// profile scopes; the specialized audio row supplies only concrete track
// identity. Caching by library keeps a multi-file item at one canonical read
// per distinct folder rather than one read per file.
func (r *audioPrefResolver) audioLanguage(ctx context.Context, libraryID int) string {
if lang, ok := r.resolvedLang[libraryID]; ok {
return lang
}
rc := settingsresolve.Context{
ProfileID: r.profileID,
DeviceID: r.deviceID,
LibraryIDs: []int{libraryID},
}
if strings.TrimSpace(r.contentID) != "" {
@@ -204,42 +204,6 @@ func TestEffectiveAudioTrackIndex_PrefersSeriesAudioPreferenceOverLibraryAndProf
}
}
func TestEffectiveAudioTrackIndex_ResolvesDeviceLanguageWithCanonicalPrecedence(t *testing.T) {
store := newDetailTestStore(t)
setProfileAudioLanguage(t, store, "en")
setScopedAudioLanguageForDevice(t, store, settingscontract.ScopeProfileDevice, "", 0, "apple-tv", "ja")
setScopedAudioLanguage(t, store, settingscontract.ScopeProfileLibrary, "", 12, "es")
setScopedAudioLanguage(t, store, settingscontract.ScopeProfileSeries, "series-1", 0, "fr")
service := &DetailService{}
service.SetUserStoreProvider(testDetailUserStoreProvider{store: store})
file := &models.MediaFile{
AudioTracks: []models.AudioTrack{
{Language: "en", Default: true},
{Language: "ja"},
{Language: "es"},
{Language: "fr"},
},
}
assertIndex := func(name, deviceID, contentID string, libraryID, want int) {
t.Helper()
file.MediaFolderID = libraryID
if got := service.effectiveAudioTrackIndex(context.Background(), AccessFilter{
UserID: 1,
ProfileID: "profile-1",
DeviceID: deviceID,
}, contentID, file); got != want {
t.Fatalf("%s: effectiveAudioTrackIndex() = %d, want %d", name, got, want)
}
}
assertIndex("device override", "apple-tv", "movie-1", 99, 1)
assertIndex("other device isolation", "iphone", "movie-1", 99, 0)
assertIndex("library over device", "apple-tv", "movie-1", 12, 2)
assertIndex("series over library and device", "apple-tv", "series-1", 12, 3)
}
func TestBuildPlaybackInfo_SetsEffectiveAudioLanguageFromOriginalWhenTrackLanguageMissing(t *testing.T) {
store := newDetailTestStore(t)
language := playback.OriginalLanguageSentinel
@@ -547,19 +511,6 @@ func setScopedAudioLanguage(
seriesID string,
libraryID int,
language string,
) {
t.Helper()
setScopedAudioLanguageForDevice(t, store, scope, seriesID, libraryID, "", language)
}
func setScopedAudioLanguageForDevice(
t *testing.T,
store userstore.UserStore,
scope settingscontract.Scope,
seriesID string,
libraryID int,
deviceID string,
language string,
) {
t.Helper()
encoded, err := json.Marshal(language)
@@ -572,7 +523,6 @@ func setScopedAudioLanguageForDevice(
ProfileID: "profile-1",
SeriesID: seriesID,
LibraryID: libraryID,
DeviceID: deviceID,
}, encoded); err != nil {
t.Fatalf("seeding %s: %v", settingskeys.PlaybackAudioLanguage, err)
}
-1
View File
@@ -2567,7 +2567,6 @@ func toVideoTrackRecords(tracks []models.VideoTrack) []VideoTrackRecord {
Codec: track.Codec,
DolbyVision: track.DolbyVision,
DVProfile: track.DVProfile,
DVLevel: track.DVLevel,
DVBLCompatID: track.DVBLCompatID,
DVELPresent: track.DVELPresent,
HDR10Plus: track.HDR10Plus,
+2 -5
View File
@@ -7,9 +7,9 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
)
func TestToVideoTrackRecordsPreservesVideoMetadata(t *testing.T) {
func TestToVideoTrackRecordsPreservesColorRange(t *testing.T) {
got := toVideoTrackRecords([]models.VideoTrack{
{ColorRange: "tv", DVLevel: 6},
{ColorRange: "tv"},
{ColorRange: "pc"},
{ColorRange: "unknown"},
})
@@ -25,9 +25,6 @@ func TestToVideoTrackRecordsPreservesVideoMetadata(t *testing.T) {
got[2].ColorRange,
)
}
if got[0].DVLevel != 6 {
t.Fatalf("DVLevel = %d, want 6", got[0].DVLevel)
}
}
func TestCatalogSeedSearchUpsertIDsIncludesChangedItemsAndEmbeddings(t *testing.T) {
-1
View File
@@ -143,7 +143,6 @@ type VideoTrackRecord struct {
Codec string `json:"codec,omitempty"`
DolbyVision string `json:"dolby_vision,omitempty"`
DVProfile int `json:"dv_profile,omitempty"`
DVLevel int `json:"dv_level,omitempty"`
DVBLCompatID int `json:"dv_bl_compat_id,omitempty"`
DVELPresent bool `json:"dv_el_present,omitempty"`
HDR10Plus bool `json:"hdr10_plus,omitempty"`
+17 -3
View File
@@ -13,8 +13,11 @@ import (
"github.com/robfig/cron/v3"
)
const cloudflareURLMode = "cloudflare_token"
const chapterThumbnailSoftwareToneMapKey = "playback.chapter_thumbnail_software_tone_map_enabled"
const (
cloudflareURLMode = "cloudflare_token"
playbackSegmentRetentionSettingKey = "playback.segment_retention_seconds"
chapterThumbnailSoftwareToneMapKey = "playback.chapter_thumbnail_software_tone_map_enabled"
)
// adminSettingDefaults is the effective value shown by the Admin UI when no
// row exists in server_settings. Keep these values aligned with the runtime
@@ -50,7 +53,8 @@ var adminSettingDefaults = map[string]string{
"markers.lazy_playback": "false",
"playback.ffmpeg_path": "/usr/lib/jellyfin-ffmpeg/ffmpeg",
playbackTranscodeDirSettingKey: DefaultTranscodeDir,
"playback.transcode_dir": DefaultTranscodeDir,
playbackSegmentRetentionSettingKey: "600",
"playback.hw_accel": "auto",
"playback.transcode_enabled": "true",
"playback.local_transcode_fallback": "true",
@@ -301,6 +305,16 @@ func NormalizeAdminSetting(key, raw string) (string, error) {
return normalizeAdminInt(key, value, 1, 99)
case "transcode_throttle_seconds":
return normalizeAdminInt(key, value, 60, 86400)
case playbackSegmentRetentionSettingKey:
normalized, err := normalizeAdminInt(key, value, 0, 86400)
if err != nil {
return "", err
}
seconds, _ := strconv.Atoi(normalized)
if seconds != 0 && seconds < 120 {
return "", fmt.Errorf("%s must be 0 or between 120 and 86400", key)
}
return normalized, nil
case "ai.max_concurrent_jobs", "subtitle_ai.max_concurrent_jobs":
return normalizeAdminInt(key, value, 1, 1024)
case "subtitle_ai.batch_size":
+15
View File
@@ -225,6 +225,7 @@ func TestNormalizeAdminSettingRejectsInvalidValues(t *testing.T) {
{key: "theme.catalog_url", value: "http://raw.githubusercontent.com/Silo-Server/silo-themes/main/catalog.json"},
{key: "theme.catalog_url", value: "https://example.com/catalog.json"},
{key: "redis.url", value: "not-a-url"},
{key: "playback.segment_retention_seconds", value: "119"},
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
@@ -235,6 +236,20 @@ func TestNormalizeAdminSettingRejectsInvalidValues(t *testing.T) {
}
}
func TestNormalizeAdminSettingAcceptsSegmentRetentionBounds(t *testing.T) {
for _, value := range []string{"0", "120", "86400"} {
t.Run(value, func(t *testing.T) {
got, err := NormalizeAdminSetting("playback.segment_retention_seconds", value)
if err != nil {
t.Fatalf("NormalizeAdminSetting: %v", err)
}
if got != value {
t.Fatalf("normalized retention = %q, want %q", got, value)
}
})
}
}
func TestNormalizeAdminSettingAcceptsApprovedThemeCatalogURL(t *testing.T) {
got, err := NormalizeAdminSetting(
"theme.catalog_url",
+6 -21
View File
@@ -3,7 +3,6 @@ package config
import (
"errors"
"fmt"
"path/filepath"
"regexp"
"sort"
"strconv"
@@ -158,9 +157,10 @@ func (c MatcherConfig) TVSeriesRootQueueEnabled() bool {
// PlaybackConfig holds transcoding and playback settings.
type PlaybackConfig struct {
FFmpegPath string `yaml:"ffmpeg_path"`
TranscodeDir string `yaml:"transcode_dir"`
HWAccel string `yaml:"hw_accel"`
FFmpegPath string `yaml:"ffmpeg_path"`
TranscodeDir string `yaml:"transcode_dir"`
SegmentRetentionSeconds int `yaml:"segment_retention_seconds"`
HWAccel string `yaml:"hw_accel"`
// HWDevice is the GPU render device for hardware transcodes. A single
// path pins every GPU workload to that device; a comma-separated list
// (e.g. "/dev/dri/renderD128,/dev/dri/renderD129") balances workloads
@@ -408,26 +408,10 @@ var defaultJellyfinCompatServerID = uuid.NewSHA1(
[]byte("https://silo.local/jellycompat"),
).String()
const playbackTranscodeDirSettingKey = "playback.transcode_dir"
const downloadArtifactDirSettingKey = "download.artifact_dir"
// DefaultTranscodeDir is the fallback playback.transcode_dir; download
// artifacts default to a sibling directory (see EffectiveDownloadArtifactDir).
// artifacts default to a sibling directory (see downloads.effectiveArtifactDir).
const DefaultTranscodeDir = "/tmp/silo-transcode"
// EffectiveDownloadArtifactDir resolves the shared prepared-download artifact
// root. Keeping this path rule in config lets API and transcode-node processes
// independently derive the same destination from the same live settings.
func EffectiveDownloadArtifactDir(artifactDir, transcodeDir string) string {
if artifactDir != "" {
return artifactDir
}
if transcodeDir == "" {
transcodeDir = DefaultTranscodeDir
}
return filepath.Join(filepath.Dir(transcodeDir), "silo-download-artifacts")
}
const DefaultJellyfinCompatEmulatedServerVersion = "10.12.0"
const DefaultJellyfinWebVersion = "10.11.6"
const DefaultJellyfinWebInstallDir = "/var/lib/silo/compat/jellyfin-web"
@@ -496,6 +480,7 @@ func setDefaults() *configRaw {
Playback: PlaybackConfig{
FFmpegPath: "/usr/lib/jellyfin-ffmpeg/ffmpeg",
TranscodeDir: DefaultTranscodeDir,
SegmentRetentionSeconds: 600,
HWAccel: "auto",
ChapterThumbnailWorkers: 1,
ChapterThumbnailExecution: "local",
+10 -8
View File
@@ -3,9 +3,7 @@ package config
import (
"fmt"
"log/slog"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/google/uuid"
@@ -318,7 +316,15 @@ func LoadFromDB(m map[string]string) (*Config, error) {
// Playback
cfg.Playback.FFmpegPath = stringOr(m, "playback.ffmpeg_path", "/usr/lib/jellyfin-ffmpeg/ffmpeg")
cfg.Playback.TranscodeDir = stringOr(m, playbackTranscodeDirSettingKey, DefaultTranscodeDir)
cfg.Playback.TranscodeDir = stringOr(m, "playback.transcode_dir", DefaultTranscodeDir)
segmentRetentionSeconds, err := intOr(m, playbackSegmentRetentionSettingKey, 600)
if err != nil {
return nil, err
}
if segmentRetentionSeconds != 0 && (segmentRetentionSeconds < 120 || segmentRetentionSeconds > 86400) {
return nil, fmt.Errorf("%s must be 0 or between 120 and 86400", playbackSegmentRetentionSettingKey)
}
cfg.Playback.SegmentRetentionSeconds = segmentRetentionSeconds
cfg.Playback.HWAccel = stringOr(m, "playback.hw_accel", "auto")
cfg.Playback.HWDevice = stringOr(m, "playback.hw_device", "")
chapterThumbnailWorkers, err := intOr(m, "playback.chapter_thumbnail_workers", 1)
@@ -605,12 +611,8 @@ func LoadFromDB(m map[string]string) (*Config, error) {
if artifactMaxBytes < 0 {
return nil, fmt.Errorf("invalid value for %q: must be non-negative", "download.artifact_max_bytes")
}
artifactDir := strings.TrimSpace(stringOr(m, downloadArtifactDirSettingKey, ""))
if artifactDir != "" && !filepath.IsAbs(artifactDir) {
return nil, fmt.Errorf("invalid value for %q: must be an absolute path", downloadArtifactDirSettingKey)
}
cfg.Download.TranscodeEnabled = downloadTranscodeEnabled
cfg.Download.ArtifactDir = artifactDir
cfg.Download.ArtifactDir = stringOr(m, "download.artifact_dir", "")
cfg.Download.MaxConcurrentPrepares = maxConcurrentPrepares
cfg.Download.ArtifactMaxBytes = artifactMaxBytes
+18 -12
View File
@@ -35,18 +35,14 @@ func TestLoadFromDBMetadataPresignExpiryRejectsInvalidDuration(t *testing.T) {
}
}
func TestLoadFromDBDownloadArtifactDirRequiresAbsolutePath(t *testing.T) {
cfg, err := LoadFromDB(map[string]string{downloadArtifactDirSettingKey: "/mnt/silo-downloads"})
if err != nil {
t.Fatalf("LoadFromDB() with absolute artifact dir: %v", err)
}
if cfg.Download.ArtifactDir != "/mnt/silo-downloads" {
t.Fatalf("artifact dir = %q", cfg.Download.ArtifactDir)
}
_, err = LoadFromDB(map[string]string{downloadArtifactDirSettingKey: "relative/downloads"})
if err == nil || !strings.Contains(err.Error(), downloadArtifactDirSettingKey) {
t.Fatalf("relative artifact dir error = %v", err)
func TestLoadFromDBRejectsInvalidSegmentRetention(t *testing.T) {
for _, value := range []string{"-1", "119", "86401"} {
t.Run(value, func(t *testing.T) {
_, err := LoadFromDB(map[string]string{playbackSegmentRetentionSettingKey: value})
if err == nil || !strings.Contains(err.Error(), playbackSegmentRetentionSettingKey) {
t.Fatalf("LoadFromDB() error = %v, want retention bounds error", err)
}
})
}
}
@@ -201,6 +197,16 @@ func TestYAMLToSettingsMapJellyfinCompatEnabledDefaultsToLegacyListener(t *testi
}
}
func TestYAMLToSettingsMapPreservesExplicitlyDisabledSegmentRetention(t *testing.T) {
m := yamlSettingsMapFromString(t, `
playback:
segment_retention_seconds: 0
`)
if got := m[playbackSegmentRetentionSettingKey]; got != "0" {
t.Fatalf("segment retention = %q, want explicit disable", got)
}
}
func yamlSettingsMapFromString(t *testing.T, body string) map[string]string {
t.Helper()
path := t.TempDir() + "/silo.yaml"
+3 -7
View File
@@ -8,7 +8,7 @@ import "strings"
//
// Keys absent from this map and from restartRequiredPrefixes apply without a
// restart: they are either read live from the settings repo at request time
// (overlays, branding, markers, download limits, ...) or hot-reloaded through the
// (overlays, branding, markers, download.*, ...) or hot-reloaded through the
// nodeconfig watcher. When converting a frozen consumer to the live config,
// remove its key here — the admin UI restart banner is driven by this
// registry.
@@ -34,15 +34,11 @@ var restartRequiredKeys = map[string]bool{
// ffmpeg path and hwaccel live (new transcode sessions), but several
// startup-built consumers still freeze them (scanner ffprobe, chapter
// thumbnails, audiobook enricher) — keep restart-required until those
// convert. transcode_dir is also captured by dedicated transcode nodes for
// session and prepared-download storage. A configured download.artifact_dir is
// likewise captured by both API and transcode-node artifact managers. The
// chapter-thumbnail worker pool is sized at construction.
// convert. transcode_dir is fully live (only the playback handler reads
// it). The chapter-thumbnail worker pool is sized at construction.
"playback.ffmpeg_path": true,
"playback.hw_accel": true,
"playback.hw_device": true,
playbackTranscodeDirSettingKey: true,
downloadArtifactDirSettingKey: true,
"playback.chapter_thumbnail_workers": true,
// Scanner / matcher toggles captured at construction. Worker counts,
-2
View File
@@ -11,8 +11,6 @@ func TestRestartRequired(t *testing.T) {
{"server.listen", true},
{"auth.jwt_secret", true},
{"ratelimit.backend", true},
{playbackTranscodeDirSettingKey, true},
{downloadArtifactDirSettingKey, true},
// Prefix-covered namespaces.
{"database.max_connections", true},
{"userdb.backend", true},
+2 -1
View File
@@ -208,7 +208,8 @@ func YAMLToSettingsMap(path string) (map[string]string, error) {
// Playback
setIfNonEmpty(m, "playback.ffmpeg_path", raw.Playback.FFmpegPath)
setIfNonEmpty(m, playbackTranscodeDirSettingKey, raw.Playback.TranscodeDir)
setIfNonEmpty(m, "playback.transcode_dir", raw.Playback.TranscodeDir)
m[playbackSegmentRetentionSettingKey] = strconv.Itoa(raw.Playback.SegmentRetentionSeconds)
setIfNonEmpty(m, "playback.hw_accel", raw.Playback.HWAccel)
if raw.Playback.ChapterThumbnailWorkers != 0 {
m["playback.chapter_thumbnail_workers"] = strconv.Itoa(raw.Playback.ChapterThumbnailWorkers)
-436
View File
@@ -1,436 +0,0 @@
// Package downloadprepare defines the internal API used to create and relay a
// prepared download artifact on a dedicated transcode node.
package downloadprepare
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/Silo-Server/silo-server/internal/playback"
)
const (
ArtifactDirectoryName = "download-artifacts"
RelayReadIdleTimeout = 2 * time.Minute
)
var (
artifactIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
ErrArtifactNotFound = errors.New("remote download artifact not found")
ErrRelayReadIdle = errors.New("remote download artifact read stalled")
)
func newHTTPClient(responseHeaderTimeout time.Duration) *http.Client {
baseTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
baseTransport = &http.Transport{}
}
transport := baseTransport.Clone()
transport.ResponseHeaderTimeout = responseHeaderTimeout
return &http.Client{
Transport: transport,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
var (
// Preparing a full file can legitimately take hours. Its lease-bound request
// context supplies cancellation, so imposing a response-header timeout here
// would abort every encode longer than that timeout before the node replies.
defaultPrepareHTTPClient = newHTTPClient(0)
defaultHTTPClient = newHTTPClient(60 * time.Second)
)
// Request is the environment-neutral portion of a prepared-file recipe. The
// transcode node supplies its own FFmpeg path, hardware mode, device list, and
// output path. ArtifactID is an opaque handle, never a caller-selected path.
type Request struct {
ArtifactID string `json:"artifact_id"`
InputPath string `json:"input_path"`
SourceVideoCodec string `json:"source_video_codec,omitempty"`
SourceVideoProfile string `json:"source_video_profile,omitempty"`
SourceVideoBitDepth int `json:"source_video_bit_depth,omitempty"`
SoftwareVideoDecode bool `json:"software_video_decode,omitempty"`
TargetCodecVideo string `json:"target_codec_video"`
TargetCodecAudio string `json:"target_codec_audio"`
TargetResolution string `json:"target_resolution,omitempty"`
TargetBitrateKbps int `json:"target_bitrate_kbps,omitempty"`
AudioTrackIndex int `json:"audio_track_index"`
TotalDuration float64 `json:"total_duration,omitempty"`
}
// Result identifies a completed artifact without exposing the node's local
// filesystem layout.
type Result struct {
ArtifactID string `json:"artifact_id"`
FileSize int64 `json:"file_size"`
}
func ValidArtifactID(id string) bool { return artifactIDPattern.MatchString(id) }
// NewRequest freezes the byte-affecting recipe while deliberately omitting
// environment-specific execution settings.
func NewRequest(artifactID string, opts playback.TranscodeOpts) Request {
return Request{
ArtifactID: artifactID,
InputPath: opts.InputPath,
SourceVideoCodec: opts.SourceVideoCodec,
SourceVideoProfile: opts.SourceVideoProfile,
SourceVideoBitDepth: opts.SourceVideoBitDepth,
SoftwareVideoDecode: opts.SoftwareVideoDecode,
TargetCodecVideo: opts.TargetCodecVideo,
TargetCodecAudio: opts.TargetCodecAudio,
TargetResolution: opts.TargetResolution,
TargetBitrateKbps: opts.TargetBitrateKbps,
AudioTrackIndex: opts.AudioTrackIndex,
TotalDuration: opts.TotalDuration,
}
}
// TranscodeOpts reconstructs the prepared-file options using the selected
// node's live execution settings.
func (r Request) TranscodeOpts(ffmpegPath, hwAccel, hwDevice string, sink playback.FFmpegLogSink) playback.TranscodeOpts {
return playback.TranscodeOpts{
InputPath: r.InputPath,
SourceVideoCodec: r.SourceVideoCodec,
SourceVideoProfile: r.SourceVideoProfile,
SourceVideoBitDepth: r.SourceVideoBitDepth,
SoftwareVideoDecode: r.SoftwareVideoDecode,
TargetCodecVideo: r.TargetCodecVideo,
TargetCodecAudio: r.TargetCodecAudio,
TargetResolution: r.TargetResolution,
TargetBitrateKbps: r.TargetBitrateKbps,
AudioTrackIndex: r.AudioTrackIndex,
SubtitleTrackIndex: -1,
FFmpegPath: ffmpegPath,
HWAccel: hwAccel,
HWDevice: hwDevice,
TotalDuration: r.TotalDuration,
NodeType: "transcode",
ExecutionMode: "download_prepare",
FFmpegLogSink: sink,
}
}
// RemotePreparer executes and manages artifacts on a selected transcode node.
type RemotePreparer interface {
Prepare(ctx context.Context, nodeURL, jwtSecret string, req Request) (Result, error)
Stat(ctx context.Context, nodeURL, jwtSecret, artifactID string) (Result, error)
Delete(ctx context.Context, nodeURL, jwtSecret, artifactID string) error
}
// HTTPPreparer implements RemotePreparer over bearer-protected node APIs. A
// full-file prepare has no transport timeout; its caller owns cancellation.
// Metadata and relay operations use a bounded response-header wait by default.
type HTTPPreparer struct {
Client *http.Client
ReadIdleTimeout time.Duration
}
func (p HTTPPreparer) Prepare(ctx context.Context, nodeURL, jwtSecret string, req Request) (Result, error) {
body, err := json.Marshal(req)
if err != nil {
return Result{}, fmt.Errorf("remote download prepare: marshal request: %w", err)
}
responseCtx, cancel := context.WithCancel(ctx)
httpReq, err := p.request(responseCtx, http.MethodPost, nodeURL, jwtSecret, "/downloads/prepare", bytes.NewReader(body))
if err != nil {
cancel()
return Result{}, fmt.Errorf("remote download prepare: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := p.prepareClient().Do(httpReq)
if err != nil {
cancel()
return Result{}, fmt.Errorf("remote download prepare: request: %w", err)
}
// The encode itself remains unbounded while waiting for response headers, but
// once the node starts its small JSON result body, each read must make progress.
resp.Body = newIdleReadCloser(resp.Body, p.readIdleTimeout(), cancel)
defer func() { _ = resp.Body.Close() }()
return decodeResult(resp, "remote download prepare")
}
func (p HTTPPreparer) Stat(ctx context.Context, nodeURL, jwtSecret, artifactID string) (Result, error) {
if !ValidArtifactID(artifactID) {
return Result{}, fmt.Errorf("remote download artifact stat: invalid artifact id")
}
httpReq, err := p.request(ctx, http.MethodHead, nodeURL, jwtSecret, "/downloads/artifacts/"+url.PathEscape(artifactID), nil)
if err != nil {
return Result{}, fmt.Errorf("remote download artifact stat: %w", err)
}
resp, err := p.client().Do(httpReq)
if err != nil {
return Result{}, fmt.Errorf("remote download artifact stat: request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return Result{}, ErrArtifactNotFound
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return Result{}, responseError(resp, "remote download artifact stat")
}
size, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
if err != nil || size < 0 {
return Result{}, fmt.Errorf("remote download artifact stat: invalid content length")
}
return Result{ArtifactID: artifactID, FileSize: size}, nil
}
func (p HTTPPreparer) Delete(ctx context.Context, nodeURL, jwtSecret, artifactID string) error {
if !ValidArtifactID(artifactID) {
return fmt.Errorf("remote download artifact delete: invalid artifact id")
}
deleteCtx, cancel := context.WithCancel(ctx)
httpReq, err := p.request(deleteCtx, http.MethodDelete, nodeURL, jwtSecret, "/downloads/artifacts/"+url.PathEscape(artifactID), nil)
if err != nil {
cancel()
return fmt.Errorf("remote download artifact delete: %w", err)
}
resp, err := p.client().Do(httpReq)
if err != nil {
cancel()
return fmt.Errorf("remote download artifact delete: request: %w", err)
}
resp.Body = newIdleReadCloser(resp.Body, p.readIdleTimeout(), cancel)
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return nil
}
if resp.StatusCode != http.StatusNoContent {
return responseError(resp, "remote download artifact delete")
}
return nil
}
// Open returns an authenticated streaming response for a GET or HEAD relay.
// The caller owns closing the body and copying only safe response headers.
func (p HTTPPreparer) Open(ctx context.Context, nodeURL, jwtSecret, artifactID, method string, sourceHeader http.Header) (*http.Response, error) {
if !ValidArtifactID(artifactID) {
return nil, fmt.Errorf("remote download artifact open: invalid artifact id")
}
if method != http.MethodGet && method != http.MethodHead {
return nil, fmt.Errorf("remote download artifact open: unsupported method %s", method)
}
relayCtx, cancel := context.WithCancel(ctx)
httpReq, err := p.request(relayCtx, method, nodeURL, jwtSecret, "/downloads/artifacts/"+url.PathEscape(artifactID), nil)
if err != nil {
cancel()
return nil, fmt.Errorf("remote download artifact open: %w", err)
}
for _, name := range []string{"Range", "If-Match", "If-Range", "If-None-Match", "If-Modified-Since", "If-Unmodified-Since"} {
if value := sourceHeader.Get(name); value != "" {
httpReq.Header.Set(name, value)
}
}
resp, err := p.client().Do(httpReq)
if err != nil {
cancel()
return nil, fmt.Errorf("remote download artifact open: request: %w", err)
}
resp.Body = newIdleReadCloser(resp.Body, p.readIdleTimeout(), cancel)
return resp, nil
}
// idleReadCloser bounds only time spent blocked in an upstream Read. Time
// between reads is deliberately ignored so local bandwidth throttling and slow
// clients cannot be mistaken for a stalled origin.
type idleReadCloser struct {
source io.ReadCloser
timeout time.Duration
readStart chan struct{}
readDone chan struct{}
stop chan struct{}
stopped chan struct{}
closeOnce sync.Once
timedOut atomic.Bool
cancel context.CancelFunc
}
func newIdleReadCloser(source io.ReadCloser, timeout time.Duration, cancel context.CancelFunc) io.ReadCloser {
if source == nil || timeout <= 0 {
return source
}
r := &idleReadCloser{
source: source,
timeout: timeout,
readStart: make(chan struct{}),
readDone: make(chan struct{}),
stop: make(chan struct{}),
stopped: make(chan struct{}),
cancel: cancel,
}
go r.watch()
return r
}
func (r *idleReadCloser) Read(p []byte) (int, error) {
if r.timedOut.Load() {
return 0, ErrRelayReadIdle
}
select {
case r.readStart <- struct{}{}:
case <-r.stop:
return 0, io.ErrClosedPipe
}
n, err := r.source.Read(p)
select {
case r.readDone <- struct{}{}:
case <-r.stop:
}
if r.timedOut.Load() {
return n, ErrRelayReadIdle
}
return n, err
}
func (r *idleReadCloser) Close() error {
r.closeOnce.Do(func() {
close(r.stop)
if r.cancel != nil {
r.cancel()
}
_ = r.source.Close()
<-r.stopped
})
return nil
}
func (r *idleReadCloser) watch() {
defer close(r.stopped)
timer := time.NewTimer(time.Hour)
if !timer.Stop() {
<-timer.C
}
reading := false
for {
select {
case <-r.readStart:
timer.Reset(r.timeout)
reading = true
case <-r.readDone:
if reading && !timer.Stop() {
select {
case <-timer.C:
default:
}
}
reading = false
case <-timer.C:
if reading {
r.timedOut.Store(true)
if r.cancel != nil {
r.cancel()
}
_ = r.source.Close()
reading = false
}
case <-r.stop:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return
}
}
}
func (p HTTPPreparer) request(ctx context.Context, method, nodeURL, jwtSecret, path string, body io.Reader) (*http.Request, error) {
base, err := url.Parse(strings.TrimSpace(nodeURL))
if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
return nil, fmt.Errorf("invalid node URL")
}
base.Path = strings.TrimRight(base.Path, "/") + path
base.RawQuery = ""
base.Fragment = ""
req, err := http.NewRequestWithContext(ctx, method, base.String(), body)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+jwtSecret)
return req, nil
}
func (p HTTPPreparer) client() *http.Client {
if p.Client != nil {
return p.Client
}
return defaultHTTPClient
}
func (p HTTPPreparer) prepareClient() *http.Client {
if p.Client != nil {
return p.Client
}
return defaultPrepareHTTPClient
}
func (p HTTPPreparer) readIdleTimeout() time.Duration {
if p.ReadIdleTimeout > 0 {
return p.ReadIdleTimeout
}
return RelayReadIdleTimeout
}
// CopyResponseHeaders forwards only representation/range metadata that is safe
// across the transcode-node relay boundary.
func CopyResponseHeaders(dst, src http.Header) {
for _, name := range []string{
"Accept-Ranges", "Content-Length", "Content-Range",
"Content-Type", "ETag", "Last-Modified",
} {
if value := src.Get(name); value != "" {
dst.Set(name, value)
}
}
}
// RelayStatusAllowed identifies the complete set of normal ServeContent
// outcomes that an internal relay must preserve unchanged.
func RelayStatusAllowed(status int) bool {
return status >= http.StatusOK && status < http.StatusMultipleChoices ||
status == http.StatusNotModified ||
status == http.StatusPreconditionFailed ||
status == http.StatusRequestedRangeNotSatisfiable
}
func decodeResult(resp *http.Response, operation string) (Result, error) {
if resp.StatusCode == http.StatusNotFound {
return Result{}, ErrArtifactNotFound
}
if resp.StatusCode != http.StatusOK {
return Result{}, responseError(resp, operation)
}
var result Result
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&result); err != nil {
return Result{}, fmt.Errorf("%s: decode response: %w", operation, err)
}
if !ValidArtifactID(result.ArtifactID) || result.FileSize < 0 {
return Result{}, fmt.Errorf("%s: invalid response", operation)
}
return result, nil
}
func responseError(resp *http.Response, operation string) error {
message, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return fmt.Errorf("%s: read node error response: %w", operation, err)
}
return fmt.Errorf("%s: node returned %d: %s", operation, resp.StatusCode, strings.TrimSpace(string(message)))
}
-234
View File
@@ -1,234 +0,0 @@
package downloadprepare
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestHTTPPreparerSendsAuthenticatedRecipe(t *testing.T) {
var got Request
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/downloads/prepare" {
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
}
if auth := r.Header.Get("Authorization"); auth != "Bearer secret" {
t.Fatalf("Authorization = %q", auth)
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatal(err)
}
_ = json.NewEncoder(w).Encode(Result{ArtifactID: got.ArtifactID, FileSize: 123})
}))
defer server.Close()
want := Request{ArtifactID: "job-1", InputPath: "/media/movie.mkv", TargetCodecVideo: "h264", TargetCodecAudio: "aac"}
result, err := (HTTPPreparer{}).Prepare(context.Background(), server.URL+"/", "secret", want)
if err != nil {
t.Fatal(err)
}
if result != (Result{ArtifactID: "job-1", FileSize: 123}) {
t.Fatalf("result = %+v", result)
}
if got != want {
t.Fatalf("request = %+v, want %+v", got, want)
}
}
func TestHTTPPreparerReportsNodeFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "mount unavailable", http.StatusUnprocessableEntity)
}))
defer server.Close()
_, err := (HTTPPreparer{}).Prepare(context.Background(), server.URL, "secret", Request{ArtifactID: "job-2"})
if err == nil {
t.Fatal("expected remote failure")
}
}
func TestHTTPPreparerManagesOpaqueArtifact(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/downloads/artifacts/artifact-1" {
t.Fatalf("path = %q", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer secret" {
t.Fatalf("Authorization = %q", r.Header.Get("Authorization"))
}
switch r.Method {
case http.MethodHead:
w.Header().Set("Content-Length", "42")
case http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("method = %s", r.Method)
}
}))
defer server.Close()
client := HTTPPreparer{}
result, err := client.Stat(context.Background(), server.URL, "secret", "artifact-1")
if err != nil || result != (Result{ArtifactID: "artifact-1", FileSize: 42}) {
t.Fatalf("Stat = (%+v, %v)", result, err)
}
if err := client.Delete(context.Background(), server.URL, "secret", "artifact-1"); err != nil {
t.Fatal(err)
}
}
func TestHTTPPreparerDeleteStopsStalledErrorBodyRead(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
<-r.Context().Done()
}))
defer server.Close()
started := time.Now()
err := (HTTPPreparer{ReadIdleTimeout: 50 * time.Millisecond}).Delete(
context.Background(), server.URL, "secret", "artifact-stalled-delete",
)
if !errors.Is(err, ErrRelayReadIdle) {
t.Fatalf("Delete error = %v, want ErrRelayReadIdle", err)
}
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("stalled delete response took %s, want a bounded failure", elapsed)
}
}
func TestHTTPPreparerRejectsArtifactPathTraversal(t *testing.T) {
if _, err := (HTTPPreparer{}).Stat(context.Background(), "http://node", "secret", "../escape"); err == nil {
t.Fatal("expected invalid artifact id error")
}
}
func TestDefaultPrepareClientHasNoResponseHeaderDeadline(t *testing.T) {
transport, ok := defaultPrepareHTTPClient.Transport.(*http.Transport)
if !ok {
t.Fatalf("transport type = %T", defaultPrepareHTTPClient.Transport)
}
if transport.ResponseHeaderTimeout != 0 {
t.Fatalf("prepare response header timeout = %s, want none", transport.ResponseHeaderTimeout)
}
}
func TestHTTPPreparerPrepareStopsStalledResultBodyRead(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
<-r.Context().Done()
}))
defer server.Close()
started := time.Now()
_, err := (HTTPPreparer{ReadIdleTimeout: 50 * time.Millisecond}).Prepare(
context.Background(), server.URL, "secret", Request{ArtifactID: "artifact-stalled-result"},
)
if !errors.Is(err, ErrRelayReadIdle) {
t.Fatalf("Prepare error = %v, want ErrRelayReadIdle", err)
}
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("stalled result read took %s, want a bounded failure", elapsed)
}
}
func TestRelayStatusAllowedPreservesServeContentOutcomes(t *testing.T) {
for _, status := range []int{
http.StatusOK,
http.StatusPartialContent,
http.StatusNotModified,
http.StatusPreconditionFailed,
http.StatusRequestedRangeNotSatisfiable,
} {
if !RelayStatusAllowed(status) {
t.Errorf("status %d should be relayed", status)
}
}
for _, status := range []int{http.StatusTemporaryRedirect, http.StatusBadRequest, http.StatusInternalServerError} {
if RelayStatusAllowed(status) {
t.Errorf("status %d should be rejected", status)
}
}
}
func TestCopyResponseHeadersDoesNotExposeOriginArtifactFilename(t *testing.T) {
src := http.Header{
"Content-Disposition": {`attachment; filename="opaque-artifact.mp4"`},
"Content-Type": {"video/mp4"},
"Content-Length": {"42"},
}
dst := make(http.Header)
CopyResponseHeaders(dst, src)
if got := dst.Get("Content-Disposition"); got != "" {
t.Fatalf("Content-Disposition = %q, want omitted", got)
}
if dst.Get("Content-Type") != "video/mp4" || dst.Get("Content-Length") != "42" {
t.Fatalf("copied headers = %v", dst)
}
}
func TestHTTPPreparerOpenStopsStalledBodyRead(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "video/mp4")
w.WriteHeader(http.StatusOK)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
<-r.Context().Done()
}))
defer server.Close()
resp, err := (HTTPPreparer{ReadIdleTimeout: 50 * time.Millisecond}).Open(
context.Background(), server.URL, "secret", "artifact-stalled", http.MethodGet, nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
started := time.Now()
_, err = io.ReadAll(resp.Body)
if !errors.Is(err, ErrRelayReadIdle) {
t.Fatalf("ReadAll error = %v, want ErrRelayReadIdle", err)
}
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("stalled read took %s, want a bounded failure", elapsed)
}
}
func TestHTTPPreparerOpenAllowsContinuedBodyProgress(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
flusher, _ := w.(http.Flusher)
for _, chunk := range []string{"one", "two", "three"} {
_, _ = io.WriteString(w, chunk)
if flusher != nil {
flusher.Flush()
}
time.Sleep(10 * time.Millisecond)
}
}))
defer server.Close()
resp, err := (HTTPPreparer{ReadIdleTimeout: 50 * time.Millisecond}).Open(
context.Background(), server.URL, "secret", "artifact-progress", http.MethodGet, nil,
)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if string(body) != "onetwothree" {
t.Fatalf("body = %q", body)
}
}
+13 -5
View File
@@ -36,10 +36,6 @@ type Artifact struct {
AudioTrackIndex int
TargetBitrateKbps int
OutputPath string
OriginNodeID int
OriginNodeURL string
OriginNodeGroup string
OriginArtifactID string
FileSize int64
Status string
ErrorMessage string
@@ -61,6 +57,12 @@ func paramsHash(format, container, codecVideo, codecAudio, resolution string, au
return hex.EncodeToString(sum[:])
}
// defaultTranscodeDir is config's TranscodeDir default and roots
// prepared download artifacts when neither download.artifact_dir nor the
// transcode dir is configured. Keeping it absolute avoids writing artifacts
// relative to the process working directory.
const defaultTranscodeDir = config.DefaultTranscodeDir
// effectiveArtifactDir resolves where prepared artifacts are written: the
// configured download.artifact_dir when set, otherwise a dedicated directory
// alongside the transcode dir. The result is always rooted at a real volume,
@@ -71,7 +73,13 @@ func paramsHash(format, container, codecVideo, codecAudio, resolution string, au
// transcode dir, so an artifact dir nested under it would be wiped on the next
// transcode sweep.
func effectiveArtifactDir(artifactDir, transcodeDir string) string {
return config.EffectiveDownloadArtifactDir(artifactDir, transcodeDir)
if artifactDir != "" {
return artifactDir
}
if transcodeDir == "" {
transcodeDir = defaultTranscodeDir
}
return filepath.Join(filepath.Dir(transcodeDir), "silo-download-artifacts")
}
// artifactOutputPath derives a deterministic output path from
+7 -224
View File
@@ -11,8 +11,7 @@ import (
)
const artifactColumns = `id, media_file_id, format, params_hash, container, codec_video, codec_audio,
resolution, audio_track_index, target_bitrate_kbps, output_path,
origin_node_id, origin_node_url, origin_node_group, origin_artifact_id, file_size, status, error_message,
resolution, audio_track_index, target_bitrate_kbps, output_path, file_size, status, error_message,
attempts, max_attempts, lease_owner, lease_expires_at, next_retry_at,
created_at, completed_at, last_used_at`
@@ -22,18 +21,6 @@ type ArtifactRepository struct {
pool *pgxpool.Pool
}
// RemoteArtifactOrphan is a node-local cleanup candidate. The durable queue
// retains abandoned locators and verifies indeterminate readiness writes before
// deleting bytes, so transient failures cannot leak or delete a winning file.
type RemoteArtifactOrphan struct {
ID int64
DownloadArtifactID string
OriginNodeID int
OriginNodeURL string
OriginArtifactID string
Attempts int
}
// NewArtifactRepository creates an ArtifactRepository.
func NewArtifactRepository(pool *pgxpool.Pool) *ArtifactRepository {
return &ArtifactRepository{pool: pool}
@@ -44,8 +31,7 @@ func scanArtifact(row pgx.Row) (*Artifact, error) {
var leaseOwner *string
if err := row.Scan(
&a.ID, &a.MediaFileID, &a.Format, &a.ParamsHash, &a.Container, &a.CodecVideo, &a.CodecAudio,
&a.Resolution, &a.AudioTrackIndex, &a.TargetBitrateKbps, &a.OutputPath,
&a.OriginNodeID, &a.OriginNodeURL, &a.OriginNodeGroup, &a.OriginArtifactID, &a.FileSize, &a.Status, &a.ErrorMessage,
&a.Resolution, &a.AudioTrackIndex, &a.TargetBitrateKbps, &a.OutputPath, &a.FileSize, &a.Status, &a.ErrorMessage,
&a.Attempts, &a.MaxAttempts, &leaseOwner, &a.LeaseExpiresAt, &a.NextRetryAt,
&a.CreatedAt, &a.CompletedAt, &a.LastUsedAt,
); err != nil {
@@ -163,15 +149,14 @@ func (r *ArtifactRepository) Heartbeat(ctx context.Context, id, owner string, le
// another node — cannot flip a row it no longer owns. Returns false when the
// fence rejected the write (the lease was lost); the caller must then NOT flip
// linked downloads, leaving that to the current owner.
func (r *ArtifactRepository) MarkReady(ctx context.Context, id, owner, outputPath string, originNodeID int, originNodeURL, originNodeGroup, originArtifactID string, fileSize int64) (bool, error) {
func (r *ArtifactRepository) MarkReady(ctx context.Context, id, owner, outputPath string, fileSize int64) (bool, error) {
tag, err := r.pool.Exec(ctx,
`UPDATE download_artifacts
SET status = 'ready', output_path = $2, origin_node_id = $3, origin_node_url = $4,
origin_node_group = $5, origin_artifact_id = $6, file_size = $7, error_message = '',
SET status = 'ready', output_path = $2, file_size = $3, error_message = '',
completed_at = now(), last_used_at = now(),
lease_owner = NULL, lease_expires_at = NULL, next_retry_at = NULL
WHERE id = $1 AND lease_owner = $8 AND status = 'running'`,
id, outputPath, originNodeID, originNodeURL, originNodeGroup, originArtifactID, fileSize, owner,
WHERE id = $1 AND lease_owner = $4 AND status = 'running'`,
id, outputPath, fileSize, owner,
)
if err != nil {
return false, fmt.Errorf("marking artifact ready: %w", err)
@@ -252,8 +237,7 @@ func (r *ArtifactRepository) Requeue(ctx context.Context, id string) error {
tag, err := r.pool.Exec(ctx,
`UPDATE download_artifacts
SET status = 'queued', attempts = 0, error_message = '', next_retry_at = NULL,
lease_owner = NULL, lease_expires_at = NULL, completed_at = NULL,
origin_node_id = 0, origin_node_url = '', origin_node_group = '', origin_artifact_id = ''
lease_owner = NULL, lease_expires_at = NULL, completed_at = NULL
WHERE id = $1`,
id,
)
@@ -266,79 +250,6 @@ func (r *ArtifactRepository) Requeue(ctx context.Context, id string) error {
return nil
}
// RequeueRemote atomically transfers a ready remote locator into the cleanup
// queue and clears it from the artifact row. The locator can therefore never
// be deleted while the row still advertises it if either database write fails.
// applied is false when the row changed concurrently or no longer exists.
func (r *ArtifactRepository) RequeueRemote(ctx context.Context, artifact *Artifact) (linked []*Download, applied bool, err error) {
return r.requeueRemote(ctx, artifact, false)
}
// RequeueRemoteExactLocator additionally fences on the origin URL. Proxy miss
// reports use it so an older signed URL cannot requeue a row after an
// administrator has moved the same node/artifact locator to a new endpoint.
func (r *ArtifactRepository) RequeueRemoteExactLocator(ctx context.Context, artifact *Artifact) (linked []*Download, applied bool, err error) {
return r.requeueRemote(ctx, artifact, true)
}
func (r *ArtifactRepository) requeueRemote(ctx context.Context, artifact *Artifact, fenceURL bool) (linked []*Download, applied bool, err error) {
if artifact == nil {
return nil, false, nil
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, false, fmt.Errorf("beginning remote artifact requeue: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
query := `UPDATE download_artifacts
SET status = 'queued', attempts = 0, error_message = '', next_retry_at = NULL,
lease_owner = NULL, lease_expires_at = NULL, completed_at = NULL,
origin_node_id = 0, origin_node_url = '', origin_node_group = '', origin_artifact_id = ''
WHERE id = $1 AND status = 'ready' AND origin_node_id = $2 AND origin_artifact_id = $3`
args := []any{artifact.ID, artifact.OriginNodeID, artifact.OriginArtifactID}
if fenceURL {
query += ` AND origin_node_url = $4`
args = append(args, artifact.OriginNodeURL)
}
tag, err := tx.Exec(ctx, query, args...)
if err != nil {
return nil, false, fmt.Errorf("requeuing remote artifact: %w", err)
}
if tag.RowsAffected() == 0 {
return nil, false, nil
}
rows, err := tx.Query(ctx,
`UPDATE downloads
SET status = 'preparing', bytes_sent = 0, completed_at = NULL,
error_message = '', updated_at = now()
WHERE artifact_id = $1 AND status NOT IN ('cancelled', 'failed', 'revoked')
RETURNING `+downloadColumns,
artifact.ID,
)
if err != nil {
return nil, false, fmt.Errorf("resetting linked downloads for remote artifact requeue: %w", err)
}
linked, err = scanDownloads(rows)
rows.Close()
if err != nil {
return nil, false, fmt.Errorf("scanning reset downloads for remote artifact requeue: %w", err)
}
if _, err := tx.Exec(ctx,
`INSERT INTO download_artifact_orphans (download_artifact_id, origin_node_id, origin_node_url, origin_artifact_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (origin_node_id, origin_artifact_id) DO UPDATE
SET download_artifact_id = EXCLUDED.download_artifact_id,
origin_node_url = EXCLUDED.origin_node_url, next_retry_at = NULL`,
artifact.ID, artifact.OriginNodeID, artifact.OriginNodeURL, artifact.OriginArtifactID,
); err != nil {
return nil, false, fmt.Errorf("enqueueing remote artifact cleanup during requeue: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, false, fmt.Errorf("committing remote artifact requeue: %w", err)
}
return linked, true, nil
}
// TouchLastUsed bumps last_used_at for LRU accounting (called on serve).
func (r *ArtifactRepository) TouchLastUsed(ctx context.Context, id string) error {
_, err := r.pool.Exec(ctx, `UPDATE download_artifacts SET last_used_at = now() WHERE id = $1`, id)
@@ -348,26 +259,6 @@ func (r *ArtifactRepository) TouchLastUsed(ctx context.Context, id string) error
return nil
}
// RefreshRemoteLocator persists an enabled node's current URL/group while
// fencing against a concurrent requeue or replacement locator.
func (r *ArtifactRepository) RefreshRemoteLocator(ctx context.Context, artifact *Artifact) (bool, error) {
if artifact == nil {
return false, nil
}
tag, err := r.pool.Exec(ctx,
`UPDATE download_artifacts
SET origin_node_url = $2, origin_node_group = $3
WHERE id = $1 AND status = 'ready'
AND origin_node_id = $4 AND origin_artifact_id = $5`,
artifact.ID, artifact.OriginNodeURL, artifact.OriginNodeGroup,
artifact.OriginNodeID, artifact.OriginArtifactID,
)
if err != nil {
return false, fmt.Errorf("refreshing remote artifact locator: %w", err)
}
return tag.RowsAffected() == 1, nil
}
// ListReady returns ready artifacts ordered by least-recently-used first.
func (r *ArtifactRepository) ListReady(ctx context.Context) ([]*Artifact, error) {
rows, err := r.pool.Query(ctx,
@@ -455,111 +346,3 @@ func (r *ArtifactRepository) DeleteArtifact(ctx context.Context, id string) erro
}
return nil
}
// EnqueueRemoteOrphan durably records an abandoned attempt before its owning
// artifact lease or locator is discarded.
func (r *ArtifactRepository) EnqueueRemoteOrphan(ctx context.Context, downloadArtifactID string, nodeID int, nodeURL, artifactID string) error {
_, err := r.pool.Exec(ctx,
`INSERT INTO download_artifact_orphans (download_artifact_id, origin_node_id, origin_node_url, origin_artifact_id, next_retry_at)
VALUES ($1, $2, $3, $4, now() + interval '30 seconds')
ON CONFLICT (origin_node_id, origin_artifact_id) DO UPDATE
SET download_artifact_id = EXCLUDED.download_artifact_id,
origin_node_url = EXCLUDED.origin_node_url,
next_retry_at = EXCLUDED.next_retry_at`,
downloadArtifactID, nodeID, nodeURL, artifactID,
)
if err != nil {
return fmt.Errorf("enqueueing remote artifact cleanup: %w", err)
}
return nil
}
// ListRemoteOrphansDue interleaves due candidates across origins so a large
// unreachable-node backlog cannot starve cleanup for healthy origins while a
// single healthy origin can still use the full batch.
func (r *ArtifactRepository) ListRemoteOrphansDue(ctx context.Context, limit int) ([]RemoteArtifactOrphan, error) {
if limit <= 0 {
limit = 100
}
rows, err := r.pool.Query(ctx,
`WITH due AS (
SELECT id, download_artifact_id, origin_node_id, origin_node_url, origin_artifact_id, attempts,
row_number() OVER (
PARTITION BY origin_node_id, origin_node_url
ORDER BY attempts, created_at, id
) AS origin_rank,
created_at
FROM download_artifact_orphans
WHERE next_retry_at IS NULL OR next_retry_at <= now()
)
SELECT id, download_artifact_id, origin_node_id, origin_node_url, origin_artifact_id, attempts
FROM due
ORDER BY origin_rank, attempts, created_at, id
LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("listing remote artifact cleanup queue: %w", err)
}
defer rows.Close()
out := make([]RemoteArtifactOrphan, 0, limit)
for rows.Next() {
var orphan RemoteArtifactOrphan
if err := rows.Scan(&orphan.ID, &orphan.DownloadArtifactID, &orphan.OriginNodeID, &orphan.OriginNodeURL, &orphan.OriginArtifactID, &orphan.Attempts); err != nil {
return nil, fmt.Errorf("scanning remote artifact cleanup row: %w", err)
}
out = append(out, orphan)
}
return out, rows.Err()
}
// PrepareRemoteOrphanCleanup serializes cleanup against requeue and verifies
// that a locator from an indeterminate MarkReady result did not actually win
// the artifact row. owned locators have their stale cleanup row removed;
// abandoned locators are retry-leased before the caller performs remote I/O.
func (r *ArtifactRepository) PrepareRemoteOrphanCleanup(ctx context.Context, orphan RemoteArtifactOrphan) (owned, claimed bool, err error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return false, false, fmt.Errorf("beginning remote artifact cleanup claim: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var originNodeID int
var originArtifactID string
err = tx.QueryRow(ctx,
`SELECT origin_node_id, origin_artifact_id
FROM download_artifacts WHERE id = $1 FOR UPDATE`,
orphan.DownloadArtifactID,
).Scan(&originNodeID, &originArtifactID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return false, false, fmt.Errorf("checking remote artifact ownership: %w", err)
}
var present int64
if err := tx.QueryRow(ctx, `SELECT id FROM download_artifact_orphans WHERE id = $1 FOR UPDATE`, orphan.ID).Scan(&present); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, false, nil
}
return false, false, fmt.Errorf("locking remote artifact cleanup row: %w", err)
}
owned = originNodeID == orphan.OriginNodeID && originArtifactID == orphan.OriginArtifactID
if owned {
if _, err := tx.Exec(ctx, `DELETE FROM download_artifact_orphans WHERE id = $1`, orphan.ID); err != nil {
return false, false, fmt.Errorf("clearing owned remote artifact cleanup row: %w", err)
}
} else {
if _, err := tx.Exec(ctx,
`UPDATE download_artifact_orphans
SET attempts = attempts + 1, next_retry_at = now() + interval '2 minutes'
WHERE id = $1`, orphan.ID); err != nil {
return false, false, fmt.Errorf("claiming remote artifact cleanup row: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return false, false, fmt.Errorf("committing remote artifact cleanup claim: %w", err)
}
return owned, true, nil
}
func (r *ArtifactRepository) DeleteRemoteOrphan(ctx context.Context, id int64) error {
if _, err := r.pool.Exec(ctx, `DELETE FROM download_artifact_orphans WHERE id = $1`, id); err != nil {
return fmt.Errorf("deleting remote artifact cleanup row: %w", err)
}
return nil
}
+3 -521
View File
@@ -9,9 +9,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/downloadprepare"
"github.com/Silo-Server/silo-server/internal/idgen"
"github.com/Silo-Server/silo-server/internal/models"
)
func newArtifactTestRepo(t *testing.T) (*ArtifactRepository, *pgxpool.Pool, int) {
@@ -25,12 +23,6 @@ func newArtifactTestRepo(t *testing.T) (*ArtifactRepository, *pgxpool.Pool, int)
if present == nil {
t.Skip("download_artifacts migration has not been applied")
}
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.download_artifact_orphans')::text`).Scan(&present); err != nil {
t.Fatalf("check download_artifact_orphans: %v", err)
}
if present == nil {
t.Skip("download_artifact_orphans migration has not been applied")
}
suffix := time.Now().UnixNano()
var folderID int
@@ -68,16 +60,6 @@ func newArtifact(t *testing.T, fileID int, hash string) *Artifact {
}
}
func remoteOrphansForArtifact(orphans []RemoteArtifactOrphan, artifactID string) []RemoteArtifactOrphan {
filtered := make([]RemoteArtifactOrphan, 0, 1)
for _, orphan := range orphans {
if orphan.DownloadArtifactID == artifactID {
filtered = append(filtered, orphan)
}
}
return filtered
}
// TestArtifactQueueClaimAndLeaseRecovery is the Phase 3 / invariant-3 acceptance
// test: a crash mid-encode (an expired lease) is recovered on the next sweep so
// the job re-enqueues and reaches ready, and concurrent workers never claim the
@@ -128,7 +110,7 @@ func TestArtifactQueueClaimAndLeaseRecovery(t *testing.T) {
if err != nil || claim2.ID != row.ID || claim2.Attempts != 2 {
t.Fatalf("reclaim ClaimNext = (%+v, %v), want attempts=2", claim2, err)
}
if applied, err := repo.MarkReady(ctx, row.ID, "worker-2", claim2.OutputPath, 0, "", "", "", 4242); err != nil || !applied {
if applied, err := repo.MarkReady(ctx, row.ID, "worker-2", claim2.OutputPath, 4242); err != nil || !applied {
t.Fatalf("MarkReady = (%v, %v), want (true, nil)", applied, err)
}
done, err := repo.GetByKey(ctx, fileID, "transcode", "hash-recovery")
@@ -199,7 +181,7 @@ func TestArtifactMarkFencedByOwner(t *testing.T) {
}
// A non-owner cannot mark the job ready or failed.
if applied, err := repo.MarkReady(ctx, row.ID, "owner-2", "/tmp/x.mp4", 0, "", "", "", 10); err != nil || applied {
if applied, err := repo.MarkReady(ctx, row.ID, "owner-2", "/tmp/x.mp4", 10); err != nil || applied {
t.Fatalf("MarkReady(non-owner) = (%v, %v), want (false, nil)", applied, err)
}
if _, applied, err := repo.MarkFailedOrRetry(ctx, row.ID, "owner-2", "boom", time.Second); err != nil || applied {
@@ -213,511 +195,11 @@ func TestArtifactMarkFencedByOwner(t *testing.T) {
}
// The real owner succeeds.
if applied, err := repo.MarkReady(ctx, row.ID, "owner-1", "/tmp/x.mp4", 0, "", "", "", 10); err != nil || !applied {
if applied, err := repo.MarkReady(ctx, row.ID, "owner-1", "/tmp/x.mp4", 10); err != nil || !applied {
t.Fatalf("MarkReady(owner) = (%v, %v), want (true, nil)", applied, err)
}
}
func TestArtifactRemoteLocatorRoundTripsAndRequeueClearsIt(t *testing.T) {
repo, _, fileID := newArtifactTestRepo(t)
ctx := context.Background()
row, _, err := repo.EnsureQueued(ctx, newArtifact(t, fileID, "hash-remote-locator"))
if err != nil {
t.Fatal(err)
}
if _, err := repo.ClaimNext(ctx, "worker", time.Minute); err != nil {
t.Fatal(err)
}
if applied, err := repo.MarkReady(ctx, row.ID, "worker", "", 17, "http://transcode", "host-a", "artifact-opaque", 4242); err != nil || !applied {
t.Fatalf("MarkReady = (%v, %v)", applied, err)
}
ready, err := repo.GetByID(ctx, row.ID)
if err != nil {
t.Fatal(err)
}
if ready.OutputPath != "" || ready.OriginNodeID != 17 || ready.OriginNodeURL != "http://transcode" || ready.OriginNodeGroup != "host-a" || ready.OriginArtifactID != "artifact-opaque" || ready.FileSize != 4242 {
t.Fatalf("ready artifact = %+v", ready)
}
ready.OriginNodeURL = "http://transcode-new"
ready.OriginNodeGroup = "host-new"
if applied, err := repo.RefreshRemoteLocator(ctx, ready); err != nil || !applied {
t.Fatalf("RefreshRemoteLocator = (%v, %v)", applied, err)
}
refreshed, err := repo.GetByID(ctx, row.ID)
if err != nil {
t.Fatal(err)
}
if refreshed.OriginNodeURL != "http://transcode-new" || refreshed.OriginNodeGroup != "host-new" {
t.Fatalf("refreshed artifact = %+v", refreshed)
}
if err := repo.Requeue(ctx, row.ID); err != nil {
t.Fatal(err)
}
queued, err := repo.GetByID(ctx, row.ID)
if err != nil {
t.Fatal(err)
}
if queued.OriginNodeID != 0 || queued.OriginNodeURL != "" || queued.OriginNodeGroup != "" || queued.OriginArtifactID != "" {
t.Fatalf("requeued artifact retained remote locator: %+v", queued)
}
}
func TestArtifactReadyPersistsRefreshedOriginLocator(t *testing.T) {
repo, _, fileID := newArtifactTestRepo(t)
ctx := context.Background()
row, _, err := repo.EnsureQueued(ctx, newArtifact(t, fileID, "hash-refresh-origin-locator"))
if err != nil {
t.Fatal(err)
}
if _, err := repo.ClaimNext(ctx, "worker", time.Minute); err != nil {
t.Fatal(err)
}
if applied, err := repo.MarkReady(ctx, row.ID, "worker", "", 17, "http://old-url", "old-group", "artifact-refresh", 4242); err != nil || !applied {
t.Fatalf("MarkReady = (%v, %v)", applied, err)
}
manager := &ArtifactManager{
repo: repo,
preparer: &lifecycleTestPreparer{
resolvedURL: "http://new-url", resolvedGroup: "new-group",
},
}
resolved, err := manager.Ready(ctx, row.ID)
if err != nil {
t.Fatal(err)
}
if resolved.OriginNodeURL != "http://new-url" || resolved.OriginNodeGroup != "new-group" {
t.Fatalf("resolved artifact = %+v", resolved)
}
persisted, err := repo.GetByID(ctx, row.ID)
if err != nil {
t.Fatal(err)
}
if persisted.OriginNodeURL != "http://new-url" || persisted.OriginNodeGroup != "new-group" {
t.Fatalf("persisted artifact = %+v", persisted)
}
}
func TestArtifactReadyMapsRemovedOriginToInactiveWhenRequeueLosesFence(t *testing.T) {
repo, _, fileID := newArtifactTestRepo(t)
ctx := context.Background()
row, _, err := repo.EnsureQueued(ctx, newArtifact(t, fileID, "hash-removed-origin-stale-fence"))
if err != nil {
t.Fatal(err)
}
if _, err := repo.ClaimNext(ctx, "worker", time.Minute); err != nil {
t.Fatal(err)
}
if applied, err := repo.MarkReady(ctx, row.ID, "worker", "", 17, "http://removed", "host-a", "artifact-removed", 4242); err != nil || !applied {
t.Fatalf("MarkReady = (%v, %v)", applied, err)
}
manager := &ArtifactManager{
repo: repo,
preparer: &lifecycleTestPreparer{
resolvedNode: 18,
resolveErr: ErrArtifactOriginRemoved,
},
}
_, err = manager.Ready(ctx, row.ID)
if !errors.Is(err, ErrDownloadNotActive) || !errors.Is(err, ErrArtifactOriginRemoved) {
t.Fatalf("Ready error = %v, want inactive removed-origin error", err)
}
}
func TestArtifactRecoveryContinuesAfterStaleLocatorRefresh(t *testing.T) {
repo, pool, fileID := newArtifactTestRepo(t)
ctx := context.Background()
ready := make([]*Artifact, 0, 2)
for i := range 2 {
row, _, err := repo.EnsureQueued(ctx, newArtifact(t, fileID, fmt.Sprintf("hash-stale-locator-batch-%d", i)))
if err != nil {
t.Fatal(err)
}
if _, err := repo.ClaimNext(ctx, "worker", time.Minute); err != nil {
t.Fatal(err)
}
if applied, err := repo.MarkReady(ctx, row.ID, "worker", "", 17, "http://old-url", "host-a", fmt.Sprintf("artifact-%d", i), 4242); err != nil || !applied {
t.Fatalf("MarkReady(%d) = (%v, %v)", i, applied, err)
}
artifact, err := repo.GetByID(ctx, row.ID)
if err != nil {
t.Fatal(err)
}
ready = append(ready, artifact)
}
// Simulate another worker replacing the first locator after this recovery
// pass read it. Its refresh must lose the fence without suppressing checks
// for the remaining artifacts on the still-healthy origin.
if _, err := pool.Exec(ctx,
`UPDATE download_artifacts SET origin_artifact_id = 'artifact-replaced' WHERE id = $1`,
ready[0].ID,
); err != nil {
t.Fatal(err)
}
preparer := &lifecycleTestPreparer{
resolvedURL: "http://new-url", resolvedGroup: "host-new",
stat: downloadprepare.Result{FileSize: 4242},
}
manager := &ArtifactManager{repo: repo, preparer: preparer}
manager.probeRemoteArtifactGroup(ctx, preparer, ready)
if len(preparer.statIDs) != 1 || preparer.statIDs[0] != ready[1].ID {
t.Fatalf("probed artifacts = %v, want only continuing artifact %s", preparer.statIDs, ready[1].ID)
}
persisted, err := repo.GetByID(ctx, ready[1].ID)
if err != nil {
t.Fatal(err)
}
if persisted.OriginNodeURL != "http://new-url" || persisted.OriginNodeGroup != "host-new" {
t.Fatalf("continued artifact locator = %+v", persisted)
}
}
func TestArtifactInvalidRemoteResultQueuesCleanup(t *testing.T) {
repo, pool, fileID := newArtifactTestRepo(t)
ctx := context.Background()
row, _, err := repo.EnsureQueued(ctx, newArtifact(t, fileID, "hash-invalid-remote-result"))
if err != nil {
t.Fatal(err)
}
claimed, err := repo.ClaimNext(ctx, "worker", time.Minute)
if err != nil {
t.Fatal(err)
}
preparer := &lifecycleTestPreparer{prepared: PreparedArtifact{
OriginNodeID: 17, OriginNodeURL: "http://transcode", OriginNodeGroup: "host-a",
OriginArtifactID: "artifact-zero-byte", FileSize: 0,
}}
manager := &ArtifactManager{
repo: repo, owner: "worker", fileRepo: fakeFileResolver{file: &models.MediaFile{ID: fileID, FilePath: "/media/movie.mkv"}},
preparer: preparer,
}
manager.encodeOne(ctx, claimed)
queued, err := repo.GetByID(ctx, row.ID)
if err != nil {
t.Fatal(err)
}
if queued.Status != ArtifactQueued {
t.Fatalf("artifact status = %q, want queued retry", queued.Status)
}
var orphanID int64
if err := pool.QueryRow(ctx,
`SELECT id FROM download_artifact_orphans
WHERE download_artifact_id = $1 AND origin_node_id = 17 AND origin_artifact_id = 'artifact-zero-byte'`,
row.ID,
).Scan(&orphanID); err != nil {
t.Fatalf("remote cleanup row: %v", err)
}
t.Cleanup(func() { _ = repo.DeleteRemoteOrphan(ctx, orphanID) })
}
func TestArtifactRecoveryDeletesWrongSizedRemoteBeforeRequeue(t *testing.T) {
repo, pool, fileID := newArtifactTestRepo(t)
ctx := context.Background()
row, _, err := repo.EnsureQueued(ctx, newArtifact(t, fileID, "hash-remote-size-mismatch"))
if err != nil {
t.Fatal(err)
}
if _, err := repo.ClaimNext(ctx, "worker", time.Minute); err != nil {
t.Fatal(err)
}
if applied, err := repo.MarkReady(ctx, row.ID, "worker", row.OutputPath, 17, "http://transcode", "host-a", "artifact-truncated", 4242); err != nil || !applied {
t.Fatalf("MarkReady = (%v, %v)", applied, err)
}
preparer := &lifecycleTestPreparer{stat: downloadprepare.Result{ArtifactID: "artifact-truncated", FileSize: 42}}
manager := &ArtifactManager{
repo: repo,
downloads: NewRepository(pool),
preparer: preparer,
}
manager.recover(ctx)
got, err := repo.GetByID(ctx, row.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != ArtifactQueued || got.OriginArtifactID != "" {
t.Fatalf("recovered artifact = %+v, want queued without remote locator", got)
}
orphans, err := repo.ListRemoteOrphansDue(ctx, 10)
if err != nil {
t.Fatal(err)
}
orphans = remoteOrphansForArtifact(orphans, row.ID)
if len(orphans) != 1 || orphans[0].OriginNodeID != 17 || orphans[0].OriginArtifactID != "artifact-truncated" {
t.Fatalf("remote cleanup queue = %+v", orphans)
}
if preparer.deleted != "artifact-truncated" {
t.Fatalf("deleted remote artifact = %q, want artifact-truncated", preparer.deleted)
}
t.Cleanup(func() { _ = repo.DeleteRemoteOrphan(ctx, orphans[0].ID) })
}
func TestRemoteRecoveryBudgetBoundsUnreachableOrigins(t *testing.T) {
repo, pool, fileID := newArtifactTestRepo(t)
ctx := context.Background()
for i := range 5 {
row, _, err := repo.EnsureQueued(ctx, newArtifact(t, fileID, fmt.Sprintf("hash-recovery-budget-%d-%d", time.Now().UnixNano(), i)))
if err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx,
`UPDATE download_artifacts
SET status = 'ready', file_size = 42, completed_at = now(),
origin_node_id = $2, origin_node_url = $3, origin_artifact_id = $4
WHERE id = $1`,
row.ID, 32000+i, fmt.Sprintf("http://unreachable-recovery-%d", i), fmt.Sprintf("artifact-blocked-%d", i),
); err != nil {
t.Fatal(err)
}
}
preparer := &lifecycleTestPreparer{statWait: true}
manager := &ArtifactManager{repo: repo, preparer: preparer, remoteRecoveryBudget: 50 * time.Millisecond}
begin := time.Now()
manager.recoverReadyArtifacts(ctx)
if elapsed := time.Since(begin); elapsed > time.Second {
t.Fatalf("recovery elapsed = %s, want bounded return", elapsed)
}
if started := preparer.statStarted.Load(); started == 0 || started > 4 {
t.Fatalf("started probes = %d, want one bounded wave of at most four", started)
}
}
func TestArtifactRemoteRequeueAtomicallyQueuesCleanup(t *testing.T) {
repo, pool, fileID := newArtifactTestRepo(t)
ctx := context.Background()
row, _, err := repo.EnsureQueued(ctx, newArtifact(t, fileID, fmt.Sprintf("hash-remote-atomic-requeue-%d", time.Now().UnixNano())))
if err != nil {
t.Fatal(err)
}
if _, err := repo.ClaimNext(ctx, "worker", time.Minute); err != nil {
t.Fatal(err)
}
if applied, err := repo.MarkReady(ctx, row.ID, "worker", row.OutputPath, 23, "http://transcode-old", "host-a", "artifact-abandoned", 4242); err != nil || !applied {
t.Fatalf("MarkReady = (%v, %v)", applied, err)
}
ready, err := repo.GetByID(ctx, row.ID)
if err != nil {
t.Fatal(err)
}
var userID int
if err := pool.QueryRow(ctx,
`INSERT INTO users (username, role, download_allowed) VALUES ($1, 'user', true) RETURNING id`,
fmt.Sprintf("requeue-user-%d", time.Now().UnixNano()),
).Scan(&userID); err != nil {
t.Fatal(err)
}
downloadID := fmt.Sprintf("requeue-download-%d", time.Now().UnixNano())
if err := NewRepository(pool).Create(ctx, &Download{
ID: downloadID, UserID: userID, MediaFileID: fileID,
ContentID: "requeue-content", Kind: KindQueued, Status: StatusCompleted,
Format: FormatTranscode, ArtifactID: row.ID, FileSize: ready.FileSize,
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM downloads WHERE id = $1`, downloadID)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
})
// An indeterminate MarkReady result may enqueue the locator even though its
// database write committed. Cleanup must recognize the winning locator and
// remove only the stale queue row, never the node-local bytes.
if err := repo.EnqueueRemoteOrphan(ctx, row.ID, ready.OriginNodeID, ready.OriginNodeURL, ready.OriginArtifactID); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE download_artifact_orphans SET next_retry_at = NULL WHERE origin_node_id = $1 AND origin_artifact_id = $2`, ready.OriginNodeID, ready.OriginArtifactID); err != nil {
t.Fatal(err)
}
orphans, err := repo.ListRemoteOrphansDue(ctx, 10)
orphans = remoteOrphansForArtifact(orphans, row.ID)
if err != nil || len(orphans) != 1 {
t.Fatalf("uncertain cleanup queue = %+v (%v)", orphans, err)
}
owned, claimed, err := repo.PrepareRemoteOrphanCleanup(ctx, orphans[0])
if err != nil || !claimed || !owned {
t.Fatalf("PrepareRemoteOrphanCleanup = (owned=%v claimed=%v err=%v)", owned, claimed, err)
}
if left, err := repo.ListRemoteOrphansDue(ctx, 10); err != nil || len(remoteOrphansForArtifact(left, row.ID)) != 0 {
t.Fatalf("owned cleanup rows left = %+v (%v)", left, err)
}
// The in-memory URL may already have followed an administrative edit; the
// transaction deliberately matches stable node/id fields and stores the
// refreshed URL as the cleanup target.
ready.OriginNodeURL = "http://transcode-new"
linked, applied, err := repo.RequeueRemote(ctx, ready)
if err != nil || !applied {
t.Fatalf("RequeueRemote = (%v, %v)", applied, err)
}
if len(linked) != 1 || linked[0].ID != downloadID || linked[0].Status != StatusPreparing {
t.Fatalf("reset linked downloads = %+v", linked)
}
queued, err := repo.GetByID(ctx, row.ID)
if err != nil {
t.Fatal(err)
}
if queued.Status != ArtifactQueued || queued.OriginArtifactID != "" {
t.Fatalf("queued artifact = %+v", queued)
}
orphans, err = repo.ListRemoteOrphansDue(ctx, 10)
if err != nil {
t.Fatal(err)
}
orphans = remoteOrphansForArtifact(orphans, row.ID)
if len(orphans) != 1 || orphans[0].DownloadArtifactID != row.ID || orphans[0].OriginNodeURL != "http://transcode-new" || orphans[0].OriginArtifactID != "artifact-abandoned" {
t.Fatalf("remote cleanup queue = %+v", orphans)
}
t.Cleanup(func() { _ = repo.DeleteRemoteOrphan(ctx, orphans[0].ID) })
// A stale caller cannot enqueue a second cleanup after the row changed.
if _, applied, err := repo.RequeueRemote(ctx, ready); err != nil || applied {
t.Fatalf("stale RequeueRemote = (%v, %v), want false, nil", applied, err)
}
}
func TestProxyMissingReportFencesCompleteRemoteLocator(t *testing.T) {
repo, _, fileID := newArtifactTestRepo(t)
ctx := context.Background()
row, _, err := repo.EnsureQueued(ctx, newArtifact(t, fileID, fmt.Sprintf("hash-proxy-missing-report-%d", time.Now().UnixNano())))
if err != nil {
t.Fatal(err)
}
if _, err := repo.ClaimNext(ctx, "worker", time.Minute); err != nil {
t.Fatal(err)
}
if applied, err := repo.MarkReady(ctx, row.ID, "worker", "", 23, "http://transcode-current", "host-a", "artifact-missing", 4242); err != nil || !applied {
t.Fatalf("MarkReady = (%v, %v)", applied, err)
}
manager := NewArtifactManager(repo, nil, nil, nil, "proxy-test", nil, nil)
stillReady, err := repo.GetByID(ctx, row.ID)
if err != nil {
t.Fatal(err)
}
stale := *stillReady
stale.OriginNodeURL = "http://transcode-stale"
if applied, err := manager.requeueRemoteArtifactExactNow(ctx, &stale, "stale API miss"); err != nil || applied {
t.Fatalf("stale exact requeue = (%v, %v), want false, nil", applied, err)
}
if err := manager.ReportRemoteArtifactMissing(ctx, row.ID, "http://transcode-stale", "artifact-missing"); err != nil {
t.Fatal(err)
}
stillReady, err = repo.GetByID(ctx, row.ID)
if err != nil {
t.Fatal(err)
}
if stillReady.Status != ArtifactReady {
t.Fatalf("stale report changed artifact = %+v", stillReady)
}
if err := manager.ReportRemoteArtifactMissing(ctx, row.ID, "http://transcode-current", "artifact-missing"); err != nil {
t.Fatal(err)
}
queued, err := repo.GetByID(ctx, row.ID)
if err != nil {
t.Fatal(err)
}
if queued.Status != ArtifactQueued || queued.OriginArtifactID != "" {
t.Fatalf("artifact after authoritative missing report = %+v", queued)
}
orphans, err := repo.ListRemoteOrphansDue(ctx, 10)
if err != nil {
t.Fatal(err)
}
orphans = remoteOrphansForArtifact(orphans, row.ID)
if len(orphans) != 1 || orphans[0].DownloadArtifactID != row.ID || orphans[0].OriginArtifactID != "artifact-missing" {
t.Fatalf("remote cleanup queue = %+v", orphans)
}
t.Cleanup(func() { _ = repo.DeleteRemoteOrphan(ctx, orphans[0].ID) })
}
func TestRemoteCleanupBudgetBoundsUnreachableOrigins(t *testing.T) {
repo, _, fileID := newArtifactTestRepo(t)
ctx := context.Background()
row, _, err := repo.EnsureQueued(ctx, newArtifact(t, fileID, fmt.Sprintf("hash-cleanup-budget-%d", time.Now().UnixNano())))
if err != nil {
t.Fatal(err)
}
suffix := time.Now().UnixNano()
originArtifactID := fmt.Sprintf("artifact-blocked-%d", suffix)
originURL := fmt.Sprintf("http://unreachable-%d", suffix)
if err := repo.EnqueueRemoteOrphan(ctx, row.ID, 31, originURL, originArtifactID); err != nil {
t.Fatal(err)
}
started := make(chan struct{})
preparer := &lifecycleTestPreparer{deleteStarted: started, deleteWait: true}
manager := &ArtifactManager{repo: repo, preparer: preparer, remoteCleanupBudget: 50 * time.Millisecond}
begin := time.Now()
manager.cleanupRemoteOrphans(ctx)
if elapsed := time.Since(begin); elapsed > time.Second {
t.Fatalf("cleanup elapsed = %s, want bounded return", elapsed)
}
select {
case <-started:
default:
t.Fatal("remote deletion was not attempted")
}
orphans, err := repo.ListRemoteOrphansDue(ctx, 100)
if err != nil {
t.Fatal(err)
}
for _, orphan := range orphans {
if orphan.DownloadArtifactID == row.ID {
t.Fatalf("timed-out orphan remained immediately due: %+v", orphan)
}
}
}
func TestListRemoteOrphansDueIsFairAcrossOrigins(t *testing.T) {
repo, pool, fileID := newArtifactTestRepo(t)
ctx := context.Background()
row, _, err := repo.EnsureQueued(ctx, newArtifact(t, fileID, fmt.Sprintf("hash-orphan-fairness-%d", time.Now().UnixNano())))
if err != nil {
t.Fatal(err)
}
suffix := time.Now().UnixNano()
artifactIDs := []string{
fmt.Sprintf("fair-origin-a-first-%d", suffix),
fmt.Sprintf("fair-origin-a-second-%d", suffix),
fmt.Sprintf("fair-origin-b-%d", suffix),
}
for i, artifactID := range artifactIDs {
nodeID := 31001
nodeURL := "http://origin-a"
if i == 2 {
nodeID = 31002
nodeURL = "http://origin-b"
}
if err := repo.EnqueueRemoteOrphan(ctx, row.ID, nodeID, nodeURL, artifactID); err != nil {
t.Fatal(err)
}
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM download_artifact_orphans WHERE origin_artifact_id = ANY($1)`, artifactIDs)
})
if _, err := pool.Exec(ctx,
`UPDATE download_artifact_orphans
SET next_retry_at = NULL, created_at = '-infinity'
WHERE origin_artifact_id = ANY($1)`,
artifactIDs,
); err != nil {
t.Fatal(err)
}
due, err := repo.ListRemoteOrphansDue(ctx, 2)
if err != nil {
t.Fatal(err)
}
seen := make(map[int]int)
for _, orphan := range due {
if orphan.OriginNodeID == 31001 || orphan.OriginNodeID == 31002 {
seen[orphan.OriginNodeID]++
}
}
if seen[31001] != 1 || seen[31002] != 1 {
t.Fatalf("due candidates by origin = %+v, want one candidate for each origin", seen)
}
}
// TestHasActiveLinkCoversEphemeralRows pins the eviction guard: an ephemeral
// (device-less web) download row must protect its artifact from LRU cleanup
// exactly like a managed row does, and terminal rows must not.
-80
View File
@@ -4,14 +4,11 @@ import (
"context"
"encoding/json"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/downloadprepare"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
)
func TestParamsHashStableAndDistinct(t *testing.T) {
@@ -52,83 +49,6 @@ func TestArtifactOutputPathDeterministic(t *testing.T) {
}
}
type lifecycleTestPreparer struct {
deleted string
prepared PreparedArtifact
resolveErr error
resolvedNode int
resolvedURL string
resolvedGroup string
stat downloadprepare.Result
statError error
statIDs []string
statStarted atomic.Int32
statWait bool
deleteErr error
deleteStarted chan struct{}
deleteWait bool
}
func (p *lifecycleTestPreparer) PrepareFile(context.Context, string, playback.TranscodeOpts, string) (PreparedArtifact, error) {
return p.prepared, nil
}
func (p *lifecycleTestPreparer) ResolveArtifact(_ context.Context, artifact *Artifact) error {
if p.resolvedNode != 0 {
artifact.OriginNodeID = p.resolvedNode
}
if p.resolvedURL != "" {
artifact.OriginNodeURL = p.resolvedURL
artifact.OriginNodeGroup = p.resolvedGroup
}
return p.resolveErr
}
func (p *lifecycleTestPreparer) StatArtifact(ctx context.Context, artifact *Artifact) (downloadprepare.Result, error) {
if p.statWait {
p.statStarted.Add(1)
select {
case <-ctx.Done():
return downloadprepare.Result{}, ctx.Err()
case <-time.After(2 * time.Second):
return downloadprepare.Result{}, context.DeadlineExceeded
}
}
p.statIDs = append(p.statIDs, artifact.ID)
return p.stat, p.statError
}
func (p *lifecycleTestPreparer) DeleteArtifact(ctx context.Context, artifact *Artifact) error {
p.deleted = artifact.OriginArtifactID
if p.deleteStarted != nil {
close(p.deleteStarted)
}
if p.deleteWait {
<-ctx.Done()
return ctx.Err()
}
return p.deleteErr
}
func TestArtifactManagerDeletesRemoteBytesThroughOwningNode(t *testing.T) {
preparer := &lifecycleTestPreparer{}
m := &ArtifactManager{preparer: preparer}
artifact := &Artifact{ID: "row-1", OriginNodeURL: "http://transcode", OriginArtifactID: "opaque-1"}
if !m.deleteArtifactBytes(context.Background(), artifact) {
t.Fatal("remote cleanup failed")
}
if preparer.deleted != "opaque-1" {
t.Fatalf("deleted = %q", preparer.deleted)
}
}
func TestRemoteArtifactRequeueRejectsNonPositiveNodeID(t *testing.T) {
manager := &ArtifactManager{repo: &ArtifactRepository{}}
_, err := manager.requeueRemoteArtifactNow(context.Background(), &Artifact{
ID: "row-1", OriginNodeID: -1, OriginNodeURL: "http://transcode", OriginArtifactID: "opaque-1",
}, "invalid node")
if err == nil {
t.Fatal("expected invalid remote locator error")
}
}
func TestEffectiveArtifactDir(t *testing.T) {
// Explicit config wins verbatim.
if got := effectiveArtifactDir("/data/artifacts", "/tmp/silo-transcode"); got != "/data/artifacts" {
+48 -444
View File
@@ -6,14 +6,10 @@ import (
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/downloadprepare"
"github.com/Silo-Server/silo-server/internal/idgen"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
@@ -25,52 +21,21 @@ const (
artifactMaxAttempts = 3
)
// PreparedArtifact describes either a local prepared file or an opaque artifact
// retained by a transcode node.
type PreparedArtifact struct {
OutputPath string
OriginNodeID int
OriginNodeURL string
OriginNodeGroup string
OriginArtifactID string
FileSize int64
}
func (a PreparedArtifact) Remote() bool {
return a.OriginNodeID > 0 && a.OriginNodeURL != "" && a.OriginArtifactID != ""
}
// EncodePreparer produces a single finalized artifact. The default
// implementation calls playback.PrepareFile and returns a local path; pooled
// implementations may return an opaque remote locator instead.
// EncodePreparer produces a single finalized file for an artifact. The default
// implementation calls playback.PrepareFile; tests substitute a fake.
type EncodePreparer interface {
PrepareFile(ctx context.Context, artifactID string, opts playback.TranscodeOpts, outputPath string) (PreparedArtifact, error)
PrepareFile(ctx context.Context, opts playback.TranscodeOpts, outputPath string) error
}
type playbackPreparer struct{}
func (playbackPreparer) PrepareFile(ctx context.Context, _ string, opts playback.TranscodeOpts, outputPath string) (PreparedArtifact, error) {
if err := playback.PrepareFile(ctx, opts, outputPath); err != nil {
return PreparedArtifact{}, err
}
stat, err := os.Stat(outputPath)
if err != nil {
return PreparedArtifact{}, fmt.Errorf("stat prepared artifact: %w", err)
}
return PreparedArtifact{OutputPath: outputPath, FileSize: stat.Size()}, nil
func (playbackPreparer) PrepareFile(ctx context.Context, opts playback.TranscodeOpts, outputPath string) error {
return playback.PrepareFile(ctx, opts, outputPath)
}
// NewPlaybackPreparer returns the production EncodePreparer (ffmpeg-backed).
func NewPlaybackPreparer() EncodePreparer { return playbackPreparer{} }
// remoteArtifactLifecycle is implemented by the pooled preparer so recovery
// and quota cleanup can manage node-local artifacts without filesystem access.
type remoteArtifactLifecycle interface {
ResolveArtifact(ctx context.Context, artifact *Artifact) error
StatArtifact(ctx context.Context, artifact *Artifact) (downloadprepare.Result, error)
DeleteArtifact(ctx context.Context, artifact *Artifact) error
}
// ArtifactNotifier publishes an event when a linked download changes state.
type ArtifactNotifier func(ctx context.Context, d *Download)
@@ -78,15 +43,13 @@ type ArtifactNotifier func(ctx context.Context, d *Download)
// jobs, drains them through a bounded worker pool with leased heartbeats, and
// recovers stranded jobs on startup.
type ArtifactManager struct {
repo *ArtifactRepository
downloads *Repository
fileRepo FileResolver
preparer EncodePreparer
owner string
liveCfg func() *config.Config
notify ArtifactNotifier
remoteRecoveryBudget time.Duration
remoteCleanupBudget time.Duration
repo *ArtifactRepository
downloads *Repository
fileRepo FileResolver
preparer EncodePreparer
owner string
liveCfg func() *config.Config
notify ArtifactNotifier
mu sync.Mutex
kick func()
@@ -136,37 +99,6 @@ func NewArtifactManager(
}
}
// ReportRemoteArtifactMissing fences a proxy-observed 404 against the signed
// database-row and node locator, then atomically requeues the artifact and its
// linked downloads. Stale tokens are harmless: the exact-locator transition
// only applies while the complete locator still owns the ready row.
func (m *ArtifactManager) ReportRemoteArtifactMissing(ctx context.Context, artifactID, originNodeURL, originArtifactID string) error {
if m == nil || m.repo == nil || strings.TrimSpace(artifactID) == "" ||
strings.TrimSpace(originNodeURL) == "" || !downloadprepare.ValidArtifactID(originArtifactID) {
return errors.New("remote artifact locator unavailable for missing report")
}
artifact, err := m.repo.GetByID(ctx, artifactID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil
}
return err
}
if artifact.Status != ArtifactReady || artifact.OriginNodeURL != originNodeURL || artifact.OriginArtifactID != originArtifactID {
return nil
}
linked, applied, err := m.repo.RequeueRemoteExactLocator(ctx, artifact)
if err != nil || !applied {
return err
}
for _, download := range linked {
m.publish(ctx, download)
}
slog.WarnContext(ctx, "remote download artifact re-queued", "component", "downloads", "artifact_id", artifact.ID, "node", artifact.OriginNodeURL, "reason", "proxy observed remote output missing")
m.triggerDrain()
return nil
}
// SetKick wires a low-latency drain trigger (e.g. taskmanager RunTask) invoked
// when a new job is enqueued.
func (m *ArtifactManager) SetKick(kick func()) {
@@ -185,17 +117,6 @@ func (m *ArtifactManager) Ready(ctx context.Context, id string) (*Artifact, erro
if a.Status != ArtifactReady {
return nil, fmt.Errorf("artifact is %s: %w", a.Status, ErrDownloadNotActive)
}
if lifecycle, ok := m.preparer.(remoteArtifactLifecycle); ok && a.OriginArtifactID != "" {
if err := m.resolveRemoteArtifact(ctx, lifecycle, a); err != nil {
if !errors.Is(err, ErrArtifactOriginRemoved) {
return nil, err
}
if m.requeueRemoteArtifact(ctx, a, "origin node removed") {
return nil, fmt.Errorf("artifact origin was removed and preparation was requeued: %w", ErrDownloadNotActive)
}
return nil, fmt.Errorf("artifact origin was removed: %w", errors.Join(ErrDownloadNotActive, err))
}
}
_ = m.repo.TouchLastUsed(ctx, id)
return a, nil
}
@@ -293,26 +214,20 @@ func (m *ArtifactManager) triggerDrain() {
}
}
// RunOnce repairs queue state and drains pending work before probing ready
// remote artifacts. Network health checks therefore never block newly queued
// downloads at startup; any artifacts requeued by the bounded probe pass are
// drained in the second pass.
// RunOnce performs a startup-safe recovery sweep and then drains the queue
// until empty. It is safe to call concurrently across nodes; FOR UPDATE SKIP
// LOCKED prevents double-encoding.
func (m *ArtifactManager) RunOnce(ctx context.Context) error {
m.recoverQueueState(ctx)
if err := m.drain(ctx); err != nil {
return err
}
m.recoverReadyArtifacts(ctx)
m.recover(ctx)
return m.drain(ctx)
}
// recover is retained as the focused recovery entrypoint used by tests.
// recover repairs state a crash or lost lease can leave behind: it reclaims
// expired-lease running rows (back to queued, or failed when attempts are
// exhausted), reconciles linked downloads against terminal artifact states, and
// re-queues ready artifacts whose output file is missing on disk. Safe to run
// repeatedly (each step is idempotent).
func (m *ArtifactManager) recover(ctx context.Context) {
m.recoverQueueState(ctx)
m.recoverReadyArtifacts(ctx)
}
func (m *ArtifactManager) recoverQueueState(ctx context.Context) {
if _, err := m.repo.ReclaimExpiredLeases(ctx); err != nil {
slog.WarnContext(ctx, "download artifact lease reclaim failed", "component", "downloads", "error", err)
}
@@ -333,13 +248,9 @@ func (m *ArtifactManager) recoverQueueState(ctx context.Context) {
m.publish(ctx, d)
}
}
}
// recoverReadyArtifacts checks local paths directly and groups remote probes
// by origin. Origins run concurrently, but each origin is short-circuited after
// its first transient failure so one blackholed node costs one bounded timeout,
// never one timeout per artifact.
func (m *ArtifactManager) recoverReadyArtifacts(ctx context.Context) {
// Disk-presence sweep: stats every ready file, so it runs on the startup
// pass and then hourly rather than on every tick.
if !m.maintenanceDue(&m.lastDiskSweep) {
return
}
@@ -348,172 +259,21 @@ func (m *ArtifactManager) recoverReadyArtifacts(ctx context.Context) {
slog.WarnContext(ctx, "download artifact ready scan failed", "component", "downloads", "error", err)
return
}
remoteGroups := make(map[string][]*Artifact)
for _, a := range ready {
if a.OriginArtifactID != "" {
key := fmt.Sprintf("%d\x00%s", a.OriginNodeID, a.OriginNodeURL)
remoteGroups[key] = append(remoteGroups[key], a)
if a.OutputPath == "" {
continue
}
if a.OutputPath != "" {
if _, statErr := os.Stat(a.OutputPath); statErr != nil {
slog.WarnContext(ctx, "download artifact output missing, re-queuing", "component", "downloads", "artifact_id", a.ID, "path", a.OutputPath)
if err := m.repo.Requeue(ctx, a.ID); err != nil {
slog.WarnContext(ctx, "re-queue artifact failed", "component", "downloads", "artifact_id", a.ID, "error", err)
}
}
}
}
if len(remoteGroups) == 0 {
return
}
lifecycle, ok := m.preparer.(remoteArtifactLifecycle)
if !ok {
slog.WarnContext(ctx, "remote download artifacts cannot be checked", "component", "downloads")
return
}
budget := m.remoteRecoveryBudget
if budget <= 0 {
budget = defaultRemoteRecoveryBudget
}
recoveryCtx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
const maxConcurrentOriginProbes = 4
sem := make(chan struct{}, maxConcurrentOriginProbes)
var wg sync.WaitGroup
for _, artifacts := range remoteGroups {
artifacts := artifacts
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-recoveryCtx.Done():
return
}
m.probeRemoteArtifactGroup(recoveryCtx, lifecycle, artifacts)
}()
}
wg.Wait()
}
func (m *ArtifactManager) probeRemoteArtifactGroup(ctx context.Context, lifecycle remoteArtifactLifecycle, artifacts []*Artifact) {
for _, a := range artifacts {
if err := m.resolveRemoteArtifact(ctx, lifecycle, a); err != nil {
if errors.Is(err, ErrArtifactOriginRemoved) {
m.requeueRemoteArtifact(ctx, a, "origin node removed")
if _, statErr := os.Stat(a.OutputPath); statErr != nil {
slog.WarnContext(ctx, "download artifact output missing, re-queuing", "component", "downloads", "artifact_id", a.ID, "path", a.OutputPath)
if err := m.repo.Requeue(ctx, a.ID); err != nil {
slog.WarnContext(ctx, "re-queue artifact failed", "component", "downloads", "artifact_id", a.ID, "error", err)
continue
}
if errors.Is(err, ErrDownloadNotActive) {
// The row changed concurrently; the origin itself is still healthy.
continue
}
slog.WarnContext(ctx, "remote download artifact resolution failed; skipping remaining origin batch", "component", "downloads", "artifact_id", a.ID, "error", err)
return
}
result, err := lifecycle.StatArtifact(ctx, a)
switch {
case errors.Is(err, ErrArtifactOriginRemoved):
m.requeueRemoteArtifact(ctx, a, "origin node removed")
case errors.Is(err, downloadprepare.ErrArtifactNotFound):
m.requeueRemoteArtifact(ctx, a, "remote output missing")
case err != nil:
// One node-level failure suppresses the rest of this origin's batch.
slog.WarnContext(ctx, "remote download artifact check failed; skipping remaining origin batch", "component", "downloads", "artifact_id", a.ID, "node", a.OriginNodeURL, "error", err)
return
case result.FileSize != a.FileSize:
m.requeueWrongSizedRemoteArtifact(ctx, lifecycle, a)
m.triggerDrain()
}
}
}
// requeueWrongSizedRemoteArtifact commits the durable fence before deleting
// bytes, but delays replacement work until the known-invalid locator has been
// quarantined. If deletion fails, the transaction's orphan row retains the
// exact locator for the regular retrying cleanup pass.
func (m *ArtifactManager) requeueWrongSizedRemoteArtifact(ctx context.Context, lifecycle remoteArtifactLifecycle, a *Artifact) {
applied, err := m.requeueRemoteArtifactWithFence(ctx, a, "remote output size mismatch", false, false)
if err != nil {
slog.WarnContext(ctx, "re-queue remote artifact failed", "component", "downloads", "artifact_id", a.ID, "error", err)
return
}
if !applied {
return
}
if err := lifecycle.DeleteArtifact(ctx, a); err != nil {
slog.WarnContext(ctx, "deleting rejected remote artifact failed", "component", "downloads", "artifact_id", a.ID, "node", a.OriginNodeURL, "error", err)
}
m.triggerDrain()
}
func (m *ArtifactManager) resolveRemoteArtifact(ctx context.Context, lifecycle remoteArtifactLifecycle, artifact *Artifact) error {
oldURL, oldGroup := artifact.OriginNodeURL, artifact.OriginNodeGroup
if err := lifecycle.ResolveArtifact(ctx, artifact); err != nil {
return err
}
if artifact.OriginNodeURL == oldURL && artifact.OriginNodeGroup == oldGroup {
return nil
}
applied, err := m.repo.RefreshRemoteLocator(ctx, artifact)
if err != nil {
return err
}
if !applied {
return fmt.Errorf("remote artifact changed while refreshing its origin: %w", ErrDownloadNotActive)
}
return nil
}
func (m *ArtifactManager) requeueRemoteArtifact(ctx context.Context, a *Artifact, reason string) bool {
applied, err := m.requeueRemoteArtifactNow(ctx, a, reason)
if err != nil {
slog.WarnContext(ctx, "re-queue remote artifact failed", "component", "downloads", "artifact_id", a.ID, "error", err)
return false
}
return applied
}
// requeueRemoteArtifactNow synchronously fences a stale remote locator, resets
// every linked download, and schedules the abandoned node-local file for
// cleanup. Request paths use the returned error so a failed state transition is
// never disguised as an ordinary missing catalog item.
func (m *ArtifactManager) requeueRemoteArtifactNow(ctx context.Context, a *Artifact, reason string) (bool, error) {
return m.requeueRemoteArtifactWithFence(ctx, a, reason, false, true)
}
func (m *ArtifactManager) requeueRemoteArtifactExactNow(ctx context.Context, a *Artifact, reason string) (bool, error) {
return m.requeueRemoteArtifactWithFence(ctx, a, reason, true, true)
}
func (m *ArtifactManager) requeueRemoteArtifactWithFence(ctx context.Context, a *Artifact, reason string, exactURL, triggerDrain bool) (bool, error) {
if m == nil || m.repo == nil || a == nil || a.ID == "" || a.OriginNodeID <= 0 || a.OriginArtifactID == "" {
return false, errors.New("remote artifact locator unavailable for requeue")
}
var linked []*Download
var applied bool
var err error
if exactURL {
linked, applied, err = m.repo.RequeueRemoteExactLocator(ctx, a)
} else {
linked, applied, err = m.repo.RequeueRemote(ctx, a)
}
if err != nil {
return false, err
}
if !applied {
return false, nil
}
for _, download := range linked {
m.publish(ctx, download)
}
slog.WarnContext(ctx, "remote download artifact re-queued", "component", "downloads", "artifact_id", a.ID, "node", a.OriginNodeURL, "reason", reason)
if triggerDrain {
m.triggerDrain()
}
return true, nil
}
// drain claims and encodes jobs through a bounded worker pool until the queue is
// empty or the context is canceled.
func (m *ArtifactManager) drain(ctx context.Context) error {
@@ -572,15 +332,7 @@ func (m *ArtifactManager) encodeOne(ctx context.Context, a *Artifact) {
}
opts := m.buildOpts(file, a)
// Each lease attempt owns a distinct node-local object. A worker that loses
// the readiness fence can therefore queue its object for deletion without
// racing the replacement worker's output on the same node.
remoteAttemptID := a.ID + "-" + uuid.NewString()
prepared, err := m.preparer.PrepareFile(hbCtx, remoteAttemptID, opts, a.OutputPath)
if err != nil {
if prepared.Remote() {
m.enqueueRemoteCleanup(ctx, a.ID, prepared, true)
}
if err := m.preparer.PrepareFile(hbCtx, opts, a.OutputPath); err != nil {
switch {
case ctx.Err() != nil:
// Parent shutting down: leave the job 'running'; its lease expires and
@@ -597,35 +349,20 @@ func (m *ArtifactManager) encodeOne(ctx context.Context, a *Artifact) {
}
}
remoteFieldsPresent := prepared.OriginNodeID != 0 || prepared.OriginNodeURL != "" || prepared.OriginNodeGroup != "" || prepared.OriginArtifactID != ""
if prepared.FileSize <= 0 || (remoteFieldsPresent && !prepared.Remote()) || (!prepared.Remote() && prepared.OutputPath == "") {
msg := "prepared artifact returned an invalid storage locator"
slog.WarnContext(ctx, "prepared artifact returned an invalid storage locator", "component", "downloads", "artifact_id", a.ID)
if prepared.Remote() {
m.enqueueRemoteCleanup(ctx, a.ID, prepared, true)
}
m.failJob(ctx, a, msg)
return
var size int64
if fi, statErr := os.Stat(a.OutputPath); statErr == nil {
size = fi.Size()
}
size := prepared.FileSize
// Fenced on lease ownership: if we lost the lease between encode and commit,
// applied is false and the current owner is responsible for flipping links —
// do not flip them here or we would race/duplicate that owner's work.
outputPath := prepared.OutputPath
if prepared.Remote() {
// Keep the deterministic API-local fallback path in the row so a later
// remote-missing requeue can safely fall back to integrated preparation.
outputPath = a.OutputPath
}
applied, err := m.repo.MarkReady(ctx, a.ID, m.owner, outputPath, prepared.OriginNodeID, prepared.OriginNodeURL, prepared.OriginNodeGroup, prepared.OriginArtifactID, size)
applied, err := m.repo.MarkReady(ctx, a.ID, m.owner, a.OutputPath, size)
if err != nil {
slog.ErrorContext(ctx, "marking artifact ready failed", "component", "downloads", "artifact_id", a.ID, "error", err)
m.cleanupRejectedPrepared(ctx, a.ID, prepared)
return
}
if !applied {
slog.WarnContext(ctx, "download artifact ready skipped; lease lost", "component", "downloads", "artifact_id", a.ID)
m.enqueueRemoteCleanup(ctx, a.ID, prepared, true)
return
}
flipped, err := m.downloads.MarkLinkedDownloadsReady(ctx, a.ID, size)
@@ -638,54 +375,6 @@ func (m *ArtifactManager) encodeOne(ctx context.Context, a *Artifact) {
}
}
func (m *ArtifactManager) cleanupRejectedPrepared(ctx context.Context, artifactID string, prepared PreparedArtifact) {
if !prepared.Remote() {
return
}
// Exec can report a transport error after PostgreSQL committed. Re-read the
// row before classifying this attempt as abandoned so cleanup never deletes
// the locator that actually won the readiness fence.
current, err := m.repo.GetByID(ctx, artifactID)
if err == nil {
if current.Status == ArtifactReady &&
current.OriginNodeID == prepared.OriginNodeID &&
current.OriginArtifactID == prepared.OriginArtifactID {
return
}
}
// A successful reread (or a definitively missing row) proves this locator
// lost. Other database errors leave MarkReady's outcome indeterminate, so a
// failed queue write must not fall back to deleting potentially-owned bytes.
safeImmediateDelete := err == nil || errors.Is(err, ErrNotFound)
m.enqueueRemoteCleanup(ctx, artifactID, prepared, safeImmediateDelete)
}
func (m *ArtifactManager) enqueueRemoteCleanup(ctx context.Context, artifactID string, prepared PreparedArtifact, safeImmediateDelete bool) bool {
if !prepared.Remote() {
return true
}
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
defer cancel()
if err := m.repo.EnqueueRemoteOrphan(cleanupCtx, artifactID, prepared.OriginNodeID, prepared.OriginNodeURL, prepared.OriginArtifactID); err != nil {
slog.WarnContext(ctx, "persisting abandoned remote artifact failed", "component", "downloads", "artifact_id", prepared.OriginArtifactID, "node", prepared.OriginNodeURL, "error", err)
// A unique attempt ID permits best-effort immediate deletion when the
// caller knows the readiness write did not commit. An indeterminate write
// must retain the bytes if the verification queue is unavailable.
if safeImmediateDelete {
lifecycle, ok := m.preparer.(remoteArtifactLifecycle)
if !ok {
return false
}
artifact := &Artifact{OriginNodeID: prepared.OriginNodeID, OriginNodeURL: prepared.OriginNodeURL, OriginNodeGroup: prepared.OriginNodeGroup, OriginArtifactID: prepared.OriginArtifactID}
if deleteErr := lifecycle.DeleteArtifact(cleanupCtx, artifact); deleteErr == nil {
return true
}
}
return false
}
return true
}
func (m *ArtifactManager) failJob(ctx context.Context, a *Artifact, msg string) {
terminal, applied, err := m.repo.MarkFailedOrRetry(ctx, a.ID, m.owner, msg, backoffFor(a.Attempts))
if err != nil {
@@ -780,11 +469,9 @@ func (m *ArtifactManager) buildOpts(file *models.MediaFile, a *Artifact) playbac
// The server-disk *quota* is download.artifact_max_bytes (see the download
// limits & restrictions design); this sweep is not a quota.
const (
failedArtifactRetention = 24 * time.Hour
unlinkedArtifactRetention = 30 * 24 * time.Hour
ephemeralDownloadRetention = 7 * 24 * time.Hour
defaultRemoteRecoveryBudget = 15 * time.Second
defaultRemoteCleanupBudget = 15 * time.Second
failedArtifactRetention = 24 * time.Hour
unlinkedArtifactRetention = 30 * 24 * time.Hour
ephemeralDownloadRetention = 7 * 24 * time.Hour
)
// Cleanup runs the hygiene sweep, then evicts ready artifacts (LRU first) once
@@ -792,7 +479,6 @@ const (
// active download row (managed or ephemeral) — only artifacts whose links are
// all terminal are evictable.
func (m *ArtifactManager) Cleanup(ctx context.Context) error {
m.cleanupRemoteOrphans(ctx)
m.sweepStale(ctx)
budget := m.downloadConfig().ArtifactMaxBytes
if budget <= 0 {
@@ -821,8 +507,10 @@ func (m *ArtifactManager) Cleanup(ctx context.Context) error {
if active {
continue
}
if !m.deleteArtifactBytes(ctx, a) {
continue
if a.OutputPath != "" {
if err := os.Remove(a.OutputPath); err != nil && !os.IsNotExist(err) {
slog.WarnContext(ctx, "removing evicted artifact file failed", "component", "downloads", "artifact_id", a.ID, "error", err)
}
}
if err := m.repo.DeleteArtifact(ctx, a.ID); err != nil {
slog.WarnContext(ctx, "deleting evicted artifact row failed", "component", "downloads", "artifact_id", a.ID, "error", err)
@@ -834,68 +522,6 @@ func (m *ArtifactManager) Cleanup(ctx context.Context) error {
return nil
}
func (m *ArtifactManager) cleanupRemoteOrphans(ctx context.Context) {
lifecycle, ok := m.preparer.(remoteArtifactLifecycle)
if !ok {
return
}
budget := m.remoteCleanupBudget
if budget <= 0 {
budget = defaultRemoteCleanupBudget
}
cleanupCtx, cancel := context.WithTimeout(ctx, budget)
defer cancel()
orphans, err := m.repo.ListRemoteOrphansDue(cleanupCtx, 100)
if err != nil {
slog.WarnContext(ctx, "listing abandoned remote artifacts failed", "component", "downloads", "error", err)
return
}
groups := make(map[string][]RemoteArtifactOrphan)
for _, orphan := range orphans {
key := fmt.Sprintf("%d\x00%s", orphan.OriginNodeID, orphan.OriginNodeURL)
groups[key] = append(groups[key], orphan)
}
const maxConcurrentOriginDeletes = 4
sem := make(chan struct{}, maxConcurrentOriginDeletes)
var wg sync.WaitGroup
for _, group := range groups {
group := group
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-cleanupCtx.Done():
return
}
for _, orphan := range group {
owned, claimed, err := m.repo.PrepareRemoteOrphanCleanup(cleanupCtx, orphan)
if err != nil {
slog.WarnContext(ctx, "claiming abandoned remote artifact cleanup failed; skipping remaining origin batch", "component", "downloads", "artifact_id", orphan.OriginArtifactID, "error", err)
return
}
if !claimed || owned {
continue
}
artifact := &Artifact{
OriginNodeID: orphan.OriginNodeID, OriginNodeURL: orphan.OriginNodeURL,
OriginArtifactID: orphan.OriginArtifactID,
}
if err := lifecycle.DeleteArtifact(cleanupCtx, artifact); err != nil {
slog.WarnContext(ctx, "abandoned remote artifact cleanup failed; skipping remaining origin batch", "component", "downloads", "artifact_id", orphan.OriginArtifactID, "node", orphan.OriginNodeURL, "error", err)
return
}
if err := m.repo.DeleteRemoteOrphan(cleanupCtx, orphan.ID); err != nil {
slog.WarnContext(ctx, "clearing abandoned remote artifact cleanup row failed", "component", "downloads", "artifact_id", orphan.OriginArtifactID, "error", err)
return
}
}
}()
}
wg.Wait()
}
// sweepStale is the age-based hygiene pass: cold terminally-failed artifacts
// (with their leftover .part files), orphaned ready artifacts no download row
// references, and expired ephemeral download rows. Best-effort; every step
@@ -931,8 +557,13 @@ func (m *ArtifactManager) sweepStale(ctx context.Context) {
// removeArtifact deletes an artifact's output file, its .part leftover, and
// its row. Used by the hygiene sweep for rows nothing can serve again.
func (m *ArtifactManager) removeArtifact(ctx context.Context, a *Artifact, reason string) {
if !m.deleteArtifactBytes(ctx, a) {
return
if a.OutputPath != "" {
if err := os.Remove(a.OutputPath); err != nil && !os.IsNotExist(err) {
slog.WarnContext(ctx, "removing swept artifact file failed", "component", "downloads", "artifact_id", a.ID, "error", err)
}
if err := os.Remove(a.OutputPath + ".part"); err != nil && !os.IsNotExist(err) {
slog.WarnContext(ctx, "removing swept artifact partial failed", "component", "downloads", "artifact_id", a.ID, "error", err)
}
}
if err := m.repo.DeleteArtifact(ctx, a.ID); err != nil {
slog.WarnContext(ctx, "deleting swept artifact row failed", "component", "downloads", "artifact_id", a.ID, "error", err)
@@ -941,33 +572,6 @@ func (m *ArtifactManager) removeArtifact(ctx context.Context, a *Artifact, reaso
slog.InfoContext(ctx, "swept stale download artifact", "component", "downloads", "artifact_id", a.ID, "reason", reason, "bytes", a.FileSize)
}
func (m *ArtifactManager) deleteArtifactBytes(ctx context.Context, a *Artifact) bool {
if a.OriginArtifactID != "" {
lifecycle, ok := m.preparer.(remoteArtifactLifecycle)
if !ok {
slog.WarnContext(ctx, "remote artifact cleanup unavailable", "component", "downloads", "artifact_id", a.ID)
return false
}
if err := lifecycle.DeleteArtifact(ctx, a); err != nil {
slog.WarnContext(ctx, "removing remote artifact failed", "component", "downloads", "artifact_id", a.ID, "node", a.OriginNodeURL, "error", err)
return false
}
return true
}
if a.OutputPath == "" {
return true
}
if err := os.Remove(a.OutputPath); err != nil && !os.IsNotExist(err) {
slog.WarnContext(ctx, "removing artifact file failed", "component", "downloads", "artifact_id", a.ID, "error", err)
return false
}
if err := os.Remove(a.OutputPath + ".part"); err != nil && !os.IsNotExist(err) {
slog.WarnContext(ctx, "removing artifact partial failed", "component", "downloads", "artifact_id", a.ID, "error", err)
return false
}
return true
}
// backoffFor returns the retry delay for the next attempt after a failure.
func backoffFor(attempts int) time.Duration {
if attempts < 1 {
+7 -21
View File
@@ -78,16 +78,6 @@ func (bm *BandwidthManager) getUserLimiter(userID int) *rate.Limiter {
// ThrottledReader wraps an io.ReadSeeker with bandwidth limiting for the given user.
// If no limits are configured, the original reader is returned as-is.
func (bm *BandwidthManager) ThrottledReader(ctx context.Context, r io.ReadSeeker, userID int) io.ReadSeeker {
limited := bm.ThrottledStreamReader(ctx, r, userID)
if limited == r {
return r
}
return &throttledReadSeeker{Reader: limited, seeker: r}
}
// ThrottledStreamReader applies the same aggregate limits to a non-seekable
// stream, such as an authenticated response relayed from a transcode node.
func (bm *BandwidthManager) ThrottledStreamReader(ctx context.Context, r io.Reader, userID int) io.Reader {
if bm == nil {
return r
}
@@ -106,18 +96,10 @@ func (bm *BandwidthManager) ThrottledStreamReader(ctx context.Context, r io.Read
}
}
type throttledReadSeeker struct {
io.Reader
seeker io.Seeker
}
func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
return r.seeker.Seek(offset, whence)
}
// throttledReader applies bandwidth limiting to forward reads.
// throttledReader implements io.ReadSeeker with bandwidth limiting.
// Seek is delegated directly (not throttled), only forward reads are limited.
type throttledReader struct {
r io.Reader
r io.ReadSeeker
ctx context.Context
serverLim *rate.Limiter
userLim *rate.Limiter
@@ -157,6 +139,10 @@ func (t *throttledReader) Read(p []byte) (int, error) {
return n, err
}
func (t *throttledReader) Seek(offset int64, whence int) (int64, error) {
return t.r.Seek(offset, whence)
}
// BytesRead returns the total bytes read through this throttled reader.
func (t *throttledReader) BytesRead() int64 {
return t.bytesRead
-7
View File
@@ -79,13 +79,6 @@ var (
ErrInvalidSubtitleRef = errors.New("invalid subtitle reference")
ErrAssetNotFound = errors.New("download asset not found")
ErrFormatUnavailable = errors.New("requested download format is not available")
// ErrResponseCommitted reports a transfer failure after response headers
// were written. Handlers must not append an API error body, while service
// lifecycle code must still treat the transfer as incomplete.
ErrResponseCommitted = errors.New("download response already committed")
// ErrArtifactOriginRemoved means a durable remote locator points at a node
// no longer present in the enabled transcode pool.
ErrArtifactOriginRemoved = errors.New("download artifact origin node was removed")
// ErrQualityUnavailable means the requested quality is valid and permitted in
// principle but cannot be fulfilled by the current server wiring.
ErrQualityUnavailable = errors.New("requested download quality is not available")
-184
View File
@@ -1,184 +0,0 @@
package downloads
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/downloadprepare"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
)
// NodeAwarePreparer keeps artifact queue ownership central while executing the
// expensive FFmpeg process on a healthy transcode node when capacity permits.
// The node retains completed bytes behind an authenticated opaque-id endpoint;
// integrated installations and unavailable pools fall back to local work.
type NodeAwarePreparer struct {
local EncodePreparer
planner nodepool.TranscodeWorkPlanner
liveCfg func() *config.Config
remote downloadprepare.RemotePreparer
originLookup artifactOriginLookup
}
type artifactOriginLookup interface {
GetByID(ctx context.Context, id int) (*nodepool.Node, error)
}
func NewNodeAwarePreparer(local EncodePreparer, planner nodepool.TranscodeWorkPlanner, liveCfg func() *config.Config) *NodeAwarePreparer {
if local == nil {
local = playbackPreparer{}
}
return &NodeAwarePreparer{
local: local,
planner: planner,
liveCfg: liveCfg,
remote: downloadprepare.HTTPPreparer{},
}
}
// SetOriginLookup supplies the authoritative node record used when the active
// pool temporarily misses an enabled node, and to recover a changed URL after
// a disabled node has left that pool.
func (p *NodeAwarePreparer) SetOriginLookup(lookup artifactOriginLookup) {
p.originLookup = lookup
}
func (p *NodeAwarePreparer) PrepareFile(ctx context.Context, artifactID string, opts playback.TranscodeOpts, outputPath string) (PreparedArtifact, error) {
cfg := p.config()
jwtSecret := ""
if cfg != nil {
jwtSecret = strings.TrimSpace(cfg.Auth.JWTSecret)
}
if cfg == nil || jwtSecret == "" || p.remote == nil || p.planner == nil || !downloadprepare.ValidArtifactID(artifactID) {
return p.local.PrepareFile(ctx, artifactID, opts, outputPath)
}
node, release := p.planner.ReserveTranscodeWork("download-prepare-" + artifactID)
if node == nil {
return p.local.PrepareFile(ctx, artifactID, opts, outputPath)
}
slog.InfoContext(ctx, "dispatching download artifact prepare", "component", "downloads", "artifact_id", artifactID, "node", node.URL)
result, err := p.remote.Prepare(ctx, node.URL, jwtSecret, downloadprepare.NewRequest(artifactID, opts))
release()
if err == nil && result.ArtifactID == artifactID {
return remotePreparedArtifact(node, result), nil
}
if err == nil {
err = fmt.Errorf("remote download prepare returned artifact id %q, want %q", result.ArtifactID, artifactID)
}
if ctx.Err() != nil {
return remotePreparedArtifact(node, downloadprepare.Result{ArtifactID: artifactID}), ctx.Err()
}
// A completed encode can outlive a lost HTTP response. Probe the same opaque
// id before falling back so retry/recovery does not duplicate expensive work.
if recovered, statErr := p.remote.Stat(ctx, node.URL, jwtSecret, artifactID); statErr == nil && recovered.ArtifactID == artifactID {
slog.InfoContext(ctx, "recovered completed download artifact after lost response", "component", "downloads", "artifact_id", artifactID, "node", node.URL)
return remotePreparedArtifact(node, recovered), nil
} else if statErr == nil {
return remotePreparedArtifact(node, downloadprepare.Result{ArtifactID: artifactID}),
fmt.Errorf("remote download artifact recovery returned artifact id %q, want %q", recovered.ArtifactID, artifactID)
} else if !errors.Is(statErr, downloadprepare.ErrArtifactNotFound) {
slog.WarnContext(ctx, "remote download artifact recovery probe failed", "component", "downloads", "artifact_id", artifactID, "node", node.URL, "error", statErr)
// The POST may have completed even though its response was lost. If the
// follow-up probe is also indeterminate, retry the same opaque id later
// instead of falling back locally and orphaning completed remote bytes.
return remotePreparedArtifact(node, downloadprepare.Result{ArtifactID: artifactID}), errors.Join(err, fmt.Errorf("remote download artifact recovery probe: %w", statErr))
}
if ctx.Err() != nil {
return PreparedArtifact{}, ctx.Err()
}
slog.WarnContext(ctx, "remote download artifact prepare unavailable; falling back to local", "component", "downloads", "artifact_id", artifactID, "node", node.URL, "error", err)
return p.local.PrepareFile(ctx, artifactID, opts, outputPath)
}
func remotePreparedArtifact(node *nodepool.Node, result downloadprepare.Result) PreparedArtifact {
group := ""
if node.Group != nil {
group = *node.Group
}
return PreparedArtifact{
OriginNodeID: node.ID,
OriginNodeURL: strings.TrimRight(node.URL, "/"),
OriginNodeGroup: group,
OriginArtifactID: result.ArtifactID,
FileSize: result.FileSize,
}
}
func (p *NodeAwarePreparer) ResolveArtifact(ctx context.Context, artifact *Artifact) error {
if artifact == nil || artifact.OriginNodeID == 0 || p.planner == nil {
return ErrArtifactOriginRemoved
}
node, ok := p.planner.TranscodeNode(artifact.OriginNodeID)
if !ok || node == nil {
if p.originLookup != nil {
configured, err := p.originLookup.GetByID(ctx, artifact.OriginNodeID)
switch {
case err == nil && configured != nil && configured.Type == nodepool.NodeTypeTranscode:
applyArtifactOrigin(artifact, configured)
if configured.Enabled {
return nil
}
case err != nil && !errors.Is(err, nodepool.ErrNodeNotFound):
return fmt.Errorf("looking up artifact origin node: %w", err)
}
}
return ErrArtifactOriginRemoved
}
applyArtifactOrigin(artifact, node)
return nil
}
func applyArtifactOrigin(artifact *Artifact, node *nodepool.Node) {
artifact.OriginNodeURL = strings.TrimRight(node.URL, "/")
artifact.OriginNodeGroup = ""
if node.Group != nil {
artifact.OriginNodeGroup = *node.Group
}
}
func (p *NodeAwarePreparer) StatArtifact(ctx context.Context, artifact *Artifact) (downloadprepare.Result, error) {
if err := p.ResolveArtifact(ctx, artifact); err != nil {
return downloadprepare.Result{}, err
}
secret, err := p.remoteCredentials(artifact)
if err != nil {
return downloadprepare.Result{}, err
}
return p.remote.Stat(ctx, artifact.OriginNodeURL, secret, artifact.OriginArtifactID)
}
func (p *NodeAwarePreparer) DeleteArtifact(ctx context.Context, artifact *Artifact) error {
// Prefer the authoritative current URL, including for a disabled node. A
// deleted node has no newer record, so retain the last persisted URL as the
// best-effort cleanup target.
_ = p.ResolveArtifact(ctx, artifact)
secret, err := p.remoteCredentials(artifact)
if err != nil {
return err
}
return p.remote.Delete(ctx, artifact.OriginNodeURL, secret, artifact.OriginArtifactID)
}
func (p *NodeAwarePreparer) remoteCredentials(artifact *Artifact) (string, error) {
if artifact == nil || strings.TrimSpace(artifact.OriginNodeURL) == "" || !downloadprepare.ValidArtifactID(artifact.OriginArtifactID) {
return "", errors.New("remote artifact locator is incomplete")
}
cfg := p.config()
if cfg == nil || strings.TrimSpace(cfg.Auth.JWTSecret) == "" || p.remote == nil {
return "", errors.New("remote artifact credentials are unavailable")
}
return strings.TrimSpace(cfg.Auth.JWTSecret), nil
}
func (p *NodeAwarePreparer) config() *config.Config {
if p == nil || p.liveCfg == nil {
return nil
}
return p.liveCfg()
}
-325
View File
@@ -1,325 +0,0 @@
package downloads
import (
"context"
"errors"
"os"
"testing"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/downloadprepare"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
)
type recordingEncodePreparer struct{ calls int }
func (p *recordingEncodePreparer) PrepareFile(_ context.Context, _ string, _ playback.TranscodeOpts, outputPath string) (PreparedArtifact, error) {
p.calls++
return PreparedArtifact{OutputPath: outputPath, FileSize: 8}, nil
}
type recordingRemotePreparer struct {
nodeURL string
secret string
request downloadprepare.Request
deleted string
deleteURL string
}
func (p *recordingRemotePreparer) Prepare(_ context.Context, nodeURL, secret string, req downloadprepare.Request) (downloadprepare.Result, error) {
p.nodeURL, p.secret, p.request = nodeURL, secret, req
return downloadprepare.Result{ArtifactID: req.ArtifactID, FileSize: 1234}, nil
}
func (p *recordingRemotePreparer) Stat(_ context.Context, _, _ string, artifactID string) (downloadprepare.Result, error) {
return downloadprepare.Result{ArtifactID: artifactID, FileSize: 1234}, nil
}
func (p *recordingRemotePreparer) Delete(_ context.Context, nodeURL, _ string, artifactID string) error {
p.deleted = artifactID
p.deleteURL = nodeURL
return nil
}
type staticArtifactOriginLookup struct {
node *nodepool.Node
err error
}
func (l staticArtifactOriginLookup) GetByID(context.Context, int) (*nodepool.Node, error) {
return l.node, l.err
}
type unavailableRemotePreparer struct{}
func (unavailableRemotePreparer) Prepare(context.Context, string, string, downloadprepare.Request) (downloadprepare.Result, error) {
return downloadprepare.Result{}, os.ErrNotExist
}
func (unavailableRemotePreparer) Stat(context.Context, string, string, string) (downloadprepare.Result, error) {
return downloadprepare.Result{}, downloadprepare.ErrArtifactNotFound
}
func (unavailableRemotePreparer) Delete(context.Context, string, string, string) error { return nil }
type responseLostRemotePreparer struct{}
func (responseLostRemotePreparer) Prepare(context.Context, string, string, downloadprepare.Request) (downloadprepare.Result, error) {
return downloadprepare.Result{}, context.DeadlineExceeded
}
func (responseLostRemotePreparer) Stat(_ context.Context, _, _ string, artifactID string) (downloadprepare.Result, error) {
return downloadprepare.Result{ArtifactID: artifactID, FileSize: 55}, nil
}
func (responseLostRemotePreparer) Delete(context.Context, string, string, string) error { return nil }
type indeterminateRemotePreparer struct{}
func (indeterminateRemotePreparer) Prepare(context.Context, string, string, downloadprepare.Request) (downloadprepare.Result, error) {
return downloadprepare.Result{}, context.DeadlineExceeded
}
func (indeterminateRemotePreparer) Stat(context.Context, string, string, string) (downloadprepare.Result, error) {
return downloadprepare.Result{}, os.ErrDeadlineExceeded
}
func (indeterminateRemotePreparer) Delete(context.Context, string, string, string) error { return nil }
type mismatchedRecoveryRemotePreparer struct{}
func (mismatchedRecoveryRemotePreparer) Prepare(context.Context, string, string, downloadprepare.Request) (downloadprepare.Result, error) {
return downloadprepare.Result{}, context.DeadlineExceeded
}
func (mismatchedRecoveryRemotePreparer) Stat(context.Context, string, string, string) (downloadprepare.Result, error) {
return downloadprepare.Result{ArtifactID: "unexpected-artifact", FileSize: 55}, nil
}
func (mismatchedRecoveryRemotePreparer) Delete(context.Context, string, string, string) error {
return nil
}
func TestNodeAwarePreparerUsesLeastLoadedHealthyNode(t *testing.T) {
group := "host-a"
pool := nodepool.NewTranscodePool()
pool.SetNodes([]*nodepool.Node{
{URL: "http://busy", Enabled: true, Healthy: true, ActiveJobs: 3},
{ID: 17, URL: "http://idle", Enabled: true, Healthy: true, ActiveJobs: 1, Group: &group},
{URL: "http://unhealthy", Enabled: true, Healthy: false},
})
local := &recordingEncodePreparer{}
remote := &recordingRemotePreparer{}
cfg := &config.Config{}
cfg.Auth.JWTSecret = "secret"
p := NewNodeAwarePreparer(local, nodepool.NewPlanner(nodepool.NewProxyPool(), pool), func() *config.Config { return cfg })
p.remote = remote
opts := playback.TranscodeOpts{InputPath: "/media/movie.mkv", TargetCodecVideo: "h264", TargetCodecAudio: "aac"}
prepared, err := p.PrepareFile(context.Background(), "artifact-1", opts, "/local/artifact-1.mp4")
if err != nil {
t.Fatal(err)
}
if local.calls != 0 {
t.Fatalf("local calls = %d, want 0", local.calls)
}
if remote.nodeURL != "http://idle" || remote.secret != "secret" || remote.request.ArtifactID != "artifact-1" || remote.request.InputPath != opts.InputPath {
t.Fatalf("remote call = node %q secret %q request %+v", remote.nodeURL, remote.secret, remote.request)
}
if prepared.OutputPath != "" || prepared.OriginNodeID != 17 || prepared.OriginNodeURL != "http://idle" || prepared.OriginNodeGroup != group || prepared.OriginArtifactID != "artifact-1" || prepared.FileSize != 1234 {
t.Fatalf("prepared = %+v", prepared)
}
}
func TestNodeAwarePreparerResolvesCurrentArtifactNodeURL(t *testing.T) {
group := "host-new"
pool := nodepool.NewTranscodePool()
pool.SetNodes([]*nodepool.Node{{ID: 17, URL: "http://new-url", Group: &group, Enabled: true, Healthy: true}})
p := NewNodeAwarePreparer(nil, nodepool.NewPlanner(nodepool.NewProxyPool(), pool), nil)
artifact := &Artifact{OriginNodeID: 17, OriginNodeURL: "http://old-url", OriginNodeGroup: "host-old", OriginArtifactID: "artifact-1"}
if err := p.ResolveArtifact(context.Background(), artifact); err != nil {
t.Fatal(err)
}
if artifact.OriginNodeURL != "http://new-url" || artifact.OriginNodeGroup != group {
t.Fatalf("artifact = %+v", artifact)
}
}
func TestNodeAwarePreparerUsesCurrentDisabledNodeURLForCleanup(t *testing.T) {
group := "host-new"
pool := nodepool.NewTranscodePool()
remote := &recordingRemotePreparer{}
cfg := &config.Config{}
cfg.Auth.JWTSecret = "secret"
p := NewNodeAwarePreparer(nil, nodepool.NewPlanner(nodepool.NewProxyPool(), pool), func() *config.Config { return cfg })
p.remote = remote
p.SetOriginLookup(staticArtifactOriginLookup{node: &nodepool.Node{
ID: 17, Type: nodepool.NodeTypeTranscode, URL: "http://new-url", Group: &group, Enabled: false,
}})
artifact := &Artifact{OriginNodeID: 17, OriginNodeURL: "http://old-url", OriginNodeGroup: "host-old", OriginArtifactID: "artifact-1"}
if err := p.ResolveArtifact(context.Background(), artifact); !errors.Is(err, ErrArtifactOriginRemoved) {
t.Fatalf("ResolveArtifact error = %v, want ErrArtifactOriginRemoved", err)
}
if artifact.OriginNodeURL != "http://new-url" || artifact.OriginNodeGroup != group {
t.Fatalf("refreshed artifact = %+v", artifact)
}
if err := p.DeleteArtifact(context.Background(), artifact); err != nil {
t.Fatal(err)
}
if remote.deleteURL != "http://new-url" || remote.deleted != "artifact-1" {
t.Fatalf("cleanup target = %q %q", remote.deleteURL, remote.deleted)
}
}
func TestNodeAwarePreparerRecoversEnabledOriginMissingFromPool(t *testing.T) {
group := "host-new"
pool := nodepool.NewTranscodePool()
p := NewNodeAwarePreparer(nil, nodepool.NewPlanner(nodepool.NewProxyPool(), pool), nil)
p.SetOriginLookup(staticArtifactOriginLookup{node: &nodepool.Node{
ID: 17, Type: nodepool.NodeTypeTranscode, URL: "http://new-url", Group: &group, Enabled: true,
}})
artifact := &Artifact{OriginNodeID: 17, OriginNodeURL: "http://old-url", OriginNodeGroup: "host-old", OriginArtifactID: "artifact-1"}
if err := p.ResolveArtifact(context.Background(), artifact); err != nil {
t.Fatalf("ResolveArtifact error = %v", err)
}
if artifact.OriginNodeURL != "http://new-url" || artifact.OriginNodeGroup != group {
t.Fatalf("refreshed artifact = %+v", artifact)
}
}
func TestNodeAwarePreparerReportsRemovedArtifactOrigin(t *testing.T) {
pool := nodepool.NewTranscodePool()
pool.SetNodes([]*nodepool.Node{{ID: 17, URL: "http://disabled", Enabled: false, Healthy: true}})
p := NewNodeAwarePreparer(nil, nodepool.NewPlanner(nodepool.NewProxyPool(), pool), nil)
artifact := &Artifact{OriginNodeID: 17, OriginNodeURL: "http://removed", OriginArtifactID: "artifact-1"}
if err := p.ResolveArtifact(context.Background(), artifact); !errors.Is(err, ErrArtifactOriginRemoved) {
t.Fatalf("ResolveArtifact error = %v, want ErrArtifactOriginRemoved", err)
}
}
func TestNodeAwarePreparerFallsBackLocallyWithoutEligibleCapacity(t *testing.T) {
limit := 1
pool := nodepool.NewTranscodePool()
pool.SetNodes([]*nodepool.Node{{URL: "http://full", Enabled: true, Healthy: true, ActiveJobs: 1, MaxJobs: &limit}})
local := &recordingEncodePreparer{}
cfg := &config.Config{}
cfg.Auth.JWTSecret = "secret"
p := NewNodeAwarePreparer(local, nodepool.NewPlanner(nodepool.NewProxyPool(), pool), func() *config.Config { return cfg })
p.remote = &recordingRemotePreparer{}
prepared, err := p.PrepareFile(context.Background(), "artifact-2", playback.TranscodeOpts{}, "/artifacts/job-2.mp4")
if err != nil || prepared.OutputPath == "" || local.calls != 1 {
t.Fatalf("prepared=%+v err=%v local calls=%d", prepared, err, local.calls)
}
}
func TestNodeAwarePreparerRetainsRequestedLocatorAfterMismatchedRecovery(t *testing.T) {
pool := nodepool.NewTranscodePool()
pool.SetNodes([]*nodepool.Node{{ID: 17, URL: "http://idle", Enabled: true, Healthy: true}})
local := &recordingEncodePreparer{}
cfg := &config.Config{}
cfg.Auth.JWTSecret = "secret"
p := NewNodeAwarePreparer(local, nodepool.NewPlanner(nodepool.NewProxyPool(), pool), func() *config.Config { return cfg })
p.remote = mismatchedRecoveryRemotePreparer{}
prepared, err := p.PrepareFile(context.Background(), "artifact-requested", playback.TranscodeOpts{}, "/local/job.mp4")
if err == nil {
t.Fatal("expected mismatched recovery error")
}
if !prepared.Remote() || prepared.OriginArtifactID != "artifact-requested" || prepared.FileSize != 0 {
t.Fatalf("prepared locator = %+v, want requested remote artifact", prepared)
}
if local.calls != 0 {
t.Fatalf("local calls = %d, want no fallback after indeterminate remote result", local.calls)
}
}
func TestNodeAwarePreparerFallsBackLocallyWithoutNodeCredentials(t *testing.T) {
pool := nodepool.NewTranscodePool()
pool.SetNodes([]*nodepool.Node{{ID: 18, URL: "http://idle", Enabled: true, Healthy: true}})
local := &recordingEncodePreparer{}
p := NewNodeAwarePreparer(local, nodepool.NewPlanner(nodepool.NewProxyPool(), pool), func() *config.Config { return &config.Config{} })
p.remote = &recordingRemotePreparer{}
if _, err := p.PrepareFile(context.Background(), "artifact-3", playback.TranscodeOpts{}, "/artifacts/job-3.mp4"); err != nil {
t.Fatal(err)
}
if local.calls != 1 {
t.Fatalf("local calls = %d, want 1", local.calls)
}
}
func TestNodeAwarePreparerUsesRemoteWithDefaultNodeLocalArtifactDir(t *testing.T) {
pool := nodepool.NewTranscodePool()
pool.SetNodes([]*nodepool.Node{{ID: 19, URL: "http://idle", Enabled: true, Healthy: true}})
local := &recordingEncodePreparer{}
cfg := &config.Config{}
cfg.Auth.JWTSecret = "secret"
p := NewNodeAwarePreparer(local, nodepool.NewPlanner(nodepool.NewProxyPool(), pool), func() *config.Config { return cfg })
p.remote = &recordingRemotePreparer{}
prepared, err := p.PrepareFile(context.Background(), "artifact-local", playback.TranscodeOpts{}, "/local/job.mp4")
if err != nil || !prepared.Remote() || local.calls != 0 {
t.Fatalf("prepared=%+v err=%v local calls=%d", prepared, err, local.calls)
}
}
func TestNodeAwarePreparerFallsBackLocallyWhenRemoteUnavailable(t *testing.T) {
pool := nodepool.NewTranscodePool()
pool.SetNodes([]*nodepool.Node{{URL: "http://idle", Enabled: true, Healthy: true}})
local := &recordingEncodePreparer{}
cfg := &config.Config{}
cfg.Auth.JWTSecret = "secret"
p := NewNodeAwarePreparer(local, nodepool.NewPlanner(nodepool.NewProxyPool(), pool), func() *config.Config { return cfg })
p.remote = unavailableRemotePreparer{}
prepared, err := p.PrepareFile(context.Background(), "artifact-4", playback.TranscodeOpts{}, "/local/job-4.mp4")
if err != nil || prepared.OutputPath == "" || local.calls != 1 {
t.Fatalf("prepared=%+v err=%v local calls=%d", prepared, err, local.calls)
}
}
func TestNodeAwarePreparerRecoversCompletedArtifactAfterResponseLoss(t *testing.T) {
pool := nodepool.NewTranscodePool()
pool.SetNodes([]*nodepool.Node{{ID: 21, URL: "http://idle", Enabled: true, Healthy: true}})
local := &recordingEncodePreparer{}
cfg := &config.Config{}
cfg.Auth.JWTSecret = "secret"
p := NewNodeAwarePreparer(local, nodepool.NewPlanner(nodepool.NewProxyPool(), pool), func() *config.Config { return cfg })
p.remote = responseLostRemotePreparer{}
prepared, err := p.PrepareFile(context.Background(), "artifact-5", playback.TranscodeOpts{}, "/local/job-5.mp4")
if err != nil || !prepared.Remote() || prepared.FileSize != 55 || local.calls != 0 {
t.Fatalf("prepared=%+v err=%v local calls=%d", prepared, err, local.calls)
}
}
func TestNodeAwarePreparerDoesNotFallBackAfterLeaseCancellation(t *testing.T) {
pool := nodepool.NewTranscodePool()
pool.SetNodes([]*nodepool.Node{{URL: "http://idle", Enabled: true, Healthy: true}})
local := &recordingEncodePreparer{}
cfg := &config.Config{}
cfg.Auth.JWTSecret = "secret"
p := NewNodeAwarePreparer(local, nodepool.NewPlanner(nodepool.NewProxyPool(), pool), func() *config.Config { return cfg })
p.remote = unavailableRemotePreparer{}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := p.PrepareFile(ctx, "artifact-6", playback.TranscodeOpts{}, "/local/job-6.mp4")
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context canceled", err)
}
if local.calls != 0 {
t.Fatalf("local calls = %d, want 0", local.calls)
}
}
func TestNodeAwarePreparerDoesNotFallBackWhenRecoveryProbeIsIndeterminate(t *testing.T) {
pool := nodepool.NewTranscodePool()
pool.SetNodes([]*nodepool.Node{{ID: 20, URL: "http://idle", Enabled: true, Healthy: true}})
local := &recordingEncodePreparer{}
cfg := &config.Config{}
cfg.Auth.JWTSecret = "secret"
p := NewNodeAwarePreparer(local, nodepool.NewPlanner(nodepool.NewProxyPool(), pool), func() *config.Config { return cfg })
p.remote = indeterminateRemotePreparer{}
if _, err := p.PrepareFile(context.Background(), "artifact-7", playback.TranscodeOpts{}, "/local/job-7.mp4"); err == nil {
t.Fatal("expected indeterminate remote error")
}
if local.calls != 0 {
t.Fatalf("local calls = %d, want 0", local.calls)
}
}
+1 -1
View File
@@ -289,7 +289,7 @@ func TestReconcileLinkedDownloads(t *testing.T) {
if _, err := arepo.ClaimNext(ctx, "w", time.Minute); err != nil {
t.Fatalf("claim ready artifact: %v", err)
}
if ok, err := arepo.MarkReady(ctx, readyArt.ID, "w", "/tmp/ready.mp4", 0, "", "", "", 4242); err != nil || !ok {
if ok, err := arepo.MarkReady(ctx, readyArt.ID, "w", "/tmp/ready.mp4", 4242); err != nil || !ok {
t.Fatalf("MarkReady = (%v, %v)", ok, err)
}
+30 -164
View File
@@ -16,7 +16,6 @@ import (
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/downloadprepare"
"github.com/Silo-Server/silo-server/internal/idgen"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
@@ -75,24 +74,6 @@ type Capability struct {
MonitoringModes []string
}
// FileTarget is an already-authorized source or prepared artifact that may be
// served locally or delegated to a trusted proxy node. Callers must obtain it
// from the request-scoped resolver methods below; a path alone is never an
// authorization grant.
type FileTarget struct {
Path string
DownloadID string
MediaFileID int
ArtifactID string
OriginNodeID int
OriginNodeURL string
OriginNodeGroup string
OriginArtifactID string
// ProxyEligible is true when a proxy can read the path directly or relay the
// opaque artifact from its owning transcode node.
ProxyEligible bool
}
// Service orchestrates download permission checks, quota enforcement, quality
// policy, file resolution, and file serving for both ephemeral/account-level
// rows (DeviceID == "") and managed device-library entries (DeviceID set).
@@ -866,37 +847,26 @@ func (s *Service) List(ctx context.Context, userID int, profileID, deviceID stri
// ServeDirect validates permissions and serves a file directly for browser
// download. No persistent download record is created.
func (s *Service) ServeDirect(ctx context.Context, w http.ResponseWriter, r *http.Request, userID, fileID int, format string, filter catalog.AccessFilter) error {
target, err := s.ResolveDirectFile(ctx, userID, fileID, format, filter)
if err != nil {
if _, _, err := s.downloadConfigForUser(ctx, userID, ""); err != nil {
return err
}
return s.serveLocalFile(ctx, w, r, target.Path, userID)
}
// ResolveDirectFile authorizes a browser-style original download without
// writing response bytes. It is used by the API before minting a proxy token.
func (s *Service) ResolveDirectFile(ctx context.Context, userID, fileID int, format string, filter catalog.AccessFilter) (*FileTarget, error) {
cfg, _, err := s.downloadConfigForUser(ctx, userID, "")
if err != nil {
return nil, err
}
if format != "" && format != FormatOriginal {
return nil, ErrFormatUnavailable
return ErrFormatUnavailable
}
file, err := s.fileRepo.GetByID(ctx, fileID)
if err != nil {
return nil, fmt.Errorf("loading media file: %w", err)
return fmt.Errorf("loading media file: %w", err)
}
if file == nil || file.MissingSince != nil {
return nil, catalog.ErrItemNotFound
return catalog.ErrItemNotFound
}
if err := s.itemAccess.EnsureAccessible(ctx, file.ContentID, filter); err != nil {
return nil, err
return err
}
if !catalog.FileAllowedByAccess(file, filter) {
return nil, catalog.ErrItemNotFound
return catalog.ErrItemNotFound
}
return &FileTarget{Path: file.FilePath, MediaFileID: file.ID, ProxyEligible: proxyDeliveryAllowed(cfg)}, nil
return s.serveLocalFile(ctx, w, r, file.FilePath, userID)
}
// ServeFile serves a download's file. Managed entries (device header present)
@@ -904,19 +874,15 @@ func (s *Service) ResolveDirectFile(ctx context.Context, userID, fileID int, for
// before serving; ephemeral rows keep today's queued→downloading→completed
// behavior. The ephemeral path never serves a managed row, and vice versa.
func (s *Service) ServeFile(ctx context.Context, w http.ResponseWriter, r *http.Request, userID int, profileID, deviceID, downloadID string, filter catalog.AccessFilter) error {
if deviceID != "" {
target, err := s.ResolveManagedFile(ctx, userID, profileID, deviceID, downloadID, filter)
if err != nil {
return err
}
return s.serveFileTarget(ctx, w, r, target, userID)
}
// Re-check policy — admin may have disabled downloads or revoked permission.
if _, _, err := s.downloadConfigForUser(ctx, userID, deviceID); err != nil {
return err
}
if deviceID != "" {
return s.serveManaged(ctx, w, r, userID, profileID, deviceID, downloadID, filter)
}
dl, err := s.repo.GetByID(ctx, downloadID)
if err != nil {
return err
@@ -959,31 +925,25 @@ func (s *Service) ServeFile(ctx context.Context, w http.ResponseWriter, r *http.
return nil
}
// ResolveManagedFile authorizes a managed entry on (user, profile, device),
// re-checks current policy and content access, and returns the source/artifact
// path for trusted proxy delegation.
func (s *Service) ResolveManagedFile(ctx context.Context, userID int, profileID, deviceID, downloadID string, filter catalog.AccessFilter) (*FileTarget, error) {
cfg, _, err := s.downloadConfigForUser(ctx, userID, deviceID)
if err != nil {
return nil, err
}
// serveManaged authorizes a managed entry on (user, profile, device), re-checks
// per-profile content access (invariant 2), and streams the original source.
func (s *Service) serveManaged(ctx context.Context, w http.ResponseWriter, r *http.Request, userID int, profileID, deviceID, downloadID string, filter catalog.AccessFilter) error {
if profileID == "" {
return nil, ErrProfileRequired
return ErrProfileRequired
}
dl, err := s.repo.GetManagedByID(ctx, downloadID, userID, profileID, deviceID)
if err != nil {
return nil, err
return err
}
if dl.Status == StatusRevoked {
return nil, fmt.Errorf("download is revoked: %w", ErrDownloadNotActive)
return fmt.Errorf("download is revoked: %w", ErrDownloadNotActive)
}
// A download id alone never authorizes access: re-check the requesting
// profile's content/library scope before serving any bytes.
if err := s.itemAccess.EnsureAccessible(ctx, dl.ContentID, filter); err != nil {
return nil, err
return err
}
proxyEligible := proxyDeliveryAllowed(cfg)
return s.resolveDownloadBytesTarget(ctx, dl, filter, proxyEligible, proxyEligible)
return s.serveDownloadBytes(ctx, w, r, dl, userID, filter)
}
// PatchStatus lets a client confirm a managed entry's local state
@@ -1085,28 +1045,20 @@ func (s *Service) resolveFile(ctx context.Context, req CreateRequest) (*models.M
// (catalog.FileAllowedByAccess): library scope and the profile's max playback
// quality can change after a row was registered.
func (s *Service) serveDownloadBytes(ctx context.Context, w http.ResponseWriter, r *http.Request, dl *Download, userID int, filter catalog.AccessFilter) error {
target, err := s.resolveDownloadBytesTarget(ctx, dl, filter, false, false)
if err != nil {
return err
}
return s.serveFileTarget(ctx, w, r, target, userID)
}
func (s *Service) resolveDownloadBytesTarget(ctx context.Context, dl *Download, filter catalog.AccessFilter, sourceProxyEligible, preparedProxyEligible bool) (*FileTarget, error) {
file, err := s.fileRepo.GetByID(ctx, dl.MediaFileID)
if err != nil {
return nil, fmt.Errorf("loading media file: %w", err)
return fmt.Errorf("loading media file: %w", err)
}
if file == nil {
return nil, catalog.ErrItemNotFound
return catalog.ErrItemNotFound
}
if dl.Format != FormatOriginal && dl.ArtifactID != "" {
if s.artifacts == nil {
return nil, ErrFormatUnavailable
return ErrFormatUnavailable
}
artifact, err := s.artifacts.Ready(ctx, dl.ArtifactID)
if err != nil {
return nil, err
return err
}
// The quality ceiling applies to what is actually served — the prepared
// artifact's resolution, not the source's (a 720p transcode of a 4K
@@ -1116,34 +1068,17 @@ func (s *Service) resolveDownloadBytesTarget(ctx context.Context, dl *Download,
served.Resolution = artifact.Resolution
}
if !catalog.FileAllowedByAccess(&served, filter) {
return nil, catalog.ErrItemNotFound
return catalog.ErrItemNotFound
}
return &FileTarget{
Path: artifact.OutputPath,
DownloadID: dl.ID,
MediaFileID: file.ID,
ArtifactID: artifact.ID,
OriginNodeID: artifact.OriginNodeID,
OriginNodeURL: artifact.OriginNodeURL,
OriginNodeGroup: artifact.OriginNodeGroup,
OriginArtifactID: artifact.OriginArtifactID,
ProxyEligible: preparedProxyEligible,
}, nil
return s.serveLocalFile(ctx, w, r, artifact.OutputPath, userID)
}
if file.MissingSince != nil {
return nil, catalog.ErrItemNotFound
return catalog.ErrItemNotFound
}
if !catalog.FileAllowedByAccess(file, filter) {
return nil, catalog.ErrItemNotFound
return catalog.ErrItemNotFound
}
return &FileTarget{Path: file.FilePath, DownloadID: dl.ID, MediaFileID: file.ID, ProxyEligible: sourceProxyEligible}, nil
}
// Download bandwidth settings are aggregate guarantees enforced by the API
// process. Until proxy nodes share a distributed limiter, keep capped transfers
// local rather than silently multiplying the configured limit per proxy node.
func proxyDeliveryAllowed(cfg config.DownloadConfig) bool {
return cfg.ServerBandwidthBPS <= 0 && cfg.UserBandwidthBPS <= 0
return s.serveLocalFile(ctx, w, r, file.FilePath, userID)
}
func (s *Service) serveLocalFile(ctx context.Context, w http.ResponseWriter, r *http.Request, path string, userID int) error {
@@ -1161,7 +1096,8 @@ func (s *Service) serveLocalFile(ctx context.Context, w http.ResponseWriter, r *
return fmt.Errorf("stat file: %w", err)
}
w.Header().Set("Content-Disposition", attachmentDisposition(path))
filename := sanitizeFilename(filepath.Base(path))
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Header().Set("Content-Type", playback.MimeFromExtension(path))
var reader io.ReadSeeker = f
@@ -1173,76 +1109,6 @@ func (s *Service) serveLocalFile(ctx context.Context, w http.ResponseWriter, r *
return nil
}
func attachmentDisposition(path string) string {
filename := sanitizeFilename(filepath.Base(path))
return fmt.Sprintf(`attachment; filename="%s"`, filename)
}
func (s *Service) serveFileTarget(ctx context.Context, w http.ResponseWriter, r *http.Request, target *FileTarget, userID int) error {
if target == nil {
return catalog.ErrItemNotFound
}
if target.OriginArtifactID == "" {
return s.serveLocalFile(ctx, w, r, target.Path, userID)
}
secret := ""
if s.artifacts != nil && s.artifacts.liveCfg != nil {
if cfg := s.artifacts.liveCfg(); cfg != nil {
secret = strings.TrimSpace(cfg.Auth.JWTSecret)
}
}
if secret == "" {
return errors.New("remote artifact credentials unavailable")
}
client := downloadprepare.HTTPPreparer{}
resp, err := client.Open(ctx, target.OriginNodeURL, secret, target.OriginArtifactID, r.Method, r.Header)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
artifact := &Artifact{
ID: target.ArtifactID,
OriginNodeID: target.OriginNodeID,
OriginNodeURL: target.OriginNodeURL,
OriginNodeGroup: target.OriginNodeGroup,
OriginArtifactID: target.OriginArtifactID,
}
if _, err := s.artifacts.requeueRemoteArtifactExactNow(ctx, artifact, "remote output missing"); err != nil {
return err
}
return fmt.Errorf("remote artifact was missing and preparation was requeued: %w", ErrDownloadNotActive)
}
if !downloadprepare.RelayStatusAllowed(resp.StatusCode) {
return fmt.Errorf("remote artifact node returned %d", resp.StatusCode)
}
// Preserve an origin-provided disposition, but supply the same sanitized
// attachment filename as local delivery when the node omits one.
if strings.TrimSpace(target.Path) != "" {
w.Header().Set("Content-Disposition", attachmentDisposition(target.Path))
}
downloadprepare.CopyResponseHeaders(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
if r.Method == http.MethodHead {
return nil
}
var reader io.Reader = resp.Body
if s.bandwidth != nil {
reader = s.bandwidth.ThrottledStreamReader(ctx, reader, userID)
}
if _, err := io.Copy(w, reader); err != nil {
// Headers and a partial body are already committed, so returning an error
// normally makes an API handler append JSON to the media response. Return a
// committed-response sentinel so lifecycle state records the failed transfer
// while the handler leaves the partial media response untouched.
if ctx.Err() == nil {
slog.WarnContext(ctx, "remote download artifact relay interrupted", "component", "downloads", "artifact_id", target.OriginArtifactID, "error", err)
}
return fmt.Errorf("%w: relaying remote artifact: %w", ErrResponseCommitted, err)
}
return nil
}
// pickBestFile selects the highest-resolution file from a list.
func pickBestFile(files []*models.MediaFile) *models.MediaFile {
if len(files) == 1 {
-19
View File
@@ -1,19 +0,0 @@
package downloads
import (
"testing"
"github.com/Silo-Server/silo-server/internal/config"
)
func TestProxyDeliveryAllowedPreservesAggregateBandwidthLimits(t *testing.T) {
if !proxyDeliveryAllowed(config.DownloadConfig{}) {
t.Fatal("uncapped downloads should be proxy eligible")
}
if proxyDeliveryAllowed(config.DownloadConfig{ServerBandwidthBPS: 1}) {
t.Fatal("server-capped downloads must stay API-local")
}
if proxyDeliveryAllowed(config.DownloadConfig{UserBandwidthBPS: 1}) {
t.Fatal("user-capped downloads must stay API-local")
}
}
+2 -2
View File
@@ -17,8 +17,8 @@ import (
// test never drains the queue, so it is never invoked.
type stubQuotaPreparer struct{}
func (stubQuotaPreparer) PrepareFile(_ context.Context, _ string, _ playback.TranscodeOpts, outputPath string) (PreparedArtifact, error) {
return PreparedArtifact{OutputPath: outputPath}, nil
func (stubQuotaPreparer) PrepareFile(_ context.Context, _ playback.TranscodeOpts, _ string) error {
return nil
}
// TestArtifactQuotaCheckedBeforeEnqueue pins two quota invariants for
-204
View File
@@ -1,204 +0,0 @@
package downloads
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/config"
)
func TestServiceRelaysRemoteArtifactOnEstablishedRoute(t *testing.T) {
const secret = "artifact-secret"
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/downloads/artifacts/artifact-1" || r.Header.Get("Authorization") != "Bearer "+secret {
t.Errorf("origin request = %s %s auth=%q", r.Method, r.URL.Path, r.Header.Get("Authorization"))
http.Error(w, "unexpected origin request", http.StatusBadRequest)
return
}
if r.Header.Get("Range") != "bytes=1-3" {
t.Errorf("Range = %q", r.Header.Get("Range"))
http.Error(w, "unexpected range", http.StatusBadRequest)
return
}
w.Header().Set("Content-Length", "3")
w.Header().Set("Content-Range", "bytes 1-3/5")
w.Header().Set("Content-Type", "video/mp4")
w.Header().Set("Content-Disposition", `attachment; filename="artifact-1.mp4"`)
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte("123"))
}))
defer origin.Close()
cfg := &config.Config{}
cfg.Auth.JWTSecret = secret
svc := &Service{artifacts: &ArtifactManager{liveCfg: func() *config.Config { return cfg }}}
req := httptest.NewRequest(http.MethodGet, "/api/v1/downloads/dl/file", nil)
req.Header.Set("Range", "bytes=1-3")
rr := httptest.NewRecorder()
err := svc.serveFileTarget(req.Context(), rr, req, &FileTarget{
Path: `/artifacts/Movie "Final".mp4`,
OriginNodeURL: origin.URL,
OriginArtifactID: "artifact-1",
}, 7)
if err != nil {
t.Fatal(err)
}
if rr.Code != http.StatusPartialContent || rr.Body.String() != "123" || rr.Header().Get("Content-Range") != "bytes 1-3/5" ||
rr.Header().Get("Content-Disposition") != `attachment; filename="Movie _Final_.mp4"` {
t.Fatalf("response status=%d body=%q range=%q disposition=%q", rr.Code, rr.Body.String(), rr.Header().Get("Content-Range"), rr.Header().Get("Content-Disposition"))
}
}
func TestServiceRequeuesReadyArtifactWhenRemoteFileIsMissing(t *testing.T) {
repo, pool, fileID := newArtifactTestRepo(t)
ctx := context.Background()
artifact, _, err := repo.EnsureQueued(ctx, newArtifact(t, fileID, fmt.Sprintf("hash-missing-remote-%d", time.Now().UnixNano())))
if err != nil {
t.Fatal(err)
}
origin := httptest.NewServer(http.NotFoundHandler())
defer origin.Close()
const (
secret = "artifact-secret"
originNodeID = 424242
originArtifactID = "artifact-missing"
)
if _, err := pool.Exec(ctx,
`UPDATE download_artifacts
SET status = 'ready', file_size = 23, completed_at = now(),
origin_node_id = $2, origin_node_url = $3, origin_node_group = 'host-a', origin_artifact_id = $4
WHERE id = $1`,
artifact.ID, originNodeID, origin.URL, originArtifactID,
); err != nil {
t.Fatal(err)
}
var userID int
if err := pool.QueryRow(ctx,
`INSERT INTO users (username, role, download_allowed) VALUES ($1, 'user', true) RETURNING id`,
fmt.Sprintf("missing-remote-user-%d", time.Now().UnixNano()),
).Scan(&userID); err != nil {
t.Fatal(err)
}
downloadID := fmt.Sprintf("missing-remote-download-%d", time.Now().UnixNano())
downloadRepo := NewRepository(pool)
if err := downloadRepo.Create(ctx, &Download{
ID: downloadID, UserID: userID, MediaFileID: fileID,
ContentID: "missing-remote-content", Kind: KindQueued, Status: StatusCompleted,
Format: FormatTranscode, ArtifactID: artifact.ID, FileSize: 23,
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx,
`DELETE FROM download_artifact_orphans
WHERE download_artifact_id = $1 AND origin_node_id = $2 AND origin_artifact_id = $3`,
artifact.ID, originNodeID, originArtifactID,
)
_, _ = pool.Exec(ctx, `DELETE FROM downloads WHERE id = $1`, downloadID)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
})
cfg := &config.Config{}
cfg.Auth.JWTSecret = secret
svc := &Service{artifacts: &ArtifactManager{
repo: repo,
liveCfg: func() *config.Config { return cfg },
}}
req := httptest.NewRequest(http.MethodGet, "/api/v1/downloads/dl/file", nil)
err = svc.serveFileTarget(req.Context(), httptest.NewRecorder(), req, &FileTarget{
ArtifactID: artifact.ID,
OriginNodeID: originNodeID,
OriginNodeURL: origin.URL,
OriginNodeGroup: "host-a",
OriginArtifactID: originArtifactID,
}, 7)
if !errors.Is(err, ErrDownloadNotActive) {
t.Fatalf("serveFileTarget error = %v, want ErrDownloadNotActive", err)
}
queued, err := repo.GetByID(ctx, artifact.ID)
if err != nil {
t.Fatal(err)
}
if queued.Status != ArtifactQueued || queued.OriginNodeID != 0 || queued.OriginArtifactID != "" {
t.Fatalf("artifact after missing remote response = %+v", queued)
}
linked, err := downloadRepo.GetByID(ctx, downloadID)
if err != nil {
t.Fatal(err)
}
if linked.Status != StatusPreparing || linked.BytesSent != 0 {
t.Fatalf("linked download after missing remote response = %+v", linked)
}
var cleanupURL string
if err := pool.QueryRow(ctx,
`SELECT origin_node_url FROM download_artifact_orphans
WHERE download_artifact_id = $1 AND origin_node_id = $2 AND origin_artifact_id = $3`,
artifact.ID, originNodeID, originArtifactID,
).Scan(&cleanupURL); err != nil {
t.Fatalf("remote cleanup row: %v", err)
}
if cleanupURL != origin.URL {
t.Fatalf("remote cleanup URL = %q, want %q", cleanupURL, origin.URL)
}
}
func TestServiceDoesNotAppendAPIErrorAfterRemoteBodyIsCommitted(t *testing.T) {
const secret = "artifact-secret"
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "10")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "123")
}))
defer origin.Close()
cfg := &config.Config{}
cfg.Auth.JWTSecret = secret
svc := &Service{artifacts: &ArtifactManager{liveCfg: func() *config.Config { return cfg }}}
req := httptest.NewRequest(http.MethodGet, "/api/v1/downloads/dl/file", nil)
rr := httptest.NewRecorder()
err := svc.serveFileTarget(req.Context(), rr, req, &FileTarget{
OriginNodeURL: origin.URL,
OriginArtifactID: "artifact-1",
}, 7)
if !errors.Is(err, ErrResponseCommitted) {
t.Fatalf("serveFileTarget error = %v, want ErrResponseCommitted", err)
}
if rr.Code != http.StatusOK || rr.Body.String() != "123" {
t.Fatalf("response status=%d body=%q", rr.Code, rr.Body.String())
}
}
func TestServiceRelaysRemotePreconditionFailure(t *testing.T) {
const secret = "artifact-secret"
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("If-Match"); got != `"old-etag"` {
t.Errorf("origin If-Match = %q", got)
http.Error(w, "unexpected condition", http.StatusBadRequest)
return
}
w.Header().Set("ETag", `"new-etag"`)
w.WriteHeader(http.StatusPreconditionFailed)
}))
defer origin.Close()
cfg := &config.Config{}
cfg.Auth.JWTSecret = secret
svc := &Service{artifacts: &ArtifactManager{liveCfg: func() *config.Config { return cfg }}}
req := httptest.NewRequest(http.MethodGet, "/api/v1/downloads/dl/file", nil)
req.Header.Set("If-Match", `"old-etag"`)
rr := httptest.NewRecorder()
if err := svc.serveFileTarget(req.Context(), rr, req, &FileTarget{
OriginNodeURL: origin.URL,
OriginArtifactID: "artifact-1",
}, 7); err != nil {
t.Fatal(err)
}
if rr.Code != http.StatusPreconditionFailed || rr.Header().Get("ETag") != `"new-etag"` {
t.Fatalf("response status=%d etag=%q body=%q", rr.Code, rr.Header().Get("ETag"), rr.Body.String())
}
}
+21
View File
@@ -12,6 +12,7 @@ package httpstream
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
@@ -171,6 +172,26 @@ func (s *RollingDeadlineWriter) BytesWritten() int64 {
return s.bytesWritten
}
// CompletedFullResponse reports whether the complete representation was
// accepted by the transport without a write error. The request context is not
// consulted because net/http may cancel it after the response reaches the
// client but before the handler performs post-response accounting. ServeContent
// returns 206 for a whole-file Range request such as "bytes=0-", so byte count
// alone is not enough to distinguish that transfer from a partial range.
func (s *RollingDeadlineWriter) CompletedFullResponse(fullSize int64) bool {
if fullSize <= 0 || s.bytesWritten != fullSize || s.firstWriteErr != nil {
return false
}
switch s.statusCode {
case http.StatusOK:
return true
case http.StatusPartialContent:
return s.Header().Get("Content-Range") == fmt.Sprintf("bytes 0-%d/%d", fullSize-1, fullSize)
default:
return false
}
}
// Outcome classifies the first write failure, or a canceled request when no
// write failure was surfaced by the transport.
func (s *RollingDeadlineWriter) Outcome(ctx context.Context) StreamOutcome {
@@ -271,6 +271,42 @@ func TestServeContentReadFromOutcomeCompletedAndCounted(t *testing.T) {
}
}
func TestCompletedFullResponse(t *testing.T) {
const body = "complete segment"
tests := []struct {
name string
rangeHeader string
method string
cancelAfterWrite bool
want bool
}{
{name: "ordinary get", method: http.MethodGet, want: true},
{name: "completed get with canceled context", method: http.MethodGet, cancelAfterWrite: true, want: true},
{name: "open ended full range", method: http.MethodGet, rangeHeader: "bytes=0-", want: true},
{name: "explicit full range", method: http.MethodGet, rangeHeader: "bytes=0-15", want: true},
{name: "partial range", method: http.MethodGet, rangeHeader: "bytes=1-", want: false},
{name: "head", method: http.MethodHead, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rr := httptest.NewRecorder()
sw := newRollingDeadlineWriter(rr, time.Second, 0)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
req := httptest.NewRequest(tt.method, "/segment.ts", nil).WithContext(ctx)
req.Header.Set("Range", tt.rangeHeader)
http.ServeContent(sw, req, "segment.ts", time.Time{}, strings.NewReader(body))
if tt.cancelAfterWrite {
cancel()
}
if got := sw.CompletedFullResponse(int64(len(body))); got != tt.want {
t.Fatalf("CompletedFullResponse = %v, want %v (status=%d range=%q bytes=%d)",
got, tt.want, sw.StatusCode(), rr.Header().Get("Content-Range"), sw.BytesWritten())
}
})
}
}
// TestReadFromPreservesCompletion exercises the io.ReaderFrom path used by
// http.ServeContent (sendfile) under a server WriteTimeout shorter than the
// transfer, with a source large enough to require multiple bounded slices.
+27 -15
View File
@@ -184,6 +184,7 @@ type PlaybackHandler struct {
FFmpegPath string
HWAccel string
TranscodeDir string
SegmentRetentionSeconds func() int
// tm is the shared transcode-session lifecycle (live map, reconstruct) — the
// same type the native handler uses, so jellycompat gets the reconstruct cap
// and node-affinity rule for free. The reconstruction recipe is carried in the
@@ -268,36 +269,40 @@ func NewPlaybackHandler(
transcodeDir := filepath.Join(os.TempDir(), "silo-transcode")
ffmpegPath := ""
hwAccel := ""
segmentRetentionSeconds := 600
if cfg != nil {
if cfg.Playback.TranscodeDir != "" {
transcodeDir = cfg.Playback.TranscodeDir
}
ffmpegPath = cfg.Playback.FFmpegPath
hwAccel = cfg.Playback.HWAccel
segmentRetentionSeconds = cfg.Playback.SegmentRetentionSeconds
}
h := &PlaybackHandler{
cfg: cfg,
content: content,
codec: codec,
deviceProfiles: deviceProfiles,
playbackStore: playbackStore,
sessionMgr: sessionMgr,
fileResolver: fileResolver,
storeProvider: storeProvider,
FFmpegPath: ffmpegPath,
HWAccel: hwAccel,
TranscodeDir: transcodeDir,
tm: playback.NewTranscodeManager(),
cfg: cfg,
content: content,
codec: codec,
deviceProfiles: deviceProfiles,
playbackStore: playbackStore,
sessionMgr: sessionMgr,
fileResolver: fileResolver,
storeProvider: storeProvider,
FFmpegPath: ffmpegPath,
HWAccel: hwAccel,
TranscodeDir: transcodeDir,
SegmentRetentionSeconds: func() int { return segmentRetentionSeconds },
tm: playback.NewTranscodeManager(),
}
// Wire the shared transcode manager with closures so it reads the handler's
// (late-set) JWTSecret lazily, matching the native handler.
h.tm.JWTSecretFn = func() string { return h.JWTSecret }
h.tm.Config = func() playback.TranscodeRuntimeConfig {
return playback.TranscodeRuntimeConfig{
TranscodeDir: h.TranscodeDir,
FFmpegPath: h.FFmpegPath,
HWAccel: h.HWAccel,
TranscodeDir: h.TranscodeDir,
FFmpegPath: h.FFmpegPath,
HWAccel: h.HWAccel,
SegmentRetentionSeconds: h.segmentRetentionSeconds(),
}
}
if reg, ok := sessionMgr.(interface {
@@ -337,6 +342,13 @@ func NewPlaybackHandler(
return h
}
func (h *PlaybackHandler) segmentRetentionSeconds() int {
if h.SegmentRetentionSeconds != nil {
return h.SegmentRetentionSeconds()
}
return 600
}
// CleanupOrphanedTranscodes removes stale per-session transcode dirs, sparing
// those whose recipe card still exists. Delegates to the shared manager.
func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
@@ -0,0 +1,22 @@
package jellycompat
import (
"testing"
"github.com/Silo-Server/silo-server/internal/config"
)
func TestPlaybackHandlerReadsLiveSegmentRetention(t *testing.T) {
cfg := &config.Config{Playback: config.PlaybackConfig{SegmentRetentionSeconds: 300}}
handler := NewPlaybackHandler(cfg, nil, nil, nil, nil, nil, nil, nil)
if got := handler.tm.Config().SegmentRetentionSeconds; got != 300 {
t.Fatalf("configured segment retention = %d, want 300", got)
}
liveRetention := 300
handler.SegmentRetentionSeconds = func() int { return liveRetention }
liveRetention = 120
if got := handler.tm.Config().SegmentRetentionSeconds; got != 120 {
t.Fatalf("reloaded segment retention = %d, want 120", got)
}
}
+7
View File
@@ -103,6 +103,13 @@ func NewRouter(deps Dependencies) chi.Router {
}
userDataHandler := NewUserDataHandler(deps.ContentService, deps.UserDataService, deps.IDCodec, deps.Config)
playbackHandler := NewPlaybackHandler(deps.Config, deps.ContentService, deps.IDCodec, deps.DeviceProfiles, deps.PlaybackStore, deps.SessionMgr, deps.FileResolver, deps.UserStoreProvider)
startupSegmentRetention := playbackHandler.SegmentRetentionSeconds
playbackHandler.SegmentRetentionSeconds = func() int {
if cfg := deps.CurrentConfig(); cfg != nil {
return cfg.Playback.SegmentRetentionSeconds
}
return startupSegmentRetention()
}
if deps.DB != nil {
playbackHandler.profileStaler = recommendations.NewRepo(deps.DB)
}
+20 -14
View File
@@ -21,6 +21,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
@@ -418,8 +419,8 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
}
}
segmentFile := name + "." + ext
segmentPath, err := transcodeSession.GetSegment(segmentFile)
segmentName := name + "." + ext
segmentLease, err := transcodeSession.OpenSegment(segmentName)
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) {
segNum, parseErr := playback.ParseSegmentNumber(name)
if parseErr == nil {
@@ -430,7 +431,7 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
lastProducedAgeMS = now.Sub(decision.Progress.LastProducedAt).Milliseconds()
}
slog.InfoContext(r.Context(), "transcode segment missing", "component", "jellycompat",
"segment", segmentFile,
"segment", segmentName,
"requested_segment", segNum,
"produced_head", decision.Progress.ProducedHead,
"last_requested_segment", decision.Progress.LastRequestedSegment,
@@ -444,7 +445,7 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
)
if decision.Wait {
slog.InfoContext(r.Context(), "transcode segment wait", "component", "jellycompat",
"segment", segmentFile,
"segment", segmentName,
"requested_segment", segNum,
"produced_head", decision.Progress.ProducedHead,
"last_requested_segment", decision.Progress.LastRequestedSegment,
@@ -456,10 +457,10 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
"session", playSession.UpstreamSessionID,
"playback_session_id", playSession.UpstreamSessionID,
)
segmentPath, err = transcodeSession.WaitForSegment(segmentFile, decision.WaitTimeout)
segmentLease, err = transcodeSession.WaitForOpenSegment(segmentName, decision.WaitTimeout)
if err != nil && errors.Is(err, playback.ErrSegmentNotFound) {
slog.InfoContext(r.Context(), "transcode segment wait timeout", "component", "jellycompat",
"segment", segmentFile,
"segment", segmentName,
"requested_segment", segNum,
"produced_head", decision.Progress.ProducedHead,
"last_requested_segment", decision.Progress.LastRequestedSegment,
@@ -479,7 +480,7 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
if seekErr != nil && !errors.Is(seekErr, playback.ErrManifestNotReady) {
slog.ErrorContext(r.Context(), "resolve transcode seek target", "component", "jellycompat",
"error", seekErr,
"segment", segmentFile,
"segment", segmentName,
"play_session", playSessionID,
"session", playSession.UpstreamSessionID,
"playback_session_id", playSession.UpstreamSessionID,
@@ -498,7 +499,7 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
if ok {
slog.InfoContext(r.Context(), "transcode seek restart", "component", "jellycompat",
"segment", segmentFile,
"segment", segmentName,
"requested_segment", segNum,
"produced_head", decision.Progress.ProducedHead,
"last_requested_segment", decision.Progress.LastRequestedSegment,
@@ -519,14 +520,14 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
seekSeconds,
segNum,
); restartErr == nil {
segmentPath, err = transcodeSession.WaitForSegment(segmentFile, 30*time.Second)
segmentLease, err = transcodeSession.WaitForOpenSegment(segmentName, 30*time.Second)
}
}
}
} else if transcodeSession.IsRunning() {
// Non-numbered segment (e.g. init.mp4 for fMP4 HLS).
// Wait briefly — the init segment is written almost immediately.
segmentPath, err = transcodeSession.WaitForSegment(segmentFile, 10*time.Second)
segmentLease, err = transcodeSession.WaitForOpenSegment(segmentName, 10*time.Second)
}
}
if err != nil {
@@ -535,11 +536,15 @@ func (h *PlaybackHandler) HandleHLSSegment(w http.ResponseWriter, r *http.Reques
return
}
if segNum, parseErr := playback.ParseSegmentNumber(name); parseErr == nil {
transcodeSession.ReportSegmentDownloaded(segNum)
defer func() { _ = segmentLease.Close() }()
sw := httpstream.NewRollingDeadlineWriter(w)
http.ServeContent(sw, r, segmentLease.Info.Name(), segmentLease.Info.ModTime(), segmentLease.File)
if r.Method == http.MethodGet &&
sw.CompletedFullResponse(segmentLease.Info.Size()) {
if segNum, parseErr := playback.ParseSegmentNumber(name); parseErr == nil {
transcodeSession.ReportSegmentDownloadedForGeneration(segNum, segmentLease.Generation)
}
}
http.ServeFile(w, r, segmentPath)
}
// hlsSegmentErrorResponse maps a segment-retrieval error to a Jellyfin-faithful
@@ -1669,6 +1674,7 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
TotalDuration: float64(source.Version.Duration),
FastStart: true,
}
opts.SegmentRetentionSeconds = h.segmentRetentionSeconds()
if source.TranscodeAudio {
opts.TargetCodecVideo = "copy"
}
-1
View File
@@ -292,7 +292,6 @@ type VideoTrack struct {
Codec string `json:"codec,omitempty"`
DolbyVision string `json:"dolby_vision,omitempty"`
DVProfile int `json:"dv_profile,omitempty"`
DVLevel int `json:"dv_level,omitempty"`
DVBLCompatID int `json:"dv_bl_compat_id,omitempty"`
DVELPresent bool `json:"dv_el_present,omitempty"`
DVEnhancementLayer string `json:"dv_enhancement_layer,omitempty"` // none, mel, fel, unknown
-113
View File
@@ -4,8 +4,6 @@ import (
"context"
"sync"
"time"
"github.com/google/uuid"
)
// Plan is the result of a node selection for one playback session.
@@ -22,22 +20,6 @@ type SessionPlanner interface {
PlanSession(sessionID, currentTranscodeURL string, needsTranscode bool, estBitrateKbps int) Plan
}
// DownloadPlanner selects proxy nodes for unbounded file delivery. Downloads
// have no predictable bitrate, so implementations must not admit them onto a
// proxy with a configured bandwidth cap.
type DownloadPlanner interface {
PlanDownload(sessionID string, preferredGroup ...string) Plan
ReleaseSession(sessionID string)
}
// TranscodeWorkPlanner reserves capacity for non-streaming GPU work such as a
// prepared download. The returned release function must be called after the
// remote operation ends or falls back locally.
type TranscodeWorkPlanner interface {
ReserveTranscodeWork(workID string) (*Node, func())
TranscodeNode(nodeID int) (*Node, bool)
}
// reservation bridges the gap between assigning a session to a node and the
// node's health reports reflecting that session.
//
@@ -109,65 +91,6 @@ func (p *Planner) PlanSession(sessionID, currentTranscodeURL string, needsTransc
return p.PlanSessionWith(sessionID, currentTranscodeURL, needsTranscode, estBitrateKbps, nil)
}
// TranscodeNode returns the current pool record for a persistent node id. It
// lets durable artifact locators follow an administrator-edited node URL/group.
func (p *Planner) TranscodeNode(nodeID int) (*Node, bool) {
if p == nil || p.transcodes == nil || nodeID == 0 {
return nil, false
}
for _, node := range p.transcodes.Nodes() {
if node != nil && node.ID == nodeID && node.Enabled {
return node, true
}
}
return nil, false
}
// PlanDownload picks a healthy proxy for an unbounded file transfer. A
// configured proxy bandwidth cap cannot be reserved accurately without a known
// transfer rate, so capped proxies are excluded instead of being oversubscribed
// during the egress meter's convergence window.
func (p *Planner) PlanDownload(sessionID string, preferredGroup ...string) Plan {
if p == nil || p.proxies == nil || sessionID == "" {
return Plan{}
}
p.mu.Lock()
defer p.mu.Unlock()
now := p.now()
p.pruneReservations(now)
delete(p.reserved, sessionID)
group := ""
if len(preferredGroup) > 0 {
group = preferredGroup[0]
}
var candidates, fallback []*Node
for _, node := range p.proxies.Nodes() {
if node == nil || !node.Enabled || !node.Healthy || !p.underCap(node, now) {
continue
}
if node.MaxBandwidthKbps != nil && *node.MaxBandwidthKbps > 0 {
continue
}
fallback = append(fallback, node)
if group != "" && node.Group != nil && *node.Group == group {
candidates = append(candidates, node)
}
}
if group == "" || len(candidates) == 0 {
candidates = fallback
}
if len(candidates) == 0 {
return Plan{}
}
rrKey := "download:" + group
proxy := candidates[p.rr[rrKey]%len(candidates)]
p.rr[rrKey]++
p.reserved[sessionID] = &reservation{proxyURL: proxy.URL, createdAt: now}
return Plan{ProxyNode: proxy}
}
// PlanSessionWith behaves like PlanSession but restricts transcode-node
// selection to nodes accepted by eligible (nil accepts every node). Capability
// -aware playback planning uses it so a recipe that only some pooled nodes
@@ -257,42 +180,6 @@ func (p *Planner) ReleaseSession(sessionID string) {
p.mu.Unlock()
}
// ReserveTranscodeWork selects the least-loaded healthy transcode node while
// sharing the same health-bridging reservation accounting as playback. Unlike
// a playback session it does not require a proxy partner: the completed file
// is written to the configured shared artifact store and served later.
func (p *Planner) ReserveTranscodeWork(workID string) (*Node, func()) {
if p == nil || p.transcodes == nil || workID == "" {
return nil, func() {}
}
p.mu.Lock()
now := p.now()
p.pruneReservations(now)
reservationID := workID + "-" + uuid.NewString()
var best *Node
for _, node := range p.transcodes.Nodes() {
if node == nil || !node.Enabled || !node.Healthy || !p.underCap(node, now) {
continue
}
if best == nil || p.effectiveJobs(node, now) < p.effectiveJobs(best, now) {
best = node
}
}
if best != nil {
p.reserved[reservationID] = &reservation{transcodeURL: best.URL, createdAt: now}
}
p.mu.Unlock()
if best == nil {
return nil, func() {}
}
var once sync.Once
return best, func() {
once.Do(func() { p.ReleaseSession(reservationID) })
}
}
// groupHealth reports, for every group label present in either pool, whether
// all of its enabled members are healthy. Pools only hold enabled nodes, so
// disabled nodes never count against a group.
-106
View File
@@ -509,109 +509,3 @@ func TestUnknownBitrateAdmittedBelowCap(t *testing.T) {
t.Fatalf("unknown-bitrate stream should be rejected at cap, got %+v", got)
}
}
func TestReserveTranscodeWorkSharesCapacityReservations(t *testing.T) {
capOne := 1
transcodes := NewTranscodePool()
transcodes.SetNodes([]*Node{
{URL: "http://transcode-a", Enabled: true, Healthy: true, MaxJobs: &capOne},
{URL: "http://transcode-b", Enabled: true, Healthy: true, MaxJobs: &capOne},
})
planner := NewPlanner(NewProxyPool(), transcodes)
first, releaseFirst := planner.ReserveTranscodeWork("download-1")
if first == nil || first.URL != "http://transcode-a" {
t.Fatalf("first node = %+v", first)
}
second, releaseSecond := planner.ReserveTranscodeWork("download-2")
if second == nil || second.URL != "http://transcode-b" {
t.Fatalf("second node = %+v", second)
}
if third, _ := planner.ReserveTranscodeWork("download-3"); third != nil {
t.Fatalf("third node = %+v, want no capacity", third)
}
releaseFirst()
third, releaseThird := planner.ReserveTranscodeWork("download-3")
if third == nil || third.URL != "http://transcode-a" {
t.Fatalf("third node after release = %+v", third)
}
releaseSecond()
releaseThird()
}
func TestReserveTranscodeWorkOverlappingAttemptsReleaseOnlyTheirOwnReservation(t *testing.T) {
capTwo := 2
transcodes := NewTranscodePool()
transcodes.SetNodes([]*Node{{URL: "http://transcode-a", Enabled: true, Healthy: true, MaxJobs: &capTwo}})
planner := NewPlanner(NewProxyPool(), transcodes)
first, releaseFirst := planner.ReserveTranscodeWork("same-artifact")
second, releaseSecond := planner.ReserveTranscodeWork("same-artifact")
if first == nil || second == nil {
t.Fatalf("overlapping reservations = first %+v second %+v", first, second)
}
if third, _ := planner.ReserveTranscodeWork("third-attempt"); third != nil {
t.Fatalf("third reservation = %+v, want full node", third)
}
releaseFirst()
third, releaseThird := planner.ReserveTranscodeWork("third-attempt")
if third == nil {
t.Fatal("first release did not free its own reservation")
}
if fourth, _ := planner.ReserveTranscodeWork("fourth-attempt"); fourth != nil {
t.Fatalf("second overlapping reservation was lost: fourth = %+v", fourth)
}
releaseSecond()
releaseThird()
}
func TestPlanDownloadSkipsBandwidthCappedProxiesAndReservesJobCapacity(t *testing.T) {
bandwidthCap := 100_000
jobCap := 1
proxies := NewProxyPool()
proxies.SetNodes([]*Node{
{URL: "http://bandwidth-capped", Enabled: true, Healthy: true, MaxBandwidthKbps: &bandwidthCap},
{URL: "http://uncapped", Enabled: true, Healthy: true, MaxJobs: &jobCap},
})
planner := NewPlanner(proxies, NewTranscodePool())
first := planner.PlanDownload("download-1")
if first.ProxyNode == nil || first.ProxyNode.URL != "http://uncapped" {
t.Fatalf("first plan = %+v", first)
}
if second := planner.PlanDownload("download-2"); second.ProxyNode != nil {
t.Fatalf("second plan = %+v, want job-cap rejection", second)
}
planner.ReleaseSession("download-1")
if second := planner.PlanDownload("download-2"); second.ProxyNode == nil {
t.Fatal("download was not admitted after reservation release")
}
}
func TestPlanDownloadPrefersArtifactOriginGroup(t *testing.T) {
groupA, groupB := "host-a", "host-b"
proxies := NewProxyPool()
proxies.SetNodes([]*Node{
{URL: "http://proxy-a", Group: &groupA, Enabled: true, Healthy: true},
{URL: "http://proxy-b", Group: &groupB, Enabled: true, Healthy: true},
})
planner := NewPlanner(proxies, NewTranscodePool())
plan := planner.PlanDownload("download-grouped", groupB)
if plan.ProxyNode == nil || plan.ProxyNode.URL != "http://proxy-b" {
t.Fatalf("plan = %+v, want proxy-b", plan)
}
}
func TestPlanDownloadFallsBackWhenOriginGroupHasNoProxy(t *testing.T) {
group := "host-a"
proxies := NewProxyPool()
proxies.SetNodes([]*Node{{URL: "http://proxy-a", Group: &group, Enabled: true, Healthy: true}})
planner := NewPlanner(proxies, NewTranscodePool())
plan := planner.PlanDownload("download-fallback", "host-missing")
if plan.ProxyNode == nil || plan.ProxyNode.URL != "http://proxy-a" {
t.Fatalf("plan = %+v, want proxy-a fallback", plan)
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ type SessionInfo struct {
UserID string `json:"user_id,omitempty"`
MediaItemID string `json:"media_item_id,omitempty"`
MediaTitle string `json:"media_title,omitempty"`
Type string `json:"type"` // "direct_play", "remux", "transcode", "download_prepare", "download"
Type string `json:"type"` // "direct_play", "remux", "transcode"
CodecVideo string `json:"codec_video,omitempty"`
CodecAudio string `json:"codec_audio,omitempty"`
Resolution string `json:"resolution,omitempty"`
+1 -4
View File
@@ -53,9 +53,6 @@ func SourceDescriptorFromFileV3(file *models.MediaFile, audioIndex int) SourceDe
source.DynamicRange = normalizeDynamicRangeV3(track)
source.HDR10Plus = track.HDR10Plus || strings.Contains(strings.ToLower(track.VideoRangeType), "hdr10+")
source.DVProfile = track.DVProfile
if track.DVLevel >= 1 && track.DVLevel <= 13 {
source.DVLevel = track.DVLevel
}
source.DVBLCompatID = track.DVBLCompatID
source.VideoCopyUnsafe = videoCopyUnsafeFile(file)
switch EnhancementLayerV3(strings.ToLower(track.DVEnhancementLayer)) {
@@ -205,7 +202,7 @@ func outputRangeEligibleV3(source SourceDescriptorV3, request StartRequestV3) (b
claims.DolbyVisionReason = "profile_7_enhancement_layer_unknown"
return false, claims
}
if hdr != nil && hdrSupportsDolbyVisionSourceV3(*hdr, source) {
if hdr != nil && containsIntV3(hdr.DolbyVisionProfiles, source.DVProfile) {
claims.DolbyVision = true
claims.DolbyVisionReason = "native_profile_supported"
return true, claims
+1 -21
View File
@@ -121,7 +121,6 @@ var (
string(playback.ReplanOperationSeekFailureRecoveryV3),
string(playback.ReplanOperationTrackChangeV3),
string(playback.ReplanOperationQualityChangeV3),
string(playback.ReplanOperationOutputChangeV3),
}
// The two operations ReplanRequestV3.Validate rejects without a failure
// classification. The schema expresses the same rule as a conditional.
@@ -161,25 +160,6 @@ func TestClientProgressPersistenceRequiresExplicitStartPosition(t *testing.T) {
}
}
func TestIntentOnlyReplansRejectFailureEvidence(t *testing.T) {
schemas := compileSchemasV3(t)
for _, operation := range []playback.ReplanOperationV3{
playback.ReplanOperationTrackChangeV3,
playback.ReplanOperationQualityChangeV3,
playback.ReplanOperationOutputChangeV3,
} {
t.Run(string(operation), func(t *testing.T) {
request := decodeJSONValue(t, mustReadFile(t, filepath.Join(goldenRootV3, "replan_request.json"))).(map[string]any)
request["operation"] = string(operation)
request["quality_preference"] = "720p"
request["failure"] = map[string]any{"classification": "decoder_failure"}
if err := schemas["replan-request.schema.json"].Validate(request); err == nil {
t.Fatal("intent-only replan with failure satisfied the schema")
}
})
}
}
// TestGoldenFixturesSatisfyTheSchemas closes the loop the schemas exist for: a
// schema that no longer accepts what cmd/playbackfixtures emits is a schema
// that describes a server nobody runs.
@@ -233,7 +213,7 @@ func TestConformanceMatrixEmbeddedWireBodiesSatisfySchemas(t *testing.T) {
request := scenario["request"].(map[string]any)
validate(name, "replan-request.schema.json", request)
switch request["operation"] {
case string(playback.ReplanOperationTrackChangeV3), string(playback.ReplanOperationQualityChangeV3), string(playback.ReplanOperationOutputChangeV3), string(playback.ReplanOperationSeekReanchorV3):
case string(playback.ReplanOperationTrackChangeV3), string(playback.ReplanOperationQualityChangeV3), string(playback.ReplanOperationSeekReanchorV3):
if failure, ok := request["failure"]; ok {
t.Errorf("scenario %q intent-only replan carries failure = %#v", name, failure)
}
+1 -3
View File
@@ -108,9 +108,7 @@ func resolveCopySeekAnchor(
interval := strconv.FormatFloat(requestedSeekSeconds, 'f', 6, 64) + "%+0.001"
cmd := exec.CommandContext(ctx, ffprobePathFromFFmpeg(ffmpegPath),
"-v", "error",
// Match the remux transport's 0:V:0 map. Uppercase V excludes attached
// pictures, thumbnails, and cover art from the video stream ordinal.
"-select_streams", "V:0",
"-select_streams", "v:0",
"-read_intervals", interval,
"-show_entries", "packet=pts_time,dts_time,flags",
"-of", "json",
@@ -17,12 +17,10 @@ func TestResolveCopySeekAnchorUsesKeyPacketTimestamp(t *testing.T) {
dir := t.TempDir()
ffmpegPath := filepath.Join(dir, "ffmpeg")
ffprobePath := filepath.Join(dir, "ffprobe")
argsPath := filepath.Join(dir, "ffprobe-args")
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write fake ffmpeg: %v", err)
}
probe := `#!/bin/sh
printf '%s\n' "$@" > "` + argsPath + `"
printf '%s' '{"packets":[{"pts_time":"N/A","dts_time":"14.500000","flags":"K__"}]}'
`
if err := os.WriteFile(ffprobePath, []byte(probe), 0o755); err != nil {
@@ -36,13 +34,6 @@ printf '%s' '{"packets":[{"pts_time":"N/A","dts_time":"14.500000","flags":"K__"}
if anchor != 14.5 || segment != 7 {
t.Fatalf("resolved anchor = %v, segment = %d; want 14.5, 7", anchor, segment)
}
args, err := os.ReadFile(argsPath)
if err != nil {
t.Fatalf("read ffprobe args: %v", err)
}
if !strings.Contains(string(args), "-select_streams\nV:0\n") {
t.Fatalf("ffprobe did not select the remux video stream:\n%s", args)
}
}
func TestResolveCopySeekAnchorCoalescesMatchingConcurrentProbes(t *testing.T) {
+19 -103
View File
@@ -281,12 +281,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
// resulting terminal.
reasonOverride = EvidenceInsufficientForDirectV3
}
// True when the burn requirement is the sole disjunct that fired: every
// other route condition still permits a source-preserving delivery.
subtitleForcedAdaptation := !quality.RequiresTranscode && videoOK &&
(rangeOK || dvStripEligible || clientDV81Eligible || clientHDR10Eligible) &&
subtitle.RequiresBurn && !remuxSubtitleOK && !hlsRemuxSubtitleOK
return planVideoTranscodeV3(input, base, source, quality, hlsSubtitle, reasonOverride, subtitleForcedAdaptation)
return planVideoTranscodeV3(input, base, source, quality, hlsSubtitle, reasonOverride)
}
// Profile 7 is normalized on the client against the original range-capable
@@ -477,7 +472,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
}
}
if deliveryAvailableV3(input.Request, DeliveryClassHLSV3) {
return planVideoTranscodeV3(input, base, source, quality, hlsSubtitle, "copy_routes_exhausted", false)
return planVideoTranscodeV3(input, base, source, quality, hlsSubtitle, "copy_routes_exhausted")
}
return terminalPlannerResultV3("adaptation_unavailable", "No validated playback route is available for this source and output route.", false)
@@ -690,13 +685,7 @@ func applyAudioOnlyAACConversionV3(plan *PlanV3, targetChannels, targetBitrateKb
// planVideoTranscodeV3 always executes on the HLS delivery, so the caller must
// pass the subtitle policy resolved against DeliveryClassHLSV3.
//
// subtitleForcedAdaptation marks the case where only the subtitle burn
// requirement forced this adaptation. Deselecting the subtitle then restores
// playback, so a refusal must name the subtitle: an HDR or version reason
// sends the user chasing a problem that is not blocking them. Retryable
// infrastructure failures and the client-route terminal keep their own reasons.
func planVideoTranscodeV3(input PlannerInputV3, base PlanV3, source SourceDescriptorV3, quality QualityResultV3, subtitle SubtitlePolicyResultV3, reasonOverride string, subtitleForcedAdaptation bool) PlannerResultV3 {
func planVideoTranscodeV3(input PlannerInputV3, base PlanV3, source SourceDescriptorV3, quality QualityResultV3, subtitle SubtitlePolicyResultV3, reasonOverride string) PlannerResultV3 {
if !deliveryAvailableV3(input.Request, DeliveryClassHLSV3) {
return terminalPlannerResultV3("client_hls_unsupported", "The client cannot execute the required HLS adaptation route.", false)
}
@@ -704,23 +693,18 @@ func planVideoTranscodeV3(input PlannerInputV3, base PlanV3, source SourceDescri
return PlannerResultV3{Terminal: subtitle.Terminal, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1}
}
if !input.Settings.TranscodeEnabled {
if subtitleForcedAdaptation {
return terminalPlannerResultV3("subtitle_conversion_unsupported", "The selected subtitle must be burned into the video, but transcoding is unavailable.", false)
reason := "transcoding_disabled"
if subtitle.RequiresBurn {
reason = "subtitle_conversion_unsupported"
}
return terminalPlannerResultV3("transcoding_disabled", "The source requires video adaptation, but transcoding is unavailable.", false)
}
if hdrTranscodeUnavailableV3(source) {
if subtitleForcedAdaptation {
return terminalPlannerResultV3("subtitle_conversion_unsupported", "The selected subtitle must be burned into the video, but this HDR source cannot be re-encoded.", false)
}
return terminalPlannerResultV3("hdr_transcode_unsupported", "This HDR source requires video encoding, but no validated HDR-preserving or tone-map recipe is installed.", false)
return terminalPlannerResultV3(reason, "The source requires video adaptation, but transcoding is unavailable.", false)
}
if is4KSourceV3(input.EffectiveFile, source) && !input.Settings.Allow4KTranscode {
if subtitleForcedAdaptation {
return terminalPlannerResultV3("subtitle_conversion_unsupported", "The selected subtitle must be burned into the video, but 4K transcoding is disabled.", false)
}
return terminalPlannerResultV3("no_alternate_version", TerminalMessage4KTranscodeDisabledV3, false)
}
if hdrTranscodeUnavailableV3(source) {
return terminalPlannerResultV3("hdr_transcode_unsupported", "This HDR source requires video encoding, but no validated HDR-preserving or tone-map recipe is installed.", false)
}
if !input.hlsRegistry().Available(TransformationVideoToH264V3) || !input.hlsRegistry().Available(TransformationAudioToAACV3) {
return terminalPlannerResultV3("conversion_tool_unavailable", "The required validated H.264/AAC conversion toolchain is unavailable.", true)
}
@@ -788,13 +772,9 @@ func canStripDolbyVisionToHDR10V3(source SourceDescriptorV3, request StartReques
}
func canClientTransformDV7ToDV81V3(source SourceDescriptorV3, request StartRequestV3) bool {
if source.DynamicRange != DynamicRangeDolbyVisionV3 || source.DVProfile != 7 ||
!clientTransformationAvailableV3(request, ClientDV7ToDV81V3, ClientDVTransformVersionV3) {
return false
}
// The registered conversion recipe produces Profile 8.1.
source.DVBLCompatID = 1
return clientSupportsDVProfileV3(request, source, 8)
return source.DynamicRange == DynamicRangeDolbyVisionV3 && source.DVProfile == 7 &&
clientSupportsDVProfileV3(request, 8) &&
clientTransformationAvailableV3(request, ClientDV7ToDV81V3, ClientDVTransformVersionV3)
}
func canClientTransformDV7ToHDR10V3(source SourceDescriptorV3, request StartRequestV3) bool {
@@ -802,13 +782,12 @@ func canClientTransformDV7ToHDR10V3(source SourceDescriptorV3, request StartRequ
clientTransformationAvailableV3(request, ClientDV7ToHDR10V3, ClientDVTransformVersionV3)
}
func clientSupportsDVProfileV3(request StartRequestV3, source SourceDescriptorV3, profile int) bool {
func clientSupportsDVProfileV3(request StartRequestV3, profile int) bool {
hdr := request.ClientPlaybackContext.Output.HDRDetails
if hdr == nil {
hdr = request.Capabilities.HDRDetails
}
source.DVProfile = profile
return hdr != nil && hdrSupportsDolbyVisionSourceV3(*hdr, source)
return hdr != nil && containsIntV3(hdr.DolbyVisionProfiles, profile)
}
func clientTransformationAvailableV3(request StartRequestV3, name, version string) bool {
@@ -1207,87 +1186,24 @@ func hdrDetailsSupportPlanV3(hdr HDRCapabilitiesV3, plan PlanV3) bool {
case "", DynamicRangeSDRV3:
return true
case DynamicRangeHDR10V3, "hdr_unknown":
return hdr.HDR10 && hdr10LimitsSupportPlanV3(hdr, plan)
return hdr.HDR10
case DynamicRangeHDR10PlusV3:
return hdr.HDR10Plus
case DynamicRangeHLGV3:
return hdr.HLG
case DynamicRangeDolbyVisionV3:
candidate := plan.Source
profile := plan.Source.DVProfile
for _, transformation := range plan.Transformations {
if transformation.Name == ClientDV7ToDV81V3 {
candidate.DVProfile = 8
candidate.DVBLCompatID = 1
profile = 8
break
}
}
return hdrSupportsDolbyVisionSourceV3(hdr, candidate)
return containsIntV3(hdr.DolbyVisionProfiles, profile)
default:
return false
}
}
func hdr10LimitsSupportPlanV3(hdr HDRCapabilitiesV3, plan PlanV3) bool {
return !(hdr.HDR10MaxWidth > 0 && (plan.EffectiveRecipe.Width == nil || *plan.EffectiveRecipe.Width > hdr.HDR10MaxWidth) ||
hdr.HDR10MaxHeight > 0 && (plan.EffectiveRecipe.Height == nil || *plan.EffectiveRecipe.Height > hdr.HDR10MaxHeight) ||
hdr.HDR10MaxFrameRate > 0 && (plan.EffectiveRecipe.FrameRate == nil || *plan.EffectiveRecipe.FrameRate > hdr.HDR10MaxFrameRate) ||
hdr.HDR10MaxBitrateKbps > 0 && (plan.EffectiveRecipe.BitrateKbps == nil || *plan.EffectiveRecipe.BitrateKbps > hdr.HDR10MaxBitrateKbps))
}
func hdrSupportsDolbyVisionSourceV3(hdr HDRCapabilitiesV3, source SourceDescriptorV3) bool {
if !containsIntV3(hdr.DolbyVisionProfiles, source.DVProfile) {
return false
}
var matchedCapability DolbyVisionProfileCapabilityV3
foundCapability := false
for _, capability := range hdr.DolbyVisionProfileLevels {
if capability.Profile == source.DVProfile {
matchedCapability = capability
foundCapability = true
break
}
}
if !foundCapability {
// Existing clients predate level-bounded Dolby Vision claims. Once a
// client sends any bounds, every advertised profile must have one.
return len(hdr.DolbyVisionProfileLevels) == 0
}
if len(matchedCapability.BLCompatibilityIDs) > 0 &&
!containsIntV3(matchedCapability.BLCompatibilityIDs, source.DVBLCompatID) {
return false
}
if source.DVLevel > 0 {
return source.DVLevel <= matchedCapability.MaxLevel
}
return dolbyVisionSourceFitsLevelV3(source, matchedCapability.MaxLevel)
}
func dolbyVisionSourceFitsLevelV3(source SourceDescriptorV3, maxLevel int) bool {
// Dolby Vision Version 2.0 defines levels by maximum pixel rate, decoded
// width, and high-tier bitrate. Use those physical bounds for legacy rows
// scanned before Silo persisted ffprobe's exact dv_level.
type levelLimit struct {
pixelRate float64
width int
bitrateKbps int
}
limits := map[int]levelLimit{
1: {22_118_400, 1280, 50_000}, 2: {27_648_000, 1280, 50_000},
3: {49_766_400, 1920, 70_000}, 4: {62_208_000, 2560, 70_000},
5: {124_416_000, 3840, 70_000}, 6: {199_065_600, 3840, 130_000},
7: {248_832_000, 3840, 130_000}, 8: {398_131_200, 3840, 130_000},
9: {497_664_000, 3840, 130_000}, 10: {995_328_000, 3840, 240_000},
11: {995_328_000, 7680, 240_000}, 12: {1_990_656_000, 7680, 480_000},
13: {3_981_312_000, 7680, 800_000},
}
limit, ok := limits[maxLevel]
if !ok || source.Width <= 0 || source.Height <= 0 || source.FrameRate <= 0 || source.BitrateKbps <= 0 {
return false
}
return source.Width <= limit.width &&
float64(source.Width*source.Height)*source.FrameRate <= limit.pixelRate &&
source.BitrateKbps <= limit.bitrateKbps
}
func ExplainPlannerResultV3(result PlannerResultV3) string {
if result.Plan != nil {
return fmt.Sprintf("%s:%s", result.Plan.Delivery, result.Plan.DecisionReason)
+2 -2
View File
@@ -70,7 +70,7 @@ func PrepareFile(ctx context.Context, opts TranscodeOpts, outputPath string) err
// Resolve a multi-device hw_device list to one concrete GPU for this
// encode. Run blocks until ffmpeg exits, so the deferred release fires at
// exactly the process-exit boundary.
opts = normalizeTranscodeOpts(opts)
opts.HWAccel = resolveEffectiveTranscodeHWAccel(opts)
hwDevice, releaseHWDevice := AcquireHWDevice(opts.HWDevice, opts.HWAccel)
opts.HWDevice = hwDevice
defer releaseHWDevice()
@@ -106,7 +106,7 @@ func PrepareFile(ctx context.Context, opts TranscodeOpts, outputPath string) err
// faststart MP4 instead of HLS segments. Full-file output needs no seek or
// segment-boundary keyframes.
func buildPrepareFileArgs(opts TranscodeOpts, outputPath string) []string {
opts = normalizeTranscodeOpts(opts)
opts.HWAccel = resolveEffectiveTranscodeHWAccel(opts)
isVideoCopy := opts.TargetCodecVideo == "copy"
isAudioCopy := opts.TargetCodecAudio == "copy"
-55
View File
@@ -55,61 +55,6 @@ func TestBuildPrepareFileArgsEmitsFaststartMP4(t *testing.T) {
}
}
func TestBuildPrepareFileArgsSharesHigh10DecodeFallback(t *testing.T) {
tests := []struct {
name string
hwAccel string
want []string
forbidden []string
}{
{
name: "qsv keeps hardware encode with software decode upload",
hwAccel: "qsv",
want: []string{"-c:v h264_qsv", "format=nv12,hwupload,hwmap=derive_device=qsv"},
forbidden: []string{"-hwaccel qsv", "-hwaccel vaapi"},
},
{
name: "vaapi keeps hardware encode with software decode upload",
hwAccel: "vaapi",
want: []string{"-c:v h264_vaapi", "scale=-2:720,format=nv12,hwupload"},
forbidden: []string{"-hwaccel vaapi"},
},
{
name: "nvenc falls back to software encode",
hwAccel: "nvenc",
want: []string{"-c:v libx264", "-vf scale=-2:720"},
forbidden: []string{"-hwaccel cuda", "h264_nvenc", "scale_cuda"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args := buildPrepareFileArgs(TranscodeOpts{
InputPath: "/media/high10.mkv",
SourceVideoCodec: "h264",
SourceVideoProfile: "High 10",
SourceVideoBitDepth: 10,
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
TargetResolution: "720p",
HWAccel: tt.hwAccel,
AudioTrackIndex: -1,
}, "/artifacts/out.mp4")
joined := strings.Join(args, " ")
for _, want := range tt.want {
if !strings.Contains(joined, want) {
t.Fatalf("args missing %q: %s", want, joined)
}
}
for _, forbidden := range tt.forbidden {
if strings.Contains(joined, forbidden) {
t.Fatalf("args unexpectedly contain %q: %s", forbidden, joined)
}
}
})
}
}
func TestResolvePrepareTarget(t *testing.T) {
settings := AdminSettings{TranscodeEnabled: true, Allow4KTranscode: true}
file := &models.MediaFile{CodecVideo: "h264", CodecAudio: "dts", Container: "mkv", Resolution: "1080p"}
+8 -75
View File
@@ -19,7 +19,6 @@ const (
FeatureRouteDiagnostics = "playback_route_diagnostics"
FeatureDeviceQuirksV3 = "device_quirks_v1"
FeatureSeekReanchorV3 = "seek_reanchor_v1"
FeatureOutputChangeV3 = "output_change_v1"
FeatureDirectStreamResumeV3 = "direct_stream_resume_v1"
FeaturePlanSourceDurationV3 = "plan_source_duration_v1"
PlanRecipeVersionV3 = "v3.4"
@@ -43,7 +42,6 @@ func ServerFeaturesV3() []string {
FeatureRouteDiagnostics,
FeatureDeviceQuirksV3,
FeatureSeekReanchorV3,
FeatureOutputChangeV3,
FeatureDirectStreamResumeV3,
// Advertised so a client can tell "this server does not populate
// source.duration_seconds" apart from "this server knows the runtime
@@ -193,21 +191,10 @@ const (
)
type HDRCapabilitiesV3 struct {
HDR10 bool `json:"hdr10"`
HDR10Plus bool `json:"hdr10_plus"`
HLG bool `json:"hlg"`
HDR10MaxWidth int `json:"hdr10_max_width,omitempty"`
HDR10MaxHeight int `json:"hdr10_max_height,omitempty"`
HDR10MaxFrameRate float64 `json:"hdr10_max_frame_rate,omitempty"`
HDR10MaxBitrateKbps int `json:"hdr10_max_bitrate_kbps,omitempty"`
DolbyVisionProfiles []int `json:"dolby_vision_profiles"`
DolbyVisionProfileLevels []DolbyVisionProfileCapabilityV3 `json:"dolby_vision_profile_levels,omitempty"`
}
type DolbyVisionProfileCapabilityV3 struct {
Profile int `json:"profile"`
MaxLevel int `json:"max_level"`
BLCompatibilityIDs []int `json:"bl_compatibility_ids,omitempty"`
HDR10 bool `json:"hdr10"`
HDR10Plus bool `json:"hdr10_plus"`
HLG bool `json:"hlg"`
DolbyVisionProfiles []int `json:"dolby_vision_profiles"`
}
type AudioPassthroughV3 struct {
@@ -401,9 +388,6 @@ const (
// legacy transcode start: the client sends a quality_preference chosen from
// the plan's available_qualities and no failure classification.
ReplanOperationQualityChangeV3 ReplanOperationV3 = "quality_change"
// ReplanOperationOutputChangeV3 refreshes output capabilities without
// declaring the active route failed, so an unchanged route stays eligible.
ReplanOperationOutputChangeV3 ReplanOperationV3 = "output_change"
)
type ReplanRequestV3 struct {
@@ -559,7 +543,6 @@ type SourceDescriptorV3 struct {
DynamicRange string `json:"dynamic_range,omitempty"`
HDR10Plus bool `json:"hdr10_plus"`
DVProfile int `json:"dolby_vision_profile,omitempty"`
DVLevel int `json:"dolby_vision_level,omitempty"`
DVBLCompatID int `json:"dv_bl_compat_id,omitempty"`
DVEnhancementLayer EnhancementLayerV3 `json:"dv_enhancement_layer"`
AudioCodec string `json:"audio_codec,omitempty"`
@@ -826,9 +809,6 @@ func (r ReplanRequestV3) Validate() error {
// and never selects seek semantics.
case ReplanOperationTrackChangeV3:
// A user track change is not a failure; no classification is required.
if r.Failure != (FailureV3{}) {
return errors.New("track_change must not include failure")
}
case ReplanOperationQualityChangeV3:
// A user quality change is not a failure either, but it must actually
// name the wanted rung: an empty preference would silently mean "auto",
@@ -837,14 +817,6 @@ func (r ReplanRequestV3) Validate() error {
if strings.TrimSpace(r.QualityPreference) == "" {
return errors.New("quality_change requires a quality_preference")
}
if r.Failure != (FailureV3{}) {
return errors.New("quality_change must not include failure")
}
case ReplanOperationOutputChangeV3:
// Output capability refreshes are intent changes, not route failures.
if r.Failure != (FailureV3{}) {
return errors.New("output_change must not include failure")
}
default:
return errors.New("invalid replan operation")
}
@@ -930,16 +902,16 @@ func validateCapabilitiesV3(c *ClientCodecCapabilitiesV3, ctx *ClientPlaybackCon
}
}
for _, hdr := range []*HDRCapabilitiesV3{c.HDRDetails, ctx.Output.HDRDetails} {
if err := validateHDRCapabilitiesV3(hdr); err != nil {
return err
if hdr != nil && len(hdr.DolbyVisionProfiles) > 16 {
return errors.New("dolby vision profile list exceeds supported size")
}
}
for name, delivery := range ctx.Deliveries {
if len(name) > 64 || len(delivery.Containers) > 64 || len(delivery.VideoCodecs) > 64 || len(delivery.AudioDecodeCodecs) > 64 || len(delivery.AudioPassthroughCodecs) > 64 || len(delivery.Features) > 64 || len(delivery.ValidatedClaims) > 64 || len(delivery.Transformations) > 16 {
return errors.New("delivery capability exceeds supported size")
}
if err := validateHDRCapabilitiesV3(delivery.HDRDetails); err != nil {
return err
if delivery.HDRDetails != nil && len(delivery.HDRDetails.DolbyVisionProfiles) > 16 {
return errors.New("dolby vision profile list exceeds supported size")
}
for _, values := range [][]string{delivery.Containers, delivery.VideoCodecs, delivery.AudioDecodeCodecs, delivery.AudioPassthroughCodecs, delivery.Features, delivery.ValidatedClaims} {
for _, value := range values {
@@ -994,45 +966,6 @@ func validateCapabilitiesV3(c *ClientCodecCapabilitiesV3, ctx *ClientPlaybackCon
return nil
}
func validateHDRCapabilitiesV3(hdr *HDRCapabilitiesV3) error {
if hdr == nil {
return nil
}
if hdr.HDR10MaxWidth < 0 || hdr.HDR10MaxHeight < 0 || hdr.HDR10MaxFrameRate < 0 || hdr.HDR10MaxBitrateKbps < 0 {
return errors.New("invalid hdr10 capability limit")
}
if !hdr.HDR10 && (hdr.HDR10MaxWidth > 0 || hdr.HDR10MaxHeight > 0 || hdr.HDR10MaxFrameRate > 0 || hdr.HDR10MaxBitrateKbps > 0) {
return errors.New("hdr10 capability limits require hdr10 support")
}
if len(hdr.DolbyVisionProfiles) > 16 || len(hdr.DolbyVisionProfileLevels) > 16 {
return errors.New("dolby vision profile list exceeds supported size")
}
seenProfiles := make(map[int]struct{}, len(hdr.DolbyVisionProfileLevels))
for _, capability := range hdr.DolbyVisionProfileLevels {
if capability.Profile <= 0 || capability.MaxLevel < 1 || capability.MaxLevel > 13 {
return errors.New("invalid dolby vision profile level capability")
}
if _, exists := seenProfiles[capability.Profile]; exists {
return errors.New("duplicate dolby vision profile level capability")
}
seenProfiles[capability.Profile] = struct{}{}
if len(capability.BLCompatibilityIDs) > 16 {
return errors.New("dolby vision base-layer compatibility list exceeds supported size")
}
seenCompatibilityIDs := make(map[int]struct{}, len(capability.BLCompatibilityIDs))
for _, compatibilityID := range capability.BLCompatibilityIDs {
if compatibilityID < 0 || compatibilityID > 15 {
return errors.New("invalid dolby vision base-layer compatibility id")
}
if _, exists := seenCompatibilityIDs[compatibilityID]; exists {
return errors.New("duplicate dolby vision base-layer compatibility id")
}
seenCompatibilityIDs[compatibilityID] = struct{}{}
}
}
return nil
}
func validateTrackPairV3(fileID int, kind, id string, index *int) error {
if len(id) > 128 {
return fmt.Errorf("%s_track_id exceeds supported size", kind)
+1 -222
View File
@@ -28,7 +28,6 @@ func TestServerFeaturesV3ReturnsCompleteIndependentSlices(t *testing.T) {
FeatureRouteDiagnostics: {},
FeatureDeviceQuirksV3: {},
FeatureSeekReanchorV3: {},
FeatureOutputChangeV3: {},
FeatureDirectStreamResumeV3: {},
FeaturePlanSourceDurationV3: {},
}
@@ -166,19 +165,6 @@ func TestReplanRequestV3OperationDefaultsAndValidates(t *testing.T) {
if err := request.Validate(); err != nil {
t.Fatalf("track change without a failure classification: %v", err)
}
for _, operation := range []ReplanOperationV3{
ReplanOperationTrackChangeV3,
ReplanOperationQualityChangeV3,
ReplanOperationOutputChangeV3,
} {
request.Operation = operation
request.QualityPreference = "720p"
request.Failure = FailureV3{Classification: "decoder_failure"}
if err := request.Validate(); err == nil {
t.Fatalf("%s with failure was accepted", operation)
}
}
request.Failure = FailureV3{}
request.Operation = ReplanOperationQualityChangeV3
request.QualityPreference = ""
@@ -190,38 +176,12 @@ func TestReplanRequestV3OperationDefaultsAndValidates(t *testing.T) {
t.Fatalf("quality change without a failure classification: %v", err)
}
request.Operation = ReplanOperationOutputChangeV3
request.QualityPreference = ""
if err := request.Validate(); err != nil {
t.Fatalf("output change without a failure classification: %v", err)
}
request.Operation = "future_operation"
if err := request.Validate(); err == nil {
t.Fatal("unknown replan operation was accepted")
}
}
func TestHDR10CapabilityLimitsBoundTheClaimedStreamClass(t *testing.T) {
width, height, frameRate, bitrate := 3840, 2160, 24.0, 80_000
plan := PlanV3{EffectiveRecipe: EffectiveRecipeV3{
VideoCodec: "hevc", Width: &width, Height: &height, FrameRate: &frameRate,
BitrateKbps: &bitrate, DynamicRange: DynamicRangeHDR10V3,
}}
hdr := HDRCapabilitiesV3{
HDR10: true, HDR10MaxWidth: 3840, HDR10MaxHeight: 2160,
HDR10MaxFrameRate: 24, HDR10MaxBitrateKbps: 80_000,
}
if !hdrDetailsSupportPlanV3(hdr, plan) {
t.Fatal("the exactly probed HDR10 stream class was rejected")
}
tooFast := 60.0
plan.EffectiveRecipe.FrameRate = &tooFast
if hdrDetailsSupportPlanV3(hdr, plan) {
t.Fatal("an HDR10 stream above the probed frame-rate ceiling was accepted")
}
}
func TestReplanRequestV3RejectsInvalidNetworkAndTrackEvidence(t *testing.T) {
start := validStartRequestV3()
request := ReplanRequestV3{
@@ -337,7 +297,7 @@ func TestProtocolV3ConformanceMatrixCoversReleaseTrain(t *testing.T) {
}
for _, required := range []string{
"evidence_tier_gating", "deliveries_negotiation", "attempt_key_echo_and_loop",
"track_change_replan", "quality_change_replan", "output_change_replan", "idempotent_replan",
"track_change_replan", "quality_change_replan", "idempotent_replan",
"concurrent_replan", "mid_seek_replan", "available_qualities",
"audio_only_planning", "output_context_invalidation", "legacy_426",
"hdr_dv_matrix", "audio_matrix", "subtitle_matrix", "recovery_matrix",
@@ -973,95 +933,6 @@ func TestPlanPlaybackV3FallsBackFromProgressiveToHLSWithoutRepeatingKey(t *testi
}
}
// pgsBurnRequestV3 selects the single embedded PGS track on a client that
// cannot render bitmap subtitles anywhere, so every route but a burn-in
// transcode is closed by the subtitle alone.
func pgsBurnRequestV3(file *models.MediaFile) StartRequestV3 {
file.SubtitleTracks = []models.SubtitleTrack{{Index: 0, Codec: "hdmv_pgs_subtitle"}}
req := validStartRequestV3()
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true}}
req.Capabilities.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
req.ClientPlaybackContext.Output.HDRDetails = &HDRCapabilitiesV3{HDR10: true}
selected := 0
req.SubtitleTrackIndex = &selected
req.SubtitleTrackID = TrackIDV3(file.ID, "subtitle", selected)
return req
}
// A user who just picked a bitmap subtitle can undo that choice; an HDR or 4K
// reason points them at a problem that is not blocking them.
func TestPlanPlaybackV3NamesTheSubtitleWhenItAloneForcedTheTranscode(t *testing.T) {
file := detailedFixtureFileV3()
req := pgsBurnRequestV3(file)
result := PlanPlaybackV3(PlannerInputV3{
Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0,
Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true},
})
if result.Terminal == nil || result.Terminal.Reason != "subtitle_conversion_unsupported" {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
if !strings.Contains(strings.ToLower(result.Terminal.Message), "subtitle") {
t.Errorf("terminal message = %q, want the subtitle named", result.Terminal.Message)
}
}
// The subtitle only owns the terminal when it is the sole blocker: a client
// that cannot take the source range at all still gets the range cause.
func TestPlanPlaybackV3KeepsTheHDRTerminalWhenTheRangeIsAlsoUnsupported(t *testing.T) {
file := detailedFixtureFileV3()
req := pgsBurnRequestV3(file)
req.Capabilities.HDRDetails = nil
req.ClientPlaybackContext.Output.HDRDetails = nil
result := PlanPlaybackV3(PlannerInputV3{
Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0,
Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true},
})
if result.Terminal == nil || result.Terminal.Reason != "hdr_transcode_unsupported" {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
}
// With transcoding disabled, the subtitle policy itself terminals a bitmap
// selection (burn-in is never offered), so the reason names the subtitle
// before the planner ever weighs range or quality causes. The selection is
// genuinely undeliverable, so that attribution is accurate for every cause mix.
func TestPlanPlaybackV3DisabledTranscodeStillNamesAnUndeliverableSubtitle(t *testing.T) {
file := detailedFixtureFileV3()
req := pgsBurnRequestV3(file)
req.Capabilities.HDRDetails = nil
req.ClientPlaybackContext.Output.HDRDetails = nil
result := PlanPlaybackV3(PlannerInputV3{
Request: req, RequestedFile: file, AudioTrackIndex: 0,
EffectiveFile: file,
Settings: PlannerSettingsV3{TranscodeEnabled: false, Allow4KTranscode: true},
})
if result.Terminal == nil || result.Terminal.Reason != "subtitle_conversion_unsupported" {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
}
func TestPlanPlaybackV3NamesTheSubtitleOverThe4KPolicy(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].VideoRange = "SDR"
file.VideoTracks[0].VideoRangeType = "SDR"
file.VideoTracks[0].ColorTransfer = "bt709"
req := pgsBurnRequestV3(file)
result := PlanPlaybackV3(PlannerInputV3{
Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0,
Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: false},
})
if result.Terminal == nil || result.Terminal.Reason != "subtitle_conversion_unsupported" {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
if !strings.Contains(strings.ToLower(result.Terminal.Message), "subtitle") {
t.Errorf("terminal message = %q, want the subtitle named", result.Terminal.Message)
}
}
func TestPlanPlaybackV3NeverClaimsUnimplementedHDRTranscode(t *testing.T) {
file := detailedFixtureFileV3()
req := validStartRequestV3()
@@ -1074,80 +945,6 @@ func TestPlanPlaybackV3NeverClaimsUnimplementedHDRTranscode(t *testing.T) {
}
}
func TestPlanPlaybackV3ReportsHDRLimitationBefore4KPolicy(t *testing.T) {
file := detailedFixtureFileV3()
file.Resolution = "2160p"
file.VideoTracks[0].Width = 3840
file.VideoTracks[0].Height = 2160
req := validStartRequestV3()
req.Capabilities.HDRDetails = nil
req.ClientPlaybackContext.Output.HDRDetails = nil
result := PlanPlaybackV3(PlannerInputV3{
Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0,
Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: false},
})
if result.Terminal == nil || result.Terminal.Reason != "hdr_transcode_unsupported" {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
}
func TestPlanPlaybackV3HonorsDolbyVisionLevelBound(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].VideoRange = "DolbyVision"
file.VideoTracks[0].VideoRangeType = "DOVIWithHDR10"
file.VideoTracks[0].DVProfile = 8
file.VideoTracks[0].DVLevel = 7
file.VideoTracks[0].DVBLCompatID = 1
req := validStartRequestV3()
req.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{
Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10},
MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true,
}}
hdr := &HDRCapabilitiesV3{
DolbyVisionProfiles: []int{8},
DolbyVisionProfileLevels: []DolbyVisionProfileCapabilityV3{{
Profile: 8, MaxLevel: 6, BLCompatibilityIDs: []int{1},
}},
}
req.Capabilities.HDRDetails = hdr
req.ClientPlaybackContext.Output.HDRDetails = hdr
result := PlanPlaybackV3(PlannerInputV3{
Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0,
Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true},
})
if result.Terminal == nil || result.Terminal.Reason != "hdr_transcode_unsupported" {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
file.VideoTracks[0].DVLevel = 6
result = PlanPlaybackV3(PlannerInputV3{
Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0,
Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true},
})
if result.Plan == nil || !result.Plan.Claims.Video.DolbyVision {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
file.VideoTracks[0].DVBLCompatID = 4
result = PlanPlaybackV3(PlannerInputV3{
Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0,
Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true},
})
if result.Terminal == nil || result.Terminal.Reason != "hdr_transcode_unsupported" {
t.Fatalf("result = %s", ExplainPlannerResultV3(result))
}
}
func TestSourceDescriptorV3OmitsInvalidDolbyVisionLevel(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].DVLevel = 14
if got := SourceDescriptorFromFileV3(file, 0).DVLevel; got != 0 {
t.Fatalf("DVLevel = %d, want omitted", got)
}
}
func TestPlanPlaybackV3Profile7StripFallsBackToValidatedHLSCopy(t *testing.T) {
file := detailedFixtureFileV3()
file.VideoTracks[0].DVProfile = 7
@@ -1590,24 +1387,6 @@ func TestStartRequestV3ValidationBoundsInnerLists(t *testing.T) {
delivery.HDRDetails = &HDRCapabilitiesV3{DolbyVisionProfiles: make([]int, 17)}
r.ClientPlaybackContext.Deliveries[DeliveryClassOriginalHTTPV3] = delivery
}},
{"capability_dolby_vision_profile_levels", func(r *StartRequestV3) {
r.Capabilities.HDRDetails = &HDRCapabilitiesV3{DolbyVisionProfileLevels: make([]DolbyVisionProfileCapabilityV3, 17)}
}},
{"invalid_dolby_vision_profile_level", func(r *StartRequestV3) {
r.Capabilities.HDRDetails = &HDRCapabilitiesV3{DolbyVisionProfileLevels: []DolbyVisionProfileCapabilityV3{{Profile: 8, MaxLevel: 14}}}
}},
{"duplicate_dolby_vision_profile_level", func(r *StartRequestV3) {
r.Capabilities.HDRDetails = &HDRCapabilitiesV3{DolbyVisionProfileLevels: []DolbyVisionProfileCapabilityV3{{Profile: 8, MaxLevel: 6}, {Profile: 8, MaxLevel: 7}}}
}},
{"dolby_vision_bl_compatibility_count", func(r *StartRequestV3) {
r.Capabilities.HDRDetails = &HDRCapabilitiesV3{DolbyVisionProfileLevels: []DolbyVisionProfileCapabilityV3{{Profile: 8, MaxLevel: 6, BLCompatibilityIDs: make([]int, 17)}}}
}},
{"invalid_dolby_vision_bl_compatibility_id", func(r *StartRequestV3) {
r.Capabilities.HDRDetails = &HDRCapabilitiesV3{DolbyVisionProfileLevels: []DolbyVisionProfileCapabilityV3{{Profile: 8, MaxLevel: 6, BLCompatibilityIDs: []int{16}}}}
}},
{"duplicate_dolby_vision_bl_compatibility_id", func(r *StartRequestV3) {
r.Capabilities.HDRDetails = &HDRCapabilitiesV3{DolbyVisionProfileLevels: []DolbyVisionProfileCapabilityV3{{Profile: 8, MaxLevel: 6, BLCompatibilityIDs: []int{1, 1}}}}
}},
{"delivery_container_length", func(r *StartRequestV3) {
delivery := r.ClientPlaybackContext.Deliveries[DeliveryClassOriginalHTTPV3]
delivery.Containers = []string{longValue}
+12 -29
View File
@@ -118,11 +118,11 @@ const (
// the RPUs would dangle — stripping yields a clean HDR10 base layer (the
// Apple-parity fallback for devices without a P7 decoder). Profile 8 RPUs
// stay: the base layer is self-contained and DV clients can render it.
func buildRemuxArgs(filePath, outputFormat string, seekSeconds float64, transcodeAudio bool, audioTrackIndex int, dvProfile int, tagSampleEntry, audioOnly bool) []string {
return buildRemuxArgsWithAudioV3(filePath, outputFormat, seekSeconds, transcodeAudio, audioTrackIndex, dvProfile, tagSampleEntry, audioOnly, 0, 0)
func buildRemuxArgs(filePath, outputFormat string, seekSeconds float64, transcodeAudio bool, audioTrackIndex int, dvProfile int, tagDVSampleEntry, audioOnly bool) []string {
return buildRemuxArgsWithAudioV3(filePath, outputFormat, seekSeconds, transcodeAudio, audioTrackIndex, dvProfile, tagDVSampleEntry, audioOnly, 0, 0)
}
func buildRemuxArgsWithAudioV3(filePath, outputFormat string, seekSeconds float64, transcodeAudio bool, audioTrackIndex int, dvProfile int, tagSampleEntry, audioOnly bool, targetAudioChannels, targetAudioBitrateKbps int) []string {
func buildRemuxArgsWithAudioV3(filePath, outputFormat string, seekSeconds float64, transcodeAudio bool, audioTrackIndex int, dvProfile int, tagDVSampleEntry, audioOnly bool, targetAudioChannels, targetAudioBitrateKbps int) []string {
args := []string{
"-nostdin",
"-hide_banner",
@@ -174,27 +174,14 @@ func buildRemuxArgsWithAudioV3(filePath, outputFormat string, seekSeconds float6
if dvProfile == 7 {
args = append(args, "-bsf:v", "dovi_rpu=strip=1")
if tagSampleEntry {
// The explicit v3 strip recipe promised the client plain HDR10.
// Safari's media element only answers "probably" for hvc1 — the
// sample entry Apple's HLS authoring spec requires — so relabel
// FFmpeg's default hev1 to match the evidence the web probe
// collects. Stripped output carries no DOVI record, so no -strict
// relaxation is needed. Legacy/auto strips keep hev1.
args = append(args, "-tag:v", "hvc1")
}
} else if (dvProfile == 5 || dvProfile == 8) && tagSampleEntry {
} else if (dvProfile == 5 || dvProfile == 8) && tagDVSampleEntry {
// FFmpeg carries the DOVI configuration record into MP4 but otherwise
// labels copied HEVC as hev1. Media3 keys decoder selection from the
// sample entry, and Safari's media element only answers "probably" for
// dvh1 — the sample entry Apple's HLS authoring spec calls for — so tag
// dvh1. FFmpeg refuses to write the dvvC configuration record box under
// either tag without -strict unofficial; dvh1 plus -strict unofficial is
// verified (7.1.4) to retain the full record. Media3 accepts both sample
// entries, so Android preserve consumers are unaffected. Only the
// explicit v3 preserve recipe opts in: legacy web/jellycompat consumers
// keep the pre-v3 hev1 labeling their demuxers accept.
args = append(args, "-tag:v", "dvh1", "-strict", "unofficial")
// sample entry, so retain an explicit Dolby Vision tag as well.
// dvhe keeps FFmpeg's dvvC box; forcing dvh1 makes FFmpeg 7.1 omit it.
// Only the explicit v3 preserve recipe opts in: legacy web/jellycompat
// consumers keep the pre-v3 hev1 labeling their demuxers accept.
args = append(args, "-tag:v", "dvhe")
}
if transcodeAudio {
@@ -252,7 +239,7 @@ func startRemuxWithOptions(ctx context.Context, filePath, outputFormat string, s
bin := ResolveFFmpegPath(ffmpegPath)
effectiveProfile := dvProfile
tagSampleEntry := false
tagDVSampleEntry := false
switch mode {
case "", RemuxDVLegacyAutoV3:
effectiveProfile = remuxDVProfile(dvProfile, supportsDoviRPUFilter(bin) &&
@@ -279,11 +266,7 @@ func startRemuxWithOptions(ctx context.Context, filePath, outputFormat string, s
}
// buildRemuxArgs uses profile 7 as the explicit strip sentinel; the
// filter is equally required for a compatible profile 8 base layer.
// The explicit recipe also labels the output hvc1: the web HDR10
// probe collects evidence for that sample entry, and the plan must
// deliver the shape it validated. Legacy/auto strips keep hev1.
effectiveProfile = 7
tagSampleEntry = true
case RemuxDVPreserveV3:
if dvProfile == 7 {
// The remux maps only the base-layer stream, so dual-layer P7
@@ -292,7 +275,7 @@ func startRemuxWithOptions(ctx context.Context, filePath, outputFormat string, s
cancel()
return nil, fmt.Errorf("Dolby Vision profile 7 cannot be preserved in a progressive remux")
}
tagSampleEntry = true
tagDVSampleEntry = true
case RemuxDVRejectP7V3:
if dvProfile == 7 {
cancel()
@@ -302,7 +285,7 @@ func startRemuxWithOptions(ctx context.Context, filePath, outputFormat string, s
cancel()
return nil, fmt.Errorf("unknown remux Dolby Vision mode %q", mode)
}
args := buildRemuxArgsWithAudioV3(filePath, outputFormat, seekSeconds, transcodeAudio, audioTrackIndex, effectiveProfile, tagSampleEntry, audioOnly, targetAudioChannels, targetAudioBitrateKbps)
args := buildRemuxArgsWithAudioV3(filePath, outputFormat, seekSeconds, transcodeAudio, audioTrackIndex, effectiveProfile, tagDVSampleEntry, audioOnly, targetAudioChannels, targetAudioBitrateKbps)
cmd := exec.CommandContext(ctx, bin, args...)
stdout, err := cmd.StdoutPipe()
+9 -96
View File
@@ -85,38 +85,17 @@ func TestBuildRemuxArgsKeepsRPUForProfile8AndPlainFiles(t *testing.T) {
// Media3 keys decoder selection from it, but legacy web/jellycompat consumers
// rely on the pre-v3 hev1 labeling their demuxers accept.
func TestBuildRemuxArgsTagsPreservedDolbyVisionOnlyWhenRequested(t *testing.T) {
for _, profile := range []int{5, 8} {
args := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, profile, true, false)
if !argsContainPair(args, "-tag:v", "dvh1") {
t.Fatalf("preserved profile %d must retain a DV sample entry, args=%v", profile, strings.Join(args, " "))
}
// Without -strict unofficial FFmpeg drops the dvvC configuration record
// and the output is an untagged HEVC stream no DV decoder will select.
if !argsContainPair(args, "-strict", "unofficial") {
t.Fatalf("preserved profile %d must allow the dvvC box, args=%v", profile, strings.Join(args, " "))
}
legacy := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, profile, false, false)
if argsContainPair(legacy, "-tag:v", "dvh1") || argsContainPair(legacy, "-strict", "unofficial") {
t.Fatalf("legacy profile %d remux must keep hev1 labeling, args=%v", profile, strings.Join(legacy, " "))
}
args := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 8, true, false)
if !argsContainPair(args, "-tag:v", "dvhe") {
t.Fatalf("preserved Dolby Vision must retain a DV sample entry, args=%v", strings.Join(args, " "))
}
}
// The explicit v3 strip recipe labels its HDR10 output hvc1 — the sample entry
// the web probe collected evidence for — while the legacy/auto strip keeps
// ffmpeg's default hev1 for pre-v3 consumers. Neither carries a DOVI record,
// so neither needs the -strict relaxation.
func TestBuildRemuxArgsTagsStrippedHDR10OnlyWhenRequested(t *testing.T) {
tagged := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 7, true, false)
if !argsContainPair(tagged, "-bsf:v", "dovi_rpu=strip=1") || !argsContainPair(tagged, "-tag:v", "hvc1") {
t.Fatalf("explicit strip must emit an hvc1-labeled HDR10 stream, args=%v", strings.Join(tagged, " "))
legacy := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 8, false, false)
if argsContainPair(legacy, "-tag:v", "dvhe") {
t.Fatalf("legacy remux consumers must keep hev1 labeling, args=%v", strings.Join(legacy, " "))
}
if argsContainPair(tagged, "-tag:v", "dvh1") || argsContainPair(tagged, "-strict", "unofficial") {
t.Fatalf("stripped output must not carry DV signaling, args=%v", strings.Join(tagged, " "))
}
legacy := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 7, false, false)
if argsContainPair(legacy, "-tag:v", "hvc1") || argsContainPair(legacy, "-tag:v", "dvh1") {
t.Fatalf("legacy strip consumers must keep hev1 labeling, args=%v", strings.Join(legacy, " "))
stripped := buildRemuxArgs("/x.mkv", "mp4", 0, false, -1, 7, false, false)
if argsContainPair(stripped, "-tag:v", "dvhe") {
t.Fatalf("HDR10 fallback must not retain a DV sample entry, args=%v", strings.Join(stripped, " "))
}
}
@@ -167,72 +146,6 @@ func writeProbeAwareFFmpeg(t *testing.T) (bin, argLog string) {
return bin, argLog
}
// writeRecordingFFmpeg stands in for an ffmpeg whose dovi_rpu probe succeeds,
// recording the arguments of every non-probe invocation so tests can assert
// what the remux mode actually mapped onto the command line.
func writeRecordingFFmpeg(t *testing.T) (bin, argLog string) {
t.Helper()
dir := t.TempDir()
bin = filepath.Join(dir, "ffmpeg")
argLog = filepath.Join(dir, "args")
script := "#!/bin/sh\n" +
"case \"$*\" in\n" +
" *-bsfs*) echo dovi_rpu; exit 0;;\n" +
" *'-f null'*) exit 0;;\n" +
"esac\n" +
"echo \"$*\" >> " + argLog + "\n" +
"exit 0\n"
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatalf("write fake ffmpeg: %v", err)
}
return bin, argLog
}
func recordedRemuxArgs(t *testing.T, session *RemuxSession, argLog string) string {
t.Helper()
_, _ = io.ReadAll(session)
_ = session.Close()
recorded, err := os.ReadFile(argLog)
if err != nil {
t.Fatalf("the remux never ran: %v", err)
}
return string(recorded)
}
// The preserve mode, not the caller, decides the sample-entry tagging:
// buildRemuxArgs cannot be handed the flag directly by production code, so the
// mapping from RemuxDVPreserveV3 must be exercised through the mode switch.
func TestPreserveModeTagsTheSampleEntry(t *testing.T) {
for _, profile := range []int{5, 8} {
bin, argLog := writeRecordingFFmpeg(t)
session, err := StartRemuxWithDVMode(t.Context(), remuxSourceFile(t), "mp4", 0, false, -1, profile, RemuxDVPreserveV3, bin)
if err != nil {
t.Fatalf("preserve remux refused profile %d: %v", profile, err)
}
recorded := recordedRemuxArgs(t, session, argLog)
if !strings.Contains(recorded, "-tag:v dvh1") || !strings.Contains(recorded, "-strict unofficial") {
t.Fatalf("preserve mode must map to dvh1 + -strict unofficial for profile %d: %s", profile, recorded)
}
}
}
func TestExplicitStripModeTagsHVC1(t *testing.T) {
for _, profile := range []int{7, 8} {
bin, argLog := writeRecordingFFmpeg(t)
session, err := StartRemuxWithDVMode(t.Context(), remuxSourceFile(t), "mp4", 0, false, -1, profile, RemuxDVStripToHDR10V3, bin)
if err != nil {
t.Fatalf("strip remux refused profile %d: %v", profile, err)
}
recorded := recordedRemuxArgs(t, session, argLog)
if !strings.Contains(recorded, "dovi_rpu=strip=1") || !strings.Contains(recorded, "-tag:v hvc1") {
t.Fatalf("explicit strip must map to dovi_rpu + hvc1 for profile %d: %s", profile, recorded)
}
if strings.Contains(recorded, "dvh1") || strings.Contains(recorded, "-strict unofficial") {
t.Fatalf("stripped output must not carry DV signaling for profile %d: %s", profile, recorded)
}
}
}
func remuxSourceFile(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "movie.mkv")
+345
View File
@@ -0,0 +1,345 @@
package playback
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
)
const (
segmentPruneHysteresis = 5
segmentPruneBatchSize = 512
segmentPruneRetryDelay = 5 * time.Second
)
type segmentPruneCandidate struct {
number int
path string
}
// scheduleSegmentPruneLocked starts one asynchronous prune pass after the
// client has advanced far enough to make useful work. The caller must hold
// s.mu. Segment retention is measured in media time so custom HLS segment
// durations retain the same back-seek window.
func (s *TranscodeSession) scheduleSegmentPruneLocked() {
retentionSeconds := s.opts.SegmentRetentionSeconds
if retentionSeconds <= 0 || s.segmentPruneRunning || s.restarting {
return
}
if strings.EqualFold(s.opts.TargetCodecVideo, "copy") {
// Copy-mode fragments follow source keyframes, so their real media-time
// floor is resolved asynchronously from EXTINF durations below. Use the
// download high-water mark only to avoid reparsing the manifest for every
// segment response.
if s.lastCompletedSegment-s.lastPruneHighWater < segmentPruneHysteresis {
return
}
} else {
segmentDuration := s.opts.SegmentDuration
if segmentDuration <= 0 {
segmentDuration = defaultSegmentDuration
}
retainedSegments := (retentionSeconds + segmentDuration - 1) / segmentDuration
floor := s.lastCompletedSegment - retainedSegments
beforeStartReady := s.pruneBeforeStart && floor == s.opts.StartSegmentNumber
if floor < s.opts.StartSegmentNumber || (!beforeStartReady && floor == s.opts.StartSegmentNumber) || floor-s.lastPruneFloor < segmentPruneHysteresis {
return
}
}
s.segmentPruneRunning = true
generation := s.segmentGeneration
downloadedThrough := s.lastCompletedSegment
s.lastPruneHighWater = downloadedThrough
go s.pruneDownloadedSegments(generation, downloadedThrough, false)
}
// pruneDownloadedSegments removes completed media files strictly behind floor.
// It visits each newly expired segment number at most once in bounded batches,
// avoiding repeated full-directory scans when FFmpeg has generated far ahead
// of the client. The current process's startup window remains present so real
// manifest reloads cannot get stuck behind startupFilesReady after cleanup.
func (s *TranscodeSession) pruneDownloadedSegments(generation uint64, downloadedThrough int, continuation bool) {
started := time.Now()
s.mu.Lock()
if generation != s.segmentGeneration || s.restarting {
s.mu.Unlock()
return
}
opts := s.opts
outputDir := s.outputDir
fromFloor := s.lastPruneFloor
pruneBeforeStart := s.pruneBeforeStart
s.mu.Unlock()
floor, complete, err := segmentRetentionFloor(outputDir, opts, downloadedThrough)
if err != nil {
slog.Warn("resolve transcode segment retention floor", "component", "playback", "error", err, "session", opts.SessionID, "playback_session_id", opts.SessionID)
s.finishSegmentPrune(generation, fromFloor, fromFloor, downloadedThrough, segmentPruneRetryDelay)
return
}
beforeStartReady := pruneBeforeStart && floor == opts.StartSegmentNumber
if !complete || floor < opts.StartSegmentNumber || (!beforeStartReady && floor == opts.StartSegmentNumber) || (!continuation && floor-fromFloor < segmentPruneHysteresis) {
s.finishSegmentPruneAttempt(generation)
return
}
segmentDuration := opts.SegmentDuration
if segmentDuration <= 0 {
segmentDuration = defaultSegmentDuration
}
freshGuard := 2*time.Duration(segmentDuration)*time.Second + 30*time.Second
startupEnd := opts.StartSegmentNumber + startupSegmentRequirement(opts)
targetFloor := floor
fromSegment := max(fromFloor, startupEnd)
if pruneBeforeStart {
// A forward restart intentionally keeps the previous generation's back
// buffer until the replacement has produced its own. Retire that older
// range first, including its startup files, then resume normal pruning
// without touching the replacement process's startup window. The old
// range can be very sparse after a long seek, so enumerate actual files
// below the new start rather than walking every intervening number.
targetFloor = min(floor, opts.StartSegmentNumber)
}
if !pruneBeforeStart && fromSegment >= targetFloor {
s.finishSegmentPrune(generation, targetFloor, targetFloor, downloadedThrough, 0)
return
}
if _, err := os.Stat(outputDir); err != nil {
if errors.Is(err, os.ErrNotExist) {
s.finishSegmentPrune(generation, targetFloor, targetFloor, downloadedThrough, 0)
return
}
slog.Warn("stat transcode directory for pruning", "component", "playback", "error", err, "session", opts.SessionID, "playback_session_id", opts.SessionID)
retryFloor := fromSegment
if pruneBeforeStart {
retryFloor = fromFloor
}
s.finishSegmentPrune(generation, retryFloor, targetFloor, downloadedThrough, segmentPruneRetryDelay)
return
}
var candidates []segmentPruneCandidate
moreCandidates := false
if pruneBeforeStart {
entries, readErr := os.ReadDir(outputDir)
if readErr != nil {
slog.Warn("read transcode directory for preserved segment pruning", "component", "playback", "error", readErr, "session", opts.SessionID, "playback_session_id", opts.SessionID)
s.finishSegmentPrune(generation, fromFloor, targetFloor, downloadedThrough, segmentPruneRetryDelay)
return
}
for _, entry := range entries {
segment, parseErr := ParseSegmentNumber(entry.Name())
if entry.IsDir() || parseErr != nil || segment >= targetFloor || entry.Name() != segmentFilename(segment, opts) {
continue
}
if len(candidates) == segmentPruneBatchSize {
moreCandidates = true
break
}
candidates = append(candidates, segmentPruneCandidate{
number: segment,
path: filepath.Join(outputDir, entry.Name()),
})
}
} else {
toSegment := min(targetFloor, fromSegment+segmentPruneBatchSize)
for segment := fromSegment; segment < toSegment; segment++ {
candidates = append(candidates, segmentPruneCandidate{
number: segment,
path: filepath.Join(outputDir, segmentFilename(segment, opts)),
})
}
moreCandidates = toSegment < targetFloor
}
if len(candidates) == 0 && !moreCandidates {
s.finishSegmentPrune(generation, targetFloor, targetFloor, downloadedThrough, 0)
return
}
processedFloor := targetFloor
if !pruneBeforeStart {
processedFloor = candidates[len(candidates)-1].number + 1
}
var retryAfter time.Duration
removed := 0
var freedBytes int64
for _, candidate := range candidates {
info, err := os.Stat(candidate.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
if pruneBeforeStart {
retryAfter = max(retryAfter, segmentPruneRetryDelay)
} else {
processedFloor, retryAfter = earlierPruneRetry(processedFloor, retryAfter, candidate.number, segmentPruneRetryDelay)
}
slog.Warn("stat downloaded transcode segment", "component", "playback", "error", err, "segment", candidate.number, "session", opts.SessionID, "playback_session_id", opts.SessionID)
continue
}
freshUntil := info.ModTime().Add(freshGuard)
if delay := time.Until(freshUntil); delay > 0 {
if pruneBeforeStart {
retryAfter = max(retryAfter, delay)
} else {
processedFloor, retryAfter = earlierPruneRetry(processedFloor, retryAfter, candidate.number, delay)
}
continue
}
// Serialize the unlink with restart and shutdown generation changes.
// Once either advances the generation, this pass cannot delete files
// generated for a replacement timeline or session object.
s.mu.Lock()
if generation != s.segmentGeneration || s.restarting {
s.mu.Unlock()
return
}
removeErr := os.Remove(candidate.path)
s.mu.Unlock()
if removeErr != nil {
if !errors.Is(removeErr, os.ErrNotExist) {
if pruneBeforeStart {
retryAfter = max(retryAfter, segmentPruneRetryDelay)
} else {
processedFloor, retryAfter = earlierPruneRetry(processedFloor, retryAfter, candidate.number, segmentPruneRetryDelay)
}
slog.Warn("remove downloaded transcode segment", "component", "playback", "error", removeErr, "segment", candidate.number, "session", opts.SessionID, "playback_session_id", opts.SessionID)
}
continue
}
removed++
freedBytes += info.Size()
}
if pruneBeforeStart && (moreCandidates || retryAfter > 0) {
processedFloor = fromFloor
}
s.finishSegmentPrune(generation, processedFloor, targetFloor, downloadedThrough, retryAfter)
if removed > 0 {
slog.Info("pruned downloaded transcode segments",
"component", "playback",
"count", removed,
"freed_bytes", freedBytes,
"floor_segment", processedFloor,
"duration_ms", time.Since(started).Milliseconds(),
"session", opts.SessionID,
"playback_session_id", opts.SessionID,
)
}
}
// segmentRetentionFloor returns the first segment that must remain to preserve
// the configured media-time window behind downloadedThrough. Encoded HLS uses
// fixed-duration fragments. Copy HLS follows source keyframes, so its floor is
// derived from the current manifest's actual EXTINF durations. complete is
// false when the manifest does not yet cover the full requested back buffer.
func segmentRetentionFloor(outputDir string, opts TranscodeOpts, downloadedThrough int) (floor int, complete bool, err error) {
retentionSeconds := opts.SegmentRetentionSeconds
if retentionSeconds <= 0 {
return 0, false, nil
}
if !strings.EqualFold(opts.TargetCodecVideo, "copy") {
segmentDuration := opts.SegmentDuration
if segmentDuration <= 0 {
segmentDuration = defaultSegmentDuration
}
retainedSegments := (retentionSeconds + segmentDuration - 1) / segmentDuration
return downloadedThrough - retainedSegments, true, nil
}
manifest, err := os.ReadFile(filepath.Join(outputDir, "stream.m3u8"))
if err != nil {
return 0, false, fmt.Errorf("read copy manifest: %w", err)
}
timeline, err := parseManifestTimeline(manifest)
if err != nil {
return 0, false, fmt.Errorf("parse copy manifest: %w", err)
}
downloadedIndex := -1
for i, entry := range timeline.entries {
if entry.duration <= 0 {
return 0, false, fmt.Errorf("copy segment %d has non-positive duration %.6f", entry.number, entry.duration)
}
if entry.number == downloadedThrough {
downloadedIndex = i
}
}
if downloadedIndex < 0 {
return 0, false, nil
}
retainedSeconds := 0.0
for i := downloadedIndex - 1; i >= 0; i-- {
floor = timeline.entries[i].number
retainedSeconds += timeline.entries[i].duration
if retainedSeconds >= float64(retentionSeconds) {
return floor, true, nil
}
}
return 0, false, nil
}
func earlierPruneRetry(currentFloor int, currentDelay time.Duration, segment int, delay time.Duration) (int, time.Duration) {
if segment < currentFloor {
return segment, max(delay, time.Millisecond)
}
return currentFloor, currentDelay
}
func (s *TranscodeSession) finishSegmentPruneAttempt(generation uint64) {
s.mu.Lock()
defer s.mu.Unlock()
if generation != s.segmentGeneration {
return
}
s.segmentPruneRunning = false
s.scheduleSegmentPruneLocked()
}
func (s *TranscodeSession) finishSegmentPrune(
generation uint64,
processedFloor int,
targetFloor int,
downloadedThrough int,
retryAfter time.Duration,
) {
s.mu.Lock()
defer s.mu.Unlock()
if generation != s.segmentGeneration {
return
}
if processedFloor > s.lastPruneFloor {
s.lastPruneFloor = processedFloor
}
if s.pruneBeforeStart && s.lastPruneFloor >= s.opts.StartSegmentNumber {
s.pruneBeforeStart = false
}
if retryAfter > 0 {
time.AfterFunc(retryAfter, func() {
s.mu.Lock()
if generation != s.segmentGeneration || s.restarting {
s.mu.Unlock()
return
}
s.mu.Unlock()
go s.pruneDownloadedSegments(generation, downloadedThrough, true)
})
return
}
if processedFloor < targetFloor {
go s.pruneDownloadedSegments(generation, downloadedThrough, true)
return
}
s.segmentPruneRunning = false
s.scheduleSegmentPruneLocked()
}
+388
View File
@@ -0,0 +1,388 @@
package playback
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestReportSegmentDownloadedPrunesOnlyExpiredBackBuffer(t *testing.T) {
dir := t.TempDir()
old := time.Now().Add(-time.Minute)
for segment := 0; segment <= 15; segment++ {
writePrunerTestFile(t, filepath.Join(dir, segmentFilename(segment, TranscodeOpts{})), []byte("segment"), old)
}
writePrunerTestFile(t, filepath.Join(dir, "init.mp4"), []byte("init"), old)
writePrunerTestFile(t, filepath.Join(dir, "stream.m3u8"), []byte("manifest"), old)
writePrunerTestFile(t, filepath.Join(dir, "seg_00004.ts.tmp"), []byte("temporary"), old)
writePrunerTestFile(t, filepath.Join(dir, "notes.txt"), []byte("other"), old)
// A newly-created file below the floor may belong to a replacement ffmpeg
// process and must survive this pass.
writePrunerTestFile(t, filepath.Join(dir, segmentFilename(4, TranscodeOpts{})), []byte("fresh"), time.Now())
session := &TranscodeSession{
opts: TranscodeOpts{
SessionID: "prune-test",
SegmentDuration: 2,
SegmentRetentionSeconds: 10,
},
outputDir: dir,
lastPruneFloor: -1,
lastRequestedSegment: 0,
}
session.ReportSegmentDownloaded(15)
waitForPrunerFileMissing(t, filepath.Join(dir, segmentFilename(9, TranscodeOpts{})))
t.Cleanup(func() {
session.mu.Lock()
session.segmentGeneration++
session.segmentPruneRunning = false
session.mu.Unlock()
})
for _, segment := range []int{0, 1, 2, 4, 10, 11, 12, 13, 14, 15} {
assertPrunerFileExists(t, filepath.Join(dir, segmentFilename(segment, TranscodeOpts{})))
}
for _, segment := range []int{3, 5, 6, 7, 8, 9} {
assertPrunerFileMissing(t, filepath.Join(dir, segmentFilename(segment, TranscodeOpts{})))
}
for _, name := range []string{"init.mp4", "stream.m3u8", "seg_00004.ts.tmp", "notes.txt"} {
assertPrunerFileExists(t, filepath.Join(dir, name))
}
}
func TestFreshSegmentIsRetriedAfterGuardExpires(t *testing.T) {
dir := t.TempDir()
opts := TranscodeOpts{
SessionID: "retry-test",
SegmentDuration: 2,
SegmentRetentionSeconds: 10,
}
guard := 2*time.Duration(opts.SegmentDuration)*time.Second + 30*time.Second
path := filepath.Join(dir, segmentFilename(3, opts))
writePrunerTestFile(t, path, []byte("segment"), time.Now().Add(-guard+time.Second))
session := &TranscodeSession{
opts: opts,
outputDir: dir,
lastPruneFloor: -1,
lastRequestedSegment: 0,
}
session.ReportSegmentDownloaded(9)
waitForPrunerFileMissing(t, path)
waitForPrunePass(t, session, 4)
}
func TestPruningContinuesAcrossBoundedBatches(t *testing.T) {
dir := t.TempDir()
old := time.Now().Add(-time.Minute)
opts := TranscodeOpts{
SessionID: "batch-test",
SegmentDuration: 2,
SegmentRetentionSeconds: 10,
}
for segment := 0; segment <= 600; segment++ {
writePrunerTestFile(t, filepath.Join(dir, segmentFilename(segment, opts)), []byte("segment"), old)
}
session := &TranscodeSession{
opts: opts,
outputDir: dir,
lastPruneFloor: -1,
lastRequestedSegment: 0,
}
session.ReportSegmentDownloaded(600)
waitForPrunerFileMissing(t, filepath.Join(dir, segmentFilename(594, opts)))
waitForPrunePass(t, session, 595)
for _, segment := range []int{0, 1, 2, 595, 600} {
assertPrunerFileExists(t, filepath.Join(dir, segmentFilename(segment, opts)))
}
}
func TestForwardRestartPrunesOnlyFilesPresentAcrossSparseGap(t *testing.T) {
dir := t.TempDir()
old := time.Now().Add(-time.Minute)
opts := TranscodeOpts{
SessionID: "sparse-restart-test",
SegmentDuration: 2,
SegmentRetentionSeconds: 10,
StartSegmentNumber: 100_000,
}
for _, segment := range []int{3, 70, 99_999, 100_000} {
writePrunerTestFile(t, filepath.Join(dir, segmentFilename(segment, opts)), []byte("segment"), old)
}
writePrunerTestFile(t, filepath.Join(dir, "seg_00004.ts.tmp"), []byte("temporary"), old)
session := &TranscodeSession{
opts: opts,
outputDir: dir,
lastPruneFloor: -1,
lastRequestedSegment: opts.StartSegmentNumber,
lastCompletedSegment: opts.StartSegmentNumber - 1,
pruneBeforeStart: true,
}
session.ReportSegmentDownloaded(opts.StartSegmentNumber + 5)
waitForPrunerFileMissing(t, filepath.Join(dir, segmentFilename(99_999, opts)))
waitForPrunePass(t, session, opts.StartSegmentNumber)
for _, segment := range []int{3, 70, 99_999} {
assertPrunerFileMissing(t, filepath.Join(dir, segmentFilename(segment, opts)))
}
assertPrunerFileExists(t, filepath.Join(dir, segmentFilename(100_000, opts)))
assertPrunerFileExists(t, filepath.Join(dir, "seg_00004.ts.tmp"))
}
func TestCopyRetentionUsesManifestDurations(t *testing.T) {
dir := t.TempDir()
old := time.Now().Add(-time.Minute)
opts := TranscodeOpts{
SessionID: "copy-duration-test",
SourceVideoCodec: "h264",
TargetCodecVideo: "copy",
SegmentDuration: 2,
SegmentRetentionSeconds: 10,
}
manifest := strings.Join([]string{
"#EXTM3U",
"#EXT-X-VERSION:7",
"#EXT-X-MEDIA-SEQUENCE:8",
"#EXTINF:1.0,",
"seg_00008.m4s",
"#EXTINF:2.0,",
"seg_00009.m4s",
"#EXTINF:3.0,",
"seg_00010.m4s",
"#EXTINF:4.0,",
"seg_00011.m4s",
"#EXTINF:5.0,",
"seg_00012.m4s",
"#EXTINF:6.0,",
"seg_00013.m4s",
"#EXTINF:7.0,",
"seg_00014.m4s",
"#EXTINF:8.0,",
"seg_00015.m4s",
"",
}, "\n")
writePrunerTestFile(t, filepath.Join(dir, "stream.m3u8"), []byte(manifest), old)
for segment := 8; segment <= 15; segment++ {
writePrunerTestFile(t, filepath.Join(dir, segmentFilename(segment, opts)), []byte("segment"), old)
}
session := &TranscodeSession{
opts: opts,
outputDir: dir,
lastPruneFloor: -1,
lastRequestedSegment: 0,
}
session.ReportSegmentDownloaded(15)
waitForPrunerFileMissing(t, filepath.Join(dir, segmentFilename(12, opts)))
t.Cleanup(func() {
session.mu.Lock()
session.segmentGeneration++
session.segmentPruneRunning = false
session.mu.Unlock()
})
assertPrunerFileExists(t, filepath.Join(dir, segmentFilename(13, opts)))
assertPrunerFileExists(t, filepath.Join(dir, segmentFilename(14, opts)))
assertPrunerFileExists(t, filepath.Join(dir, segmentFilename(15, opts)))
}
func TestReportSegmentDownloadedDisabledKeepsSegments(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, segmentFilename(3, TranscodeOpts{}))
writePrunerTestFile(t, path, []byte("segment"), time.Now().Add(-time.Minute))
session := &TranscodeSession{
opts: TranscodeOpts{SegmentDuration: 2},
outputDir: dir,
lastPruneFloor: -1,
lastRequestedSegment: 0,
}
session.ReportSegmentDownloaded(500)
time.Sleep(20 * time.Millisecond)
assertPrunerFileExists(t, path)
}
func TestOldGenerationDownloadDoesNotAdvanceRestartedSession(t *testing.T) {
session := &TranscodeSession{
opts: TranscodeOpts{SegmentRetentionSeconds: 600},
lastRequestedSegment: 100,
}
oldGeneration := session.SegmentGeneration()
session.mu.Lock()
session.segmentGeneration++
session.lastRequestedSegment = 20
session.mu.Unlock()
session.ReportSegmentDownloadedForGeneration(101, oldGeneration)
if got := session.LastRequestedSegment(); got != 20 {
t.Fatalf("last requested segment = %d, want restarted position 20", got)
}
}
func TestCloseInvalidatesInFlightSegmentGeneration(t *testing.T) {
session := &TranscodeSession{
opts: TranscodeOpts{SegmentRetentionSeconds: 600},
outputDir: t.TempDir(),
lastRequestedSegment: 20,
}
downloadGeneration := session.SegmentGeneration()
if err := session.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
session.ReportSegmentDownloadedForGeneration(100, downloadGeneration)
if got := session.LastRequestedSegment(); got != 20 {
t.Fatalf("last requested segment = %d, want closed-session position 20", got)
}
}
func TestOpenSegmentDescriptorSurvivesUnlink(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "seg_00001.ts")
want := []byte("complete segment")
writePrunerTestFile(t, path, want, time.Now())
session := &TranscodeSession{outputDir: dir}
segment, err := session.OpenSegment("seg_00001.ts")
if err != nil {
t.Fatalf("OpenSegment: %v", err)
}
defer func() { _ = segment.Close() }()
if err := os.Remove(path); err != nil {
t.Fatalf("remove opened segment: %v", err)
}
got, err := io.ReadAll(segment.File)
if err != nil {
t.Fatalf("read opened segment: %v", err)
}
if string(got) != string(want) {
t.Fatalf("opened segment = %q, want %q", got, want)
}
}
func TestOpenSegmentLeaseCapturesGeneration(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "seg_00001.ts")
writePrunerTestFile(t, path, []byte("complete segment"), time.Now())
session := &TranscodeSession{outputDir: dir, segmentGeneration: 7}
segment, err := session.OpenSegment("seg_00001.ts")
if err != nil {
t.Fatalf("OpenSegment: %v", err)
}
defer func() { _ = segment.Close() }()
if segment.Generation != 7 {
t.Fatalf("segment generation = %d, want 7", segment.Generation)
}
}
func TestGenerationTokenRejectsPriorSessionIncarnation(t *testing.T) {
oldDir := t.TempDir()
newDir := t.TempDir()
const name = "seg_00007.ts"
writePrunerTestFile(t, filepath.Join(oldDir, name), []byte("old segment"), time.Now())
writePrunerTestFile(t, filepath.Join(newDir, name), []byte("new segment"), time.Now())
oldSession := &TranscodeSession{outputDir: oldDir}
oldLease, err := oldSession.OpenSegment(name)
if err != nil {
t.Fatalf("open old segment: %v", err)
}
defer func() { _ = oldLease.Close() }()
newSession := &TranscodeSession{outputDir: newDir}
newLease, err := newSession.OpenSegment(name)
if err != nil {
t.Fatalf("open new segment: %v", err)
}
defer func() { _ = newLease.Close() }()
if oldLease.Generation != newLease.Generation {
t.Fatalf("numeric generations differ: old=%d new=%d; test requires restart collision", oldLease.Generation, newLease.Generation)
}
if oldLease.GenerationToken == newLease.GenerationToken {
t.Fatal("separate session objects reused an opaque generation token")
}
newSession.ReportSegmentDownloadedForGenerationToken(7, oldLease.GenerationToken)
if got := newSession.LastRequestedSegment(); got != 0 {
t.Fatalf("prior-session token advanced replacement to %d", got)
}
newSession.ReportSegmentDownloadedForGenerationToken(7, newLease.GenerationToken)
if got := newSession.LastRequestedSegment(); got != 7 {
t.Fatalf("current token advanced replacement to %d, want 7", got)
}
}
func TestOpenSegmentWaitsThroughRestartInsteadOfLeasingStaleFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "seg_00001.ts")
writePrunerTestFile(t, path, []byte("stale segment"), time.Now())
session := &TranscodeSession{outputDir: dir, restarting: true}
if segment, err := session.OpenSegment("seg_00001.ts"); !errors.Is(err, ErrSegmentNotFound) {
if segment != nil {
_ = segment.Close()
}
t.Fatalf("OpenSegment error = %v, want ErrSegmentNotFound", err)
}
}
func writePrunerTestFile(t *testing.T, path string, contents []byte, modTime time.Time) {
t.Helper()
if err := os.WriteFile(path, contents, 0o600); err != nil {
t.Fatalf("write %s: %v", filepath.Base(path), err)
}
if err := os.Chtimes(path, modTime, modTime); err != nil {
t.Fatalf("set times for %s: %v", filepath.Base(path), err)
}
}
func waitForPrunePass(t *testing.T, session *TranscodeSession, wantFloor int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
session.mu.Lock()
running := session.segmentPruneRunning
floor := session.lastPruneFloor
session.mu.Unlock()
if !running && floor >= wantFloor {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("segment prune pass did not finish")
}
func waitForPrunerFileMissing(t *testing.T, path string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); os.IsNotExist(err) {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("expected %s to be removed", filepath.Base(path))
}
func assertPrunerFileExists(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected %s to exist: %v", filepath.Base(path), err)
}
}
func assertPrunerFileMissing(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("expected %s to be removed, stat error = %v", filepath.Base(path), err)
}
}
@@ -4147,124 +4147,6 @@
"selected_quality": "720p"
}
},
{
"name": "output_change",
"category": "output_change_replan",
"request": {
"protocol_version": 3,
"client_features": [
"playback_plan_v3"
],
"operation": "output_change",
"playback_attempt_id": "attempt-golden-0001",
"replan_request_id": "replan-output-change-0001",
"failed_plan_id": "plan:478677870860e5e5108c18bff749b34b",
"plan_attempt_id": "plan-attempt-golden-0001",
"plan_attempt_key": "v3:f0144c47fa349e3e",
"attempted_plan_keys": [
"v3:f0144c47fa349e3e"
],
"attempt_count": 1,
"quality_preference": "auto",
"position_seconds": 42.5,
"metered": true,
"bandwidth_estimate_kbps": 3500,
"bandwidth_cap_kbps": 4000,
"selected_tracks": {
"audio": {
"id": "file:42:audio:0",
"index": 0
}
},
"client_capabilities": {
"video_evidence": "exact",
"audio_evidence": "exact",
"codecs_video": [
"h264"
],
"codecs_video_hardware": [
"h264"
],
"codecs_audio": [
"aac"
],
"containers": [
"mp4"
],
"max_resolution": "1080p",
"hdr": false,
"video_decode": [
{
"codec": "h264",
"profiles": [
"high"
],
"levels": [
41
],
"bit_depths": [
8
],
"max_width": 1920,
"max_height": 1080,
"max_frame_rate": 60,
"max_bitrate_kbps": 20000,
"hardware": true
}
]
},
"client_playback_context": {
"protocol_version": 3,
"form_factor": "tv",
"app_version": "3.0-test",
"device": {
"platform": "android",
"os_version": "15",
"manufacturer": "NVIDIA",
"model": "SHIELD Android TV",
"platform_details": {
"abis": "arm64-v8a",
"sdk_int": "35"
}
},
"output": {
"output_context_id": "7"
},
"deliveries": {
"original_http": {
"enabled": true,
"supported_on_device": true,
"containers": [
"mp4"
],
"video_codecs": [
"h264"
],
"audio_decode_codecs": [
"aac"
],
"audio_passthrough_codecs": [],
"subtitles": {
"embedded_text": true,
"sidecar_text": true,
"ass_styling": false,
"embedded_bitmap": false,
"sidecar_bitmap": false,
"font_attachments": false
},
"features": [],
"auth_header_refresh": true,
"validated_claims": [],
"transformations": []
}
}
}
},
"expected": {
"http_status": 200,
"preserve_unmodified_tracks": true
}
},
{
"name": "track_change_idempotent_duplicate",
"category": "idempotent_replan",
+188 -21
View File
@@ -20,6 +20,7 @@ import (
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/google/uuid"
)
func init() {
@@ -52,15 +53,16 @@ type TranscodeOpts struct {
StreamOriginSeconds float64
// CopySeekAnchorResolved distinguishes a valid zero-second origin from
// older/shared recipes that never resolved a copy seek anchor.
CopySeekAnchorResolved bool
TargetResolution string // e.g., 1080p, 720p
TargetCodecVideo string // e.g., h264 (or hevc if allowed)
TargetCodecAudio string // e.g., aac
SegmentDuration int // seconds, default 6
StartSegmentNumber int // -hls_segment_start_number, default 0
FFmpegPath string // optional explicit ffmpeg binary path
HWAccel string // auto, qsv, vaapi, nvenc, none
HWDevice string // e.g., /dev/dri/renderD128 (default if empty)
CopySeekAnchorResolved bool
TargetResolution string // e.g., 1080p, 720p
TargetCodecVideo string // e.g., h264 (or hevc if allowed)
TargetCodecAudio string // e.g., aac
SegmentDuration int // seconds, default 6
SegmentRetentionSeconds int // downloaded media retained behind the client; 0 disables pruning
StartSegmentNumber int // -hls_segment_start_number, default 0
FFmpegPath string // optional explicit ffmpeg binary path
HWAccel string // auto, qsv, vaapi, nvenc, none
HWDevice string // e.g., /dev/dri/renderD128 (default if empty)
// AvoidHWDevice asks the initial multi-device allocator to prefer any other
// present render device. It is a process-local startup hint used after an
// early GPU failure; the selected concrete device remains fully reserved and
@@ -119,6 +121,13 @@ type TranscodeSession struct {
done chan struct{} // closed when the monitor goroutine finishes
stdinPipe io.WriteCloser
lastRequestedSegment int
lastCompletedSegment int
lastPruneFloor int
lastPruneHighWater int
segmentPruneRunning bool
pruneBeforeStart bool
segmentGeneration uint64
segmentIncarnation string
throttler *TranscodeThrottler
stderrLinesLogged int
stderrBytesLogged int
@@ -217,7 +226,8 @@ func StartTranscode(ctx context.Context, opts TranscodeOpts) (*TranscodeSession,
if opts.SegmentDuration <= 0 {
opts.SegmentDuration = defaultSegmentDuration
}
opts = normalizeTranscodeOpts(opts)
opts = resolveSoftwareVideoDecode(opts)
opts.HWAccel = resolveEffectiveTranscodeHWAccel(opts)
configuredHWDevices := ParseHWDeviceSet(opts.HWDevice)
reserveHWDeviceOnRestart := configuredHWDevices.Multi() && hwAccelBalancesRenderDevices(opts.HWAccel)
// Resolve a multi-device hw_device list to one concrete GPU. Restarts reuse
@@ -245,6 +255,10 @@ func StartTranscode(ctx context.Context, opts TranscodeOpts) (*TranscodeSession,
done: make(chan struct{}),
stderr: newBoundedTailBuffer(stderrTailMaxBytes),
lastRequestedSegment: opts.StartSegmentNumber,
lastCompletedSegment: opts.StartSegmentNumber - 1,
lastPruneFloor: opts.StartSegmentNumber - 1,
lastPruneHighWater: opts.StartSegmentNumber - 1,
segmentIncarnation: uuid.NewString(),
reserveHWDeviceOnRestart: reserveHWDeviceOnRestart,
}
@@ -371,19 +385,12 @@ func resolveSoftwareVideoDecode(opts TranscodeOpts) TranscodeOpts {
return opts
}
// normalizeTranscodeOpts resolves source-specific decode safety and the
// configured hardware execution mode in one place. Every FFmpeg entry point
// must pass through this helper so streaming and prepared-file recipes cannot
// disagree about whether a source may use hardware decode or encode.
func normalizeTranscodeOpts(opts TranscodeOpts) TranscodeOpts {
opts = resolveSoftwareVideoDecode(opts)
opts.HWAccel = resolveEffectiveTranscodeHWAccel(opts)
return opts
}
// buildFFmpegArgs constructs the full ffmpeg argument list from TranscodeOpts.
func buildFFmpegArgs(opts TranscodeOpts) []string {
opts = normalizeTranscodeOpts(opts)
opts = resolveSoftwareVideoDecode(opts)
// Resolve "auto" into a concrete accel method once so all downstream
// helpers (appendHWAccelArgs, appendVideoArgs, etc.) see the real value.
opts.HWAccel = resolveEffectiveTranscodeHWAccel(opts)
isVideoCopy := opts.TargetCodecVideo == "copy"
isAudioCopy := opts.TargetCodecAudio == "copy"
@@ -1884,6 +1891,59 @@ func (s *TranscodeSession) GetSegment(name string) (string, error) {
return segPath, nil
}
// SegmentLease binds an opened segment descriptor to the FFmpeg generation it
// came from. Keeping the descriptor open makes it a filesystem-backed lease: a
// concurrent prune may unlink the directory entry, but the response can still
// read the complete file on POSIX filesystems.
type SegmentLease struct {
File *os.File
Info os.FileInfo
Generation uint64
GenerationToken string
}
// Close releases the opened segment descriptor.
func (l *SegmentLease) Close() error {
return l.File.Close()
}
// OpenSegment opens a completed segment for serving and captures its FFmpeg
// generation atomically with the open.
func (s *TranscodeSession) OpenSegment(name string) (*SegmentLease, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.restarting {
return nil, ErrSegmentNotFound
}
clean := filepath.Base(name)
segment, err := os.Open(filepath.Join(s.outputDir, clean))
if err != nil {
if os.IsNotExist(err) {
return nil, ErrSegmentNotFound
}
return nil, fmt.Errorf("open segment: %w", err)
}
info, err := segment.Stat()
if err != nil {
_ = segment.Close()
return nil, fmt.Errorf("stat segment: %w", err)
}
if info.Size() <= 0 {
_ = segment.Close()
return nil, ErrSegmentNotFound
}
if s.segmentIncarnation == "" {
s.segmentIncarnation = uuid.NewString()
}
return &SegmentLease{
File: segment,
Info: info,
Generation: s.segmentGeneration,
GenerationToken: s.segmentGenerationTokenLocked(),
}, nil
}
// Close terminates the ffmpeg process and removes the temporary output directory.
func (s *TranscodeSession) Close() error {
return s.shutdown(true)
@@ -1918,6 +1978,8 @@ func (s *TranscodeSession) shutdown(removeOutput bool) error {
defer s.mu.Unlock()
s.running = false
s.segmentGeneration++
s.segmentPruneRunning = false
// Clean up temporary directory.
if removeOutput && s.outputDir != "" {
@@ -2033,6 +2095,22 @@ func (s *TranscodeSession) restart(
return nil
}
s.restarting = true
s.segmentGeneration++
s.segmentPruneRunning = false
previousRequestedSegment := s.lastRequestedSegment
forwardRestart := startSegment > previousRequestedSegment
s.lastRequestedSegment = startSegment
s.lastCompletedSegment = startSegment - 1
if forwardRestart && s.opts.SegmentRetentionSeconds > 0 {
// Keep the old prune cursor reachable until the replacement process has
// produced a complete back buffer. Otherwise every forward restart leaves
// one retained window permanently behind startSegment.
s.pruneBeforeStart = true
} else {
s.lastPruneFloor = startSegment - 1
s.pruneBeforeStart = false
}
s.lastPruneHighWater = startSegment - 1
cancelCurrent := s.cancel
done := s.done
s.mu.Unlock()
@@ -2129,6 +2207,7 @@ func (s *TranscodeSession) restart(
s.restarting = false
s.stdinPipe = stdinPipe
s.lastRequestedSegment = startSegment
s.lastCompletedSegment = startSegment - 1
s.done = make(chan struct{})
hook := s.restartHook
s.mu.Unlock()
@@ -2192,6 +2271,49 @@ func (s *TranscodeSession) WaitForSegment(name string, timeout time.Duration) (s
}
}
// WaitForOpenSegment is the opened-file counterpart to WaitForSegment. It
// closes the stat-to-open race for HTTP serving while preserving the same
// restart and ffmpeg-exit error contract.
func (s *TranscodeSession) WaitForOpenSegment(name string, timeout time.Duration) (*SegmentLease, error) {
deadline := time.After(timeout)
for {
segment, err := s.OpenSegment(name)
if err == nil {
return segment, nil
}
if !errors.Is(err, ErrSegmentNotFound) {
return nil, err
}
s.mu.Lock()
running := s.running
restarting := s.restarting
waitErr := s.waitErr
s.mu.Unlock()
if restarting {
select {
case <-deadline:
return nil, ErrSegmentNotFound
case <-time.After(100 * time.Millisecond):
continue
}
}
if !running && waitErr != nil {
return nil, fmt.Errorf("%w: %w", ErrTranscodeFailed, waitErr)
}
if !running {
return nil, ErrSegmentNotFound
}
select {
case <-deadline:
return nil, ErrSegmentNotFound
case <-time.After(100 * time.Millisecond):
}
}
}
// RewriteManifestPaths prefixes relative segment references in an HLS manifest
// with segPrefix (e.g. "segment/") and optionally appends rawQuery as a query
// string. This ensures the HLS player's segment requests match server routes
@@ -2439,9 +2561,54 @@ func (s *TranscodeSession) RestartSeekTarget(segNum int) (float64, bool, error)
func (s *TranscodeSession) ReportSegmentDownloaded(segNum int) {
s.mu.Lock()
defer s.mu.Unlock()
s.reportSegmentDownloadedLocked(segNum)
}
// SegmentGeneration identifies the current ffmpeg timeline. Callers that hold
// an open segment across a restart use it to prevent the old response from
// advancing the replacement process's download position.
func (s *TranscodeSession) SegmentGeneration() uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.segmentGeneration
}
// ReportSegmentDownloadedForGeneration records completion only if the served
// file belongs to the current ffmpeg timeline.
func (s *TranscodeSession) ReportSegmentDownloadedForGeneration(segNum int, generation uint64) {
s.mu.Lock()
defer s.mu.Unlock()
if generation != s.segmentGeneration {
return
}
s.reportSegmentDownloadedLocked(segNum)
}
// ReportSegmentDownloadedForGenerationToken records a completion relayed over
// HTTP only when it belongs to this exact session object and FFmpeg timeline.
// The opaque incarnation prevents a delayed proxy acknowledgement from matching
// a reconstructed session whose numeric generation restarted at zero.
func (s *TranscodeSession) ReportSegmentDownloadedForGenerationToken(segNum int, generationToken string) {
s.mu.Lock()
defer s.mu.Unlock()
if generationToken == "" || generationToken != s.segmentGenerationTokenLocked() {
return
}
s.reportSegmentDownloadedLocked(segNum)
}
func (s *TranscodeSession) segmentGenerationTokenLocked() string {
return s.segmentIncarnation + ":" + strconv.FormatUint(s.segmentGeneration, 10)
}
func (s *TranscodeSession) reportSegmentDownloadedLocked(segNum int) {
if segNum > s.lastRequestedSegment {
s.lastRequestedSegment = segNum
}
if segNum > s.lastCompletedSegment {
s.lastCompletedSegment = segNum
}
s.scheduleSegmentPruneLocked()
}
// LastRequestedSegment returns the highest segment number downloaded by the client.
+6 -4
View File
@@ -20,10 +20,11 @@ import (
// internal/playback does not import internal/config (avoiding an import cycle);
// each embedding handler adapts its own config snapshot into this shape.
type TranscodeRuntimeConfig struct {
TranscodeDir string
FFmpegPath string
HWAccel string
HWDevice string
TranscodeDir string
FFmpegPath string
HWAccel string
HWDevice string
SegmentRetentionSeconds int
}
// sessionReconstructor is the SessionManager capability used to re-register a
@@ -579,6 +580,7 @@ func (m *TranscodeManager) doReconstructTranscode(ctx context.Context, sessionID
// operator config change applies to reconstructed sessions too.
opts.HWAccel = cfg.HWAccel
opts.HWDevice = cfg.HWDevice
opts.SegmentRetentionSeconds = cfg.SegmentRetentionSeconds
// Resume near the segment the client is actually requesting. The card records
// the original start; if the client has played past it, spawning ffmpeg at the
@@ -2,11 +2,148 @@ package playback
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
func TestForwardRestartPreservesConfiguredBackBuffer(t *testing.T) {
truePath, err := exec.LookPath("true")
if err != nil {
t.Skipf("`true` not found in PATH: %v", err)
}
dir := t.TempDir()
old := time.Now().Add(-time.Minute)
for _, segment := range []int{3, 40, 64, 65, 69, 70, 100} {
name := filepath.Join(dir, segmentFilename(segment, TranscodeOpts{}))
if err := os.WriteFile(name, []byte("segment"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(name, old, old); err != nil {
t.Fatal(err)
}
}
session := &TranscodeSession{
outputDir: dir,
lastRequestedSegment: 70,
lastCompletedSegment: 70,
lastPruneFloor: -1,
opts: TranscodeOpts{
OutputDir: dir,
TargetCodecVideo: "h264",
SegmentDuration: 4,
SegmentRetentionSeconds: 20,
StartSegmentNumber: 0,
FFmpegPath: truePath,
},
}
if err := session.Restart(context.Background(), 400, 100); err != nil {
t.Fatalf("Restart: %v", err)
}
// Restart itself has no downstream-completion proof for the replacement
// generation, so it must not unlink even old files synchronously.
for _, segment := range []int{3, 40, 64, 65, 69, 70, 100} {
path := filepath.Join(dir, segmentFilename(segment, TranscodeOpts{}))
if _, err := os.Stat(path); err != nil {
t.Errorf("segment %d was removed during restart: %v", segment, err)
}
}
// Once the replacement generation has produced a complete back buffer,
// the preserved range must become eligible again rather than leaking one
// full window after every forward seek.
session.ReportSegmentDownloaded(105)
waitForPrunerFileMissing(t, filepath.Join(dir, segmentFilename(70, TranscodeOpts{})))
for _, segment := range []int{3, 40, 64, 65, 69, 70} {
path := filepath.Join(dir, segmentFilename(segment, TranscodeOpts{}))
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("expired pre-restart segment %d survived completed-download pruning: %v", segment, err)
}
}
if _, err := os.Stat(filepath.Join(dir, segmentFilename(100, TranscodeOpts{}))); err != nil {
t.Fatalf("replacement startup segment was removed: %v", err)
}
}
func TestForwardRestartWithoutCompletedDownloadPreservesFreshFiles(t *testing.T) {
truePath, err := exec.LookPath("true")
if err != nil {
t.Skipf("`true` not found in PATH: %v", err)
}
dir := t.TempDir()
for _, segment := range []int{3, 40, 70} {
if err := os.WriteFile(filepath.Join(dir, segmentFilename(segment, TranscodeOpts{})), []byte("segment"), 0o600); err != nil {
t.Fatal(err)
}
}
session := &TranscodeSession{
outputDir: dir,
lastRequestedSegment: 70,
lastCompletedSegment: -1,
lastPruneFloor: -1,
opts: TranscodeOpts{
OutputDir: dir,
TargetCodecVideo: "h264",
SegmentDuration: 4,
SegmentRetentionSeconds: 20,
StartSegmentNumber: 0,
FFmpegPath: truePath,
},
}
if err := session.Restart(context.Background(), 400, 100); err != nil {
t.Fatalf("Restart: %v", err)
}
time.Sleep(20 * time.Millisecond)
for _, segment := range []int{3, 40, 70} {
if _, err := os.Stat(filepath.Join(dir, segmentFilename(segment, TranscodeOpts{}))); err != nil {
t.Errorf("fresh segment %d was removed without a completed download: %v", segment, err)
}
}
}
func TestForwardRestartKeepsSegmentsWhenRetentionDisabled(t *testing.T) {
truePath, err := exec.LookPath("true")
if err != nil {
t.Skipf("`true` not found in PATH: %v", err)
}
dir := t.TempDir()
for _, segment := range []int{3, 40, 70} {
name := filepath.Join(dir, segmentFilename(segment, TranscodeOpts{}))
if err := os.WriteFile(name, []byte("segment"), 0o600); err != nil {
t.Fatal(err)
}
}
session := &TranscodeSession{
outputDir: dir,
lastRequestedSegment: 70,
lastCompletedSegment: 70,
opts: TranscodeOpts{
OutputDir: dir,
TargetCodecVideo: "h264",
SegmentDuration: 4,
StartSegmentNumber: 0,
FFmpegPath: truePath,
},
}
if err := session.Restart(context.Background(), 400, 100); err != nil {
t.Fatalf("Restart: %v", err)
}
for _, segment := range []int{3, 40, 70} {
path := filepath.Join(dir, segmentFilename(segment, TranscodeOpts{}))
if _, err := os.Stat(path); err != nil {
t.Errorf("disabled retention removed segment %d: %v", segment, err)
}
}
}
// TestSegmentRecoveryDecisionWaitsWhileRestarting covers half of issue #243's
// seek-freeze: while a restart is already in flight, a concurrent segment
// request must WAIT for the restart's output rather than trigger another
-281
View File
@@ -1,281 +0,0 @@
package proxy
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/nodeconfig"
"github.com/Silo-Server/silo-server/internal/streamtoken"
)
type recordingArtifactMissReporter struct {
artifactID string
originNodeURL string
originArtifactID string
}
func (r *recordingArtifactMissReporter) ReportRemoteArtifactMissing(_ context.Context, artifactID, originNodeURL, originArtifactID string) error {
r.artifactID = artifactID
r.originNodeURL = originNodeURL
r.originArtifactID = originArtifactID
return nil
}
func newDownloadProxyServer(t *testing.T, secret string) *Server {
t.Helper()
w := nodeconfig.NewWatcher(nil, nil, nil, nodeconfig.BootstrapOverrides{})
cfg := &config.Config{}
cfg.Auth.JWTSecret = secret
w.SetConfigForTest(cfg)
return NewServer(w, nil)
}
func TestProxyDownloadServesAuthorizedRange(t *testing.T) {
const secret = "download-proxy-secret"
dir := t.TempDir()
path := filepath.Join(dir, "prepared movie.mp4")
if err := os.WriteFile(path, []byte("0123456789"), 0o600); err != nil {
t.Fatal(err)
}
token, err := streamtoken.Sign(streamtoken.Claims{
SessionID: "download-1",
MediaPath: path,
PlayMethod: streamtoken.PlayMethodDownload,
UserID: 7,
ProfileID: "profile-1",
MediaFileID: 42,
}, secret, time.Minute)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/downloads/file/"+token, nil)
req.Header.Set("Range", "bytes=2-5")
rr := httptest.NewRecorder()
newDownloadProxyServer(t, secret).Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusPartialContent {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if rr.Body.String() != "2345" {
t.Fatalf("body = %q, want %q", rr.Body.String(), "2345")
}
if got := rr.Header().Get("Content-Disposition"); !strings.Contains(got, "prepared movie.mp4") {
t.Fatalf("Content-Disposition = %q", got)
}
}
func TestProxyDownloadRelaysNodeLocalArtifactRange(t *testing.T) {
const secret = "download-proxy-secret"
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/downloads/artifacts/artifact-1" {
t.Errorf("origin path = %q", r.URL.Path)
http.Error(w, "unexpected path", http.StatusBadRequest)
return
}
if auth := r.Header.Get("Authorization"); auth != "Bearer "+secret {
t.Errorf("origin Authorization = %q", auth)
http.Error(w, "unexpected authorization", http.StatusBadRequest)
return
}
if got := r.Header.Get("Range"); got != "bytes=2-5" {
t.Errorf("origin Range = %q", got)
http.Error(w, "unexpected range", http.StatusBadRequest)
return
}
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Content-Disposition", `attachment; filename="artifact-1.mp4"`)
w.Header().Set("Content-Length", "4")
w.Header().Set("Content-Range", "bytes 2-5/10")
w.Header().Set("Content-Type", "video/mp4")
w.WriteHeader(http.StatusPartialContent)
_, _ = io.WriteString(w, "2345")
}))
defer origin.Close()
token, err := streamtoken.Sign(streamtoken.Claims{
SessionID: "download-remote-1",
PlayMethod: streamtoken.PlayMethodDownload,
TranscodeNode: origin.URL,
DownloadArtifactID: "artifact-1",
DownloadFilename: "Movie Final.mp4",
UserID: 7,
}, secret, time.Minute)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/downloads/file/"+token, nil)
req.Header.Set("Range", "bytes=2-5")
rr := httptest.NewRecorder()
newDownloadProxyServer(t, secret).Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusPartialContent || rr.Body.String() != "2345" {
t.Fatalf("status = %d body = %q", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Content-Range"); got != "bytes 2-5/10" {
t.Fatalf("Content-Range = %q", got)
}
if got := rr.Header().Get("Content-Disposition"); !strings.Contains(got, "Movie Final.mp4") || strings.Contains(got, "artifact-1.mp4") {
t.Fatalf("Content-Disposition = %q", got)
}
}
func TestProxyDownloadCORSAllowsConditionalHeaders(t *testing.T) {
req := httptest.NewRequest(http.MethodOptions, "/downloads/file/token", nil)
req.Header.Set("Origin", "https://web.example")
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
req.Header.Set("Access-Control-Request-Headers", "Range, If-Range, If-None-Match, If-Modified-Since, If-Match, If-Unmodified-Since")
rr := httptest.NewRecorder()
newDownloadProxyServer(t, "download-proxy-secret").Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
allowed := strings.ToLower(rr.Header().Get("Access-Control-Allow-Headers"))
for _, name := range []string{"range", "if-range", "if-none-match", "if-modified-since", "if-match", "if-unmodified-since"} {
if !strings.Contains(allowed, name) {
t.Errorf("Access-Control-Allow-Headers = %q, missing %s", allowed, name)
}
}
}
func TestProxyDownloadRelaysNodeLocalArtifactPreconditionFailure(t *testing.T) {
const secret = "download-proxy-secret"
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("If-Match"); got != `"old-etag"` {
t.Errorf("origin If-Match = %q", got)
http.Error(w, "unexpected condition", http.StatusBadRequest)
return
}
w.Header().Set("ETag", `"new-etag"`)
w.WriteHeader(http.StatusPreconditionFailed)
}))
defer origin.Close()
token, err := streamtoken.Sign(streamtoken.Claims{
SessionID: "download-remote-412",
PlayMethod: streamtoken.PlayMethodDownload,
TranscodeNode: origin.URL,
DownloadArtifactID: "artifact-1",
}, secret, time.Minute)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/downloads/file/"+token, nil)
req.Header.Set("If-Match", `"old-etag"`)
rr := httptest.NewRecorder()
newDownloadProxyServer(t, secret).Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusPreconditionFailed || rr.Header().Get("ETag") != `"new-etag"` {
t.Fatalf("status=%d etag=%q body=%q", rr.Code, rr.Header().Get("ETag"), rr.Body.String())
}
}
func TestProxyDownloadReportsNodeLocalArtifactMissing(t *testing.T) {
const secret = "download-proxy-secret"
origin := httptest.NewServer(http.NotFoundHandler())
defer origin.Close()
token, err := streamtoken.Sign(streamtoken.Claims{
SessionID: "download-remote-missing",
PlayMethod: streamtoken.PlayMethodDownload,
TranscodeNode: origin.URL,
DownloadArtifactID: "artifact-opaque",
DownloadArtifactRowID: "artifact-row",
}, secret, time.Minute)
if err != nil {
t.Fatal(err)
}
reporter := &recordingArtifactMissReporter{}
server := newDownloadProxyServer(t, secret)
server.SetRemoteArtifactMissReporter(reporter)
rr := httptest.NewRecorder()
server.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/downloads/file/"+token, nil))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if reporter.artifactID != "artifact-row" || reporter.originNodeURL != origin.URL || reporter.originArtifactID != "artifact-opaque" {
t.Fatalf("missing report = %+v", reporter)
}
}
func TestProxyDownloadRejectsRemoteArtifactWithInvalidOpaqueID(t *testing.T) {
const secret = "download-proxy-secret"
token, err := streamtoken.Sign(streamtoken.Claims{
SessionID: "download-remote-invalid",
PlayMethod: streamtoken.PlayMethodDownload,
TranscodeNode: "http://origin.invalid",
DownloadArtifactID: "../escape",
}, secret, time.Minute)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
newDownloadProxyServer(t, secret).Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/downloads/file/"+token, nil))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestProxyDownloadRejectsPlaybackToken(t *testing.T) {
const secret = "download-proxy-secret"
token, err := streamtoken.Sign(streamtoken.Claims{
SessionID: "playback-1",
MediaPath: "/media/movie.mkv",
PlayMethod: "direct",
}, secret, time.Minute)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/downloads/file/"+token, nil)
rr := httptest.NewRecorder()
newDownloadProxyServer(t, secret).Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
body, _ := io.ReadAll(rr.Result().Body)
t.Fatalf("status = %d, body = %s", rr.Code, body)
}
}
func TestProxyDownloadRejectsExpiredToken(t *testing.T) {
const secret = "download-proxy-secret"
token, err := streamtoken.Sign(streamtoken.Claims{
SessionID: "download-expired",
MediaPath: "/media/movie.mkv",
PlayMethod: streamtoken.PlayMethodDownload,
}, secret, -time.Minute)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodHead, "/downloads/file/"+token, nil)
rr := httptest.NewRecorder()
newDownloadProxyServer(t, secret).Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestProxyDownloadReturnsNotFoundForMissingFile(t *testing.T) {
const secret = "download-proxy-secret"
token, err := streamtoken.Sign(streamtoken.Claims{
SessionID: "download-missing",
MediaPath: filepath.Join(t.TempDir(), "gone.mp4"),
PlayMethod: streamtoken.PlayMethodDownload,
}, secret, time.Minute)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/downloads/file/"+token, nil)
rr := httptest.NewRecorder()
newDownloadProxyServer(t, secret).Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
+133
View File
@@ -0,0 +1,133 @@
package proxy
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/nodeconfig"
"github.com/Silo-Server/silo-server/internal/streamtoken"
"github.com/Silo-Server/silo-server/internal/transcodeproxy"
)
func TestTranscodeProxyAcknowledgesOnlyFullDownstreamResponse(t *testing.T) {
const (
body = "complete segment"
generation = "incarnation:17"
)
tests := []struct {
name string
rangeHeader string
wantStatus int
wantAck int32
}{
{name: "ordinary get", wantStatus: http.StatusOK, wantAck: 1},
{name: "whole-file range", rangeHeader: "bytes=0-", wantStatus: http.StatusPartialContent, wantAck: 1},
{name: "partial range", rangeHeader: "bytes=1-", wantStatus: http.StatusPartialContent},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var acknowledgements atomic.Int32
seenRange := make(chan string, 1)
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if r.Header.Get(transcodeproxy.RequestHeader) != "1" {
t.Error("segment request omitted proxy marker")
}
seenRange <- r.Header.Get("Range")
w.Header().Set(transcodeproxy.GenerationHeader, generation)
http.ServeContent(w, r, "seg_00007.ts", time.Time{}, strings.NewReader(body))
case http.MethodPost:
if r.Header.Get(transcodeproxy.GenerationHeader) != generation {
t.Errorf("ack generation = %q, want %q", r.Header.Get(transcodeproxy.GenerationHeader), generation)
}
acknowledgements.Add(1)
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
}
}))
defer node.Close()
server := newCompletionTestProxy(node.Client())
claims := &streamtoken.Claims{SessionID: "public", TranscodeNode: node.URL}
req := httptest.NewRequest(http.MethodGet, "/stream/transcode/token/segment/seg_00007.ts", nil)
req.Header.Set("Range", tt.rangeHeader)
rr := httptest.NewRecorder()
server.proxyToTranscodeNode(rr, req, claims, "/transcode/remote/segment/seg_00007.ts")
if rr.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d; body = %q", rr.Code, tt.wantStatus, rr.Body.String())
}
if got := <-seenRange; got != tt.rangeHeader {
t.Fatalf("forwarded Range = %q, want %q", got, tt.rangeHeader)
}
if got := rr.Header().Get(transcodeproxy.GenerationHeader); got != "" {
t.Fatalf("internal generation leaked downstream: %q", got)
}
if got := acknowledgements.Load(); got != tt.wantAck {
t.Fatalf("acknowledgements = %d, want %d", got, tt.wantAck)
}
})
}
}
func TestTranscodeProxyDoesNotAcknowledgeFailedDownstreamWrite(t *testing.T) {
var acknowledgements atomic.Int32
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
acknowledgements.Add(1)
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set(transcodeproxy.GenerationHeader, "incarnation:21")
http.ServeContent(w, r, "seg_00007.ts", time.Time{}, strings.NewReader("complete segment"))
}))
defer node.Close()
server := newCompletionTestProxy(node.Client())
claims := &streamtoken.Claims{SessionID: "public", TranscodeNode: node.URL}
req := httptest.NewRequest(http.MethodGet, "/stream/transcode/token/segment/seg_00007.ts", nil)
w := &failingCompletionResponseWriter{header: make(http.Header), remaining: 5}
server.proxyToTranscodeNode(w, req, claims, "/transcode/remote/segment/seg_00007.ts")
if got := acknowledgements.Load(); got != 0 {
t.Fatalf("failed downstream transfer produced %d acknowledgement(s)", got)
}
}
func newCompletionTestProxy(client *http.Client) *Server {
watcher := nodeconfig.NewWatcher(nil, nil, nil, nodeconfig.BootstrapOverrides{})
cfg := &config.Config{}
cfg.Auth.JWTSecret = "proxy-test-secret"
watcher.SetConfigForTest(cfg)
server := NewServer(watcher, nil)
server.httpClient = client
return server
}
type failingCompletionResponseWriter struct {
header http.Header
status int
remaining int
}
func (w *failingCompletionResponseWriter) Header() http.Header { return w.header }
func (w *failingCompletionResponseWriter) WriteHeader(status int) { w.status = status }
func (w *failingCompletionResponseWriter) Write(p []byte) (int, error) {
if w.remaining <= 0 {
return 0, io.ErrClosedPipe
}
n := min(len(p), w.remaining)
w.remaining -= n
return n, io.ErrClosedPipe
}
+30 -171
View File
@@ -1,52 +1,36 @@
package proxy
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/Silo-Server/silo-server/internal/downloadprepare"
"github.com/Silo-Server/silo-server/internal/downloads"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/nodeconfig"
"github.com/Silo-Server/silo-server/internal/nodesessions"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/streamtoken"
"github.com/Silo-Server/silo-server/internal/transcodeproxy"
)
// Server is the HTTP handler for proxy mode.
type Server struct {
watcher *nodeconfig.Watcher
tracker *nodesessions.Tracker
httpClient *http.Client
artifactMissReporter remoteArtifactMissReporter
egress *egressMeter
watcher *nodeconfig.Watcher
tracker *nodesessions.Tracker
httpClient *http.Client
egress *egressMeter
// subCache stores full-track PGS (.sup) extracts under the transcode dir
// so repeat selections skip the whole-file ffmpeg demux.
subCache *playback.SubtitleCache
// Download limits are node-local once egress is delegated. Rebuild the
// manager when hot-reloaded settings change so existing transfers retain
// their original limiter while new transfers use the new values.
downloadBandwidthMu sync.Mutex
downloadBandwidth *downloads.BandwidthManager
downloadServerBPS int64
downloadUserBPS int64
}
type remoteArtifactMissReporter interface {
ReportRemoteArtifactMissing(ctx context.Context, artifactID, originNodeURL, originArtifactID string) error
}
// NewServer creates a new proxy server backed by a config watcher and session
@@ -57,26 +41,14 @@ func NewServer(watcher *nodeconfig.Watcher, tracker *nodesessions.Tracker) *Serv
tracker: tracker,
// No overall timeout — stream bodies are long-lived. Hung nodes are
// bounded by the transport's response-header timeout instead.
httpClient: &http.Client{
Transport: newStreamTransport(),
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
},
egress: newEgressMeter(),
httpClient: &http.Client{Transport: newStreamTransport()},
egress: newEgressMeter(),
subCache: playback.NewSubtitleCache(func() string {
return watcher.Config().Playback.TranscodeDir
}),
}
}
// SetRemoteArtifactMissReporter wires the authoritative database transition
// used when an origin returns 404 after the API's proxy preflight. It must be
// called during construction, before the server begins handling requests.
func (s *Server) SetRemoteArtifactMissReporter(reporter remoteArtifactMissReporter) {
s.artifactMissReporter = reporter
}
// newStreamTransport tunes the proxy→transcode-node connection pool. Many
// concurrent viewers fan their segment fetches through one proxy→node pair,
// and Go's default of 2 idle connections per host causes constant connection
@@ -99,15 +71,13 @@ func (s *Server) Handler() http.Handler {
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "HEAD", "OPTIONS"},
AllowedHeaders: []string{
"Accept", "Authorization", "Content-Type", "Range",
"If-Match", "If-Modified-Since", "If-None-Match", "If-Range", "If-Unmodified-Since",
},
MaxAge: 86400,
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "Range"},
MaxAge: 86400,
}))
r.Get("/api/v1/health", s.handleHealth)
r.Group(func(r chi.Router) {
// Streaming and download bytes count toward the node's measured egress.
// Everything under /stream counts toward the node's measured
// egress bandwidth.
r.Use(s.meterEgress)
r.Head("/stream/direct/{token}", s.handleDirectPlay)
r.Get("/stream/direct/{token}", s.handleDirectPlay)
@@ -118,8 +88,6 @@ func (s *Server) Handler() http.Handler {
r.Get("/stream/transcode/{token}/segment/{name}", s.handleTranscodeSegment)
r.Get("/stream/subtitles/{token}/{track}/fonts", s.handleSubtitleFonts)
r.Get("/stream/subtitles/{token}/{track}", s.handleSubtitle)
r.Head("/downloads/file/{token}", s.handleDownloadFile)
r.Get("/downloads/file/{token}", s.handleDownloadFile)
})
// Admin routes — bearer-auth protected.
@@ -188,126 +156,6 @@ func (s *Server) handleDirectPlay(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, claims.MediaPath)
}
func (s *Server) handleDownloadFile(w http.ResponseWriter, r *http.Request) {
claims := s.verifyToken(w, r)
if claims == nil {
return
}
remoteArtifact := claims.DownloadArtifactID != "" && strings.TrimSpace(claims.TranscodeNode) != ""
if claims.PlayMethod != streamtoken.PlayMethodDownload || (strings.TrimSpace(claims.MediaPath) == "" && !remoteArtifact) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// HEAD is a capability/path preflight, not an active transfer. Counting it
// would briefly consume job capacity and could make a health report retire
// the API's reservation before the client starts its GET.
if s.tracker != nil && r.Method != http.MethodHead {
info := sessionInfo(s.tracker, claims, "download")
s.tracker.Track(r.Context(), info)
defer s.tracker.Remove(context.WithoutCancel(r.Context()), claims.SessionID)
}
if remoteArtifact {
s.relayDownloadArtifact(w, r, claims)
return
}
f, err := os.Open(claims.MediaPath)
if err != nil {
http.NotFound(w, r)
return
}
defer func() { _ = f.Close() }()
stat, err := f.Stat()
if err != nil {
http.Error(w, "download unavailable", http.StatusInternalServerError)
return
}
filename := filepath.Base(claims.MediaPath)
if disposition := mime.FormatMediaType("attachment", map[string]string{"filename": filename}); disposition != "" {
w.Header().Set("Content-Disposition", disposition)
}
w.Header().Set("Content-Type", playback.MimeFromExtension(claims.MediaPath))
reader := io.ReadSeeker(f)
if bandwidth := s.downloadBandwidthManager(); bandwidth != nil {
reader = bandwidth.ThrottledReader(r.Context(), f, claims.UserID)
}
http.ServeContent(w, r, stat.Name(), stat.ModTime(), reader)
}
func (s *Server) relayDownloadArtifact(w http.ResponseWriter, r *http.Request, claims *streamtoken.Claims) {
if !downloadprepare.ValidArtifactID(claims.DownloadArtifactID) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
cfg := s.watcher.Config()
if cfg == nil || strings.TrimSpace(cfg.Auth.JWTSecret) == "" {
http.Error(w, "download unavailable", http.StatusServiceUnavailable)
return
}
client := downloadprepare.HTTPPreparer{Client: s.httpClient}
resp, err := client.Open(r.Context(), claims.TranscodeNode, cfg.Auth.JWTSecret, claims.DownloadArtifactID, r.Method, r.Header)
if err != nil {
slog.WarnContext(r.Context(), "download artifact relay failed", "component", "proxy", "artifact_id", claims.DownloadArtifactID, "node", claims.TranscodeNode, "error", err)
http.Error(w, "download unavailable", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
if s.artifactMissReporter != nil && strings.TrimSpace(claims.DownloadArtifactRowID) != "" {
reportCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
err := s.artifactMissReporter.ReportRemoteArtifactMissing(
reportCtx, claims.DownloadArtifactRowID, claims.TranscodeNode, claims.DownloadArtifactID,
)
cancel()
if err != nil {
slog.WarnContext(r.Context(), "report missing remote download artifact", "component", "proxy", "artifact_id", claims.DownloadArtifactRowID, "error", err)
}
}
http.NotFound(w, r)
return
}
if !downloadprepare.RelayStatusAllowed(resp.StatusCode) {
http.Error(w, "download unavailable", http.StatusBadGateway)
return
}
downloadprepare.CopyResponseHeaders(w.Header(), resp.Header)
if filename := filepath.Base(strings.TrimSpace(claims.DownloadFilename)); filename != "" && filename != "." {
if disposition := mime.FormatMediaType("attachment", map[string]string{"filename": filename}); disposition != "" {
w.Header().Set("Content-Disposition", disposition)
}
}
w.WriteHeader(resp.StatusCode)
if r.Method == http.MethodHead {
return
}
var reader io.Reader = resp.Body
if bandwidth := s.downloadBandwidthManager(); bandwidth != nil {
reader = bandwidth.ThrottledStreamReader(r.Context(), reader, claims.UserID)
}
if _, err := io.Copy(w, reader); err != nil && r.Context().Err() == nil {
slog.WarnContext(r.Context(), "download artifact relay interrupted", "component", "proxy", "artifact_id", claims.DownloadArtifactID, "error", err)
}
}
func (s *Server) downloadBandwidthManager() *downloads.BandwidthManager {
cfg := s.watcher.Config()
if cfg == nil {
return nil
}
serverBPS := cfg.Download.ServerBandwidthBPS
userBPS := cfg.Download.UserBandwidthBPS
s.downloadBandwidthMu.Lock()
defer s.downloadBandwidthMu.Unlock()
if s.downloadBandwidth == nil || serverBPS != s.downloadServerBPS || userBPS != s.downloadUserBPS {
s.downloadBandwidth = downloads.NewBandwidthManager(serverBPS, userBPS)
s.downloadServerBPS = serverBPS
s.downloadUserBPS = userBPS
}
return s.downloadBandwidth
}
func (s *Server) handleRemux(w http.ResponseWriter, r *http.Request) {
claims := s.verifyToken(w, r)
if claims == nil {
@@ -498,6 +346,9 @@ func (s *Server) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, cl
}
targetURL := claims.TranscodeNode + path
isSegmentRoute := strings.Contains(path, "/segment/")
_, segmentParseErr := playback.ParseSegmentNumber(filepath.Base(path))
isMediaSegment := segmentParseErr == nil
if rawQuery := r.URL.RawQuery; rawQuery != "" {
targetURL = fmt.Sprintf("%s?%s", targetURL, rawQuery)
}
@@ -508,6 +359,9 @@ func (s *Server) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, cl
return
}
req.Header.Set("Authorization", "Bearer "+cfg.Auth.JWTSecret)
if isSegmentRoute {
transcodeproxy.PrepareRequest(req, r)
}
// Forward the verified stream token so the transcode node can self-reconstruct
// a lost session after its OWN restart: the token carries the full byte-affecting
// recipe, so the node can re-spawn ffmpeg seeked to the requested segment instead
@@ -523,16 +377,21 @@ func (s *Server) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, cl
http.Error(w, "transcode node unavailable", http.StatusBadGateway)
return
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
// Copy response headers
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
generation := resp.Header.Get(transcodeproxy.GenerationHeader)
transcodeproxy.CopyResponseHeaders(w.Header(), resp.Header)
sw := httpstream.NewRollingDeadlineWriter(w)
sw.WriteHeader(resp.StatusCode)
if _, copyErr := io.Copy(sw, resp.Body); copyErr != nil {
return
}
if isMediaSegment && generation != "" && r.Method == http.MethodGet &&
sw.CompletedFullResponse(transcodeproxy.FullRepresentationSize(resp)) {
if ackErr := transcodeproxy.Acknowledge(r.Context(), s.httpClient, claims.TranscodeNode+path, cfg.Auth.JWTSecret, generation); ackErr != nil {
slog.WarnContext(r.Context(), "acknowledge transcode segment completion", "component", "proxy", "error", ackErr, "playback_session_id", claims.SessionID)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func (s *Server) handleForceReload(w http.ResponseWriter, r *http.Request) {
-11
View File
@@ -105,7 +105,6 @@ type ffprobeChapter struct {
type ffprobeSideData struct {
SideDataType string `json:"side_data_type"`
DVProfile int `json:"dv_profile"`
DVLevel int `json:"dv_level"`
DVBlPresent int `json:"dv_bl_present"`
DVElPresent int `json:"dv_el_present"`
DVBLCompatID int `json:"dv_bl_signal_compatibility_id"`
@@ -207,7 +206,6 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData {
Codec: s.CodecName,
DolbyVision: dolbyVisionProfile(s.SideDataList),
DVProfile: dvProfile,
DVLevel: dolbyVisionLevel(s.SideDataList),
DVBLCompatID: dolbyVisionBLCompatID(s.SideDataList),
DVELPresent: dolbyVisionELPresent(s.SideDataList),
DVEnhancementLayer: dolbyVisionEnhancementLayer(dolbyVisionELPresent(s.SideDataList)),
@@ -795,15 +793,6 @@ func dolbyVisionProfileNumber(sideData []ffprobeSideData) int {
return 0
}
func dolbyVisionLevel(sideData []ffprobeSideData) int {
for _, data := range sideData {
if strings.EqualFold(data.SideDataType, "DOVI configuration record") && data.DVLevel > 0 {
return data.DVLevel
}
}
return 0
}
func dolbyVisionBLCompatID(sideData []ffprobeSideData) int {
for _, data := range sideData {
if strings.EqualFold(data.SideDataType, "DOVI configuration record") && data.DVBLCompatID > 0 {
@@ -47,7 +47,6 @@ func TestConvertProbeDataVideoRangeTypes(t *testing.T) {
wantRange string
wantRangeType string
wantProfile int
wantLevel int
wantCompatID int
wantEL bool
wantHDR10Plus bool
@@ -93,14 +92,12 @@ func TestConvertProbeDataVideoRangeTypes(t *testing.T) {
SideDataList: []ffprobeSideData{{
SideDataType: "DOVI configuration record",
DVProfile: 8,
DVLevel: 6,
DVBLCompatID: 1,
}},
},
wantRange: "DolbyVision",
wantRangeType: "DOVIWithHDR10",
wantProfile: 8,
wantLevel: 6,
wantCompatID: 1,
},
{
@@ -179,9 +176,6 @@ func TestConvertProbeDataVideoRangeTypes(t *testing.T) {
if track.DVProfile != tt.wantProfile {
t.Fatalf("DVProfile = %d, want %d", track.DVProfile, tt.wantProfile)
}
if track.DVLevel != tt.wantLevel {
t.Fatalf("DVLevel = %d, want %d", track.DVLevel, tt.wantLevel)
}
if track.DVBLCompatID != tt.wantCompatID {
t.Fatalf("DVBLCompatID = %d, want %d", track.DVBLCompatID, tt.wantCompatID)
}
-1
View File
@@ -3661,7 +3661,6 @@ func applyProbeData(mf *models.MediaFile, probe *ProbeData, probeSource string)
Codec: vt.Codec,
DolbyVision: vt.DolbyVision,
DVProfile: vt.DVProfile,
DVLevel: vt.DVLevel,
DVBLCompatID: vt.DVBLCompatID,
DVELPresent: vt.DVELPresent,
DVEnhancementLayer: vt.DVEnhancementLayer,
-1
View File
@@ -59,7 +59,6 @@ type VideoTrackInfo struct {
Codec string
DolbyVision string
DVProfile int
DVLevel int
DVBLCompatID int
DVELPresent bool
DVEnhancementLayer string
-16
View File
@@ -7,12 +7,6 @@ import (
"github.com/golang-jwt/jwt/v5"
)
const (
// PlayMethodDownload identifies a token minted only after the API has
// authorized a file download. Proxy download routes reject playback tokens.
PlayMethodDownload = "download"
)
// Claims holds everything a stateless proxy or transcode node needs
// to serve a streaming session without database access.
//
@@ -49,16 +43,6 @@ type Claims struct {
UserID int `json:"uid,omitempty"`
ProfileID string `json:"pid,omitempty"`
MediaFileID int `json:"mfid,omitempty"`
// DownloadArtifactID is an opaque transcode-node artifact handle. For
// download tokens TranscodeNode is its authenticated origin; MediaPath stays
// empty so node-local filesystem paths never leave the owning node.
DownloadArtifactID string `json:"daid,omitempty"`
// DownloadArtifactRowID identifies the authoritative database row so a
// proxy can fence and requeue a signed remote locator that returns 404.
DownloadArtifactRowID string `json:"darid,omitempty"`
// DownloadFilename is the client-facing attachment name. Remote artifact
// ids are internal attempt handles and must never become saved filenames.
DownloadFilename string `json:"dfn,omitempty"`
// Reconstruction recipe — the byte-affecting encode parameters, mirroring the
// former playback.RecipeCard. Zero for direct/remux tokens, which reconstruct
-18
View File
@@ -52,24 +52,6 @@ func (m *Manager) RemoveProvider(name string) {
delete(m.providers, name)
}
// ProviderNames returns the names of every currently registered provider,
// sorted for a stable response. Empty when none are configured.
//
// The slice is always non-nil so callers can hand it straight to a JSON
// response without it marshalling as null — clients feature-detecting subtitle
// search read an empty list as "no providers here", not as a missing field.
func (m *Manager) ProviderNames() []string {
m.mu.RLock()
names := make([]string, 0, len(m.providers))
for name := range m.providers {
names = append(names, name)
}
m.mu.RUnlock()
sort.Strings(names)
return names
}
// Search fans out to all registered providers concurrently.
func (m *Manager) Search(ctx context.Context, req SearchRequest) (*SearchResponse, error) {
m.mu.RLock()
-47
View File
@@ -116,53 +116,6 @@ func (m *mockS3Client) DeleteObject(_ context.Context, _, key string) error {
return nil
}
type stubProvider struct {
name string
}
func (s stubProvider) Name() string { return s.name }
func (s stubProvider) Search(context.Context, SearchRequest) ([]SubtitleResult, error) {
return nil, nil
}
func (s stubProvider) Download(context.Context, string) ([]byte, SubtitleFormat, error) {
return nil, FormatSRT, nil
}
func TestManagerProviderNames(t *testing.T) {
manager := NewManager(newMockSubtitleRepo(), newMockS3Client(), "test-bucket")
names := manager.ProviderNames()
if names == nil {
t.Fatal("ProviderNames() = nil, want non-nil empty slice")
}
if len(names) != 0 {
t.Fatalf("ProviderNames() = %v, want empty", names)
}
manager.RegisterProvider(stubProvider{name: "subdl"})
manager.RegisterProvider(stubProvider{name: "opensubtitles"})
manager.RegisterProvider(stubProvider{name: "subsource"})
want := []string{"opensubtitles", "subdl", "subsource"}
names = manager.ProviderNames()
if len(names) != len(want) {
t.Fatalf("ProviderNames() = %v, want %v", names, want)
}
for i, name := range want {
if names[i] != name {
t.Fatalf("ProviderNames() = %v, want %v (sorted)", names, want)
}
}
manager.RemoveProvider("subdl")
names = manager.ProviderNames()
if len(names) != 2 || names[0] != "opensubtitles" || names[1] != "subsource" {
t.Fatalf("ProviderNames() after removal = %v, want [opensubtitles subsource]", names)
}
}
func TestManagerUploadStoresSubtitle(t *testing.T) {
repo := newMockSubtitleRepo()
s3 := newMockS3Client()
-106
View File
@@ -1,106 +0,0 @@
package transcodenode
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
)
// InputPathAuthorizer approves a local media input before a node passes it to
// FFmpeg. Implementations must reject protocol URLs and paths outside managed
// library roots.
type InputPathAuthorizer interface {
Allowed(ctx context.Context, path string) (bool, error)
}
type mediaFolderSource interface {
List(ctx context.Context) ([]*models.MediaFolder, error)
}
// MediaRootAuthorizer resolves the deployment's current library roots and
// permits only existing, absolute filesystem paths contained by those roots.
type MediaRootAuthorizer struct {
folders mediaFolderSource
}
// NewMediaRootAuthorizer creates an FFmpeg input authorizer backed by the
// authoritative media-folder repository.
func NewMediaRootAuthorizer(folders mediaFolderSource) *MediaRootAuthorizer {
return &MediaRootAuthorizer{folders: folders}
}
// Allowed reports whether path resolves inside one of the configured media
// roots. Symlinks are resolved on both sides so a link cannot escape a root.
func (a *MediaRootAuthorizer) Allowed(ctx context.Context, path string) (bool, error) {
if a == nil || a.folders == nil || !plainAbsolutePath(path) {
return false, nil
}
folders, err := a.folders.List(ctx)
if err != nil {
return false, err
}
for _, folder := range folders {
if folder == nil {
continue
}
for _, root := range folder.Paths {
if existingPathWithinRoot(root, path) {
return true, nil
}
}
}
return false, nil
}
func plainAbsolutePath(path string) bool {
path = strings.TrimSpace(path)
return path != "" && !strings.ContainsRune(path, '\x00') && filepath.IsAbs(path)
}
func existingPathWithinRoot(root, target string) bool {
if !plainAbsolutePath(root) || !plainAbsolutePath(target) {
return false
}
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return false
}
resolvedTarget, err := filepath.EvalSymlinks(filepath.Clean(target))
if err != nil {
return false
}
info, err := os.Stat(resolvedTarget)
if err != nil || !info.Mode().IsRegular() {
return false
}
return resolvedPathContained(resolvedRoot, resolvedTarget)
}
// pathWithinRoot validates a not-yet-created output by resolving the root and
// target parent. The caller creates the basename only after this check.
func pathWithinRoot(root, target string) bool {
if !plainAbsolutePath(root) || !plainAbsolutePath(target) {
return false
}
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
if err != nil {
return false
}
resolvedParent, err := filepath.EvalSymlinks(filepath.Dir(filepath.Clean(target)))
if err != nil {
return false
}
resolvedTarget := filepath.Join(resolvedParent, filepath.Base(target))
return resolvedPathContained(resolvedRoot, resolvedTarget)
}
func resolvedPathContained(root, target string) bool {
rel, err := filepath.Rel(root, target)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
@@ -1,107 +0,0 @@
package transcodenode
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
type staticMediaFolders struct {
folders []*models.MediaFolder
err error
}
func (s staticMediaFolders) List(context.Context) ([]*models.MediaFolder, error) {
return s.folders, s.err
}
func TestMediaRootAuthorizerAllowsOnlyExistingFilesWithinLibraryRoots(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "movie.mkv")
if err := os.WriteFile(mediaPath, []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
authorizer := NewMediaRootAuthorizer(staticMediaFolders{
folders: []*models.MediaFolder{{Paths: []string{root}}},
})
allowed, err := authorizer.Allowed(context.Background(), mediaPath)
if err != nil || !allowed {
t.Fatalf("approved media path: allowed = %v, err = %v", allowed, err)
}
for _, candidate := range []string{
"relative/movie.mkv",
"http://example.test/movie.mkv",
"file:" + mediaPath,
"concat:" + mediaPath + "|" + mediaPath,
filepath.Join(t.TempDir(), "outside.mkv"),
filepath.Join(root, "missing.mkv"),
} {
allowed, err := authorizer.Allowed(context.Background(), candidate)
if err != nil {
t.Fatalf("candidate %q: %v", candidate, err)
}
if allowed {
t.Errorf("candidate %q was allowed", candidate)
}
}
}
func TestMediaRootAuthorizerRejectsSymlinkEscape(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink semantics differ on Windows")
}
root := t.TempDir()
outside := filepath.Join(t.TempDir(), "outside.mkv")
if err := os.WriteFile(outside, []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "linked.mkv")
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
authorizer := NewMediaRootAuthorizer(staticMediaFolders{
folders: []*models.MediaFolder{{Paths: []string{root}}},
})
allowed, err := authorizer.Allowed(context.Background(), link)
if err != nil {
t.Fatal(err)
}
if allowed {
t.Fatal("symlink escape was allowed")
}
}
func TestMediaRootAuthorizerPropagatesRepositoryErrors(t *testing.T) {
wantErr := errors.New("database unavailable")
authorizer := NewMediaRootAuthorizer(staticMediaFolders{err: wantErr})
allowed, err := authorizer.Allowed(context.Background(), "/media/movie.mkv")
if allowed || !errors.Is(err, wantErr) {
t.Fatalf("allowed = %v, err = %v", allowed, err)
}
}
func TestPathWithinRootRejectsSymlinkedOutputParent(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink semantics differ on Windows")
}
root := t.TempDir()
outside := t.TempDir()
linkedParent := filepath.Join(root, "linked")
if err := os.Symlink(outside, linkedParent); err != nil {
t.Fatal(err)
}
if pathWithinRoot(root, filepath.Join(linkedParent, "artifact.mp4")) {
t.Fatal("output under symlinked escape was accepted")
}
if !pathWithinRoot(root, filepath.Join(root, "artifact.mp4")) {
t.Fatal("direct child output was rejected")
}
}
+111 -360
View File
@@ -19,12 +19,12 @@ import (
"golang.org/x/sync/singleflight"
"github.com/Silo-Server/silo-server/internal/chapterthumbs"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/downloadprepare"
"github.com/Silo-Server/silo-server/internal/httpstream"
"github.com/Silo-Server/silo-server/internal/nodeconfig"
"github.com/Silo-Server/silo-server/internal/nodesessions"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/streamtoken"
"github.com/Silo-Server/silo-server/internal/transcodeproxy"
)
// TranscodeStartRequest is the JSON body for POST /transcode/start.
@@ -81,27 +81,12 @@ const sessionIdleTTL = 10 * time.Minute
// sessionReapInterval is how often the idle reaper sweeps for stale jobs.
const sessionReapInterval = time.Minute
// Session tracking is monitoring-only and must never make a healthy node's
// control-plane response depend on Redis latency.
const sessionTrackingOperationTimeout = 2 * time.Second
type sessionTracker interface {
Track(context.Context, nodesessions.SessionInfo)
Remove(context.Context, string)
Cleanup(context.Context)
NodeURL() string
NodeName() string
}
// Server is the HTTP handler for transcode mode.
type Server struct {
watcher *nodeconfig.Watcher
tracker sessionTracker
ffmpegSink playback.FFmpegLogSink
inputPaths InputPathAuthorizer
transcodeDir string
artifactRoot string
sessions map[string]*playback.TranscodeSession
watcher *nodeconfig.Watcher
tracker *nodesessions.Tracker
ffmpegSink playback.FFmpegLogSink
sessions map[string]*playback.TranscodeSession
// lastAccess records, per registered session id, when a manifest or segment
// request last touched the job (registration counts as the first access).
// Guarded by mu alongside sessions; the idle reaper closes jobs whose entry
@@ -109,9 +94,6 @@ type Server struct {
lastAccess map[string]time.Time
reaperOnce sync.Once
mu sync.RWMutex
// reloadMu keeps force-reload teardown atomic with session creation and
// reconstruction. It is always acquired before lifecycleMu or mu.
reloadMu sync.RWMutex
activeJobs atomic.Int32
// reconstructGroup single-flights node-side session reconstruction per session
@@ -125,12 +107,11 @@ type Server struct {
reconstructSemOnce sync.Once
reconstructSem chan struct{}
// lifecycleMu guards lifecycleLocks, the per-session locks that serialize
// lifecycleMu guards lifecycleLocks, the per-session mutexes that serialize
// every path which spawns ffmpeg into a session's output dir (fresh start and
// reconstruct). reconstructGroup only single-flights reconstructs against each
// other; without this a reconstruct racing a fresh /transcode/start could run
// two ffmpeg writers against the same dir. Artifact readers use the shared side
// so concurrent relays remain independent while prepare/delete stay exclusive.
// two ffmpeg writers against the same dir.
lifecycleMu sync.Mutex
lifecycleLocks map[string]*sessionLifecycleLock
@@ -139,17 +120,19 @@ type Server struct {
recipeStore recipeStore
}
// sessionLifecycleLock is a refcounted per-session lock; the refcount lets the
// sessionLifecycleLock is a refcounted per-session mutex; the refcount lets the
// node drop the map entry once no path holds or waits on it so the map stays
// bounded over the node's lifetime.
type sessionLifecycleLock struct {
mu sync.RWMutex
mu sync.Mutex
refs int
}
func (s *Server) retainSessionLifecycleLock(sessionID string) *sessionLifecycleLock {
// lockSessionLifecycle acquires the per-session lifecycle mutex and returns a
// release func. Held across "check existing → spawn → register" so a fresh start
// and a reconstruct never run concurrent ffmpeg writers for one session's dir.
func (s *Server) lockSessionLifecycle(sessionID string) func() {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
if s.lifecycleLocks == nil {
s.lifecycleLocks = make(map[string]*sessionLifecycleLock)
}
@@ -159,39 +142,17 @@ func (s *Server) retainSessionLifecycleLock(sessionID string) *sessionLifecycleL
s.lifecycleLocks[sessionID] = lk
}
lk.refs++
return lk
}
s.lifecycleMu.Unlock()
func (s *Server) releaseSessionLifecycleLock(sessionID string, lk *sessionLifecycleLock) {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
lk.refs--
if lk.refs == 0 {
delete(s.lifecycleLocks, sessionID)
}
}
// lockSessionLifecycle acquires the per-session lifecycle mutex and returns a
// release func. Held across "check existing → spawn → register" so a fresh start
// and a reconstruct never run concurrent ffmpeg writers for one session's dir.
func (s *Server) lockSessionLifecycle(sessionID string) func() {
lk := s.retainSessionLifecycleLock(sessionID)
lk.mu.Lock()
return func() {
lk.mu.Unlock()
s.releaseSessionLifecycleLock(sessionID, lk)
}
}
// lockSessionLifecycleRead holds the shared side of a lifecycle lock. Artifact
// relays can therefore proceed concurrently, while preparation and deletion
// remain exclusive for the full transfer.
func (s *Server) lockSessionLifecycleRead(sessionID string) func() {
lk := s.retainSessionLifecycleLock(sessionID)
lk.mu.RLock()
return func() {
lk.mu.RUnlock()
s.releaseSessionLifecycleLock(sessionID, lk)
s.lifecycleMu.Lock()
lk.refs--
if lk.refs == 0 {
delete(s.lifecycleLocks, sessionID)
}
s.lifecycleMu.Unlock()
}
}
@@ -216,31 +177,11 @@ func (s *Server) restartSessionLocked(ctx context.Context, sessionID string, ses
// NewServer creates a new transcode server.
func NewServer(watcher *nodeconfig.Watcher, tracker *nodesessions.Tracker) *Server {
var trackerImpl sessionTracker
if tracker != nil {
trackerImpl = tracker
}
transcodeDir := config.DefaultTranscodeDir
artifactDir := ""
if watcher != nil {
if cfg := watcher.Config(); cfg != nil {
if strings.TrimSpace(cfg.Playback.TranscodeDir) != "" {
transcodeDir = cfg.Playback.TranscodeDir
}
artifactDir = cfg.Download.ArtifactDir
}
}
artifactRoot := filepath.Join(transcodeDir, downloadprepare.ArtifactDirectoryName)
if strings.TrimSpace(artifactDir) != "" {
artifactRoot = config.EffectiveDownloadArtifactDir(artifactDir, transcodeDir)
}
s := &Server{
watcher: watcher,
tracker: trackerImpl,
transcodeDir: transcodeDir,
artifactRoot: artifactRoot,
sessions: make(map[string]*playback.TranscodeSession),
lastAccess: make(map[string]time.Time),
watcher: watcher,
tracker: tracker,
sessions: make(map[string]*playback.TranscodeSession),
lastAccess: make(map[string]time.Time),
}
return s
}
@@ -256,14 +197,22 @@ func NewServer(watcher *nodeconfig.Watcher, tracker *nodesessions.Tracker) *Serv
// writing into TranscodeDir/<sessionID>: a dir younger than the max token
// lifetime may still be reused, while older dirs are never reconstructable.
func (s *Server) StartOrphanSweeper(ctx context.Context) {
dir := s.transcodeDir
dir := ""
if cfg := s.watcher.Config(); cfg != nil {
dir = cfg.Playback.TranscodeDir
}
playback.StartPeriodicOrphanCleanup(ctx, "transcodenode", dir, func() (int, error) {
// Re-read config each run so a hot-reloaded TranscodeDir is honored.
cfg := s.watcher.Config()
if cfg == nil {
return 0, nil
}
// Spare the live registered jobs by id, not by age alone: now that the
// sweep runs during live traffic, a long-lived session that re-serves
// already-written segments stops advancing its dir mtime, so the age
// guard could misclassify it as orphaned. The live set is authoritative
// (in-flight reconstructs are covered by their fresh writes + age guard).
return playback.CleanupOrphanedTranscodeDirs(dir, s.activeSessionIDs(), playback.MaxTokenTTL)
return playback.CleanupOrphanedTranscodeDirs(cfg.Playback.TranscodeDir, s.activeSessionIDs(), playback.MaxTokenTTL)
}, playback.OrphanCleanupInterval)
}
@@ -273,23 +222,10 @@ func (s *Server) StartOrphanSweeper(ctx context.Context) {
func (s *Server) activeSessionIDs() map[string]struct{} {
s.mu.RLock()
defer s.mu.RUnlock()
active := make(map[string]struct{}, len(s.sessions)+1)
active := make(map[string]struct{}, len(s.sessions))
for id := range s.sessions {
active[id] = struct{}{}
}
// Node-local prepared downloads may live under the existing persistent
// transcode volume. They have their own lifecycle and must never be mistaken
// for an orphaned HLS session directory. Protect the top-level container for
// both the default and an explicitly nested artifact directory.
if s.transcodeDir != "" && s.artifactRoot != "" {
if rel, err := filepath.Rel(s.transcodeDir, s.artifactRoot); err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
if first, _, ok := strings.Cut(rel, string(filepath.Separator)); ok {
active[first] = struct{}{}
} else {
active[rel] = struct{}{}
}
}
}
return active
}
@@ -439,12 +375,6 @@ func (s *Server) SetRecipeStore(store recipeStore) {
s.recipeStore = store
}
// SetInputPathAuthorizer wires the library-root authority used by every node
// endpoint that accepts an FFmpeg input path.
func (s *Server) SetInputPathAuthorizer(authorizer InputPathAuthorizer) {
s.inputPaths = authorizer
}
// Handler returns the chi.Router with all transcode routes.
func (s *Server) Handler() http.Handler {
s.startIdleReaper()
@@ -455,178 +385,17 @@ func (s *Server) Handler() http.Handler {
r.Use(s.requireBearer)
r.Get("/hw-capabilities", s.handleHWCapabilities)
r.Post("/chapter-thumbnails/extract", s.handleChapterThumbnailExtract)
r.Post("/downloads/prepare", s.handleDownloadPrepare)
r.Head("/downloads/artifacts/{artifact_id}", s.handleDownloadArtifact)
r.Get("/downloads/artifacts/{artifact_id}", s.handleDownloadArtifact)
r.Delete("/downloads/artifacts/{artifact_id}", s.handleDeleteDownloadArtifact)
r.Post("/transcode/start", s.handleStart)
r.Delete("/transcode/{session_id}", s.handleStop)
r.Get("/transcode/{session_id}/master.m3u8", s.handleManifest)
r.Get("/transcode/{session_id}/segment/{name}", s.handleSegment)
r.Post("/transcode/{session_id}/segment/{name}/downloaded", s.handleSegmentDownloaded)
r.Post("/admin/force-reload", s.handleForceReload)
r.Get("/status", s.handleStatus)
})
return r
}
func (s *Server) handleDownloadPrepare(w http.ResponseWriter, r *http.Request) {
var req downloadprepare.Request
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if !downloadprepare.ValidArtifactID(req.ArtifactID) || strings.TrimSpace(req.InputPath) == "" {
http.Error(w, "a valid artifact_id and input_path are required", http.StatusBadRequest)
return
}
cfg := s.watcher.Config()
if cfg == nil {
http.Error(w, "node not configured", http.StatusServiceUnavailable)
return
}
if !s.requireApprovedInputPath(w, r, req.InputPath) {
return
}
artifactRoot := s.artifactRoot
if err := os.MkdirAll(artifactRoot, 0o755); err != nil {
http.Error(w, "artifact directory unavailable", http.StatusInternalServerError)
return
}
outputPath := filepath.Join(artifactRoot, req.ArtifactID+".mp4")
unlock := s.lockSessionLifecycle("download-artifact-" + req.ArtifactID)
defer unlock()
if stat, err := os.Stat(outputPath); err == nil && stat.Mode().IsRegular() && stat.Size() > 0 {
writeDownloadPrepareResult(w, req.ArtifactID, stat.Size())
return
}
jobCtx := r.Context()
s.activeJobs.Add(1)
defer s.activeJobs.Add(-1)
if s.tracker != nil {
finishTracking := s.trackDownloadPrepare(jobCtx, nodesessions.SessionInfo{
SessionID: "download-" + req.ArtifactID,
NodeURL: s.tracker.NodeURL(),
NodeName: s.tracker.NodeName(),
Type: "download_prepare",
CodecVideo: req.TargetCodecVideo,
CodecAudio: req.TargetCodecAudio,
Resolution: req.TargetResolution,
StartedAt: time.Now().UTC().Format(time.RFC3339),
})
defer finishTracking()
}
opts := req.TranscodeOpts(cfg.Playback.FFmpegPath, cfg.Playback.HWAccel, cfg.Playback.HWDevice, s.ffmpegSink)
if err := playback.PrepareFile(jobCtx, opts, outputPath); err != nil {
if jobCtx.Err() == nil {
slog.ErrorContext(jobCtx, "prepare download artifact", "component", "transcodenode", "artifact_id", req.ArtifactID, "error", err)
}
http.Error(w, "failed to prepare download artifact", http.StatusInternalServerError)
return
}
stat, err := os.Stat(outputPath)
if err != nil || !stat.Mode().IsRegular() {
http.Error(w, "prepared download artifact unavailable", http.StatusInternalServerError)
return
}
writeDownloadPrepareResult(w, req.ArtifactID, stat.Size())
}
func writeDownloadPrepareResult(w http.ResponseWriter, artifactID string, fileSize int64) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(downloadprepare.Result{ArtifactID: artifactID, FileSize: fileSize})
}
func (s *Server) sessionOutputDir(sessionID string) string {
return filepath.Join(s.transcodeDir, sessionID)
}
func (s *Server) handleDownloadArtifact(w http.ResponseWriter, r *http.Request) {
artifactID := chi.URLParam(r, "artifact_id")
if !downloadprepare.ValidArtifactID(artifactID) {
http.NotFound(w, r)
return
}
cfg := s.watcher.Config()
if cfg == nil {
http.Error(w, "node not configured", http.StatusServiceUnavailable)
return
}
// Share the lock with other readers, but serialize with preparation and
// deletion. A recovery HEAD must not report a definitive 404 while
// PrepareFile is still publishing this artifact.
unlock := s.lockSessionLifecycleRead("download-artifact-" + artifactID)
defer unlock()
path := filepath.Join(s.artifactRoot, artifactID+".mp4")
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(w, "artifact unavailable", http.StatusInternalServerError)
return
}
defer func() { _ = f.Close() }()
stat, err := f.Stat()
if err != nil || !stat.Mode().IsRegular() {
http.Error(w, "artifact unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Disposition", `attachment; filename="`+artifactID+`.mp4"`)
w.Header().Set("Content-Type", playback.MimeFromExtension(path))
w.Header().Set("ETag", `"`+artifactID+`-`+strconv.FormatInt(stat.Size(), 10)+`"`)
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
}
func (s *Server) handleDeleteDownloadArtifact(w http.ResponseWriter, r *http.Request) {
artifactID := chi.URLParam(r, "artifact_id")
if !downloadprepare.ValidArtifactID(artifactID) {
http.NotFound(w, r)
return
}
cfg := s.watcher.Config()
if cfg == nil {
http.Error(w, "node not configured", http.StatusServiceUnavailable)
return
}
unlock := s.lockSessionLifecycle("download-artifact-" + artifactID)
defer unlock()
path := filepath.Join(s.artifactRoot, artifactID+".mp4")
for _, candidate := range []string{path, path + ".part"} {
if err := os.Remove(candidate); err != nil && !os.IsNotExist(err) {
http.Error(w, "failed to remove artifact", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusNoContent)
}
// trackDownloadPrepare runs the monitoring lifecycle off the request path.
// One goroutine owns both operations so Remove can never overtake Track, even
// when the encode completes before Redis responds. finish only signals that
// the job ended; it never waits for either bounded Redis operation.
func (s *Server) trackDownloadPrepare(ctx context.Context, info nodesessions.SessionInfo) func() {
finished := make(chan struct{})
var finishOnce sync.Once
baseCtx := context.WithoutCancel(ctx)
go func() {
trackCtx, cancelTrack := context.WithTimeout(baseCtx, sessionTrackingOperationTimeout)
s.tracker.Track(trackCtx, info)
cancelTrack()
<-finished
removeCtx, cancelRemove := context.WithTimeout(baseCtx, sessionTrackingOperationTimeout)
s.tracker.Remove(removeCtx, info.SessionID)
cancelRemove()
}()
return func() {
finishOnce.Do(func() { close(finished) })
}
}
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(HealthResponse{
@@ -656,15 +425,8 @@ func (s *Server) handleChapterThumbnailExtract(w http.ResponseWriter, r *http.Re
writeChapterThumbnailError(w, http.StatusBadRequest, "invalid_request", "input_path is required")
return
}
if !s.requireApprovedInputPath(w, r, req.InputPath) {
return
}
cfg := s.watcher.Config()
if cfg == nil {
writeChapterThumbnailError(w, http.StatusServiceUnavailable, "node_unavailable", "node not configured")
return
}
frame, reason, err := chapterthumbs.ExtractFrame(r.Context(), chapterthumbs.FrameExtractOptions{
InputPath: req.InputPath,
SeekSeconds: req.SeekSeconds,
@@ -697,10 +459,6 @@ func writeChapterThumbnailError(w http.ResponseWriter, status int, reason string
func (s *Server) requireBearer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg := s.watcher.Config()
if cfg == nil {
http.Error(w, "node not configured", http.StatusServiceUnavailable)
return
}
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != cfg.Auth.JWTSecret {
http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -721,41 +479,33 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session_id and input_path are required", http.StatusBadRequest)
return
}
if !s.requireApprovedInputPath(w, r, req.InputPath) {
return
}
s.reloadMu.RLock()
defer s.reloadMu.RUnlock()
cfg := s.watcher.Config()
if cfg == nil {
http.Error(w, "node not configured", http.StatusServiceUnavailable)
return
}
outputDir := s.sessionOutputDir(req.SessionID)
outputDir := filepath.Join(cfg.Playback.TranscodeDir, req.SessionID)
opts := playback.TranscodeOpts{
InputPath: req.InputPath,
OutputDir: outputDir,
SessionID: req.SessionID,
SourceVideoCodec: req.SourceVideoCodec,
SourceVideoProfile: req.SourceVideoProfile,
SourceVideoBitDepth: req.SourceVideoBitDepth,
SoftwareVideoDecode: req.SoftwareVideoDecode,
VideoBitstreamFilter: req.VideoBitstreamFilter,
SeekSeconds: req.SeekSeconds,
StreamOriginSeconds: req.StreamOriginSeconds,
CopySeekAnchorResolved: req.CopySeekAnchorResolved,
StartSegmentNumber: req.StartSegmentNumber,
TargetResolution: req.TargetResolution,
TargetCodecVideo: req.TargetCodecVideo,
TargetCodecAudio: req.TargetCodecAudio,
TargetAudioChannels: req.TargetAudioChannels,
TargetAudioBitrateKbps: req.TargetAudioBitrateKbps,
TargetBitrateKbps: req.TargetBitrateKbps,
SegmentDuration: req.SegmentDuration,
FFmpegPath: cfg.Playback.FFmpegPath,
HWAccel: req.HWAccel,
InputPath: req.InputPath,
OutputDir: outputDir,
SessionID: req.SessionID,
SourceVideoCodec: req.SourceVideoCodec,
SourceVideoProfile: req.SourceVideoProfile,
SourceVideoBitDepth: req.SourceVideoBitDepth,
SoftwareVideoDecode: req.SoftwareVideoDecode,
VideoBitstreamFilter: req.VideoBitstreamFilter,
SeekSeconds: req.SeekSeconds,
StreamOriginSeconds: req.StreamOriginSeconds,
CopySeekAnchorResolved: req.CopySeekAnchorResolved,
StartSegmentNumber: req.StartSegmentNumber,
TargetResolution: req.TargetResolution,
TargetCodecVideo: req.TargetCodecVideo,
TargetCodecAudio: req.TargetCodecAudio,
TargetAudioChannels: req.TargetAudioChannels,
TargetAudioBitrateKbps: req.TargetAudioBitrateKbps,
TargetBitrateKbps: req.TargetBitrateKbps,
SegmentDuration: req.SegmentDuration,
SegmentRetentionSeconds: cfg.Playback.SegmentRetentionSeconds,
FFmpegPath: cfg.Playback.FFmpegPath,
HWAccel: req.HWAccel,
// This node's configured device (or device list — StartTranscode
// resolves it to one GPU), matching what reconstruction uses so fresh
// and reconstructed sessions balance identically.
@@ -852,24 +602,6 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) requireApprovedInputPath(w http.ResponseWriter, r *http.Request, path string) bool {
if s.inputPaths == nil {
http.Error(w, "input path authority unavailable", http.StatusServiceUnavailable)
return false
}
allowed, err := s.inputPaths.Allowed(r.Context(), path)
if err != nil {
slog.ErrorContext(r.Context(), "authorize transcode input path", "component", "transcodenode", "error", err)
http.Error(w, "input path authority unavailable", http.StatusServiceUnavailable)
return false
}
if !allowed {
http.Error(w, "input_path must be an approved absolute media file", http.StatusBadRequest)
return false
}
return true
}
// reconstructFromToken rebuilds a transcode session this node lost to its own
// restart. The proxy forwards the client's verified stream token in the
// X-Silo-Stream-Token header; the token carries the full byte-affecting recipe
@@ -887,9 +619,6 @@ func (s *Server) reconstructFromToken(r *http.Request, sessionID string, request
return nil
}
cfg := s.watcher.Config()
if cfg == nil {
return nil
}
claims, err := streamtoken.Verify(tokenStr, cfg.Auth.JWTSecret)
if err != nil {
slog.WarnContext(r.Context(), "transcode node reconstruct: invalid stream token", "component", "transcodenode", "error", err,
@@ -951,16 +680,6 @@ func (s *Server) reconstructFromToken(r *http.Request, sessionID string, request
// single-flight in reconstructFromToken, so it is the sole writer racing to
// register sessionID. Returns nil if the spawn fails or the slot wait is canceled.
func (s *Server) spawnReconstruct(r *http.Request, sessionID string, requestedSegment int, card playback.RecipeCard) *playback.TranscodeSession {
if s.inputPaths == nil {
slog.ErrorContext(r.Context(), "transcode node reconstruct input authority unavailable", "component", "transcodenode", "session", sessionID)
return nil
}
allowed, err := s.inputPaths.Allowed(r.Context(), card.InputPath)
if err != nil || !allowed {
slog.WarnContext(r.Context(), "transcode node reconstruct input rejected", "component", "transcodenode", "session", sessionID, "error", err)
return nil
}
// Pace the cold-start burst so a node restart that loses many sessions does not
// launch every ffmpeg at once. A client that disconnects while waiting releases
// its slot rather than queueing dead work.
@@ -969,8 +688,6 @@ func (s *Server) spawnReconstruct(r *http.Request, sessionID string, requestedSe
return nil
}
defer release()
s.reloadMu.RLock()
defer s.reloadMu.RUnlock()
// Serialize against a concurrent fresh /transcode/start for this session so the
// two never run ffmpeg writers against the same dir. Re-check under the lock and
@@ -985,10 +702,7 @@ func (s *Server) spawnReconstruct(r *http.Request, sessionID string, requestedSe
}
cfg := s.watcher.Config()
if cfg == nil {
return nil
}
outputDir := s.sessionOutputDir(sessionID)
outputDir := filepath.Join(cfg.Playback.TranscodeDir, sessionID)
opts := card.TranscodeOpts(outputDir, cfg.Playback.FFmpegPath, s.ffmpegSink)
opts.SessionID = sessionID
// Re-resolve environment-specific encode knobs from this node's live config; the
@@ -996,6 +710,7 @@ func (s *Server) spawnReconstruct(r *http.Request, sessionID string, requestedSe
// rebuild. Run as a transcode node, not integrated (card.TranscodeOpts defaults).
opts.HWAccel = cfg.Playback.HWAccel
opts.HWDevice = cfg.Playback.HWDevice
opts.SegmentRetentionSeconds = cfg.Playback.SegmentRetentionSeconds
opts.NodeType = "transcode"
opts.ExecutionMode = "transcode_node"
@@ -1106,9 +821,9 @@ func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
slog.ErrorContext(r.Context(), "close transcode session", "component", "transcodenode", "error", err, "session", sessionID, "playback_session_id", sessionID)
}
if err := os.RemoveAll(s.sessionOutputDir(sessionID)); err != nil {
slog.WarnContext(r.Context(), "remove transcode session directory", "component", "transcodenode", "session", sessionID, "error", err)
}
cfg := s.watcher.Config()
outputDir := filepath.Join(cfg.Playback.TranscodeDir, sessionID)
os.RemoveAll(outputDir)
// Drop the recipe so a buffered/retrying request after a node restart cannot
// reconstruct a new ffmpeg for this now-stopped session. Best-effort: a stop
@@ -1190,7 +905,7 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) {
s.touchSession(sessionID)
}
segPath, err := session.GetSegment(name)
segmentLease, err := session.OpenSegment(name)
if err != nil && err == playback.ErrSegmentNotFound {
segNum, parseErr := playback.ParseSegmentNumber(name)
if parseErr == nil {
@@ -1225,7 +940,7 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) {
"session", sessionID,
"playback_session_id", sessionID,
)
segPath, err = session.WaitForSegment(name, decision.WaitTimeout)
segmentLease, err = session.WaitForOpenSegment(name, decision.WaitTimeout)
if err != nil && err == playback.ErrSegmentNotFound {
slog.InfoContext(r.Context(), "transcode segment wait timeout", "component", "transcodenode",
"segment", name,
@@ -1270,7 +985,7 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) {
seekSeconds,
segNum,
); restartErr == nil {
segPath, err = session.WaitForSegment(name, 30*time.Second)
segmentLease, err = session.WaitForOpenSegment(name, 30*time.Second)
}
}
if !ok && session.IsCopyVideo() {
@@ -1280,7 +995,7 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) {
} else if session.IsRunning() {
// Non-numbered segment (e.g., init.mp4 for fMP4 HLS).
// Wait briefly — the init segment is written almost immediately.
segPath, err = session.WaitForSegment(name, 10*time.Second)
segmentLease, err = session.WaitForOpenSegment(name, 10*time.Second)
}
}
if err != nil {
@@ -1290,23 +1005,59 @@ func (s *Server) handleSegment(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store, max-age=0")
w.Header().Set("Pragma", "no-cache")
http.ServeFile(w, r, segPath)
proxied := r.Header.Get(transcodeproxy.RequestHeader) == "1"
if proxied {
w.Header().Set(transcodeproxy.GenerationHeader, segmentLease.GenerationToken)
}
defer func() { _ = segmentLease.Close() }()
sw := httpstream.NewRollingDeadlineWriter(w)
http.ServeContent(sw, r, segmentLease.Info.Name(), segmentLease.Info.ModTime(), segmentLease.File)
if !proxied && r.Method == http.MethodGet &&
sw.CompletedFullResponse(segmentLease.Info.Size()) {
if segmentNumber, parseErr := playback.ParseSegmentNumber(name); parseErr == nil {
session.ReportSegmentDownloadedForGeneration(segmentNumber, segmentLease.Generation)
}
}
}
// handleSegmentDownloaded records completion only after the central API has
// delivered the full segment to its downstream playback client. The generation
// returned with the original segment response prevents a delayed acknowledgement
// from advancing a replacement FFmpeg timeline.
func (s *Server) handleSegmentDownloaded(w http.ResponseWriter, r *http.Request) {
sessionID := chi.URLParam(r, "session_id")
name := chi.URLParam(r, "name")
segmentNumber, err := playback.ParseSegmentNumber(name)
if err != nil {
http.Error(w, "invalid segment", http.StatusBadRequest)
return
}
generationToken := r.Header.Get(transcodeproxy.GenerationHeader)
if generationToken == "" {
http.Error(w, "invalid segment generation", http.StatusBadRequest)
return
}
session, ok := s.acquireSessionTouched(sessionID)
if !ok {
http.Error(w, "session not found", http.StatusNotFound)
return
}
session.ReportSegmentDownloadedForGenerationToken(segmentNumber, generationToken)
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleForceReload(w http.ResponseWriter, r *http.Request) {
s.reloadMu.Lock()
defer s.reloadMu.Unlock()
if err := s.watcher.ForceReload(r.Context()); err != nil {
http.Error(w, "reload failed: "+err.Error(), http.StatusInternalServerError)
return
}
cfg := s.watcher.Config()
s.mu.Lock()
stopped := make([]string, 0, len(s.sessions))
for id, session := range s.sessions {
session.Close()
if err := os.RemoveAll(s.sessionOutputDir(id)); err != nil {
slog.WarnContext(r.Context(), "remove transcode session directory during reload", "component", "transcodenode", "session", id, "error", err)
}
os.RemoveAll(filepath.Join(cfg.Playback.TranscodeDir, id))
delete(s.sessions, id)
delete(s.lastAccess, id)
stopped = append(stopped, id)
+106 -465
View File
@@ -7,98 +7,24 @@ import (
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/downloadprepare"
"github.com/Silo-Server/silo-server/internal/nodeconfig"
"github.com/Silo-Server/silo-server/internal/nodesessions"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/streamtoken"
"github.com/Silo-Server/silo-server/internal/transcodeproxy"
)
const testSecret = "node-reconstruct-test-secret"
type allowInputPaths struct{}
func (allowInputPaths) Allowed(context.Context, string) (bool, error) {
return true, nil
}
type blockingSessionTracker struct {
trackStarted chan struct{}
trackRelease chan struct{}
removeStarted chan struct{}
mu sync.Mutex
events []string
trackHasDeadline bool
removeHasDeadline bool
}
type blockingResponseWriter struct {
header http.Header
writeStarted chan struct{}
releaseWrite chan struct{}
startOnce sync.Once
}
func newBlockingResponseWriter() *blockingResponseWriter {
return &blockingResponseWriter{
header: make(http.Header),
writeStarted: make(chan struct{}),
releaseWrite: make(chan struct{}),
}
}
func (w *blockingResponseWriter) Header() http.Header { return w.header }
func (*blockingResponseWriter) WriteHeader(int) {}
func (w *blockingResponseWriter) Write(p []byte) (int, error) {
w.startOnce.Do(func() { close(w.writeStarted) })
<-w.releaseWrite
return len(p), nil
}
func newBlockingSessionTracker() *blockingSessionTracker {
return &blockingSessionTracker{
trackStarted: make(chan struct{}),
trackRelease: make(chan struct{}),
removeStarted: make(chan struct{}),
}
}
func (t *blockingSessionTracker) Track(ctx context.Context, _ nodesessions.SessionInfo) {
_, hasDeadline := ctx.Deadline()
t.mu.Lock()
t.trackHasDeadline = hasDeadline
t.events = append(t.events, "track")
t.mu.Unlock()
close(t.trackStarted)
<-t.trackRelease
t.mu.Lock()
t.events = append(t.events, "track-done")
t.mu.Unlock()
}
func (t *blockingSessionTracker) Remove(ctx context.Context, _ string) {
_, hasDeadline := ctx.Deadline()
t.mu.Lock()
t.removeHasDeadline = hasDeadline
t.events = append(t.events, "remove")
t.mu.Unlock()
close(t.removeStarted)
}
func (*blockingSessionTracker) Cleanup(context.Context) {}
func (*blockingSessionTracker) NodeURL() string { return "http://node" }
func (*blockingSessionTracker) NodeName() string { return "node" }
// newTestServer builds a transcode Server whose config carries a known JWT secret
// so reconstructFromToken can verify forwarded stream tokens. The tracker is left
// nil: the guard-rejection cases never reach the spawn/track path.
@@ -108,44 +34,122 @@ func newTestServer(t *testing.T) *Server {
cfg := &config.Config{}
cfg.Auth.JWTSecret = testSecret
cfg.Playback.TranscodeDir = t.TempDir()
cfg.Download.ArtifactDir = filepath.Join(cfg.Playback.TranscodeDir, downloadprepare.ArtifactDirectoryName)
w.SetConfigForTest(cfg)
return &Server{
watcher: w,
inputPaths: allowInputPaths{},
transcodeDir: cfg.Playback.TranscodeDir,
artifactRoot: filepath.Join(cfg.Playback.TranscodeDir, downloadprepare.ArtifactDirectoryName),
sessions: make(map[string]*playback.TranscodeSession),
watcher: w,
sessions: make(map[string]*playback.TranscodeSession),
lastAccess: make(map[string]time.Time),
}
}
func TestNewServerUsesConfiguredDownloadArtifactDir(t *testing.T) {
w := nodeconfig.NewWatcher(nil, nil, nil, nodeconfig.BootstrapOverrides{})
cfg := &config.Config{}
cfg.Playback.TranscodeDir = filepath.Join(t.TempDir(), "transcodes")
cfg.Download.ArtifactDir = filepath.Join(t.TempDir(), "prepared-downloads")
w.SetConfigForTest(cfg)
func TestProxiedSegmentCompletionRequiresDownstreamAcknowledgement(t *testing.T) {
truePath, err := exec.LookPath("true")
if err != nil {
t.Skipf("`true` not found in PATH: %v", err)
}
server := NewServer(w, nil)
if server.artifactRoot != cfg.Download.ArtifactDir {
t.Fatalf("artifact root = %q, want configured %q", server.artifactRoot, cfg.Download.ArtifactDir)
const sessionID = "proxy-completion-session"
server := newTestServer(t)
outputDir := t.TempDir()
session, err := playback.StartTranscode(context.Background(), playback.TranscodeOpts{
SessionID: sessionID,
OutputDir: outputDir,
FFmpegPath: truePath,
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
SegmentDuration: 2,
SegmentRetentionSeconds: 600,
})
if err != nil {
t.Fatalf("StartTranscode: %v", err)
}
t.Cleanup(func() { _ = session.Close() })
server.sessions[sessionID] = session
server.lastAccess[sessionID] = time.Now()
const segmentName = "seg_00007.ts"
if err := os.WriteFile(filepath.Join(outputDir, segmentName), []byte("complete segment"), 0o600); err != nil {
t.Fatal(err)
}
segmentReq := withNodeRouteParams(
httptest.NewRequest(http.MethodGet, "/transcode/"+sessionID+"/segment/"+segmentName, nil),
map[string]string{"session_id": sessionID, "name": segmentName},
)
segmentReq.Header.Set(transcodeproxy.RequestHeader, "1")
segmentRR := httptest.NewRecorder()
server.handleSegment(segmentRR, segmentReq)
if segmentRR.Code != http.StatusOK {
t.Fatalf("segment status = %d, body = %q", segmentRR.Code, segmentRR.Body.String())
}
generation := segmentRR.Header().Get(transcodeproxy.GenerationHeader)
if generation == "" {
t.Fatal("proxied segment omitted generation")
}
if got := session.LastRequestedSegment(); got != 0 {
t.Fatalf("node counted proxy-hop completion as client completion: got %d, want 0", got)
}
ackReq := withNodeRouteParams(
httptest.NewRequest(http.MethodPost, "/transcode/"+sessionID+"/segment/"+segmentName+"/downloaded", nil),
map[string]string{"session_id": sessionID, "name": segmentName},
)
ackReq.Header.Set(transcodeproxy.GenerationHeader, generation)
ackRR := httptest.NewRecorder()
server.handleSegmentDownloaded(ackRR, ackReq)
if ackRR.Code != http.StatusNoContent {
t.Fatalf("ack status = %d, body = %q", ackRR.Code, ackRR.Body.String())
}
if got := session.LastRequestedSegment(); got != 7 {
t.Fatalf("acknowledged segment = %d, want 7", got)
}
}
func TestNewServerKeepsDefaultDownloadArtifactsInsideTranscodeVolume(t *testing.T) {
w := nodeconfig.NewWatcher(nil, nil, nil, nodeconfig.BootstrapOverrides{})
cfg := &config.Config{}
cfg.Playback.TranscodeDir = filepath.Join(t.TempDir(), "transcodes")
w.SetConfigForTest(cfg)
func TestProxiedSegmentCompletionRejectsStaleGeneration(t *testing.T) {
truePath, err := exec.LookPath("true")
if err != nil {
t.Skipf("`true` not found in PATH: %v", err)
}
server := NewServer(w, nil)
want := filepath.Join(cfg.Playback.TranscodeDir, downloadprepare.ArtifactDirectoryName)
if server.artifactRoot != want {
t.Fatalf("artifact root = %q, want mounted path %q", server.artifactRoot, want)
const sessionID = "stale-proxy-completion-session"
server := newTestServer(t)
session, err := playback.StartTranscode(context.Background(), playback.TranscodeOpts{
SessionID: sessionID,
OutputDir: t.TempDir(),
FFmpegPath: truePath,
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
SegmentDuration: 2,
})
if err != nil {
t.Fatalf("StartTranscode: %v", err)
}
if _, protected := server.activeSessionIDs()[downloadprepare.ArtifactDirectoryName]; !protected {
t.Fatal("default artifact directory is not protected from the transcode orphan sweep")
t.Cleanup(func() { _ = session.Close() })
server.sessions[sessionID] = session
server.lastAccess[sessionID] = time.Now()
const segmentName = "seg_00007.ts"
ackReq := withNodeRouteParams(
httptest.NewRequest(http.MethodPost, "/transcode/"+sessionID+"/segment/"+segmentName+"/downloaded", nil),
map[string]string{"session_id": sessionID, "name": segmentName},
)
ackReq.Header.Set(transcodeproxy.GenerationHeader, "999")
ackRR := httptest.NewRecorder()
server.handleSegmentDownloaded(ackRR, ackReq)
if ackRR.Code != http.StatusNoContent {
t.Fatalf("ack status = %d, body = %q", ackRR.Code, ackRR.Body.String())
}
if got := session.LastRequestedSegment(); got != 0 {
t.Fatalf("stale acknowledgement advanced segment to %d", got)
}
}
func withNodeRouteParams(req *http.Request, values map[string]string) *http.Request {
routeCtx := chi.NewRouteContext()
for key, value := range values {
routeCtx.URLParams.Add(key, value)
}
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
}
func TestHandleStartRequireReadyRejectsExitedFFmpeg(t *testing.T) {
@@ -180,369 +184,6 @@ func TestHandleStartRequireReadyRejectsExitedFFmpeg(t *testing.T) {
}
}
func TestHandleDownloadPrepareKeepsStartupArtifactRootAcrossReload(t *testing.T) {
server := newTestServer(t)
artifactDir := server.artifactRoot
server.watcher.Config().Download.ArtifactDir = t.TempDir()
server.watcher.Config().Playback.TranscodeDir = t.TempDir()
ffmpegPath := filepath.Join(t.TempDir(), "ffmpeg.sh")
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nfor last; do :; done\nprintf artifact > \"$last\"\n"), 0o755); err != nil {
t.Fatal(err)
}
server.watcher.Config().Playback.FFmpegPath = ffmpegPath
server.watcher.Config().Playback.HWAccel = "none"
outputPath := filepath.Join(artifactDir, "artifact-1.mp4")
body, err := json.Marshal(downloadprepare.Request{
ArtifactID: "artifact-1",
InputPath: "/media/movie.mkv",
TargetCodecVideo: "copy",
TargetCodecAudio: "copy",
AudioTrackIndex: -1,
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/downloads/prepare", bytes.NewReader(body))
rr := httptest.NewRecorder()
server.handleDownloadPrepare(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
info, err := os.Stat(outputPath)
if err != nil {
t.Fatalf("prepared output: %v", err)
}
responseBody := rr.Body.String()
var result downloadprepare.Result
if err := json.Unmarshal([]byte(responseBody), &result); err != nil {
t.Fatal(err)
}
if result.ArtifactID != "artifact-1" || result.FileSize != info.Size() || strings.Contains(responseBody, artifactDir) {
t.Fatalf("prepare result = %+v body=%s", result, responseBody)
}
if got := server.activeJobs.Load(); got != 0 {
t.Fatalf("active jobs after prepare = %d, want 0", got)
}
}
func TestSessionOutputDirKeepsStartupPathAcrossReload(t *testing.T) {
server := newTestServer(t)
startupDir := server.transcodeDir
server.watcher.Config().Playback.TranscodeDir = t.TempDir()
if got, want := server.sessionOutputDir("session-1"), filepath.Join(startupDir, "session-1"); got != want {
t.Fatalf("session output dir = %q, want startup path %q", got, want)
}
}
func TestHandleStartWaitsForForceReloadGate(t *testing.T) {
server := newTestServer(t)
ffmpegPath := filepath.Join(t.TempDir(), "failing-ffmpeg.sh")
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}
server.watcher.Config().Playback.FFmpegPath = ffmpegPath
body, err := json.Marshal(TranscodeStartRequest{
SessionID: "reload-gated-start", InputPath: "/media/movie.mkv",
TargetCodecVideo: "h264", TargetCodecAudio: "aac", SegmentDuration: 2, RequireReady: true,
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/transcode/start", bytes.NewReader(body))
rr := httptest.NewRecorder()
done := make(chan struct{})
server.reloadMu.Lock()
locked := true
defer func() {
if locked {
server.reloadMu.Unlock()
}
}()
go func() {
server.handleStart(rr, req)
close(done)
}()
select {
case <-done:
t.Fatal("start completed while force-reload gate was held")
case <-time.After(50 * time.Millisecond):
}
server.reloadMu.Unlock()
locked = false
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("start did not resume after force-reload gate was released")
}
}
func TestHandleDownloadPrepareTrackingDoesNotBlockAndRemovesAfterTrack(t *testing.T) {
server := newTestServer(t)
tracker := newBlockingSessionTracker()
server.tracker = tracker
ffmpegPath := filepath.Join(t.TempDir(), "ffmpeg.sh")
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nfor last; do :; done\ntouch \"$last\"\n"), 0o755); err != nil {
t.Fatal(err)
}
server.watcher.Config().Playback.FFmpegPath = ffmpegPath
server.watcher.Config().Playback.HWAccel = "none"
body, err := json.Marshal(downloadprepare.Request{
ArtifactID: "artifact-tracking",
InputPath: "/media/movie.mkv",
TargetCodecVideo: "copy",
TargetCodecAudio: "copy",
AudioTrackIndex: -1,
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/downloads/prepare", bytes.NewReader(body))
rr := httptest.NewRecorder()
handlerDone := make(chan struct{})
go func() {
server.handleDownloadPrepare(rr, req)
close(handlerDone)
}()
select {
case <-tracker.trackStarted:
case <-time.After(time.Second):
t.Fatal("tracking did not start")
}
select {
case <-handlerDone:
case <-time.After(250 * time.Millisecond):
close(tracker.trackRelease)
<-handlerDone
t.Fatal("download prepare waited for session tracking")
}
if rr.Code != http.StatusOK {
close(tracker.trackRelease)
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
close(tracker.trackRelease)
select {
case <-tracker.removeStarted:
case <-time.After(time.Second):
t.Fatal("tracking cleanup was not scheduled")
}
tracker.mu.Lock()
defer tracker.mu.Unlock()
if !tracker.trackHasDeadline || !tracker.removeHasDeadline {
t.Fatalf("tracking deadlines: track=%t remove=%t", tracker.trackHasDeadline, tracker.removeHasDeadline)
}
if got := strings.Join(tracker.events, ","); got != "track,track-done,remove" {
t.Fatalf("tracking order = %q", got)
}
}
func TestHandleDownloadPrepareRejectsInvalidArtifactID(t *testing.T) {
server := newTestServer(t)
body, err := json.Marshal(downloadprepare.Request{
ArtifactID: "../artifact-2",
InputPath: "/media/movie.mkv",
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/downloads/prepare", bytes.NewReader(body))
rr := httptest.NewRecorder()
server.handleDownloadPrepare(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestHandleDownloadPrepareRejectsUnavailableConfig(t *testing.T) {
server := newTestServer(t)
server.watcher.SetConfigForTest(nil)
body := []byte(`{"artifact_id":"artifact-3","input_path":"/media/movie.mkv"}`)
req := httptest.NewRequest(http.MethodPost, "/downloads/prepare", bytes.NewReader(body))
rr := httptest.NewRecorder()
server.handleDownloadPrepare(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestHandleStartRejectsUnapprovedInputPath(t *testing.T) {
server := newTestServer(t)
server.inputPaths = NewMediaRootAuthorizer(staticMediaFolders{})
body := []byte(`{"session_id":"unsafe-input","input_path":"http://example.test/movie.mkv"}`)
req := httptest.NewRequest(http.MethodPost, "/transcode/start", bytes.NewReader(body))
rr := httptest.NewRecorder()
server.handleStart(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestDownloadPrepareRouteRequiresNodeBearer(t *testing.T) {
server := newTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/downloads/prepare", strings.NewReader(`{}`))
rr := httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestDownloadArtifactRoutesServeRangeAndDeleteNodeLocalFile(t *testing.T) {
server := newTestServer(t)
root := server.artifactRoot
if want := filepath.Join(server.watcher.Config().Playback.TranscodeDir, downloadprepare.ArtifactDirectoryName); root != want {
t.Fatalf("artifact root = %q, want %q", root, want)
}
if _, protected := server.activeSessionIDs()[downloadprepare.ArtifactDirectoryName]; !protected {
t.Fatal("artifact directory is not protected from the orphan transcode sweep")
}
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, "artifact-range.mp4")
if err := os.WriteFile(path, []byte("0123456789"), 0o600); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/downloads/artifacts/artifact-range", nil)
req.Header.Set("Authorization", "Bearer "+testSecret)
req.Header.Set("Range", "bytes=3-6")
rr := httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusPartialContent || rr.Body.String() != "3456" {
t.Fatalf("GET status=%d body=%q", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Content-Range"); got != "bytes 3-6/10" {
t.Fatalf("Content-Range = %q", got)
}
deleteReq := httptest.NewRequest(http.MethodDelete, "/downloads/artifacts/artifact-range", nil)
deleteReq.Header.Set("Authorization", "Bearer "+testSecret)
deleteRR := httptest.NewRecorder()
server.Handler().ServeHTTP(deleteRR, deleteReq)
if deleteRR.Code != http.StatusNoContent {
t.Fatalf("DELETE status=%d body=%s", deleteRR.Code, deleteRR.Body.String())
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("artifact still exists: %v", err)
}
}
func TestDownloadArtifactHeadWaitsForInFlightPrepare(t *testing.T) {
server := newTestServer(t)
const artifactID = "artifact-in-flight"
unlocks := server.lockSessionLifecycle("download-artifact-" + artifactID)
req := httptest.NewRequest(http.MethodHead, "/downloads/artifacts/"+artifactID, nil)
req.SetPathValue("artifact_id", artifactID)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("artifact_id", artifactID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rr := httptest.NewRecorder()
done := make(chan struct{})
go func() {
server.handleDownloadArtifact(rr, req)
close(done)
}()
select {
case <-done:
unlocks()
t.Fatal("HEAD reported a result while preparation held the artifact lock")
case <-time.After(50 * time.Millisecond):
}
path := filepath.Join(server.artifactRoot, artifactID+".mp4")
if err := os.MkdirAll(server.artifactRoot, 0o755); err != nil {
unlocks()
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("ready"), 0o600); err != nil {
unlocks()
t.Fatal(err)
}
unlocks()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("HEAD did not resume after preparation published the artifact")
}
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestDownloadArtifactHeadDoesNotWaitForConcurrentRelay(t *testing.T) {
server := newTestServer(t)
const artifactID = "artifact-concurrent-read"
if err := os.MkdirAll(server.artifactRoot, 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(server.artifactRoot, artifactID+".mp4")
if err := os.WriteFile(path, []byte("ready"), 0o600); err != nil {
t.Fatal(err)
}
blocked := newBlockingResponseWriter()
releaseOnce := sync.Once{}
release := func() { releaseOnce.Do(func() { close(blocked.releaseWrite) }) }
defer release()
getDone := make(chan struct{})
go func() {
req := httptest.NewRequest(http.MethodGet, "/downloads/artifacts/"+artifactID, nil)
req.Header.Set("Authorization", "Bearer "+testSecret)
server.Handler().ServeHTTP(blocked, req)
close(getDone)
}()
select {
case <-blocked.writeStarted:
case <-time.After(2 * time.Second):
t.Fatal("GET did not begin relaying the artifact")
}
headDone := make(chan *httptest.ResponseRecorder, 1)
go func() {
req := httptest.NewRequest(http.MethodHead, "/downloads/artifacts/"+artifactID, nil)
req.Header.Set("Authorization", "Bearer "+testSecret)
rr := httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
headDone <- rr
}()
select {
case rr := <-headDone:
if rr.Code != http.StatusOK {
t.Fatalf("HEAD status = %d, body = %s", rr.Code, rr.Body.String())
}
case <-time.After(500 * time.Millisecond):
t.Fatal("HEAD waited for a concurrent artifact relay")
}
release()
select {
case <-getDone:
case <-time.After(2 * time.Second):
t.Fatal("GET did not finish after the response writer was released")
}
}
func TestDownloadArtifactRoutesRequireBearer(t *testing.T) {
server := newTestServer(t)
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodDelete} {
rr := httptest.NewRecorder()
server.Handler().ServeHTTP(rr, httptest.NewRequest(method, "/downloads/artifacts/artifact-1", nil))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("%s status=%d body=%s", method, rr.Code, rr.Body.String())
}
}
}
func TestHandleStartDistinctReplacementFailurePreservesPredecessor(t *testing.T) {
server := newTestServer(t)
server.sessions["public-session"] = &playback.TranscodeSession{}
+110
View File
@@ -0,0 +1,110 @@
// Package transcodeproxy owns the private HTTP completion contract shared by
// Silo's integrated API proxy and its dedicated proxy node.
package transcodeproxy
import (
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
const (
// RequestHeader marks a segment hop whose immediate consumer is another
// Silo server, not the playback client. The transcode node defers download
// accounting until that server confirms downstream completion.
RequestHeader = "X-Silo-Transcode-Proxy"
// GenerationHeader carries an opaque session-incarnation and FFmpeg-timeline
// token. It is private to Silo hops and must not be forwarded to the client.
GenerationHeader = "X-Silo-Transcode-Segment-Generation"
)
var representationRequestHeaders = []string{
"Range",
"If-Range",
"If-Match",
"If-None-Match",
"If-Modified-Since",
"If-Unmodified-Since",
}
// PrepareRequest makes the upstream node serve the same byte representation
// requested by the downstream client and suppresses completion at this hop.
func PrepareRequest(upstream, downstream *http.Request) {
upstream.Header.Set(RequestHeader, "1")
for _, name := range representationRequestHeaders {
for _, value := range downstream.Header.Values(name) {
upstream.Header.Add(name, value)
}
}
}
// CopyResponseHeaders forwards public response metadata while retaining the
// generation token inside the trusted Silo hop.
func CopyResponseHeaders(dst, src http.Header) {
for name, values := range src {
if http.CanonicalHeaderKey(name) == GenerationHeader {
continue
}
for _, value := range values {
dst.Add(name, value)
}
}
}
// FullRepresentationSize resolves the total entity size needed to distinguish
// a whole-file 206 response from an ordinary partial range.
func FullRepresentationSize(resp *http.Response) int64 {
if resp == nil {
return -1
}
if resp.StatusCode == http.StatusOK {
return resp.ContentLength
}
if resp.StatusCode != http.StatusPartialContent {
return -1
}
_, total, ok := strings.Cut(resp.Header.Get("Content-Range"), "/")
if !ok || total == "*" {
return -1
}
size, err := strconv.ParseInt(total, 10, 64)
if err != nil || size <= 0 {
return -1
}
return size
}
// Acknowledge reports a completed downstream response to the transcode node.
// The opaque generation token makes delayed acknowledgements harmless after a
// restart or reconstruction.
func Acknowledge(ctx context.Context, client *http.Client, targetURL, jwtSecret, generation string) error {
if client == nil {
client = http.DefaultClient
}
// The downstream request is commonly canceled as soon as its response body
// reaches the client, while the handler is still issuing this acknowledgement.
// Preserve request-scoped values, but give the post-response hop its own
// bounded lifetime so a successful delivery is not mistaken for a disconnect.
ackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ackCtx, http.MethodPost, targetURL+"/downloaded", nil)
if err != nil {
return fmt.Errorf("build acknowledgement: %w", err)
}
req.Header.Set("Authorization", "Bearer "+jwtSecret)
req.Header.Set(GenerationHeader, generation)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("send acknowledgement: %w", err)
}
defer func() { _ = resp.Body.Close() }()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("acknowledgement status %d", resp.StatusCode)
}
return nil
}
@@ -0,0 +1,42 @@
package transcodeproxy
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestAcknowledgeSurvivesCompletedDownstreamRequest(t *testing.T) {
const (
jwtSecret = "test-secret"
generation = "incarnation:generation"
)
acknowledged := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %q, want POST", r.Method)
}
if got := r.Header.Get("Authorization"); got != "Bearer "+jwtSecret {
t.Errorf("Authorization = %q", got)
}
if got := r.Header.Get(GenerationHeader); got != generation {
t.Errorf("generation = %q, want %q", got, generation)
}
acknowledged <- struct{}{}
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
requestCtx, cancelRequest := context.WithCancel(context.Background())
cancelRequest()
if err := Acknowledge(requestCtx, server.Client(), server.URL+"/segment", jwtSecret, generation); err != nil {
t.Fatalf("Acknowledge() error = %v", err)
}
select {
case <-acknowledged:
default:
t.Fatal("acknowledgement request was not delivered")
}
}
@@ -1,20 +0,0 @@
-- +goose Up
ALTER TABLE public.download_artifacts
ADD COLUMN origin_node_id integer NOT NULL DEFAULT 0,
ADD COLUMN origin_node_url text NOT NULL DEFAULT '',
ADD COLUMN origin_node_group text NOT NULL DEFAULT '',
ADD COLUMN origin_artifact_id text NOT NULL DEFAULT '',
ADD CONSTRAINT download_artifacts_origin_check
CHECK (
(origin_node_id = 0 AND origin_node_url = '' AND origin_node_group = '' AND origin_artifact_id = '')
OR
(origin_node_id > 0 AND origin_node_url <> '' AND origin_artifact_id <> '')
) NOT VALID;
-- +goose Down
ALTER TABLE public.download_artifacts
DROP CONSTRAINT download_artifacts_origin_check,
DROP COLUMN origin_artifact_id,
DROP COLUMN origin_node_group,
DROP COLUMN origin_node_url,
DROP COLUMN origin_node_id;
@@ -1,20 +0,0 @@
-- +goose Up
CREATE TABLE public.download_artifact_orphans (
id bigserial PRIMARY KEY,
download_artifact_id text NOT NULL,
origin_node_id integer NOT NULL,
origin_node_url text NOT NULL,
origin_artifact_id text NOT NULL,
attempts integer NOT NULL DEFAULT 0,
next_retry_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (origin_node_id, origin_artifact_id),
CONSTRAINT download_artifact_orphans_locator_check
CHECK (download_artifact_id <> '' AND origin_node_id > 0 AND origin_node_url <> '' AND origin_artifact_id <> '')
);
CREATE INDEX download_artifact_orphans_due_idx
ON public.download_artifact_orphans (next_retry_at, created_at);
-- +goose Down
DROP TABLE IF EXISTS public.download_artifact_orphans;
@@ -1,6 +0,0 @@
-- +goose Up
ALTER TABLE public.download_artifacts
VALIDATE CONSTRAINT download_artifacts_origin_check;
-- +goose Down
SELECT 1;
@@ -1,6 +0,0 @@
-- +goose Up
CREATE INDEX download_artifact_orphans_origin_fairness_idx
ON public.download_artifact_orphans (origin_node_id, origin_node_url, attempts, created_at, id);
-- +goose Down
DROP INDEX IF EXISTS public.download_artifact_orphans_origin_fairness_idx;
+1 -1
View File
@@ -145,7 +145,7 @@ function isCapturedProfileAuthorityActive(snapshot: ProfileRequestContextSnapsho
);
}
export function getOrCreateDeviceId(): string {
function getOrCreateDeviceId(): string {
const existing = storage.get(storage.KEYS.DEVICE_ID);
if (existing) {
return existing;

Some files were not shown because too many files have changed in this diff Show More