ok trnscode swp

This commit is contained in:
Olive Vaughn 2026-08-26 02:16:50 -04:00
parent 54668c0d93
commit e9766de877
12 changed files with 221 additions and 243 deletions

29
Dockerfile.govod Normal file
View file

@ -0,0 +1,29 @@
FROM golang:1.23-bookworm AS builder
ARG MEMORIES_COMMIT=951b79a38f5f6438c956b98d63f760fdb4cd6c50
WORKDIR /src
RUN git clone https://github.com/pulsejet/memories.git . \
&& git checkout "$MEMORIES_COMMIT"
WORKDIR /src/go-vod
COPY patches/go-vod-sheepflix.patch /tmp/go-vod-sheepflix.patch
RUN git apply /tmp/go-vod-sheepflix.patch \
&& gofmt -w transcoder/handler.go transcoder/stream.go \
&& CGO_ENABLED=0 go build -ldflags="-s -w" -o /go-vod .
# go-vod itself uses Jellyfin's FFmpeg distribution. Pin the stable Jellyfin
# runtime so driver/codec behavior does not change unexpectedly on rebuild.
FROM jellyfin/jellyfin:10.11.11
USER root
RUN ln -sf /usr/lib/jellyfin-ffmpeg/ffmpeg /usr/local/bin/ffmpeg \
&& ln -sf /usr/lib/jellyfin-ffmpeg/ffprobe /usr/local/bin/ffprobe
COPY --from=builder /go-vod /app/go-vod
COPY govod-config.json /app/config.json
EXPOSE 8080
ENV LIBVA_DRIVER_NAME=i965
ENTRYPOINT ["/app/go-vod", "/app/config.json"]

View file

@ -1,35 +0,0 @@
FROM golang:1.23-alpine AS builder
ARG GO_TRANSCODE_COMMIT=f8137e68c7b8da88c51aec9563eec9585c10c723
WORKDIR /src
RUN apk add --no-cache git
RUN git clone https://github.com/m1k1o/go-transcode.git . \
&& git checkout "$GO_TRANSCODE_COMMIT"
COPY patches/go-transcode-vaapi.patch /tmp/go-transcode-vaapi.patch
RUN git apply /tmp/go-transcode-vaapi.patch \
&& gofmt -w hlsvod/manager.go hlsvod/probe.go hlsvod/transcode.go \
&& CGO_ENABLED=0 go build -o /go-transcode .
# Keep Alpine 3.17: upstream documents an FFmpeg 6 segmenting regression with
# newer Alpine releases. Install both Intel and Mesa VAAPI drivers so this also
# works if the Unraid host exposes an AMD render device.
FROM alpine:3.17
WORKDIR /app
RUN apk add --no-cache \
bash \
ffmpeg \
intel-media-driver \
libva-intel-driver \
mesa-va-gallium
COPY --from=builder /go-transcode /app/go-transcode
EXPOSE 8080
ENV TRANSCODE_BIND=:8080 \
TRANSCODE_VAAPI_DEVICE=/dev/dri/renderD128
ENTRYPOINT ["/app/go-transcode"]
CMD ["serve"]

View file

@ -1,28 +0,0 @@
# go-transcode VOD configuration. Hardware encoding is enabled by the
# TRANSCODE_VAAPI_DEVICE environment variable in docker-compose.yml.
vod:
media-dir: /media
transcode-dir: /tmp/go-transcode
cache: true
cache-dir: /cache
# Avoid expensive full-file keyframe scans. FFmpeg forces HLS boundaries.
video-keyframes: false
video-profiles:
480p:
width: 854
height: 480
bitrate: 1200
720p:
width: 1280
height: 720
bitrate: 3000
1080p:
width: 1920
height: 1080
bitrate: 6000
audio-profile:
bitrate: 192

View file

@ -15,19 +15,17 @@ services:
transcode: transcode:
build: build:
context: . context: .
dockerfile: Dockerfile.transcode dockerfile: Dockerfile.govod
devices: devices:
- /dev/dri:/dev/dri - /dev/dri:/dev/dri
environment: environment:
- TRANSCODE_VAAPI_DEVICE=/dev/dri/renderD128
- LIBVA_DRIVER_NAME=i965 - LIBVA_DRIVER_NAME=i965
volumes: volumes:
- /mnt/user/plex:/media:ro - /mnt/user/plex:/media:ro
- ./transcode-config.yaml:/app/config.yaml:ro - govod-cache:/cache
- transcode-cache-v5:/cache
ports: ports:
- "8090:8080" - "8090:8080"
restart: unless-stopped restart: unless-stopped
volumes: volumes:
transcode-cache-v5: govod-cache:

16
govod-config.json Normal file
View file

@ -0,0 +1,16 @@
{
"bind": ":8080",
"tempdir": "/cache",
"chunkSize": 4,
"lookBehind": 3,
"goalBufferMin": 3,
"goalBufferMax": 8,
"streamIdleTime": 60,
"managerIdleTime": 300,
"qf": 22,
"vaapi": true,
"vaapiLowPower": false,
"useTranspose": true,
"forceSwTranspose": false,
"useGopSize": true
}

View file

@ -1,8 +1,11 @@
package api package api
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"os/exec"
"strconv" "strconv"
"strings" "strings"
@ -27,6 +30,7 @@ func NewRouter(lib *library.Library) http.Handler {
r.Get("/api/search", searchHandler(lib)) r.Get("/api/search", searchHandler(lib))
r.Get("/api/rescan", rescanHandler(lib)) r.Get("/api/rescan", rescanHandler(lib))
r.Get("/api/stream/{id}", streamHandler(lib)) r.Get("/api/stream/{id}", streamHandler(lib))
r.Get("/api/subtitles/{id}/{index}", subtitleHandler(lib))
return r return r
} }
@ -101,12 +105,15 @@ func detailHandler(lib *library.Library) http.HandlerFunc {
probeResult, err := probe.Run(fullPath) probeResult, err := probe.Run(fullPath)
if err != nil { if err != nil {
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"item": item, "item": item,
"probe": nil, "probe": nil,
"probe_error": err.Error(), "probe_error": err.Error(),
}) })
return return
} }
for i := range probeResult.Subtitles {
probeResult.Subtitles[i].URL = fmt.Sprintf("/api/subtitles/%s/%d", id, probeResult.Subtitles[i].Index)
}
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"item": item, "item": item,
@ -142,6 +149,75 @@ func streamHandler(lib *library.Library) http.HandlerFunc {
} }
} }
func subtitleHandler(lib *library.Library) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
index, err := strconv.Atoi(chi.URLParam(r, "index"))
if err != nil || index < 0 {
http.Error(w, "invalid subtitle index", http.StatusBadRequest)
return
}
_, fullPath, ok := lib.Get(id)
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
metadata, err := probe.Run(fullPath)
if err != nil {
http.Error(w, "unable to inspect media", http.StatusInternalServerError)
return
}
var subtitle *probe.SubTrack
for i := range metadata.Subtitles {
if metadata.Subtitles[i].Index == index {
subtitle = &metadata.Subtitles[i]
break
}
}
if subtitle == nil {
http.Error(w, "subtitle track not found", http.StatusNotFound)
return
}
if !isTextSubtitleCodec(subtitle.Codec) {
http.Error(w, fmt.Sprintf("subtitle codec %q requires OCR or burn-in", subtitle.Codec), http.StatusUnsupportedMediaType)
return
}
cmd := exec.CommandContext(r.Context(), "ffmpeg",
"-v", "error",
"-i", fullPath,
"-map", fmt.Sprintf("0:%d", index),
"-c:s", "webvtt",
"-f", "webvtt",
"pipe:1",
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
http.Error(w, fmt.Sprintf("subtitle conversion failed: %s", strings.TrimSpace(stderr.String())), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = w.Write(stdout.Bytes())
}
}
func isTextSubtitleCodec(codec string) bool {
switch strings.ToLower(codec) {
case "ass", "ssa", "subrip", "srt", "text", "webvtt", "mov_text":
return true
default:
return false
}
}
func rescanHandler(lib *library.Library) http.HandlerFunc { func rescanHandler(lib *library.Library) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
go lib.Scan() go lib.Scan()

17
internal/api/api_test.go Normal file
View file

@ -0,0 +1,17 @@
package api
import "testing"
func TestIsTextSubtitleCodec(t *testing.T) {
for _, codec := range []string{"ass", "ssa", "subrip", "srt", "text", "webvtt", "mov_text", "SUBRIP"} {
if !isTextSubtitleCodec(codec) {
t.Errorf("expected %q to be treated as text", codec)
}
}
for _, codec := range []string{"hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle", ""} {
if isTextSubtitleCodec(codec) {
t.Errorf("expected %q to require bitmap handling", codec)
}
}
}

View file

@ -61,7 +61,7 @@ func (l *Library) Scan() error {
RelPath: f.RelPath, RelPath: f.RelPath,
Size: f.Size, Size: f.Size,
ModTime: f.ModTime, ModTime: f.ModTime,
StreamURL: "/vod/" + f.RelPath + "/index.m3u8", StreamURL: "/vod/" + id + "/media/" + f.RelPath + "/index.m3u8",
fullPath: f.Path, fullPath: f.Path,
} }
items = append(items, item) items = append(items, item)

View file

@ -8,13 +8,13 @@ import (
) )
type ProbeResult struct { type ProbeResult struct {
Duration float64 `json:"duration"` Duration float64 `json:"duration"`
Width int `json:"width"` Width int `json:"width"`
Height int `json:"height"` Height int `json:"height"`
VideoCodec string `json:"video_codec"` VideoCodec string `json:"video_codec"`
Bitrate int64 `json:"bitrate"` Bitrate int64 `json:"bitrate"`
Audio []AudioTrack `json:"audio_tracks"` Audio []AudioTrack `json:"audio_tracks"`
Subtitles []SubTrack `json:"subtitle_tracks"` Subtitles []SubTrack `json:"subtitle_tracks"`
} }
type AudioTrack struct { type AudioTrack struct {
@ -28,6 +28,7 @@ type SubTrack struct {
Index int `json:"index"` Index int `json:"index"`
Codec string `json:"codec"` Codec string `json:"codec"`
Language string `json:"language"` Language string `json:"language"`
URL string `json:"url,omitempty"`
} }
type ffprobeOutput struct { type ffprobeOutput struct {

View file

@ -1,139 +0,0 @@
diff --git a/hlsvod/manager.go b/hlsvod/manager.go
index 662c41d..51460f8 100644
--- a/hlsvod/manager.go
+++ b/hlsvod/manager.go
@@ -378,12 +378,17 @@ func (m *ManagerCtx) waitForSegment(index int) (chan struct{}, bool) {
func (m *ManagerCtx) transcodeSegments(offset, limit int) error {
logger := m.logger.With().Int("offset", offset).Int("limit", limit).Logger()
+ inputCodec := ""
+ if m.metadata.Video != nil {
+ inputCodec = m.metadata.Video.CodecName
+ }
segmentTimes := m.breakpoints[offset : offset+limit+1]
logger.Info().Interface("segments-times", segmentTimes).Msg("transcoding segments")
segments, err := TranscodeSegments(m.ctx, m.config.FFmpegBinary, TranscodeConfig{
InputFilePath: m.config.MediaPath,
+ InputCodec: inputCodec,
OutputDirPath: m.config.TranscodeDir,
SegmentPrefix: m.config.SegmentPrefix, // This does not need to match.
diff --git a/hlsvod/probe.go b/hlsvod/probe.go
index c39461c..58c3edd 100644
--- a/hlsvod/probe.go
+++ b/hlsvod/probe.go
@@ -83,6 +83,7 @@ func ProbeMedia(ctx context.Context, ffprobeBinary string, inputFilePath string)
}
data.Video = &ProbeVideoData{
+ CodecName: stream.CodecName,
Width: stream.Width,
Height: stream.Height,
Duration: duration,
@@ -118,6 +119,7 @@ func ProbeMedia(ctx context.Context, ffprobeBinary string, inputFilePath string)
}
type ProbeVideoData struct {
+ CodecName string
Width int
Height int
Duration time.Duration
diff --git a/hlsvod/transcode.go b/hlsvod/transcode.go
index 9bbe037..c0297fe 100644
--- a/hlsvod/transcode.go
+++ b/hlsvod/transcode.go
@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"log"
+ "os"
"os/exec"
"path"
"strings"
@@ -13,6 +14,7 @@ import (
type TranscodeConfig struct {
InputFilePath string // Transcoded video input.
+ InputCodec string // ffprobe codec name, e.g. h264 or hevc.
OutputDirPath string // Segments output path.
SegmentPrefix string // e.g. prefix-000001.ts
SegmentOffset int // Start segment number.
@@ -69,6 +71,21 @@ func TranscodeSegments(ctx context.Context, ffmpegBinary string, config Transcod
}...)
}
+ // Haswell can decode H.264 in hardware, but not HEVC Main 10. Use full
+ // hardware transcoding for H.264 and software decode plus VAAPI upload for
+ // other codecs. Scaling and H.264 encoding always remain on the GPU.
+ vaapiDevice := os.Getenv("TRANSCODE_VAAPI_DEVICE")
+ hardwareDecode := vaapiDevice != "" && config.InputCodec == "h264"
+ if vaapiDevice != "" {
+ args = append(args, []string{
+ "-init_hw_device", "vaapi=va:" + vaapiDevice,
+ "-filter_hw_device", "va",
+ }...)
+ if hardwareDecode {
+ args = append(args, "-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi")
+ }
+ }
+
// Input specs
args = append(args, []string{
"-i", config.InputFilePath, // Input file
@@ -89,14 +106,30 @@ func TranscodeSegments(ctx context.Context, ffmpegBinary string, config Transcod
scale = fmt.Sprintf("scale=%d:-2", profile.Width)
}
- args = append(args, []string{
- "-vf", scale,
- "-c:v", "libx264",
- "-preset", "faster",
- "-profile:v", "high",
- "-level:v", "4.0",
- "-b:v", fmt.Sprintf("%dk", profile.Bitrate),
- }...)
+ if vaapiDevice != "" {
+ if profile.Width >= profile.Height {
+ scale = fmt.Sprintf("scale_vaapi=w=-2:h=%d:format=nv12", profile.Height)
+ } else {
+ scale = fmt.Sprintf("scale_vaapi=w=%d:h=-2:format=nv12", profile.Width)
+ }
+ if !hardwareDecode {
+ scale = "format=nv12,hwupload," + scale
+ }
+ args = append(args, []string{
+ "-vf", scale, "-c:v", "h264_vaapi", "-profile:v", "high",
+ "-level:v", "4.0", "-rc_mode", "VBR",
+ "-b:v", fmt.Sprintf("%dk", profile.Bitrate),
+ "-maxrate", fmt.Sprintf("%dk", profile.Bitrate*3/2),
+ "-bufsize", fmt.Sprintf("%dk", profile.Bitrate*2),
+ "-bf", "2", "-compression_level", "0",
+ }...)
+ } else {
+ args = append(args, []string{
+ "-vf", scale, "-c:v", "libx264", "-preset", "faster",
+ "-profile:v", "high", "-level:v", "4.0",
+ "-b:v", fmt.Sprintf("%dk", profile.Bitrate),
+ }...)
+ }
}
// Audio specs
@@ -106,12 +127,15 @@ func TranscodeSegments(ctx context.Context, ffmpegBinary string, config Transcod
args = append(args, []string{
"-c:a", "aac",
"-b:a", fmt.Sprintf("%dk", profile.Bitrate),
+ "-af", "aresample=async=1:min_hard_comp=0.100",
}...)
}
// Segmenting specs
args = append(args, []string{
"-f", "segment",
+ "-muxdelay", "0",
+ "-muxpreload", "0",
"-segment_time_delta", "0.2",
"-segment_format", "mpegts",
"-segment_times", commaSeparatedSegTimes,

View file

@ -0,0 +1,69 @@
diff --git a/transcoder/handler.go b/transcoder/handler.go
index 8c5bb08..0ad7458 100644
--- a/transcoder/handler.go
+++ b/transcoder/handler.go
@@ -37,6 +37,14 @@ func NewHandler(c *Config) *Handler {
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Range, Content-Type")
+ if r.Method == http.MethodOptions {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
// Check version if monitoring is enabled
if h.c.VersionMonitor && !h.versionOk(w, r) {
return
@@ -55,15 +63,15 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Serve actual file from manager
- if len(parts) < 3 {
+ if len(parts) < 5 || parts[0] != "vod" {
log.Println("Invalid URL", url)
w.WriteHeader(http.StatusBadRequest)
return
}
// Get streamid and chunk
- streamid := parts[0]
- path := "/" + strings.Join(parts[1:len(parts)-1], "/")
+ streamid := parts[1]
+ path := "/" + strings.Join(parts[2:len(parts)-1], "/")
chunk := parts[len(parts)-1]
// Check if POST request to create temp file
diff --git a/transcoder/stream.go b/transcoder/stream.go
index cf353fc..f8694fc 100644
--- a/transcoder/stream.go
+++ b/transcoder/stream.go
@@ -370,8 +370,12 @@ func (s *Stream) transcodeArgs(startAt float64, isHls bool) []string {
// Check whether hwaccel should be used
if s.c.VAAPI {
CV = ENCODER_VAAPI
- extra := "-hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format vaapi -init_hw_device vaapi=memories:/dev/dri/renderD128 -filter_hw_device memories"
+ extra := "-init_hw_device vaapi=memories:/dev/dri/renderD128 -filter_hw_device memories"
args = append(args, strings.Split(extra, " ")...)
+ if s.m.probe.CodecName == CODEC_H264 {
+ extra = "-hwaccel vaapi -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format vaapi"
+ args = append(args, strings.Split(extra, " ")...)
+ }
} else if s.c.NVENC {
CV = ENCODER_NVENC
extra := "-hwaccel cuda"
@@ -397,7 +401,11 @@ func (s *Stream) transcodeArgs(startAt float64, isHls bool) []string {
scalerArgs = append(scalerArgs, "force_original_aspect_ratio=decrease")
if CV == ENCODER_VAAPI {
- format = "format=nv12|vaapi,hwupload"
+ if s.m.probe.CodecName == CODEC_H264 {
+ format = "format=nv12|vaapi,hwupload"
+ } else {
+ format = "format=nv12,hwupload"
+ }
scaler = "scale_vaapi"
scalerArgs = append(scalerArgs, "format=nv12")
} else if CV == ENCODER_NVENC {

View file

@ -1,26 +0,0 @@
cors: true
vod:
media-dir: /media
cache: true
cache-dir: /cache
# Full-file keyframe probing is expensive and can be OOM-killed on Unraid.
# FFmpeg will force segment-boundary keyframes during transcoding instead.
video-keyframes: false
video-profiles:
1080p:
width: 1920
height: 1080
bitrate: 6000
720p:
width: 1280
height: 720
bitrate: 3000
480p:
width: 854
height: 480
bitrate: 1200
audio-profile:
bitrate: 192