* feat(activity): refine play-method tags and add a Jellyfin-client pill
Two related tagging improvements to the admin activity views, squashed:
Split audio transcodes into their own tag. The Play Method summary and
Server Activity popover bucketed every session by its raw play_method,
lumping real video transcodes together with video-copy HLS repackages and
having no separate tag for audio-only transcodes. Classify each session by
the per-stream decisions the backend already reports:
- video re-encoded -> "transcode" (yellow)
- only audio re-encoded -> "audio" (red)
- streams only repackaged -> "remux" (blue, incl. video-copy HLS)
- nothing touched -> "direct" (green)
ordered direct -> remux -> transcode -> audio across the distribution bar,
legend, method filter/sort, the per-row badge, and the Server Activity
stream counts.
Add a Jellyfin-client "JF" pill. Sessions from a Jellyfin-ecosystem client
(Jellyfin Web, Findroid, Swiftfin, Infuse, etc.) get a purple "JF" pill
next to the play-method tag. Detection is UI-only: isJellyfinSession()
positively matches client_name (set from the Jellyfin MediaBrowser auth
header) and then the raw user agent against the known Jellyfin client
tokens, mirroring the server's client-labeling list. The pill is orthogonal
to the method classification — a session can be both "transcode" and JF.
Pure UI/presentation change; no backend behavior changes.
AI-use disclosure: implemented with AI assistance (Claude Code).
* fix(web): cache-control on SPA shell so deploys bust stale UI
The frontend handler served index.html with no cache directives, leaving
freshness to browser/CDN heuristics. A stale index.html at a CDN edge kept
serving old content-hashed bundles, so a client-side hard refresh couldn't
recover — one browser would show the new UI while another showed the old.
Apply the standard SPA cache policy:
- index.html (and SPA-route fallbacks): no-cache + a truncated-SHA-256
ETag, so the shell is cached but revalidated on every load and answers
an unchanged request with a cheap 304.
- /assets/* (Vite content-hashed bundles): public, max-age=31536000,
immutable — cached indefinitely; a new build changes the filename hash,
which busts them automatically.
- other stable-named bundled files (sw.js, icons, fonts): no-cache, so a
changed service worker or icon can't stay stuck in a cache.
Caching is preserved (no no-store anywhere); only the tiny HTML shell is
revalidated, which is what busts a stale UI on deploy.
* fix(activity): compute the method bucket server-side and unify every session surface
Review follow-ups for the play-method tags (PR #387):
- The server now emits effective_play_method (additive field) from the same
per-stream decisions that drive the badges, so all consumers — web, realtime
popover, and the Android/Apple admin views later — agree on the bucket
instead of each client re-reducing raw play_method. Rows with an unknown
play_method (stale rows from older nodes) stay unbucketed rather than being
misreported as audio transcodes off the bare transcode_audio flag; the web
fallback classifier mirrors that and reports "unknown".
- Jellyfin-ecosystem detection moved server-side as is_jellyfin_client, owned
next to the client-labeling rules so the two lists cannot drift; the web
token list is gone. Adds kodi/mpv/delfin/finamp, which reach Silo only
through the Jellyfin compat surface.
- The dashboard stream cards, stats session table, and household streams panel
now use the same classification as the activity page and popover — they
previously showed contradictory tags for the same live session.
- One shared method->label/color table in adminActivityPresentation.ts
replaces the four independent copies (METHOD_META + three switches); the
method column sort now uses the shared cost-order comparator instead of
alphabetical; dead "copy"/"hls" order entries removed and the reachable
"unknown" bucket is styled.
* fix(server): make SPA revalidation RFC-compliant and stop rebuilding the shell per request
Review follow-ups for the SPA cache policy (PR #387):
- Stable-URL bundled files (sw.js, icons, vendor bundles) now carry a content
ETag. The embedded FS has no modtimes, so http.FileServer emits no validator
of its own — no-cache alone forced a full re-download of multi-megabyte
vendor trees on every use because there was nothing to revalidate against.
- Shell and favicon conditional requests go through http.ServeContent, which
implements RFC 9110 If-None-Match semantics (weak comparison, ETag lists).
The previous exact string compare never matched once a fronting proxy
compressed the response and weakened the ETag to W/"...", silently killing
the 304 path in the most common deployment topology.
- The rendered shell (index read + branding render + SHA-256) is cached per
branding snapshot via the new Snapshot.RenderKey instead of being rebuilt on
every request — the 304 revalidation that no-cache makes the common case now
costs two header writes. The misnamed weakContentETag (it emits a strong
validator) is renamed contentETag.
* fix(activity): show the JF pill on every session surface, not just the mobile row
Review comments on PR #387: the JF pill only rendered inside Admin
Activity's sm:hidden mobile row, so the desktop table — and the other
session surfaces that now share the method classification — never
identified Jellyfin-compat sessions.
Extract the pill into a shared JellyfinSessionPill component (renders
nothing for native sessions) and drop it into the Admin Activity desktop
client line, the dashboard stream cards, the household streams panel,
and the stats active-session table.
* fix(playback): sync real encode decisions and client identity for compat transcodes
Review comments on PR #387:
- Jellyfin HLS sessions that copy video and re-encode only audio synced as
full video transcodes: ensureUpstreamPlayback resets transcodeAudio for the
transcode transport method, and the TargetCodecVideo "copy" decision lived
only in TranscodeOpts. A new SessionManager.SetTranscodeStreamDetails
mirrors the actual decisions onto the upstream session when the transcode
starts (local and remote-node paths, via an optional interface so test
fakes are unaffected), so these sessions now bucket as "audio"/"remux".
- Transcode recipe cards now record TranscodeAudio derived from the opts
(only an explicit "copy" leaves audio untouched — empty runs ffmpeg's aac
default), so a session rebuilt after a restart keeps the same bucket.
- Recipe cards carry client name/version/user-agent, and reconstruction
restores them, so the admin client label and the JF pill survive server
restarts; the compat fallback card populates them from the live
MediaBrowser request. Deliberately not projected into stream-token claims,
where a user agent would bloat every stream URL.
* feat(api): capability endpoint for the live-session activity fields
Review comment on PR #387: effective_play_method and is_jellyfin_client are
omitempty, so an independently deployed client cannot distinguish an older
server from a supported one reporting an unknown method or a non-Jellyfin
session. GET /admin/sessions/capabilities advertises both fields plus the
closed bucket vocabulary, following the additive capability-endpoint rule
(same pattern as /collections/capabilities).
* fix(playback): treat empty target audio codec as an AAC re-encode in live state
ffmpeg defaults an empty target audio codec to AAC (appendAudioArgs), and the
new recipe logic already records that as an audio transcode — but the live
native path computed transcodeAudio=false for an empty codec, so the running
stream reported remux until a restart flipped it to audio. Extract the
predicate into playback.TranscodesAudio, share it across the live path, the
recipe card, and the compat mirror, and make appendAudioArgs case-insensitive
so the ffmpeg switch agrees with the predicate for any spelling.
Part of #387 review follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(jellycompat): re-sync sessions after recording compat encode decisions
ensureUpstreamPlayback flushes the session (compat_start) before
ensureTranscodeSession / startRemoteTranscode record the actual codec
decisions, and that later mutation triggered no sync — so the admin view
showed a video-copy stream as a full video transcode until the periodic
reconciler ran. Trigger syncSessionsNow after the details are recorded
successfully; the helper is shared, so both the local and remote-node
paths are covered.
Part of #387 review follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
567 lines
19 KiB
Go
567 lines
19 KiB
Go
package playback
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestStartTranscodeRejectsUnvalidatedBitstreamFilter(t *testing.T) {
|
|
_, err := StartTranscode(context.Background(), TranscodeOpts{
|
|
VideoBitstreamFilter: "arbitrary_filter=1",
|
|
TargetCodecVideo: "copy",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("unvalidated bitstream filter was accepted")
|
|
}
|
|
_, err = StartTranscode(context.Background(), TranscodeOpts{
|
|
VideoBitstreamFilter: DV7ToHDR10BitstreamFilter,
|
|
TargetCodecVideo: "h264",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("DV copy filter was accepted for encoded video")
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_QSVDropsSuperfastPreset(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-1",
|
|
TargetCodecVideo: "h264",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
HWAccel: "qsv",
|
|
FastStart: true,
|
|
TargetResolution: "1080p",
|
|
TargetBitrateKbps: 2000,
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
if strings.Contains(joined, "-preset superfast") {
|
|
t.Fatalf("QSV args should not use superfast preset: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "-preset veryfast") {
|
|
t.Fatalf("QSV args should use veryfast preset: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_CPUPreservesSuperfastFastStart(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-1",
|
|
TargetCodecVideo: "h264",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
HWAccel: "none",
|
|
FastStart: true,
|
|
TargetResolution: "1080p",
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
if !strings.Contains(joined, "-preset superfast") {
|
|
t.Fatalf("CPU args should preserve superfast preset: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_CopyVideoFromStartUsesZeroBasedTimestamps(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-copy",
|
|
TargetCodecVideo: "copy",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
if strings.Contains(joined, "-copyts") {
|
|
t.Fatalf("copy-video from-start should not preserve source timestamps: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "-avoid_negative_ts make_zero") {
|
|
t.Fatalf("copy-video from-start should zero-base timestamps: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_CopyVideoAppliesValidatedBitstreamFilter(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-dv7",
|
|
TargetCodecVideo: "copy",
|
|
TargetCodecAudio: "copy",
|
|
VideoBitstreamFilter: DV7ToHDR10BitstreamFilter,
|
|
SegmentDuration: 2,
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
if !strings.Contains(joined, "-c:v copy -bsf:v dovi_rpu=strip=1") {
|
|
t.Fatalf("copy-video args should apply the validated DV bitstream filter: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_CopyVideoResumePreservesSourceTimestamps(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-copy-resume",
|
|
SeekSeconds: 478.0,
|
|
StartSegmentNumber: 239,
|
|
TargetCodecVideo: "copy",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
// Resume must preserve source timestamps so TFDT in seg_K matches
|
|
// playlist time K*segDur (the EXT-X-START anchor). Without -copyts,
|
|
// strict players (ATV / ExoPlayer) treat the TFDT/playlist mismatch
|
|
// as a discontinuity and abort.
|
|
if !strings.Contains(joined, "-copyts") {
|
|
t.Fatalf("copy-video resume should preserve source timestamps: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "-avoid_negative_ts disabled") {
|
|
t.Fatalf("copy-video resume should disable negative-ts adjustment: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "-avoid_negative_ts make_zero") {
|
|
t.Fatalf("copy-video resume must not zero-base timestamps (ATV resume regression): %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_CopyVideoSeekPreservesCodecCopy(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-copy-seek",
|
|
SeekSeconds: 240.86,
|
|
StartSegmentNumber: 120,
|
|
TargetCodecVideo: "copy",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
|
|
// Video must remain copy — no re-encoding.
|
|
if !strings.Contains(joined, "-c:v copy") {
|
|
t.Fatalf("copy-mode seek should preserve -c:v copy: %s", joined)
|
|
}
|
|
// Must not contain any video encoder.
|
|
for _, enc := range []string{"h264_qsv", "h264_vaapi", "h264_nvenc", "libx264", "hevc_qsv", "hevc_nvenc"} {
|
|
if strings.Contains(joined, enc) {
|
|
t.Fatalf("copy-mode seek should not use encoder %s: %s", enc, joined)
|
|
}
|
|
}
|
|
// Seek must be before input.
|
|
ssIdx := strings.Index(joined, "-ss")
|
|
iIdx := strings.Index(joined, "-i ")
|
|
if ssIdx < 0 || iIdx < 0 || ssIdx > iIdx {
|
|
t.Fatalf("seek (-ss) should appear before input (-i): %s", joined)
|
|
}
|
|
// Audio should be transcoded to AAC.
|
|
if !strings.Contains(joined, "-c:a aac") {
|
|
t.Fatalf("copy-mode seek should transcode audio to AAC: %s", joined)
|
|
}
|
|
// Should use -noaccurate_seek for copy video + transcode audio.
|
|
if !strings.Contains(joined, "-noaccurate_seek") {
|
|
t.Fatalf("copy-mode seek with audio transcode should use -noaccurate_seek: %s", joined)
|
|
}
|
|
// Should use fMP4 segments.
|
|
if !strings.Contains(joined, "-hls_segment_type fmp4") {
|
|
t.Fatalf("copy-mode should use fMP4 segments: %s", joined)
|
|
}
|
|
// Should have start_number for seek alignment.
|
|
if !strings.Contains(joined, "-start_number 120") {
|
|
t.Fatalf("copy-mode seek should set start_number: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_MPEG2CopyVideoUsesMPEGTS(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-mpeg2-copy",
|
|
SourceVideoCodec: "mpeg2video",
|
|
TargetCodecVideo: "copy",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
if !strings.Contains(joined, "-c:v copy") {
|
|
t.Fatalf("mpeg2 copy-mode should preserve video copy: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "-hls_segment_type mpegts") {
|
|
t.Fatalf("mpeg2 copy-mode should use MPEG-TS HLS segments: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "seg_%05d.ts") {
|
|
t.Fatalf("mpeg2 copy-mode should write .ts segments: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "movflags=+frag_discont") {
|
|
t.Fatalf("mpeg2 MPEG-TS copy-mode should not use fMP4 movflags: %s", joined)
|
|
}
|
|
for _, enc := range []string{"h264_qsv", "h264_vaapi", "h264_nvenc", "libx264", "hevc_qsv", "hevc_nvenc", "libx265"} {
|
|
if strings.Contains(joined, enc) {
|
|
t.Fatalf("mpeg2 copy-mode should not use encoder %s: %s", enc, joined)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_MPEG4Part2DisablesHardwareDecode(t *testing.T) {
|
|
for _, hwAccel := range []string{"qsv", "vaapi"} {
|
|
t.Run(hwAccel, func(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/xvid.avi",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-xvid",
|
|
SourceVideoCodec: "mpeg4",
|
|
TargetCodecVideo: "h264",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
HWAccel: hwAccel,
|
|
TargetResolution: "420p",
|
|
TargetBitrateKbps: 720,
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
for _, forbidden := range []string{
|
|
"-hwaccel vaapi",
|
|
"h264_qsv",
|
|
"h264_vaapi",
|
|
"scale_vaapi",
|
|
"hwmap=derive_device=qsv",
|
|
} {
|
|
if strings.Contains(joined, forbidden) {
|
|
t.Fatalf("mpeg4 part 2 source should use software transcode, found %q: %s", forbidden, joined)
|
|
}
|
|
}
|
|
if !strings.Contains(joined, "-c:v libx264") {
|
|
t.Fatalf("mpeg4 part 2 source should fall back to libx264: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "-vf scale=-2:420") {
|
|
t.Fatalf("mpeg4 part 2 software fallback should preserve requested scaling: %s", joined)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_BitmapBurnInCPUUsesOverlayFilterComplex(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-pgs",
|
|
SourceVideoCodec: "h264",
|
|
TargetCodecVideo: "h264",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
HWAccel: "none",
|
|
TargetResolution: "1080p",
|
|
SubtitleTrackIndex: 2,
|
|
SubtitleBurnIn: true,
|
|
SubtitleCodec: "hdmv_pgs_subtitle",
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
// Overlay runs at native resolution first, then scales.
|
|
want := "-filter_complex [0:v:0][0:s:2]overlay=eof_action=pass,scale=-2:1080[vout]"
|
|
if !strings.Contains(joined, want) {
|
|
t.Fatalf("bitmap burn-in should use overlay filter_complex %q: %s", want, joined)
|
|
}
|
|
// The graph output replaces the raw video stream mapping.
|
|
if !strings.Contains(joined, "-map [vout]") {
|
|
t.Fatalf("bitmap burn-in should map the filter graph output: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "-map 0:v:0") {
|
|
t.Fatalf("bitmap burn-in must not also map the raw video stream: %s", joined)
|
|
}
|
|
// -vf and -filter_complex on the same video stream is an ffmpeg error.
|
|
if strings.Contains(joined, "-vf ") {
|
|
t.Fatalf("bitmap burn-in must not emit -vf alongside -filter_complex: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "subtitles=") {
|
|
t.Fatalf("bitmap burn-in must not use the libass subtitles filter: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "-c:v libx264") {
|
|
t.Fatalf("bitmap burn-in requires a video encode: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_BitmapBurnInNoScaleKeepsNativeResolution(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-pgs-native",
|
|
TargetCodecVideo: "h264",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
HWAccel: "none",
|
|
SubtitleTrackIndex: 0,
|
|
SubtitleBurnIn: true,
|
|
SubtitleCodec: "dvd_subtitle",
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
if !strings.Contains(joined, "-filter_complex [0:v:0][0:s:0]overlay=eof_action=pass[vout]") {
|
|
t.Fatalf("native-resolution bitmap burn-in should overlay without scaling: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_BitmapBurnInVAAPIRoundTripsThroughCPU(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-pgs-vaapi",
|
|
SourceVideoCodec: "h264",
|
|
TargetCodecVideo: "h264",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
HWAccel: "vaapi",
|
|
TargetResolution: "720p",
|
|
SubtitleTrackIndex: 1,
|
|
SubtitleBurnIn: true,
|
|
SubtitleCodec: "hdmv_pgs_subtitle",
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
want := "-filter_complex [0:v:0]hwdownload,format=yuv420p[vmain];[vmain][0:s:1]overlay=eof_action=pass,scale=-2:720,format=nv12,hwupload[vout]"
|
|
if !strings.Contains(joined, want) {
|
|
t.Fatalf("vaapi bitmap burn-in should hwdownload → overlay → hwupload %q: %s", want, joined)
|
|
}
|
|
if !strings.Contains(joined, "-map [vout]") {
|
|
t.Fatalf("vaapi bitmap burn-in should map the filter graph output: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "-vf ") {
|
|
t.Fatalf("vaapi bitmap burn-in must not emit -vf: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "-c:v h264_vaapi") {
|
|
t.Fatalf("vaapi bitmap burn-in should keep the hardware encoder: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_TextBurnInStillUsesSubtitlesFilter(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-srt",
|
|
TargetCodecVideo: "h264",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
HWAccel: "none",
|
|
TargetResolution: "1080p",
|
|
SubtitleTrackIndex: 1,
|
|
SubtitleBurnIn: true,
|
|
SubtitleCodec: "subrip",
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
if !strings.Contains(joined, "-vf scale=-2:1080,subtitles='/media/movie.mkv':si=1") {
|
|
t.Fatalf("text burn-in should keep the libass subtitles -vf path: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "-filter_complex") {
|
|
t.Fatalf("text burn-in must not switch to filter_complex: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "-map 0:v:0") {
|
|
t.Fatalf("text burn-in should keep the raw video stream mapping: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_LegacyBurnInWithoutCodecKeepsTextPath(t *testing.T) {
|
|
// Recipe cards / tokens minted before SubtitleCodec existed decode with an
|
|
// empty codec; they must reconstruct the exact same (text) command line.
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-legacy",
|
|
TargetCodecVideo: "h264",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
HWAccel: "none",
|
|
SubtitleTrackIndex: 0,
|
|
SubtitleBurnIn: true,
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
if !strings.Contains(joined, "subtitles='/media/movie.mkv':si=0") {
|
|
t.Fatalf("legacy burn-in without codec should keep the subtitles filter: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "-filter_complex") {
|
|
t.Fatalf("legacy burn-in without codec must not use filter_complex: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_BitmapBurnInWithCopyVideoIsInert(t *testing.T) {
|
|
// The API layer forces an encode before starting a burn-in transcode; if a
|
|
// copy recipe slips through anyway the builder must stay a valid copy
|
|
// command (no filter graph, raw stream mapping) rather than emit filters
|
|
// against an unencoded stream.
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-copy-burnin",
|
|
TargetCodecVideo: "copy",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
SubtitleTrackIndex: 0,
|
|
SubtitleBurnIn: true,
|
|
SubtitleCodec: "hdmv_pgs_subtitle",
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
if !strings.Contains(joined, "-c:v copy") {
|
|
t.Fatalf("copy recipe should stay codec copy: %s", joined)
|
|
}
|
|
// Note: "-filter_complex_threads" is a legitimate copy-mode arg; only the
|
|
// filter graph option itself must be absent.
|
|
if strings.Contains(joined, "-filter_complex ") || strings.Contains(joined, "overlay") {
|
|
t.Fatalf("copy recipe must not emit a filter graph: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "-map 0:v:0") {
|
|
t.Fatalf("copy recipe should map the raw video stream: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestResolveEffectiveTranscodeHWAccel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
opts TranscodeOpts
|
|
want string
|
|
}{
|
|
{
|
|
name: "hardware video transcode",
|
|
opts: TranscodeOpts{HWAccel: "qsv", SourceVideoCodec: "h264", TargetCodecVideo: "h264"},
|
|
want: "qsv",
|
|
},
|
|
{
|
|
name: "copy video does not use hardware encode",
|
|
opts: TranscodeOpts{HWAccel: "qsv", SourceVideoCodec: "h264", TargetCodecVideo: "copy"},
|
|
want: "none",
|
|
},
|
|
{
|
|
name: "mpeg4 part 2 falls back to software",
|
|
opts: TranscodeOpts{HWAccel: "vaapi", SourceVideoCodec: "mpeg4", TargetCodecVideo: "h264"},
|
|
want: "none",
|
|
},
|
|
{
|
|
name: "nvenc passthrough",
|
|
opts: TranscodeOpts{HWAccel: "nvenc", SourceVideoCodec: "h264", TargetCodecVideo: "h264"},
|
|
want: "nvenc",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := resolveEffectiveTranscodeHWAccel(tt.opts); got != tt.want {
|
|
t.Fatalf("resolveEffectiveTranscodeHWAccel() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_NVENCH264UsesCudaPipeline(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-nvenc",
|
|
SourceVideoCodec: "h264",
|
|
TargetCodecVideo: "h264",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
HWAccel: "nvenc",
|
|
TargetResolution: "720p",
|
|
TargetBitrateKbps: 2000,
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
if !strings.Contains(joined, "-hwaccel cuda") {
|
|
t.Fatalf("nvenc args should enable cuda hwaccel: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "-c:v h264_nvenc") {
|
|
t.Fatalf("nvenc args should use h264_nvenc encoder: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "-vf scale_cuda=w=-2:h=720:format=nv12") {
|
|
t.Fatalf("nvenc args should use scale_cuda, not software scale: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "-vf scale=-2:720") {
|
|
t.Fatalf("nvenc args must not use software scale on cuda frames: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "-b:v 2000k -maxrate 2000k -bufsize 4000k") {
|
|
t.Fatalf("nvenc args should include bitrate cap controls: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_VAAPIScalingUsesHardwareFilter(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-vaapi",
|
|
SourceVideoCodec: "h264",
|
|
TargetCodecVideo: "h264",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
HWAccel: "vaapi",
|
|
TargetResolution: "720p",
|
|
TargetBitrateKbps: 2000,
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
if !strings.Contains(joined, "-hwaccel vaapi") {
|
|
t.Fatalf("vaapi args should enable vaapi hwaccel: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "-vf scale_vaapi=w=-2:h=720:format=nv12") {
|
|
t.Fatalf("vaapi args should use scale_vaapi, not software scale: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "-vf scale=-2:720") {
|
|
t.Fatalf("vaapi args must not use software scale on hardware frames: %s", joined)
|
|
}
|
|
}
|
|
|
|
func TestBuildFFmpegArgs_EncodedTranscodePreservesExistingTimestampPolicy(t *testing.T) {
|
|
args := buildFFmpegArgs(TranscodeOpts{
|
|
InputPath: "/media/movie.mkv",
|
|
OutputDir: "/tmp/out",
|
|
SessionID: "session-encoded",
|
|
SeekSeconds: 2780.63,
|
|
TargetCodecVideo: "h264",
|
|
TargetCodecAudio: "aac",
|
|
SegmentDuration: 2,
|
|
})
|
|
|
|
joined := strings.Join(args, " ")
|
|
if !strings.Contains(joined, "-copyts") {
|
|
t.Fatalf("encoded args should preserve original timestamps: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "-avoid_negative_ts disabled") {
|
|
t.Fatalf("encoded args should keep avoid_negative_ts disabled: %s", joined)
|
|
}
|
|
}
|
|
|
|
// TranscodesAudio must agree with appendAudioArgs: only an explicit "copy"
|
|
// passes audio through; an empty codec runs ffmpeg's AAC default.
|
|
func TestTranscodesAudioMatchesFFmpegDefault(t *testing.T) {
|
|
cases := []struct {
|
|
codec string
|
|
want bool
|
|
}{
|
|
{"copy", false},
|
|
{"COPY", false},
|
|
{"", true},
|
|
{"aac", true},
|
|
{"opus", true},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := TranscodesAudio(tc.codec); got != tc.want {
|
|
t.Errorf("TranscodesAudio(%q) = %v, want %v", tc.codec, got, tc.want)
|
|
}
|
|
args := appendAudioArgs(nil, TranscodeOpts{TargetCodecAudio: tc.codec})
|
|
copied := strings.Contains(strings.Join(args, " "), "-c:a copy")
|
|
if copied != !tc.want {
|
|
t.Errorf("appendAudioArgs(%q) copy=%v disagrees with TranscodesAudio=%v", tc.codec, copied, tc.want)
|
|
}
|
|
}
|
|
}
|