diff --git a/Dockerfile.govod b/Dockerfile.govod new file mode 100644 index 0000000..0a3b223 --- /dev/null +++ b/Dockerfile.govod @@ -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"] diff --git a/Dockerfile.transcode b/Dockerfile.transcode deleted file mode 100644 index ec3912d..0000000 --- a/Dockerfile.transcode +++ /dev/null @@ -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"] diff --git a/config.example.yaml b/config.example.yaml deleted file mode 100644 index 7dd10c3..0000000 --- a/config.example.yaml +++ /dev/null @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 432f9f2..097a15b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,19 +15,17 @@ services: transcode: build: context: . - dockerfile: Dockerfile.transcode + dockerfile: Dockerfile.govod devices: - /dev/dri:/dev/dri environment: - - TRANSCODE_VAAPI_DEVICE=/dev/dri/renderD128 - LIBVA_DRIVER_NAME=i965 volumes: - /mnt/user/plex:/media:ro - - ./transcode-config.yaml:/app/config.yaml:ro - - transcode-cache-v5:/cache + - govod-cache:/cache ports: - "8090:8080" restart: unless-stopped volumes: - transcode-cache-v5: + govod-cache: diff --git a/govod-config.json b/govod-config.json new file mode 100644 index 0000000..98fbcfe --- /dev/null +++ b/govod-config.json @@ -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 +} diff --git a/internal/api/api.go b/internal/api/api.go index ac94c1b..bad2626 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,8 +1,11 @@ package api import ( + "bytes" "encoding/json" + "fmt" "net/http" + "os/exec" "strconv" "strings" @@ -27,6 +30,7 @@ func NewRouter(lib *library.Library) http.Handler { r.Get("/api/search", searchHandler(lib)) r.Get("/api/rescan", rescanHandler(lib)) r.Get("/api/stream/{id}", streamHandler(lib)) + r.Get("/api/subtitles/{id}/{index}", subtitleHandler(lib)) return r } @@ -101,12 +105,15 @@ func detailHandler(lib *library.Library) http.HandlerFunc { probeResult, err := probe.Run(fullPath) if err != nil { writeJSON(w, http.StatusOK, map[string]any{ - "item": item, - "probe": nil, + "item": item, + "probe": nil, "probe_error": err.Error(), }) 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{ "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 { return func(w http.ResponseWriter, r *http.Request) { go lib.Scan() diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..4e8bc70 --- /dev/null +++ b/internal/api/api_test.go @@ -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) + } + } +} diff --git a/internal/library/library.go b/internal/library/library.go index 82e70c0..ece7836 100644 --- a/internal/library/library.go +++ b/internal/library/library.go @@ -61,7 +61,7 @@ func (l *Library) Scan() error { RelPath: f.RelPath, Size: f.Size, ModTime: f.ModTime, - StreamURL: "/vod/" + f.RelPath + "/index.m3u8", + StreamURL: "/vod/" + id + "/media/" + f.RelPath + "/index.m3u8", fullPath: f.Path, } items = append(items, item) diff --git a/internal/probe/probe.go b/internal/probe/probe.go index 4d9956b..f5eb05a 100644 --- a/internal/probe/probe.go +++ b/internal/probe/probe.go @@ -8,13 +8,13 @@ import ( ) type ProbeResult struct { - Duration float64 `json:"duration"` - Width int `json:"width"` - Height int `json:"height"` - VideoCodec string `json:"video_codec"` - Bitrate int64 `json:"bitrate"` - Audio []AudioTrack `json:"audio_tracks"` - Subtitles []SubTrack `json:"subtitle_tracks"` + Duration float64 `json:"duration"` + Width int `json:"width"` + Height int `json:"height"` + VideoCodec string `json:"video_codec"` + Bitrate int64 `json:"bitrate"` + Audio []AudioTrack `json:"audio_tracks"` + Subtitles []SubTrack `json:"subtitle_tracks"` } type AudioTrack struct { @@ -28,6 +28,7 @@ type SubTrack struct { Index int `json:"index"` Codec string `json:"codec"` Language string `json:"language"` + URL string `json:"url,omitempty"` } type ffprobeOutput struct { diff --git a/patches/go-transcode-vaapi.patch b/patches/go-transcode-vaapi.patch deleted file mode 100644 index 73370d5..0000000 --- a/patches/go-transcode-vaapi.patch +++ /dev/null @@ -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, diff --git a/patches/go-vod-sheepflix.patch b/patches/go-vod-sheepflix.patch new file mode 100644 index 0000000..87ee382 --- /dev/null +++ b/patches/go-vod-sheepflix.patch @@ -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 { diff --git a/transcode-config.yaml b/transcode-config.yaml deleted file mode 100644 index c53709e..0000000 --- a/transcode-config.yaml +++ /dev/null @@ -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