Merge pull request #238 from bnema/feat/cef-codec-runtime-cleanup
Use codec-enabled CEF runtime and remove transcoding proxy
37 files · +95 -3941
M README.md +25 -0
| @@ -76,6 +76,31 @@ sudo apt install libwebkitgtk-6.0-4 libgtk-4-1 gstreamer1.0-plugins-base gstream | |||||
| 76 | 76 | ||||
| 77 | > ⚠️ Ubuntu 24.04 ships GLib 2.80, but Dumber requires GLib 2.84+. Use Arch, Fedora 41+, or the Flatpak. | 77 | > ⚠️ Ubuntu 24.04 ships GLib 2.80, but Dumber requires GLib 2.84+. Use Arch, Fedora 41+, or the Flatpak. | ||
| 78 | 78 | ||||
| 79 | + | ### CEF engine (optional) | |||
| 80 | + | ||||
| 81 | + | Dumber supports a Chromium Embedded Framework engine as an alternative to WebKit. | |||
| 82 | + | To use it on Arch: | |||
| 83 | + | ||||
| 84 | + | ```bash | |||
| 85 | + | sudo pacman -S cef | |||
| 86 | + | ``` | |||
| 87 | + | ||||
| 88 | + | CEF runtime precedence is deterministic: `CEF_DIR` wins first, then `engine.cef.cef_dir`, then `/usr/lib/cef`, then `~/.local/share/cef`. | |||
| 89 | + | Set `engine.cef.cef_dir` in config when `CEF_DIR` is unset: | |||
| 90 | + | ||||
| 91 | + | ```toml | |||
| 92 | + | [engine.cef] | |||
| 93 | + | cef_dir = "/custom/path" | |||
| 94 | + | ``` | |||
| 95 | + | ||||
| 96 | + | Or set the higher-precedence `CEF_DIR` environment variable: | |||
| 97 | + | ||||
| 98 | + | ```bash | |||
| 99 | + | CEF_DIR=/custom/path dumber browse | |||
| 100 | + | ``` | |||
| 101 | + | ||||
| 102 | + | Then set `engine.type = "cef"` in your config. | |||
| 103 | + | ||||
| 79 | ## Keyboard-Driven | 104 | ## Keyboard-Driven | ||
| 80 | 105 | ||||
| 81 | Four modal modes. Enter a mode, act, escape out. Vim and Zellij users already know this pattern. | 106 | Four modal modes. Enter a mode, act, escape out. Vim and Zellij users already know this pattern. | ||
M go.mod +1 -3
| @@ -6,8 +6,7 @@ require ( | |||||
| 6 | github.com/a-h/templ v0.3.1001 | 6 | github.com/a-h/templ v0.3.1001 | ||
| 7 | github.com/andybalholm/brotli v1.2.1 | 7 | github.com/andybalholm/brotli v1.2.1 | ||
| 8 | github.com/bnema/purego v0.11.0-bnema.2 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | ||
| 9 | - | github.com/bnema/purego-cef v0.11.0 | 9 | + | github.com/bnema/purego-cef v0.12.0 |
| 10 | - | github.com/bnema/purego-ffmpeg v0.3.1 | |||
| 11 | github.com/bnema/purego-pipewire v0.1.2 | 10 | github.com/bnema/purego-pipewire v0.1.2 | ||
| 12 | github.com/bnema/purego-sqlite v0.1.2 | 11 | github.com/bnema/purego-sqlite v0.1.2 | ||
| 13 | github.com/bnema/puregotk v0.5.1 | 12 | github.com/bnema/puregotk v0.5.1 | ||
| @@ -16,7 +15,6 @@ require ( | |||||
| 16 | github.com/charmbracelet/lipgloss v1.1.0 | 15 | github.com/charmbracelet/lipgloss v1.1.0 | ||
| 17 | github.com/fsnotify/fsnotify v1.9.0 | 16 | github.com/fsnotify/fsnotify v1.9.0 | ||
| 18 | github.com/godbus/dbus/v5 v5.2.2 | 17 | github.com/godbus/dbus/v5 v5.2.2 | ||
| 19 | - | github.com/google/uuid v1.6.0 | |||
| 20 | github.com/pelletier/go-toml/v2 v2.3.0 | 18 | github.com/pelletier/go-toml/v2 v2.3.0 | ||
| 21 | github.com/pressly/goose/v3 v3.27.1 | 19 | github.com/pressly/goose/v3 v3.27.1 | ||
| 22 | github.com/rs/zerolog v1.35.1 | 20 | github.com/rs/zerolog v1.35.1 | ||
M go.sum +2 -2 generated file: collapsed
D internal/application/port/transcoder.go +0 -35
| @@ -1,35 +0,0 @@ | |||||
| 1 | - | package port | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "io" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | // TranscodeSession represents an active GPU transcoding session. | |||
| 9 | - | // Read returns transcoded chunks. Close aborts and frees GPU resources. | |||
| 10 | - | type TranscodeSession interface { | |||
| 11 | - | io.ReadCloser | |||
| 12 | - | // ContentType returns the MIME type of the transcoded output (e.g., "video/webm"). | |||
| 13 | - | ContentType() string | |||
| 14 | - | } | |||
| 15 | - | ||||
| 16 | - | // HWCapabilities describes the detected GPU hardware encoding capabilities. | |||
| 17 | - | type HWCapabilities struct { | |||
| 18 | - | API string // "vaapi", "nvenc", or "" (none available) | |||
| 19 | - | Device string // auto-detected device path | |||
| 20 | - | Encoders []string // e.g., ["av1_vaapi"] | |||
| 21 | - | Decoders []string // e.g., ["h264_vaapi"] | |||
| 22 | - | } | |||
| 23 | - | ||||
| 24 | - | // MediaTranscoder handles GPU-accelerated video transcoding. | |||
| 25 | - | type MediaTranscoder interface { | |||
| 26 | - | // Available returns true if a compatible GPU encoder was found. | |||
| 27 | - | Available() bool | |||
| 28 | - | // Capabilities returns the detected GPU hardware info. | |||
| 29 | - | Capabilities() HWCapabilities | |||
| 30 | - | // Start begins a GPU transcode session, fetching from sourceURL. | |||
| 31 | - | // Headers are forwarded to the source HTTP request (cookies, auth). | |||
| 32 | - | Start(ctx context.Context, sourceURL string, headers map[string]string) (TranscodeSession, error) | |||
| 33 | - | // Close shuts down all active transcode sessions and releases resources. | |||
| 34 | - | Close() | |||
| 35 | - | } | |||
M internal/bootstrap/engine.go +1 -16
| @@ -15,7 +15,6 @@ import ( | |||||
| 15 | "github.com/bnema/dumber/internal/infrastructure/env" | 15 | "github.com/bnema/dumber/internal/infrastructure/env" | ||
| 16 | "github.com/bnema/dumber/internal/infrastructure/handlers" | 16 | "github.com/bnema/dumber/internal/infrastructure/handlers" | ||
| 17 | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | 17 | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | ||
| 18 | - | "github.com/bnema/dumber/internal/infrastructure/transcoder" | |||
| 19 | "github.com/bnema/dumber/internal/infrastructure/webkit" | 18 | "github.com/bnema/dumber/internal/infrastructure/webkit" | ||
| 20 | "github.com/bnema/dumber/internal/ui/theme" | 19 | "github.com/bnema/dumber/internal/ui/theme" | ||
| 21 | "github.com/rs/zerolog" | 20 | "github.com/rs/zerolog" | ||
| @@ -84,12 +83,6 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 84 | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | 83 | EnableAudioHandler: cfg.Engine.CEF.EnableAudioHandler, | ||
| 85 | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | 84 | TraceHandlers: cfg.Engine.CEF.TraceHandlers, | ||
| 86 | } | 85 | } | ||
| 87 | - | transcodingCfg := cef.TranscodingRuntimeConfig{ | |||
| 88 | - | Enabled: cfg.Transcoding.Enabled, | |||
| 89 | - | HWAccel: cfg.Transcoding.HWAccel, | |||
| 90 | - | MaxConcurrent: cfg.Transcoding.MaxConcurrent, | |||
| 91 | - | Quality: cfg.Transcoding.Quality, | |||
| 92 | - | } | |||
| 93 | deps := cef.EngineDependencies{ | 86 | deps := cef.EngineDependencies{ | ||
| 94 | RegisterHandlers: handlers.RegisterAll, | 87 | RegisterHandlers: handlers.RegisterAll, | ||
| 95 | RegisterAccentHandlers: handlers.RegisterAccentHandlers, | 88 | RegisterAccentHandlers: handlers.RegisterAccentHandlers, | ||
| @@ -99,20 +92,12 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 99 | ContextMenuExecutorFactory: contextMenuExecutorFactory, | 92 | ContextMenuExecutorFactory: contextMenuExecutorFactory, | ||
| 100 | Clipboard: clipboardinfra.New(), | 93 | Clipboard: clipboardinfra.New(), | ||
| 101 | ImageDataResolver: webkit.NewContextMenuResolver(), | 94 | ImageDataResolver: webkit.NewContextMenuResolver(), | ||
| 102 | - | MediaClassifier: cef.MediaClassifier{ | |||
| 103 | - | IsProprietaryVideoMIME: transcoder.IsProprietaryVideoMIME, | |||
| 104 | - | IsOpenVideoMIME: transcoder.IsOpenVideoMIME, | |||
| 105 | - | IsStreamingManifestMIME: transcoder.IsStreamingManifestMIME, | |||
| 106 | - | IsStreamingManifestURL: transcoder.IsStreamingManifestURL, | |||
| 107 | - | IsEagerTranscodeURL: transcoder.IsEagerTranscodeURL, | |||
| 108 | - | ParseSyntheticTranscodeURL: transcoder.ParseSyntheticTranscodeURL, | |||
| 109 | - | }, | |||
| 110 | } | 95 | } | ||
| 111 | audioFactory := audiofactory.NewAudioOutputFactory() | 96 | audioFactory := audiofactory.NewAudioOutputFactory() | ||
| 112 | return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{ | 97 | return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{ | ||
| 113 | StateRoot: profile.CEFUserDataDir(), | 98 | StateRoot: profile.CEFUserDataDir(), | ||
| 114 | LogFile: profile.CEFLogFile(), | 99 | LogFile: profile.CEFLogFile(), | ||
| 115 | - | }, cefCfg, transcodingCfg, audioFactory, deps) | 100 | + | }, cefCfg, audioFactory, deps) |
| 116 | default: | 101 | default: | ||
| 117 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | 102 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | ||
| 118 | } | 103 | } | ||
M internal/domain/url/startup.go +5 -2
| @@ -1,5 +1,7 @@ | |||||
| 1 | package url | 1 | package url | ||
| 2 | 2 | ||||
| 3 | + | import "strings" | |||
| 4 | + | ||||
| 3 | const defaultBrowserStartupURL = "dumb://history" | 5 | const defaultBrowserStartupURL = "dumb://history" | ||
| 4 | 6 | ||||
| 5 | // DefaultBrowserStartupURL is the user-facing destination used when the browser | 7 | // DefaultBrowserStartupURL is the user-facing destination used when the browser | ||
| @@ -12,8 +14,9 @@ func DefaultBrowserStartupURL() string { | |||||
| 12 | // ResolveBrowserStartupURL returns the explicit URL when provided, otherwise | 14 | // ResolveBrowserStartupURL returns the explicit URL when provided, otherwise | ||
| 13 | // the global user-facing browser startup URL. | 15 | // the global user-facing browser startup URL. | ||
| 14 | func ResolveBrowserStartupURL(rawURL string) string { | 16 | func ResolveBrowserStartupURL(rawURL string) string { | ||
| 15 | - | if rawURL != "" { | 17 | + | trimmed := strings.TrimSpace(rawURL) |
| 16 | - | return rawURL | 18 | + | if trimmed != "" { |
| 19 | + | return trimmed | |||
| 17 | } | 20 | } | ||
| 18 | return defaultBrowserStartupURL | 21 | return defaultBrowserStartupURL | ||
| 19 | } | 22 | } | ||
M internal/domain/url/startup_test.go +7 -0
| @@ -16,6 +16,13 @@ func TestResolveBrowserStartupURL_DefaultsToHistory(t *testing.T) { | |||||
| 16 | } | 16 | } | ||
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | + | func TestResolveBrowserStartupURL_WhitespaceOnlyDefaultsToHistory(t *testing.T) { | |||
| 20 | + | got := ResolveBrowserStartupURL(" ") | |||
| 21 | + | if got != "dumb://history" { | |||
| 22 | + | t.Fatalf("expected default browser startup URL for whitespace-only input, got %q", got) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 19 | func TestDefaultBrowserStartupURL(t *testing.T) { | 26 | func TestDefaultBrowserStartupURL(t *testing.T) { | ||
| 20 | got := DefaultBrowserStartupURL() | 27 | got := DefaultBrowserStartupURL() | ||
| 21 | if got != "dumb://history" { | 28 | if got != "dumb://history" { | ||
M internal/infrastructure/cef/content_injector.go +1 -231
| @@ -3,8 +3,6 @@ package cef | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | - | "net/url" | |||
| 7 | - | "strings" | |||
| 8 | "sync" | 6 | "sync" | ||
| 9 | 7 | ||||
| 10 | "github.com/bnema/dumber/internal/infrastructure/webutil" | 8 | "github.com/bnema/dumber/internal/infrastructure/webutil" | ||
| @@ -121,7 +119,7 @@ const videoDiagnosticJS = `(function(){ | |||||
| 121 | } | 119 | } | ||
| 122 | 120 | ||||
| 123 | // Patch attachShadow to automatically observe new shadow roots. | 121 | // Patch attachShadow to automatically observe new shadow roots. | ||
| 124 | - | // Use a marker to avoid double-patching if redditDirectVideoJS also patches. | 122 | + | // Use a marker to avoid double-patching. |
| 125 | var origAttach = Element.prototype.__dumberOrigAttachShadow || Element.prototype.attachShadow; | 123 | var origAttach = Element.prototype.__dumberOrigAttachShadow || Element.prototype.attachShadow; | ||
| 126 | if (!Element.prototype.__dumberPatched) { | 124 | if (!Element.prototype.__dumberPatched) { | ||
| 127 | Element.prototype.__dumberOrigAttachShadow = origAttach; | 125 | Element.prototype.__dumberOrigAttachShadow = origAttach; | ||
| @@ -164,227 +162,6 @@ const videoDiagnosticJS = `(function(){ | |||||
| 164 | 'aac:', MediaSource ? MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') : 'n/a'); | 162 | 'aac:', MediaSource ? MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') : 'n/a'); | ||
| 165 | })();` | 163 | })();` | ||
| 166 | 164 | ||||
| 167 | - | // redditDirectVideoJS replaces Reddit's blob/MSE playback path with a direct | |||
| 168 | - | // HLS source when MP4 MSE buffers are unsupported. This lets the browser issue | |||
| 169 | - | // a normal media request that our transcoding handler can answer with WebM. | |||
| 170 | - | const redditDirectVideoJS = `(function(){ | |||
| 171 | - | var tag = '[REDDIT-VIDEO-PATCH]'; | |||
| 172 | - | if (!/(\.|^)reddit\.com$/i.test(location.hostname)) return; | |||
| 173 | - | if (!window.MediaSource) return; | |||
| 174 | - | if (MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e"') && | |||
| 175 | - | MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"')) { | |||
| 176 | - | return; | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | console.warn(tag, 'active for unsupported Reddit MSE playback'); | |||
| 180 | - | ||||
| 181 | - | function ensureStyle() { | |||
| 182 | - | if (document.getElementById('dumber-reddit-direct-video-style')) return; | |||
| 183 | - | var style = document.createElement('style'); | |||
| 184 | - | style.id = 'dumber-reddit-direct-video-style'; | |||
| 185 | - | style.textContent = | |||
| 186 | - | 'shreddit-player[data-dumber-direct-video="1"] { position: relative !important; }' + | |||
| 187 | - | 'shreddit-player[data-dumber-direct-video="1"] > .dumber-direct-video {' + | |||
| 188 | - | ' position: absolute !important; inset: 0 !important; width: 100% !important; height: 100% !important;' + | |||
| 189 | - | ' object-fit: contain !important; background: #000 !important; z-index: 2147483647 !important;' + | |||
| 190 | - | ' display: block !important; }'; | |||
| 191 | - | document.head.appendChild(style); | |||
| 192 | - | } | |||
| 193 | - | ||||
| 194 | - | function candidateRoots(video) { | |||
| 195 | - | var roots = []; | |||
| 196 | - | var seen = []; | |||
| 197 | - | function add(root) { | |||
| 198 | - | if (!root) return; | |||
| 199 | - | if (seen.indexOf(root) !== -1) return; | |||
| 200 | - | seen.push(root); | |||
| 201 | - | roots.push(root); | |||
| 202 | - | } | |||
| 203 | - | ||||
| 204 | - | var current = video; | |||
| 205 | - | while (current) { | |||
| 206 | - | add(current); | |||
| 207 | - | if (current.shadowRoot) add(current.shadowRoot); | |||
| 208 | - | current = current.parentElement; | |||
| 209 | - | } | |||
| 210 | - | add(document); | |||
| 211 | - | return roots; | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | function findHlsSource(video) { | |||
| 215 | - | var selectors = [ | |||
| 216 | - | 'source[type="application/vnd.apple.mpegURL"][src]', | |||
| 217 | - | 'source[type="application/x-mpegURL"][src]', | |||
| 218 | - | 'source[src*="HLSPlaylist.m3u8"]', | |||
| 219 | - | '[src*="HLSPlaylist.m3u8"]' | |||
| 220 | - | ]; | |||
| 221 | - | ||||
| 222 | - | var roots = candidateRoots(video); | |||
| 223 | - | for (var i = 0; i < roots.length; i++) { | |||
| 224 | - | var root = roots[i]; | |||
| 225 | - | if (!root.querySelector) continue; | |||
| 226 | - | for (var j = 0; j < selectors.length; j++) { | |||
| 227 | - | var el = root.querySelector(selectors[j]); | |||
| 228 | - | if (el && el.src) return el.src; | |||
| 229 | - | } | |||
| 230 | - | if (root.querySelectorAll) { | |||
| 231 | - | var all = root.querySelectorAll('*'); | |||
| 232 | - | for (var k = 0; k < all.length; k++) { | |||
| 233 | - | if (all[k].shadowRoot) { | |||
| 234 | - | roots.push(all[k].shadowRoot); | |||
| 235 | - | } | |||
| 236 | - | } | |||
| 237 | - | } | |||
| 238 | - | } | |||
| 239 | - | return ''; | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | - | function buildPlaybackURL(hlsURL) { | |||
| 243 | - | return location.origin + '/__dumber__/transcode.webm?src=' + encodeURIComponent(hlsURL) + | |||
| 244 | - | '&referer=' + encodeURIComponent(location.href) + | |||
| 245 | - | '&origin=' + encodeURIComponent(location.origin); | |||
| 246 | - | } | |||
| 247 | - | ||||
| 248 | - | function getPlayerRoot(video) { | |||
| 249 | - | return video.closest('shreddit-player') || video.parentElement; | |||
| 250 | - | } | |||
| 251 | - | ||||
| 252 | - | function monitorReplacement(video) { | |||
| 253 | - | if (!video || video._dumberReplacementDiag) return; | |||
| 254 | - | video._dumberReplacementDiag = true; | |||
| 255 | - | [ | |||
| 256 | - | 'loadstart', 'loadedmetadata', 'loadeddata', 'canplay', 'playing', | |||
| 257 | - | 'waiting', 'stalled', 'suspend', 'abort', 'emptied', 'error', | |||
| 258 | - | ].forEach(function(evt) { | |||
| 259 | - | video.addEventListener(evt, function() { | |||
| 260 | - | var err = video.error ? ('code=' + video.error.code + ' msg=' + video.error.message) : 'none'; | |||
| 261 | - | console.warn(tag, 'replacement', evt, 'ready:', video.readyState, 'net:', video.networkState, | |||
| 262 | - | 'paused:', video.paused, 'error:', err, 'src:', | |||
| 263 | - | (video.currentSrc || video.src || '').substring(0, 120)); | |||
| 264 | - | }); | |||
| 265 | - | }); | |||
| 266 | - | } | |||
| 267 | - | ||||
| 268 | - | function ensureReplacement(video, playbackURL) { | |||
| 269 | - | var root = getPlayerRoot(video); | |||
| 270 | - | if (!root) return null; | |||
| 271 | - | ensureStyle(); | |||
| 272 | - | ||||
| 273 | - | var replacement = root.querySelector(':scope > .dumber-direct-video'); | |||
| 274 | - | if (!replacement) { | |||
| 275 | - | replacement = document.createElement('video'); | |||
| 276 | - | replacement.className = 'dumber-direct-video'; | |||
| 277 | - | replacement.controls = true; | |||
| 278 | - | replacement.playsInline = true; | |||
| 279 | - | replacement.preload = 'auto'; | |||
| 280 | - | replacement.autoplay = true; | |||
| 281 | - | replacement.muted = true; | |||
| 282 | - | replacement.loop = video.loop; | |||
| 283 | - | root.setAttribute('data-dumber-direct-video', '1'); | |||
| 284 | - | root.appendChild(replacement); | |||
| 285 | - | } | |||
| 286 | - | ||||
| 287 | - | replacement.poster = video.poster || replacement.poster || ''; | |||
| 288 | - | monitorReplacement(replacement); | |||
| 289 | - | if (replacement.src !== playbackURL) { | |||
| 290 | - | replacement.src = playbackURL; | |||
| 291 | - | replacement.load(); | |||
| 292 | - | } | |||
| 293 | - | return replacement; | |||
| 294 | - | } | |||
| 295 | - | ||||
| 296 | - | function shouldForce(video) { | |||
| 297 | - | var current = video.currentSrc || video.src || ''; | |||
| 298 | - | return current.indexOf('blob:') === 0; | |||
| 299 | - | } | |||
| 300 | - | ||||
| 301 | - | function forceDirectPlayback(video) { | |||
| 302 | - | if (!video || !shouldForce(video)) return; | |||
| 303 | - | ||||
| 304 | - | var hlsURL = findHlsSource(video); | |||
| 305 | - | if (!hlsURL) { | |||
| 306 | - | console.warn(tag, 'blob video found without HLS source'); | |||
| 307 | - | return; | |||
| 308 | - | } | |||
| 309 | - | var playbackURL = buildPlaybackURL(hlsURL); | |||
| 310 | - | if (video._dumberPlaybackURL === playbackURL) return; | |||
| 311 | - | video._dumberPlaybackURL = playbackURL; | |||
| 312 | - | ||||
| 313 | - | console.warn(tag, 'forcing replacement video source:', playbackURL.substring(0, 120), 'from:', hlsURL.substring(0, 120)); | |||
| 314 | - | ||||
| 315 | - | var replacement = ensureReplacement(video, playbackURL); | |||
| 316 | - | if (!replacement) return; | |||
| 317 | - | ||||
| 318 | - | var root = getPlayerRoot(video); | |||
| 319 | - | if (root && root.querySelectorAll) { | |||
| 320 | - | root.querySelectorAll('video').forEach(function(candidate) { | |||
| 321 | - | if (candidate === replacement) return; | |||
| 322 | - | try { candidate.pause(); } catch (e) {} | |||
| 323 | - | try { candidate.removeAttribute('src'); } catch (e) {} | |||
| 324 | - | try { candidate.srcObject = null; } catch (e) {} | |||
| 325 | - | try { candidate.load(); } catch (e) {} | |||
| 326 | - | candidate.style.display = 'none'; | |||
| 327 | - | }); | |||
| 328 | - | } else { | |||
| 329 | - | try { video.pause(); } catch (e) {} | |||
| 330 | - | try { video.removeAttribute('src'); } catch (e) {} | |||
| 331 | - | try { video.srcObject = null; } catch (e) {} | |||
| 332 | - | try { video.load(); } catch (e) {} | |||
| 333 | - | video.style.display = 'none'; | |||
| 334 | - | } | |||
| 335 | - | ||||
| 336 | - | var playPromise = replacement.play(); | |||
| 337 | - | if (playPromise && typeof playPromise.catch === 'function') { | |||
| 338 | - | playPromise.catch(function(err) { | |||
| 339 | - | console.warn(tag, 'replacement video.play() rejected:', err && err.message ? err.message : err); | |||
| 340 | - | }); | |||
| 341 | - | } | |||
| 342 | - | } | |||
| 343 | - | ||||
| 344 | - | function patchTree(root) { | |||
| 345 | - | if (!root || !root.querySelectorAll) return; | |||
| 346 | - | root.querySelectorAll('video').forEach(forceDirectPlayback); | |||
| 347 | - | root.querySelectorAll('*').forEach(function(el) { | |||
| 348 | - | if (el.shadowRoot) patchTree(el.shadowRoot); | |||
| 349 | - | }); | |||
| 350 | - | } | |||
| 351 | - | ||||
| 352 | - | new MutationObserver(function(mutations) { | |||
| 353 | - | mutations.forEach(function(mutation) { | |||
| 354 | - | if (mutation.type === 'attributes' && mutation.target && mutation.target.nodeName === 'VIDEO') { | |||
| 355 | - | forceDirectPlayback(mutation.target); | |||
| 356 | - | } | |||
| 357 | - | mutation.addedNodes.forEach(function(node) { | |||
| 358 | - | if (node.nodeName === 'VIDEO') forceDirectPlayback(node); | |||
| 359 | - | patchTree(node); | |||
| 360 | - | if (node.shadowRoot) patchTree(node.shadowRoot); | |||
| 361 | - | }); | |||
| 362 | - | }); | |||
| 363 | - | }).observe(document.documentElement, { | |||
| 364 | - | subtree: true, | |||
| 365 | - | childList: true, | |||
| 366 | - | attributes: true, | |||
| 367 | - | attributeFilter: ['src'] | |||
| 368 | - | }); | |||
| 369 | - | ||||
| 370 | - | // Chain to existing attachShadow patch (e.g. videoDiagnosticJS) instead of overwriting. | |||
| 371 | - | var prevAttach = Element.prototype.attachShadow; | |||
| 372 | - | Element.prototype.attachShadow = function(opts) { | |||
| 373 | - | var sr = prevAttach.call(this, opts); | |||
| 374 | - | patchTree(sr); | |||
| 375 | - | return sr; | |||
| 376 | - | }; | |||
| 377 | - | ||||
| 378 | - | patchTree(document); | |||
| 379 | - | var pollCount = 0; | |||
| 380 | - | var maxPolls = 30; | |||
| 381 | - | var pollID = setInterval(function() { | |||
| 382 | - | patchTree(document); | |||
| 383 | - | pollCount++; | |||
| 384 | - | if (pollCount >= maxPolls) clearInterval(pollID); | |||
| 385 | - | }, 1000); | |||
| 386 | - | })();` | |||
| 387 | - | ||||
| 388 | // contentInjector implements port.ContentInjector for the CEF engine. | 165 | // contentInjector implements port.ContentInjector for the CEF engine. | ||
| 389 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | 166 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | ||
| 390 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | 167 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | ||
| @@ -523,13 +300,6 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||||
| 523 | if ci.videoDiagnosticsEnabled { | 300 | if ci.videoDiagnosticsEnabled { | ||
| 524 | wv.RunJavaScript(context.Background(), videoDiagnosticJS) | 301 | wv.RunJavaScript(context.Background(), videoDiagnosticJS) | ||
| 525 | } | 302 | } | ||
| 526 | - | ||||
| 527 | - | if parsed, err := url.Parse(uri); err == nil { | |||
| 528 | - | host := parsed.Hostname() | |||
| 529 | - | if host == "reddit.com" || strings.HasSuffix(host, ".reddit.com") { | |||
| 530 | - | wv.RunJavaScript(context.Background(), redditDirectVideoJS) | |||
| 531 | - | } | |||
| 532 | - | } | |||
| 533 | } | 303 | } | ||
| 534 | 304 | ||||
| 535 | // injectCSS injects a CSS string as a <style> element via JavaScript. | 305 | // injectCSS injects a CSS string as a <style> element via JavaScript. | ||
M internal/infrastructure/cef/engine.go +0 -45
| @@ -42,7 +42,6 @@ type Engine struct { | |||||
| 42 | clipboardTextOrchestrator port.ClipboardTextOrchestrator | 42 | clipboardTextOrchestrator port.ClipboardTextOrchestrator | ||
| 43 | onClipboardCopied func(textLen int) | 43 | onClipboardCopied func(textLen int) | ||
| 44 | resolver port.ImageDataResolver | 44 | resolver port.ImageDataResolver | ||
| 45 | - | mediaClassifier MediaClassifier | |||
| 46 | downloadMu sync.RWMutex | 45 | downloadMu sync.RWMutex | ||
| 47 | downloadHandler *downloadHandler | 46 | downloadHandler *downloadHandler | ||
| 48 | alreadyRunningAppRelaunchMu sync.RWMutex | 47 | alreadyRunningAppRelaunchMu sync.RWMutex | ||
| @@ -71,20 +70,6 @@ type Engine struct { | |||||
| 71 | browserCreateLastHeight atomic.Int32 | 70 | browserCreateLastHeight atomic.Int32 | ||
| 72 | browserCreateComplete atomic.Bool | 71 | browserCreateComplete atomic.Bool | ||
| 73 | 72 | ||||
| 74 | - | transcoderState transcoderStartupState | |||
| 75 | - | transcoderStateLogged atomic.Bool | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | type transcoderStartupState struct { | |||
| 79 | - | ConfigEnabled bool | |||
| 80 | - | ProbeAttempted bool | |||
| 81 | - | HWAccel string | |||
| 82 | - | MaxConcurrent int | |||
| 83 | - | Quality string | |||
| 84 | - | Status string | |||
| 85 | - | API string | |||
| 86 | - | Encoders []string | |||
| 87 | - | Decoders []string | |||
| 88 | } | 73 | } | ||
| 89 | 74 | ||||
| 90 | func (e *Engine) Factory() port.WebViewFactory { | 75 | func (e *Engine) Factory() port.WebViewFactory { | ||
| @@ -387,7 +372,6 @@ func (e *Engine) SetHandlerContext(ctx context.Context) { | |||||
| 387 | if e.messageRouter != nil { | 372 | if e.messageRouter != nil { | ||
| 388 | e.messageRouter.SetBaseContext(ctx) | 373 | e.messageRouter.SetBaseContext(ctx) | ||
| 389 | } | 374 | } | ||
| 390 | - | e.logTranscoderStartupState() | |||
| 391 | } | 375 | } | ||
| 392 | 376 | ||||
| 393 | func (e *Engine) recordContextInitialized() { | 377 | func (e *Engine) recordContextInitialized() { | ||
| @@ -492,32 +476,3 @@ func (e *Engine) currentDownloadHandler() *downloadHandler { | |||||
| 492 | return e.downloadHandler | 476 | return e.downloadHandler | ||
| 493 | } | 477 | } | ||
| 494 | 478 | ||||
| 495 | - | func (e *Engine) logTranscoderStartupState() { | |||
| 496 | - | if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) { | |||
| 497 | - | return | |||
| 498 | - | } | |||
| 499 | - | ||||
| 500 | - | state := e.transcoderState | |||
| 501 | - | log := logging.FromContext(e.currentContext()) | |||
| 502 | - | event := log.Info(). | |||
| 503 | - | Str("component", "cef-transcoder"). | |||
| 504 | - | Bool("config_enabled", state.ConfigEnabled). | |||
| 505 | - | Bool("probe_attempted", state.ProbeAttempted). | |||
| 506 | - | Str("hwaccel", state.HWAccel). | |||
| 507 | - | Int("max_concurrent", state.MaxConcurrent). | |||
| 508 | - | Str("quality", state.Quality). | |||
| 509 | - | Str("status", state.Status). | |||
| 510 | - | Bool("request_handler_enabled", e.factory != nil && e.factory.transcoder != nil) | |||
| 511 | - | ||||
| 512 | - | if state.API != "" { | |||
| 513 | - | event = event.Str("api", state.API) | |||
| 514 | - | } | |||
| 515 | - | if len(state.Encoders) > 0 { | |||
| 516 | - | event = event.Strs("encoders", state.Encoders) | |||
| 517 | - | } | |||
| 518 | - | if len(state.Decoders) > 0 { | |||
| 519 | - | event = event.Strs("decoders", state.Decoders) | |||
| 520 | - | } | |||
| 521 | - | ||||
| 522 | - | event.Msg("cef: transcoder startup state") | |||
| 523 | - | } | |||
M internal/infrastructure/cef/engine_init.go +1 -80
| @@ -18,7 +18,6 @@ import ( | |||||
| 18 | "github.com/bnema/dumber/assets" | 18 | "github.com/bnema/dumber/assets" | ||
| 19 | "github.com/bnema/dumber/internal/application/port" | 19 | "github.com/bnema/dumber/internal/application/port" | ||
| 20 | "github.com/bnema/dumber/internal/infrastructure/config" | 20 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 21 | - | transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder" | |||
| 22 | "github.com/bnema/dumber/internal/logging" | 21 | "github.com/bnema/dumber/internal/logging" | ||
| 23 | ) | 22 | ) | ||
| 24 | 23 | ||||
| @@ -37,12 +36,10 @@ func NewEngine( | |||||
| 37 | opts port.EngineOptions, | 36 | opts port.EngineOptions, | ||
| 38 | paths RuntimePaths, | 37 | paths RuntimePaths, | ||
| 39 | cfg RuntimeConfig, | 38 | cfg RuntimeConfig, | ||
| 40 | - | transcodingCfg TranscodingRuntimeConfig, | |||
| 41 | audioFactory port.AudioOutputFactory, | 39 | audioFactory port.AudioOutputFactory, | ||
| 42 | deps EngineDependencies, | 40 | deps EngineDependencies, | ||
| 43 | ) (*Engine, error) { | 41 | ) (*Engine, error) { | ||
| 44 | logger := logging.FromContext(ctx) | 42 | logger := logging.FromContext(ctx) | ||
| 45 | - | deps.MediaClassifier = deps.MediaClassifier.normalize() | |||
| 46 | stateRoot := resolvedStateRoot(paths.StateRoot, opts) | 43 | stateRoot := resolvedStateRoot(paths.StateRoot, opts) | ||
| 47 | cleanStaleSingletonLocks(logger, stateRoot) | 44 | cleanStaleSingletonLocks(logger, stateRoot) | ||
| 48 | windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate() | 45 | windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate() | ||
| @@ -67,7 +64,6 @@ func NewEngine( | |||||
| 67 | ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory, | 64 | ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory, | ||
| 68 | clipboard: deps.Clipboard, | 65 | clipboard: deps.Clipboard, | ||
| 69 | resolver: deps.ImageDataResolver, | 66 | resolver: deps.ImageDataResolver, | ||
| 70 | - | mediaClassifier: deps.MediaClassifier, | |||
| 71 | } | 67 | } | ||
| 72 | 68 | ||||
| 73 | logger.Info(). | 69 | logger.Info(). | ||
| @@ -86,12 +82,9 @@ func NewEngine( | |||||
| 86 | return wireEngine( | 82 | return wireEngine( | ||
| 87 | ctx, | 83 | ctx, | ||
| 88 | eng, | 84 | eng, | ||
| 89 | - | cfg, | |||
| 90 | - | transcodingCfg, | |||
| 91 | windowlessFrameRate, | 85 | windowlessFrameRate, | ||
| 92 | audioFactory, | 86 | audioFactory, | ||
| 93 | logger, | 87 | logger, | ||
| 94 | - | deps.MediaClassifier, | |||
| 95 | deps.CurrentConfigPayload, | 88 | deps.CurrentConfigPayload, | ||
| 96 | deps.DefaultConfigPayload, | 89 | deps.DefaultConfigPayload, | ||
| 97 | ) | 90 | ) | ||
| @@ -168,9 +161,8 @@ func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logge | |||||
| 168 | 161 | ||||
| 169 | // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init. | 162 | // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init. | ||
| 170 | func wireEngine( | 163 | func wireEngine( | ||
| 171 | - | ctx context.Context, eng *Engine, _ RuntimeConfig, transcodingCfg TranscodingRuntimeConfig, | 164 | + | ctx context.Context, eng *Engine, |
| 172 | windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger, | 165 | windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger, | ||
| 173 | - | mediaClassifier MediaClassifier, | |||
| 174 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | 166 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | ||
| 175 | ) (*Engine, error) { | 167 | ) (*Engine, error) { | ||
| 176 | gl, err := newGLLoader() | 168 | gl, err := newGLLoader() | ||
| @@ -179,24 +171,19 @@ func wireEngine( | |||||
| 179 | return nil, fmt.Errorf("GL loader: %w", err) | 171 | return nil, fmt.Errorf("GL loader: %w", err) | ||
| 180 | } | 172 | } | ||
| 181 | 173 | ||||
| 182 | - | mediaTranscoder, transcoderState := initializeEngineTranscoder(transcodingCfg, logger) | |||
| 183 | eng.gl = gl | 174 | eng.gl = gl | ||
| 184 | eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{ | 175 | eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{ | ||
| 185 | scale: detectHiDPIScale(logger), | 176 | scale: detectHiDPIScale(logger), | ||
| 186 | windowlessFrameRate: windowlessFrameRate, | 177 | windowlessFrameRate: windowlessFrameRate, | ||
| 187 | - | transcoder: mediaTranscoder, | |||
| 188 | - | mediaClassifier: mediaClassifier, | |||
| 189 | audioOutputFactory: audioFactory, | 178 | audioOutputFactory: audioFactory, | ||
| 190 | }) | 179 | }) | ||
| 191 | eng.pool = newWebViewPool(eng.factory) | 180 | eng.pool = newWebViewPool(eng.factory) | ||
| 192 | eng.contentInj = newContentInjector(eng, nil) | 181 | eng.contentInj = newContentInjector(eng, nil) | ||
| 193 | - | eng.transcoderState = transcoderState | |||
| 194 | 182 | ||||
| 195 | messageRouter, schemeHandler, err := newEngineSchemeHandler( | 183 | messageRouter, schemeHandler, err := newEngineSchemeHandler( | ||
| 196 | ctx, | 184 | ctx, | ||
| 197 | eng, | 185 | eng, | ||
| 198 | logger, | 186 | logger, | ||
| 199 | - | mediaTranscoder, | |||
| 200 | currentConfigPayload, | 187 | currentConfigPayload, | ||
| 201 | defaultConfigPayload, | 188 | defaultConfigPayload, | ||
| 202 | ) | 189 | ) | ||
| @@ -211,45 +198,10 @@ func wireEngine( | |||||
| 211 | return eng, nil | 198 | return eng, nil | ||
| 212 | } | 199 | } | ||
| 213 | 200 | ||||
| 214 | - | func initializeEngineTranscoder( | |||
| 215 | - | transcodingCfg TranscodingRuntimeConfig, | |||
| 216 | - | logger *zerolog.Logger, | |||
| 217 | - | ) (port.MediaTranscoder, transcoderStartupState) { | |||
| 218 | - | transcoderState := buildTranscoderStartupState(transcodingCfg) | |||
| 219 | - | if !transcodingCfg.Enabled { | |||
| 220 | - | return nil, transcoderState | |||
| 221 | - | } | |||
| 222 | - | ||||
| 223 | - | transcoderState.ProbeAttempted = true | |||
| 224 | - | tc := transcoderpkg.New(config.TranscodingConfig{ | |||
| 225 | - | Enabled: transcodingCfg.Enabled, | |||
| 226 | - | HWAccel: transcodingCfg.HWAccel, | |||
| 227 | - | MaxConcurrent: transcodingCfg.MaxConcurrent, | |||
| 228 | - | Quality: transcodingCfg.Quality, | |||
| 229 | - | }, logger) | |||
| 230 | - | caps := tc.Capabilities() | |||
| 231 | - | transcoderState.API = caps.API | |||
| 232 | - | transcoderState.Encoders = append([]string(nil), caps.Encoders...) | |||
| 233 | - | transcoderState.Decoders = append([]string(nil), caps.Decoders...) | |||
| 234 | - | if !tc.Available() { | |||
| 235 | - | transcoderState.Status = "unavailable_no_compatible_gpu" | |||
| 236 | - | logger.Warn().Msg("cef: GPU transcoding enabled but no compatible GPU found — feature disabled") | |||
| 237 | - | return nil, transcoderState | |||
| 238 | - | } | |||
| 239 | - | ||||
| 240 | - | transcoderState.Status = "available" | |||
| 241 | - | logger.Info(). | |||
| 242 | - | Str("api", caps.API). | |||
| 243 | - | Strs("encoders", caps.Encoders). | |||
| 244 | - | Msg("cef: GPU transcoding available") | |||
| 245 | - | return tc, transcoderState | |||
| 246 | - | } | |||
| 247 | - | ||||
| 248 | func newEngineSchemeHandler( | 201 | func newEngineSchemeHandler( | ||
| 249 | ctx context.Context, | 202 | ctx context.Context, | ||
| 250 | eng *Engine, | 203 | eng *Engine, | ||
| 251 | logger *zerolog.Logger, | 204 | logger *zerolog.Logger, | ||
| 252 | - | mediaTranscoder port.MediaTranscoder, | |||
| 253 | currentConfigPayload func() ([]byte, error), | 205 | currentConfigPayload func() ([]byte, error), | ||
| 254 | defaultConfigPayload func() ([]byte, error), | 206 | defaultConfigPayload func() ([]byte, error), | ||
| 255 | ) (*MessageRouter, *dumbSchemeHandler, error) { | 207 | ) (*MessageRouter, *dumbSchemeHandler, error) { | ||
| @@ -257,7 +209,6 @@ func newEngineSchemeHandler( | |||||
| 257 | schemeHandler, err := newDumbSchemeHandler( | 209 | schemeHandler, err := newDumbSchemeHandler( | ||
| 258 | ctx, | 210 | ctx, | ||
| 259 | messageRouter, | 211 | messageRouter, | ||
| 260 | - | mediaTranscoder, | |||
| 261 | currentConfigPayload, | 212 | currentConfigPayload, | ||
| 262 | defaultConfigPayload, | 213 | defaultConfigPayload, | ||
| 263 | ) | 214 | ) | ||
| @@ -328,36 +279,6 @@ func registerEngineSchemeFactory( | |||||
| 328 | Msg("cef: registered internal https handler factory") | 279 | Msg("cef: registered internal https handler factory") | ||
| 329 | } | 280 | } | ||
| 330 | 281 | ||||
| 331 | - | func buildTranscoderStartupState(cfg TranscodingRuntimeConfig) transcoderStartupState { | |||
| 332 | - | hwaccel := cfg.HWAccel | |||
| 333 | - | if hwaccel == "" { | |||
| 334 | - | hwaccel = "auto" | |||
| 335 | - | } | |||
| 336 | - | ||||
| 337 | - | maxConcurrent := cfg.MaxConcurrent | |||
| 338 | - | if maxConcurrent <= 0 { | |||
| 339 | - | maxConcurrent = 2 | |||
| 340 | - | } | |||
| 341 | - | ||||
| 342 | - | quality := cfg.Quality | |||
| 343 | - | if quality == "" { | |||
| 344 | - | quality = "medium" | |||
| 345 | - | } | |||
| 346 | - | ||||
| 347 | - | status := "disabled_in_config" | |||
| 348 | - | if cfg.Enabled { | |||
| 349 | - | status = "configured_enabled" | |||
| 350 | - | } | |||
| 351 | - | ||||
| 352 | - | return transcoderStartupState{ | |||
| 353 | - | ConfigEnabled: cfg.Enabled, | |||
| 354 | - | HWAccel: hwaccel, | |||
| 355 | - | MaxConcurrent: maxConcurrent, | |||
| 356 | - | Quality: quality, | |||
| 357 | - | Status: status, | |||
| 358 | - | } | |||
| 359 | - | } | |||
| 360 | - | ||||
| 361 | // getPrimaryMonitor returns the primary GDK monitor, or nil if unavailable. | 282 | // getPrimaryMonitor returns the primary GDK monitor, or nil if unavailable. | ||
| 362 | func getPrimaryMonitor() *gdk.Monitor { | 283 | func getPrimaryMonitor() *gdk.Monitor { | ||
| 363 | display := gdk.DisplayGetDefault() | 284 | display := gdk.DisplayGetDefault() | ||
M internal/infrastructure/cef/factory.go +2 -19
| @@ -25,16 +25,12 @@ type WebViewFactory struct { | |||||
| 25 | scale int32 | 25 | scale int32 | ||
| 26 | windowlessFrameRate int32 | 26 | windowlessFrameRate int32 | ||
| 27 | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | 27 | bgColor atomic.Uint32 // packed ARGB for BrowserSettings.BackgroundColor | ||
| 28 | - | transcoder port.MediaTranscoder | |||
| 29 | - | mediaClassifier MediaClassifier | |||
| 30 | audioOutputFactory port.AudioOutputFactory | 28 | audioOutputFactory port.AudioOutputFactory | ||
| 31 | } | 29 | } | ||
| 32 | 30 | ||||
| 33 | type webViewFactoryOptions struct { | 31 | type webViewFactoryOptions struct { | ||
| 34 | scale int32 | 32 | scale int32 | ||
| 35 | windowlessFrameRate int32 | 33 | windowlessFrameRate int32 | ||
| 36 | - | transcoder port.MediaTranscoder | |||
| 37 | - | mediaClassifier MediaClassifier | |||
| 38 | audioOutputFactory port.AudioOutputFactory | 34 | audioOutputFactory port.AudioOutputFactory | ||
| 39 | } | 35 | } | ||
| 40 | 36 | ||||
| @@ -64,9 +60,7 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) | |||||
| 64 | gl: gl, | 60 | gl: gl, | ||
| 65 | scale: opts.scale, | 61 | scale: opts.scale, | ||
| 66 | windowlessFrameRate: opts.windowlessFrameRate, | 62 | windowlessFrameRate: opts.windowlessFrameRate, | ||
| 67 | - | transcoder: opts.transcoder, | 63 | + | audioOutputFactory: opts.audioOutputFactory, |
| 68 | - | mediaClassifier: opts.mediaClassifier, | |||
| 69 | - | audioOutputFactory: opts.audioOutputFactory, | |||
| 70 | } | 64 | } | ||
| 71 | } | 65 | } | ||
| 72 | 66 | ||||
| @@ -121,19 +115,8 @@ func (f *WebViewFactory) newWebView(ctx context.Context) *WebView { | |||||
| 121 | backgroundColor: f.bgColor.Load(), | 115 | backgroundColor: f.bgColor.Load(), | ||
| 122 | } | 116 | } | ||
| 123 | 117 | ||||
| 124 | - | var transcodingHandler purecef.ResourceRequestHandler | |||
| 125 | - | if f.transcoder != nil { | |||
| 126 | - | transcodingHandler = newTranscodingRequestHandler(f.transcoder, f.mediaClassifier, func() context.Context { | |||
| 127 | - | if f.engine != nil { | |||
| 128 | - | return f.engine.currentContext() | |||
| 129 | - | } | |||
| 130 | - | return ctx | |||
| 131 | - | }) | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | handlers := &handlerSet{ | 118 | handlers := &handlerSet{ | ||
| 135 | - | wv: wv, | 119 | + | wv: wv, |
| 136 | - | transcodingHandler: transcodingHandler, | |||
| 137 | } | 120 | } | ||
| 138 | wv.handlers = handlers | 121 | wv.handlers = handlers | ||
| 139 | wv.findCtrl = newFindController() | 122 | wv.findCtrl = newFindController() | ||
M internal/infrastructure/cef/runtime_config.go +0 -39
| @@ -23,48 +23,10 @@ type RuntimeConfig struct { | |||||
| 23 | TraceHandlers bool | 23 | TraceHandlers bool | ||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | - | type TranscodingRuntimeConfig struct { | |||
| 27 | - | Enabled bool | |||
| 28 | - | HWAccel string | |||
| 29 | - | MaxConcurrent int | |||
| 30 | - | Quality string | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error | 26 | type HandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error | ||
| 34 | 27 | ||||
| 35 | type AccentHandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.AccentKeyHandler) error | 28 | type AccentHandlerRegistrar func(context.Context, port.WebUIHandlerRouter, port.AccentKeyHandler) error | ||
| 36 | 29 | ||||
| 37 | - | type MediaClassifier struct { | |||
| 38 | - | IsProprietaryVideoMIME func(string) bool | |||
| 39 | - | IsOpenVideoMIME func(string) bool | |||
| 40 | - | IsStreamingManifestMIME func(string) bool | |||
| 41 | - | IsStreamingManifestURL func(string) bool | |||
| 42 | - | IsEagerTranscodeURL func(string) bool | |||
| 43 | - | ParseSyntheticTranscodeURL func(string) (string, string, string, bool) | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | - | func (m MediaClassifier) normalize() MediaClassifier { | |||
| 47 | - | if m.IsProprietaryVideoMIME == nil { | |||
| 48 | - | m.IsProprietaryVideoMIME = func(string) bool { return false } | |||
| 49 | - | } | |||
| 50 | - | if m.IsOpenVideoMIME == nil { | |||
| 51 | - | m.IsOpenVideoMIME = func(string) bool { return false } | |||
| 52 | - | } | |||
| 53 | - | if m.IsStreamingManifestMIME == nil { | |||
| 54 | - | m.IsStreamingManifestMIME = func(string) bool { return false } | |||
| 55 | - | } | |||
| 56 | - | if m.IsStreamingManifestURL == nil { | |||
| 57 | - | m.IsStreamingManifestURL = func(string) bool { return false } | |||
| 58 | - | } | |||
| 59 | - | if m.IsEagerTranscodeURL == nil { | |||
| 60 | - | m.IsEagerTranscodeURL = func(string) bool { return false } | |||
| 61 | - | } | |||
| 62 | - | if m.ParseSyntheticTranscodeURL == nil { | |||
| 63 | - | m.ParseSyntheticTranscodeURL = func(string) (string, string, string, bool) { return "", "", "", false } | |||
| 64 | - | } | |||
| 65 | - | return m | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | type EngineDependencies struct { | 30 | type EngineDependencies struct { | ||
| 69 | RegisterHandlers HandlerRegistrar | 31 | RegisterHandlers HandlerRegistrar | ||
| 70 | RegisterAccentHandlers AccentHandlerRegistrar | 32 | RegisterAccentHandlers AccentHandlerRegistrar | ||
| @@ -74,5 +36,4 @@ type EngineDependencies struct { | |||||
| 74 | ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory | 36 | ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory | ||
| 75 | Clipboard port.Clipboard | 37 | Clipboard port.Clipboard | ||
| 76 | ImageDataResolver port.ImageDataResolver | 38 | ImageDataResolver port.ImageDataResolver | ||
| 77 | - | MediaClassifier MediaClassifier | |||
| 78 | } | 39 | } | ||
M internal/infrastructure/cef/scheme_handler.go +0 -94
| @@ -10,7 +10,6 @@ import ( | |||||
| 10 | "html" | 10 | "html" | ||
| 11 | "io" | 11 | "io" | ||
| 12 | "io/fs" | 12 | "io/fs" | ||
| 13 | - | "net" | |||
| 14 | "net/http" | 13 | "net/http" | ||
| 15 | "net/url" | 14 | "net/url" | ||
| 16 | "os" | 15 | "os" | ||
| @@ -18,7 +17,6 @@ import ( | |||||
| 18 | "strconv" | 17 | "strconv" | ||
| 19 | "strings" | 18 | "strings" | ||
| 20 | "sync" | 19 | "sync" | ||
| 21 | - | "time" | |||
| 22 | "unsafe" | 20 | "unsafe" | ||
| 23 | 21 | ||||
| 24 | "github.com/andybalholm/brotli" | 22 | "github.com/andybalholm/brotli" | ||
| @@ -26,7 +24,6 @@ import ( | |||||
| 26 | 24 | ||||
| 27 | "github.com/bnema/dumber/internal/application/port" | 25 | "github.com/bnema/dumber/internal/application/port" | ||
| 28 | domainurl "github.com/bnema/dumber/internal/domain/url" | 26 | domainurl "github.com/bnema/dumber/internal/domain/url" | ||
| 29 | - | "github.com/bnema/dumber/internal/infrastructure/netguard" | |||
| 30 | "github.com/bnema/dumber/internal/infrastructure/webutil" | 27 | "github.com/bnema/dumber/internal/infrastructure/webutil" | ||
| 31 | "github.com/bnema/dumber/internal/logging" | 28 | "github.com/bnema/dumber/internal/logging" | ||
| 32 | "github.com/rs/zerolog" | 29 | "github.com/rs/zerolog" | ||
| @@ -59,7 +56,6 @@ var cefNewResourceHandler = purecef.NewResourceHandler | |||||
| 59 | type dumbSchemeHandler struct { | 56 | type dumbSchemeHandler struct { | ||
| 60 | ctx context.Context | 57 | ctx context.Context | ||
| 61 | messageRouter *MessageRouter | 58 | messageRouter *MessageRouter | ||
| 62 | - | transcoder port.MediaTranscoder | |||
| 63 | faviconService port.FaviconService | 59 | faviconService port.FaviconService | ||
| 64 | assets embed.FS | 60 | assets embed.FS | ||
| 65 | assetsSet bool | 61 | assetsSet bool | ||
| @@ -95,7 +91,6 @@ type dumbSchemeHandler struct { | |||||
| 95 | func newDumbSchemeHandler( | 91 | func newDumbSchemeHandler( | ||
| 96 | ctx context.Context, | 92 | ctx context.Context, | ||
| 97 | router *MessageRouter, | 93 | router *MessageRouter, | ||
| 98 | - | transcoder port.MediaTranscoder, | |||
| 99 | currentConfigPayload func() ([]byte, error), | 94 | currentConfigPayload func() ([]byte, error), | ||
| 100 | defaultConfigPayload func() ([]byte, error), | 95 | defaultConfigPayload func() ([]byte, error), | ||
| 101 | ) (*dumbSchemeHandler, error) { | 96 | ) (*dumbSchemeHandler, error) { | ||
| @@ -110,7 +105,6 @@ func newDumbSchemeHandler( | |||||
| 110 | return &dumbSchemeHandler{ | 105 | return &dumbSchemeHandler{ | ||
| 111 | ctx: ctx, | 106 | ctx: ctx, | ||
| 112 | messageRouter: router, | 107 | messageRouter: router, | ||
| 113 | - | transcoder: transcoder, | |||
| 114 | assetDir: systemviewsAssetDir, | 108 | assetDir: systemviewsAssetDir, | ||
| 115 | logger: log.With().Str("component", "scheme-handler").Logger(), | 109 | logger: log.With().Str("component", "scheme-handler").Logger(), | ||
| 116 | currentConfigPayload: currentConfigPayload, | 110 | currentConfigPayload: currentConfigPayload, | ||
| @@ -227,8 +221,6 @@ func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Req | |||||
| 227 | return denied, true | 221 | return denied, true | ||
| 228 | } | 222 | } | ||
| 229 | return h.handleConfigAPI(h.defaultConfigPayload), true | 223 | return h.handleConfigAPI(h.defaultConfigPayload), true | ||
| 230 | - | case "/api/transcode": | |||
| 231 | - | return h.handleTranscodeAPI(request), true | |||
| 232 | case "/api/favicon": | 224 | case "/api/favicon": | ||
| 233 | return h.handleFaviconAPI(request), true | 225 | return h.handleFaviconAPI(request), true | ||
| 234 | default: | 226 | default: | ||
| @@ -370,92 +362,6 @@ func isTrustedSystemviewURL(raw string) bool { | |||||
| 370 | return isInternalPageHost(host) | 362 | return isInternalPageHost(host) | ||
| 371 | } | 363 | } | ||
| 372 | 364 | ||||
| 373 | - | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | |||
| 374 | - | if h.transcoder == nil { | |||
| 375 | - | return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | |||
| 376 | - | } | |||
| 377 | - | ||||
| 378 | - | reqURL := request.GetURL() | |||
| 379 | - | parsed, err := url.Parse(reqURL) | |||
| 380 | - | if err != nil { | |||
| 381 | - | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"}) | |||
| 382 | - | } | |||
| 383 | - | ||||
| 384 | - | sourceURL := strings.TrimSpace(parsed.Query().Get("src")) | |||
| 385 | - | if sourceURL == "" { | |||
| 386 | - | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"}) | |||
| 387 | - | } | |||
| 388 | - | ||||
| 389 | - | sourceParsed, err := url.Parse(sourceURL) | |||
| 390 | - | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) { | |||
| 391 | - | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"}) | |||
| 392 | - | } | |||
| 393 | - | headers := make(map[string]string) | |||
| 394 | - | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | |||
| 395 | - | headers["User-Agent"] = userAgent | |||
| 396 | - | } | |||
| 397 | - | if referer := strings.TrimSpace(parsed.Query().Get("referer")); referer != "" { | |||
| 398 | - | headers["Referer"] = referer | |||
| 399 | - | } else if referer := request.GetReferrerURL(); referer != "" { | |||
| 400 | - | headers["Referer"] = referer | |||
| 401 | - | } | |||
| 402 | - | if origin := strings.TrimSpace(parsed.Query().Get("origin")); origin != "" { | |||
| 403 | - | headers["Origin"] = origin | |||
| 404 | - | } | |||
| 405 | - | ||||
| 406 | - | ctx, cancel := context.WithTimeout(h.ctx, 5*time.Minute) | |||
| 407 | - | h.logger.Info(). | |||
| 408 | - | Str("source_url", logging.TruncateURL(sourceURL, maxSchemeTruncatedURLLength)). | |||
| 409 | - | Int("forwarded_header_count", len(headers)). | |||
| 410 | - | Msg("scheme handler returning transcoding stream") | |||
| 411 | - | ||||
| 412 | - | return cefNewResourceHandler(&transcodingResourceHandler{ | |||
| 413 | - | transcoder: h.transcoder, | |||
| 414 | - | sourceURL: sourceURL, | |||
| 415 | - | headers: headers, | |||
| 416 | - | ctx: ctx, | |||
| 417 | - | cancel: cancel, | |||
| 418 | - | validateSource: func(ctx context.Context) error { | |||
| 419 | - | return validateTranscodeSourceURL(ctx, sourceParsed) | |||
| 420 | - | }, | |||
| 421 | - | logf: func() zerolog.Logger { | |||
| 422 | - | return h.logger.With().Str("component", "scheme-transcoding").Logger() | |||
| 423 | - | }, | |||
| 424 | - | }) | |||
| 425 | - | } | |||
| 426 | - | ||||
| 427 | - | func validateTranscodeSourceURL(ctx context.Context, source *url.URL) error { | |||
| 428 | - | if source == nil || source.Host == "" { | |||
| 429 | - | return fmt.Errorf("invalid src") | |||
| 430 | - | } | |||
| 431 | - | host := source.Hostname() | |||
| 432 | - | if host == "" { | |||
| 433 | - | return fmt.Errorf("invalid src host") | |||
| 434 | - | } | |||
| 435 | - | if ip := net.ParseIP(host); ip != nil { | |||
| 436 | - | if isBlockedTranscodeIP(ip) { | |||
| 437 | - | return fmt.Errorf("private src host not allowed") | |||
| 438 | - | } | |||
| 439 | - | return nil | |||
| 440 | - | } | |||
| 441 | - | ||||
| 442 | - | lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) | |||
| 443 | - | defer cancel() | |||
| 444 | - | addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host) | |||
| 445 | - | if err != nil || len(addrs) == 0 { | |||
| 446 | - | return fmt.Errorf("src host could not be resolved") | |||
| 447 | - | } | |||
| 448 | - | for _, addr := range addrs { | |||
| 449 | - | if isBlockedTranscodeIP(addr.IP) { | |||
| 450 | - | return fmt.Errorf("private src host not allowed") | |||
| 451 | - | } | |||
| 452 | - | } | |||
| 453 | - | return nil | |||
| 454 | - | } | |||
| 455 | - | ||||
| 456 | - | func isBlockedTranscodeIP(ip net.IP) bool { | |||
| 457 | - | return netguard.IsBlockedTranscodeIP(ip) | |||
| 458 | - | } | |||
| 459 | 365 | ||||
| 460 | func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | 366 | func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | ||
| 461 | if build == nil { | 367 | if build == nil { | ||
M internal/infrastructure/cef/scheme_handler_test.go +36 -59
| @@ -4,9 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/base64" | 5 | "encoding/base64" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | - | "net" | |||
| 8 | "net/http" | 7 | "net/http" | ||
| 9 | - | "net/url" | |||
| 10 | "testing" | 8 | "testing" | ||
| 11 | 9 | ||||
| 12 | purecef "github.com/bnema/purego-cef/cef" | 10 | purecef "github.com/bnema/purego-cef/cef" | ||
| @@ -51,6 +49,20 @@ func (panicFaviconService) EnsureDiskCache(context.Context, string) { | |||||
| 51 | } | 49 | } | ||
| 52 | func (panicFaviconService) Close() {} | 50 | func (panicFaviconService) Close() {} | ||
| 53 | 51 | ||||
| 52 | + | // noopConfigPayload returns a builder that always returns an empty JSON object. | |||
| 53 | + | func noopConfigPayload() func() ([]byte, error) { | |||
| 54 | + | return func() ([]byte, error) { return []byte(`{}`), nil } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // newTestDumbSchemeHandler creates a dumbSchemeHandler with noop config payloads. | |||
| 58 | + | // It calls t.Helper() and fails the test if construction errors. | |||
| 59 | + | func newTestDumbSchemeHandler(t *testing.T) *dumbSchemeHandler { | |||
| 60 | + | t.Helper() | |||
| 61 | + | h, err := newDumbSchemeHandler(context.Background(), nil, noopConfigPayload(), noopConfigPayload()) | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | return h | |||
| 64 | + | } | |||
| 65 | + | ||||
| 54 | func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) { | 66 | func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) { | ||
| 55 | data, err := resolveConfigPayload(func() ([]byte, error) { | 67 | data, err := resolveConfigPayload(func() ([]byte, error) { | ||
| 56 | return []byte(`{"engine_type":"cef"}`), nil | 68 | return []byte(`{"engine_type":"cef"}`), nil | ||
| @@ -74,22 +86,21 @@ func TestResolveConfigPayload_NilBuilderFails(t *testing.T) { | |||||
| 74 | } | 86 | } | ||
| 75 | 87 | ||||
| 76 | func TestNewDumbSchemeHandler_NilCurrentConfigPayloadFails(t *testing.T) { | 88 | func TestNewDumbSchemeHandler_NilCurrentConfigPayloadFails(t *testing.T) { | ||
| 77 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, nil, func() ([]byte, error) { return nil, nil }) | 89 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return nil, nil }) |
| 78 | require.Error(t, err) | 90 | require.Error(t, err) | ||
| 79 | require.Nil(t, h) | 91 | require.Nil(t, h) | ||
| 80 | require.Contains(t, err.Error(), "current config payload builder") | 92 | require.Contains(t, err.Error(), "current config payload builder") | ||
| 81 | } | 93 | } | ||
| 82 | 94 | ||||
| 83 | func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | 95 | func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | ||
| 84 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return nil, nil }, nil) | 96 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return nil, nil }, nil) |
| 85 | require.Error(t, err) | 97 | require.Error(t, err) | ||
| 86 | require.Nil(t, h) | 98 | require.Nil(t, h) | ||
| 87 | require.Contains(t, err.Error(), "default config payload builder") | 99 | require.Contains(t, err.Error(), "default config payload builder") | ||
| 88 | } | 100 | } | ||
| 89 | 101 | ||||
| 90 | func TestRejectForbiddenAPIOrigin_AllowsInternalOrigin(t *testing.T) { | 102 | func TestRejectForbiddenAPIOrigin_AllowsInternalOrigin(t *testing.T) { | ||
| 91 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 103 | + | h := newTestDumbSchemeHandler(t) |
| 92 | - | require.NoError(t, err) | |||
| 93 | 104 | ||||
| 94 | request := cefmocks.NewMockRequest(t) | 105 | request := cefmocks.NewMockRequest(t) | ||
| 95 | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | 106 | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | ||
| @@ -102,8 +113,7 @@ func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) { | |||||
| 102 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 113 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 103 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 114 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 104 | 115 | ||||
| 105 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 116 | + | h := newTestDumbSchemeHandler(t) |
| 106 | - | require.NoError(t, err) | |||
| 107 | 117 | ||||
| 108 | request := cefmocks.NewMockRequest(t) | 118 | request := cefmocks.NewMockRequest(t) | ||
| 109 | request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | 119 | request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | ||
| @@ -126,8 +136,7 @@ func TestRejectUntrustedConfigRequesterRequiresTrustedOriginOrReferrer(t *testin | |||||
| 126 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 136 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 127 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 137 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 128 | 138 | ||||
| 129 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 139 | + | h := newTestDumbSchemeHandler(t) |
| 130 | - | require.NoError(t, err) | |||
| 131 | 140 | ||||
| 132 | trustedOrigin := cefmocks.NewMockRequest(t) | 141 | trustedOrigin := cefmocks.NewMockRequest(t) | ||
| 133 | trustedOrigin.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | 142 | trustedOrigin.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | ||
| @@ -164,7 +173,7 @@ func TestHandleMessageAPIRequiresTrustedOriginOrReferrer(t *testing.T) { | |||||
| 164 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 173 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 165 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 174 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 166 | 175 | ||||
| 167 | - | h, err := newDumbSchemeHandler(context.Background(), NewMessageRouter(context.Background()), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 176 | + | h, err := newDumbSchemeHandler(context.Background(), NewMessageRouter(context.Background()), noopConfigPayload(), noopConfigPayload()) |
| 168 | require.NoError(t, err) | 177 | require.NoError(t, err) | ||
| 169 | 178 | ||||
| 170 | trusted := cefmocks.NewMockRequest(t) | 179 | trusted := cefmocks.NewMockRequest(t) | ||
| @@ -196,7 +205,7 @@ func TestHandleConfigAPIUsesPrivateNoCORSHeaders(t *testing.T) { | |||||
| 196 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 205 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 197 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 206 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 198 | 207 | ||||
| 199 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{"ok":true}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 208 | + | h, err := newDumbSchemeHandler(context.Background(), nil, func() ([]byte, error) { return []byte(`{"ok":true}`), nil }, noopConfigPayload()) |
| 200 | require.NoError(t, err) | 209 | require.NoError(t, err) | ||
| 201 | 210 | ||||
| 202 | response := cefmocks.NewMockResponse(t) | 211 | response := cefmocks.NewMockResponse(t) | ||
| @@ -216,8 +225,7 @@ func TestHandleConfigAPIOptionsUsesPrivateNoCORSHeaders(t *testing.T) { | |||||
| 216 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 225 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 217 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 226 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 218 | 227 | ||||
| 219 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 228 | + | h := newTestDumbSchemeHandler(t) |
| 220 | - | require.NoError(t, err) | |||
| 221 | 229 | ||||
| 222 | request := cefmocks.NewMockRequest(t) | 230 | request := cefmocks.NewMockRequest(t) | ||
| 223 | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | 231 | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | ||
| @@ -256,8 +264,7 @@ func TestIsTrustedSystemviewURL(t *testing.T) { | |||||
| 256 | } | 264 | } | ||
| 257 | 265 | ||||
| 258 | func TestRejectUntrustedFaviconRequesterAllowsTrustedOriginWithoutReferrer(t *testing.T) { | 266 | func TestRejectUntrustedFaviconRequesterAllowsTrustedOriginWithoutReferrer(t *testing.T) { | ||
| 259 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 267 | + | h := newTestDumbSchemeHandler(t) |
| 260 | - | require.NoError(t, err) | |||
| 261 | 268 | ||||
| 262 | request := cefmocks.NewMockRequest(t) | 269 | request := cefmocks.NewMockRequest(t) | ||
| 263 | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | 270 | request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once() | ||
| @@ -270,8 +277,7 @@ func TestRejectUntrustedFaviconRequesterRejectsUntrustedOriginEvenWithTrustedRef | |||||
| 270 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 277 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 271 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 278 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 272 | 279 | ||||
| 273 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 280 | + | h := newTestDumbSchemeHandler(t) |
| 274 | - | require.NoError(t, err) | |||
| 275 | 281 | ||||
| 276 | request := cefmocks.NewMockRequest(t) | 282 | request := cefmocks.NewMockRequest(t) | ||
| 277 | request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | 283 | request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once() | ||
| @@ -284,8 +290,7 @@ func TestRejectUntrustedFaviconRequesterRequiresTrustedReferrerWhenOriginAbsent( | |||||
| 284 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 290 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 285 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 291 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 286 | 292 | ||||
| 287 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 293 | + | h := newTestDumbSchemeHandler(t) |
| 288 | - | require.NoError(t, err) | |||
| 289 | 294 | ||||
| 290 | trusted := cefmocks.NewMockRequest(t) | 295 | trusted := cefmocks.NewMockRequest(t) | ||
| 291 | trusted.EXPECT().GetHeaderByName("Origin").Return("").Once() | 296 | trusted.EXPECT().GetHeaderByName("Origin").Return("").Once() | ||
| @@ -304,8 +309,7 @@ func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T) | |||||
| 304 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 309 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 305 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 310 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 306 | 311 | ||||
| 307 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 312 | + | h := newTestDumbSchemeHandler(t) |
| 308 | - | require.NoError(t, err) | |||
| 309 | h.setFaviconService(panicFaviconService{}) | 313 | h.setFaviconService(panicFaviconService{}) | ||
| 310 | 314 | ||||
| 311 | request := cefmocks.NewMockRequest(t) | 315 | request := cefmocks.NewMockRequest(t) | ||
| @@ -315,23 +319,6 @@ func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T) | |||||
| 315 | require.NotNil(t, h.handleFaviconAPI(request)) | 319 | require.NotNil(t, h.handleFaviconAPI(request)) | ||
| 316 | } | 320 | } | ||
| 317 | 321 | ||||
| 318 | - | func TestValidateTranscodeSourceURL_RejectsPrivateHosts(t *testing.T) { | |||
| 319 | - | privateURL, err := url.Parse("http://127.0.0.1/video.mp4") | |||
| 320 | - | require.NoError(t, err) | |||
| 321 | - | ||||
| 322 | - | err = validateTranscodeSourceURL(context.Background(), privateURL) | |||
| 323 | - | require.Error(t, err) | |||
| 324 | - | require.Contains(t, err.Error(), "private src host") | |||
| 325 | - | } | |||
| 326 | - | ||||
| 327 | - | func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) { | |||
| 328 | - | for _, rawIP := range []string{"100.64.0.1", "198.18.0.1", "192.0.2.1", "64:ff9b::a00:1", "2001::1", "2001:db8::1", "2002:0a00:0001::1"} { | |||
| 329 | - | t.Run(rawIP, func(t *testing.T) { | |||
| 330 | - | require.True(t, isBlockedTranscodeIP(net.ParseIP(rawIP))) | |||
| 331 | - | }) | |||
| 332 | - | } | |||
| 333 | - | } | |||
| 334 | - | ||||
| 335 | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | 322 | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | ||
| 336 | request := cefmocks.NewMockRequest(t) | 323 | request := cefmocks.NewMockRequest(t) | ||
| 337 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | 324 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | ||
| @@ -361,8 +348,7 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) { | |||||
| 361 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 348 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 362 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 349 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 363 | 350 | ||||
| 364 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 351 | + | h := newTestDumbSchemeHandler(t) |
| 365 | - | require.NoError(t, err) | |||
| 366 | 352 | ||||
| 367 | const bridgeNonce = "bridge-nonce" | 353 | const bridgeNonce = "bridge-nonce" | ||
| 368 | browser := cefmocks.NewMockBrowser(t) | 354 | browser := cefmocks.NewMockBrowser(t) | ||
| @@ -398,8 +384,7 @@ func TestSchemeHandler_APIFocusSyncRejectsRequestsWithoutTrustedBridgeHeader(t * | |||||
| 398 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 384 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 399 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 385 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 400 | 386 | ||||
| 401 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 387 | + | h := newTestDumbSchemeHandler(t) |
| 402 | - | require.NoError(t, err) | |||
| 403 | 388 | ||||
| 404 | request := cefmocks.NewMockRequest(t) | 389 | request := cefmocks.NewMockRequest(t) | ||
| 405 | request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return("").Once() | 390 | request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return("").Once() | ||
| @@ -423,8 +408,7 @@ func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) { | |||||
| 423 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 408 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 424 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 409 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 425 | 410 | ||||
| 426 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 411 | + | h := newTestDumbSchemeHandler(t) |
| 427 | - | require.NoError(t, err) | |||
| 428 | 412 | ||||
| 429 | const bridgeNonce = "bridge-nonce" | 413 | const bridgeNonce = "bridge-nonce" | ||
| 430 | browser := cefmocks.NewMockBrowser(t) | 414 | browser := cefmocks.NewMockBrowser(t) | ||
| @@ -459,8 +443,7 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T) | |||||
| 459 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 443 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 460 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 444 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 461 | 445 | ||||
| 462 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 446 | + | h := newTestDumbSchemeHandler(t) |
| 463 | - | require.NoError(t, err) | |||
| 464 | 447 | ||||
| 465 | const bridgeNonce = "bridge-nonce" | 448 | const bridgeNonce = "bridge-nonce" | ||
| 466 | browser := cefmocks.NewMockBrowser(t) | 449 | browser := cefmocks.NewMockBrowser(t) | ||
| @@ -498,8 +481,7 @@ func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) { | |||||
| 498 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 481 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 499 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 482 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 500 | 483 | ||||
| 501 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 484 | + | h := newTestDumbSchemeHandler(t) |
| 502 | - | require.NoError(t, err) | |||
| 503 | 485 | ||||
| 504 | browser := cefmocks.NewMockBrowser(t) | 486 | browser := cefmocks.NewMockBrowser(t) | ||
| 505 | h.bridgeNonceValidator = func(_ purecef.Browser, _ string) bool { return false } | 487 | h.bridgeNonceValidator = func(_ purecef.Browser, _ string) bool { return false } | ||
| @@ -526,8 +508,7 @@ func TestSchemeHandler_APIPopupOpenInvokesPopupCallback(t *testing.T) { | |||||
| 526 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 508 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 527 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 509 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 528 | 510 | ||||
| 529 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 511 | + | h := newTestDumbSchemeHandler(t) |
| 530 | - | require.NoError(t, err) | |||
| 531 | 512 | ||||
| 532 | const bridgeNonce = "bridge-nonce" | 513 | const bridgeNonce = "bridge-nonce" | ||
| 533 | browser := cefmocks.NewMockBrowser(t) | 514 | browser := cefmocks.NewMockBrowser(t) | ||
| @@ -570,8 +551,7 @@ func TestSchemeHandler_APIPopupNavigateInvokesPopupCallback(t *testing.T) { | |||||
| 570 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 551 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 571 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 552 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 572 | 553 | ||||
| 573 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 554 | + | h := newTestDumbSchemeHandler(t) |
| 574 | - | require.NoError(t, err) | |||
| 575 | 555 | ||||
| 576 | const bridgeNonce = "bridge-nonce" | 556 | const bridgeNonce = "bridge-nonce" | ||
| 577 | browser := cefmocks.NewMockBrowser(t) | 557 | browser := cefmocks.NewMockBrowser(t) | ||
| @@ -611,8 +591,7 @@ func TestSchemeHandler_APIPopupCloseInvokesPopupCallback(t *testing.T) { | |||||
| 611 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 591 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 612 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 592 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 613 | 593 | ||||
| 614 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 594 | + | h := newTestDumbSchemeHandler(t) |
| 615 | - | require.NoError(t, err) | |||
| 616 | 595 | ||||
| 617 | const bridgeNonce = "bridge-nonce" | 596 | const bridgeNonce = "bridge-nonce" | ||
| 618 | browser := cefmocks.NewMockBrowser(t) | 597 | browser := cefmocks.NewMockBrowser(t) | ||
| @@ -709,8 +688,7 @@ func TestSchemeHandler_APIPopupBridgeRejectsInvalidPayloads(t *testing.T) { | |||||
| 709 | 688 | ||||
| 710 | for _, test := range tests { | 689 | for _, test := range tests { | ||
| 711 | t.Run(test.name, func(t *testing.T) { | 690 | t.Run(test.name, func(t *testing.T) { | ||
| 712 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 691 | + | h := newTestDumbSchemeHandler(t) |
| 713 | - | require.NoError(t, err) | |||
| 714 | 692 | ||||
| 715 | const bridgeNonce = "bridge-nonce" | 693 | const bridgeNonce = "bridge-nonce" | ||
| 716 | browser := cefmocks.NewMockBrowser(t) | 694 | browser := cefmocks.NewMockBrowser(t) | ||
| @@ -748,8 +726,7 @@ func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) { | |||||
| 748 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | 726 | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | ||
| 749 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | 727 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | ||
| 750 | 728 | ||||
| 751 | - | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | 729 | + | h := newTestDumbSchemeHandler(t) |
| 752 | - | require.NoError(t, err) | |||
| 753 | 730 | ||||
| 754 | request := cefmocks.NewMockRequest(t) | 731 | request := cefmocks.NewMockRequest(t) | ||
| 755 | response := cefmocks.NewMockResponse(t) | 732 | response := cefmocks.NewMockResponse(t) | ||
D internal/infrastructure/cef/transcoding_handler.go +0 -490
| @@ -1,490 +0,0 @@ | |||||
| 1 | - | package cef | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "errors" | |||
| 6 | - | "io" | |||
| 7 | - | "sync" | |||
| 8 | - | "time" | |||
| 9 | - | "unsafe" | |||
| 10 | - | ||||
| 11 | - | purecef "github.com/bnema/purego-cef/cef" | |||
| 12 | - | ||||
| 13 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 14 | - | "github.com/bnema/dumber/internal/logging" | |||
| 15 | - | "github.com/rs/zerolog" | |||
| 16 | - | ) | |||
| 17 | - | ||||
| 18 | - | // --------------------------------------------------------------------------- | |||
| 19 | - | // Compile-time interface checks | |||
| 20 | - | // --------------------------------------------------------------------------- | |||
| 21 | - | ||||
| 22 | - | var _ purecef.ResourceRequestHandler = (*transcodingRequestHandler)(nil) | |||
| 23 | - | ||||
| 24 | - | const ( | |||
| 25 | - | maxTranscodingURLLength = 240 | |||
| 26 | - | httpStatusOK = 200 | |||
| 27 | - | httpStatusForbidden = 403 | |||
| 28 | - | httpStatusBadGateway = 502 | |||
| 29 | - | ) | |||
| 30 | - | ||||
| 31 | - | // transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler | |||
| 32 | - | // accepts a purecef.ResourceHandler, and the struct implements all methods. | |||
| 33 | - | ||||
| 34 | - | // --------------------------------------------------------------------------- | |||
| 35 | - | // transcodingResourceHandler — serves a transcoded WebM stream to CEF | |||
| 36 | - | // --------------------------------------------------------------------------- | |||
| 37 | - | ||||
| 38 | - | type transcodingResourceHandler struct { | |||
| 39 | - | transcoder port.MediaTranscoder | |||
| 40 | - | sourceURL string | |||
| 41 | - | headers map[string]string | |||
| 42 | - | session port.TranscodeSession | |||
| 43 | - | ctx context.Context | |||
| 44 | - | cancel context.CancelFunc | |||
| 45 | - | logf func() zerolog.Logger | |||
| 46 | - | validateSource func(context.Context) error | |||
| 47 | - | mu sync.Mutex | |||
| 48 | - | startErr error | |||
| 49 | - | startErrStatus int | |||
| 50 | - | totalBytes int64 | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | // Open starts the transcode session asynchronously. CEF will call | |||
| 54 | - | // GetResponseHeaders and Read once the callback fires. | |||
| 55 | - | func (rh *transcodingResourceHandler) Open( | |||
| 56 | - | _ purecef.Request, handleRequest *int32, callback purecef.Callback, | |||
| 57 | - | ) int32 { | |||
| 58 | - | // Signal async handling: set handleRequest = 0. | |||
| 59 | - | if handleRequest != nil { | |||
| 60 | - | *handleRequest = 0 | |||
| 61 | - | } | |||
| 62 | - | ||||
| 63 | - | log := rh.logger() | |||
| 64 | - | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |||
| 65 | - | log.Info(). | |||
| 66 | - | Str("source_url", sourceURL). | |||
| 67 | - | Int("forwarded_header_count", len(rh.headers)). | |||
| 68 | - | Msg("cef: starting transcoding resource stream") | |||
| 69 | - | ||||
| 70 | - | go func() { | |||
| 71 | - | if rh.validateSource != nil { | |||
| 72 | - | if err := rh.validateSource(rh.ctx); err != nil { | |||
| 73 | - | log.Warn(). | |||
| 74 | - | Err(err). | |||
| 75 | - | Str("source_url", sourceURL). | |||
| 76 | - | Msg("cef: rejected transcoding source") | |||
| 77 | - | rh.setStartErr(err, httpStatusForbidden) | |||
| 78 | - | rh.cancel() | |||
| 79 | - | callback.Cont() | |||
| 80 | - | return | |||
| 81 | - | } | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers) | |||
| 85 | - | if err != nil { | |||
| 86 | - | log.Error(). | |||
| 87 | - | Err(err). | |||
| 88 | - | Str("source_url", sourceURL). | |||
| 89 | - | Msg("cef: failed to start transcoding resource stream") | |||
| 90 | - | // Let CEF know we failed — Cancel will be called. | |||
| 91 | - | rh.setStartErr(err, httpStatusBadGateway) | |||
| 92 | - | rh.cancel() | |||
| 93 | - | callback.Cont() | |||
| 94 | - | return | |||
| 95 | - | } | |||
| 96 | - | rh.mu.Lock() | |||
| 97 | - | rh.session = session | |||
| 98 | - | rh.mu.Unlock() | |||
| 99 | - | log.Info(). | |||
| 100 | - | Str("source_url", sourceURL). | |||
| 101 | - | Str("content_type", session.ContentType()). | |||
| 102 | - | Msg("cef: transcoding resource stream ready") | |||
| 103 | - | callback.Cont() | |||
| 104 | - | }() | |||
| 105 | - | ||||
| 106 | - | return 1 | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | func (rh *transcodingResourceHandler) setStartErr(err error, status int) { | |||
| 110 | - | rh.mu.Lock() | |||
| 111 | - | defer rh.mu.Unlock() | |||
| 112 | - | rh.startErr = err | |||
| 113 | - | rh.startErrStatus = status | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | func (rh *transcodingResourceHandler) getStartErr() (int, error) { | |||
| 117 | - | rh.mu.Lock() | |||
| 118 | - | defer rh.mu.Unlock() | |||
| 119 | - | return rh.startErrStatus, rh.startErr | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | func (rh *transcodingResourceHandler) addTotalBytes(n int) int64 { | |||
| 123 | - | rh.mu.Lock() | |||
| 124 | - | defer rh.mu.Unlock() | |||
| 125 | - | rh.totalBytes += int64(n) | |||
| 126 | - | return rh.totalBytes | |||
| 127 | - | } | |||
| 128 | - | ||||
| 129 | - | func (rh *transcodingResourceHandler) getTotalBytes() int64 { | |||
| 130 | - | rh.mu.Lock() | |||
| 131 | - | defer rh.mu.Unlock() | |||
| 132 | - | return rh.totalBytes | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | // GetResponseHeaders sets the streaming response metadata. | |||
| 136 | - | func (rh *transcodingResourceHandler) GetResponseHeaders( | |||
| 137 | - | response purecef.Response, responseLength *int64, _ uintptr, | |||
| 138 | - | ) { | |||
| 139 | - | if status, startErr := rh.getStartErr(); startErr != nil { | |||
| 140 | - | if status == 0 { | |||
| 141 | - | status = httpStatusBadGateway | |||
| 142 | - | } | |||
| 143 | - | response.SetStatus(int32(status)) | |||
| 144 | - | if status == httpStatusForbidden { | |||
| 145 | - | response.SetStatusText("Forbidden") | |||
| 146 | - | } else { | |||
| 147 | - | response.SetStatusText("Bad Gateway") | |||
| 148 | - | } | |||
| 149 | - | response.SetMimeType("text/plain") | |||
| 150 | - | if responseLength != nil { | |||
| 151 | - | *responseLength = 0 | |||
| 152 | - | } | |||
| 153 | - | return | |||
| 154 | - | } | |||
| 155 | - | response.SetStatus(httpStatusOK) | |||
| 156 | - | response.SetStatusText("OK") | |||
| 157 | - | response.SetMimeType("video/webm") | |||
| 158 | - | response.SetHeaderByName("Accept-Ranges", "none", 1) | |||
| 159 | - | // CORS wildcard is required because transcoded streams are loaded by <video> | |||
| 160 | - | // elements on arbitrary origins (e.g. reddit.com). The streams contain no | |||
| 161 | - | // sensitive data — they are re-encoded from publicly-accessible media URLs. | |||
| 162 | - | response.SetHeaderByName("Access-Control-Allow-Origin", "*", 1) | |||
| 163 | - | response.SetHeaderByName("Cache-Control", "no-store", 1) | |||
| 164 | - | // Streaming — unknown length. | |||
| 165 | - | if responseLength != nil { | |||
| 166 | - | *responseLength = -1 | |||
| 167 | - | } | |||
| 168 | - | } | |||
| 169 | - | ||||
| 170 | - | // Read copies transcoded data from the session into the CEF output buffer. | |||
| 171 | - | func (rh *transcodingResourceHandler) Read( | |||
| 172 | - | dataOut unsafe.Pointer, bytesToRead int32, | |||
| 173 | - | bytesRead *int32, _ purecef.ResourceReadCallback, | |||
| 174 | - | ) int32 { | |||
| 175 | - | rh.mu.Lock() | |||
| 176 | - | session := rh.session | |||
| 177 | - | rh.mu.Unlock() | |||
| 178 | - | if session == nil { | |||
| 179 | - | return 0 | |||
| 180 | - | } | |||
| 181 | - | ||||
| 182 | - | dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead)) | |||
| 183 | - | n, err := session.Read(dst) | |||
| 184 | - | if n > 0 { | |||
| 185 | - | totalBytes := rh.addTotalBytes(n) | |||
| 186 | - | if totalBytes == int64(n) { | |||
| 187 | - | rh.logger().Info(). | |||
| 188 | - | Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)). | |||
| 189 | - | Int("first_chunk_bytes", n). | |||
| 190 | - | Msg("cef: transcoding resource stream produced first bytes") | |||
| 191 | - | } | |||
| 192 | - | if bytesRead != nil { | |||
| 193 | - | *bytesRead = int32(n) | |||
| 194 | - | } | |||
| 195 | - | return 1 | |||
| 196 | - | } | |||
| 197 | - | if err != nil { | |||
| 198 | - | totalBytes := rh.getTotalBytes() | |||
| 199 | - | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |||
| 200 | - | log := rh.logger().With(). | |||
| 201 | - | Str("source_url", sourceURL). | |||
| 202 | - | Int64("total_bytes", totalBytes). | |||
| 203 | - | Logger() | |||
| 204 | - | switch { | |||
| 205 | - | case errors.Is(err, io.EOF) && totalBytes == 0: | |||
| 206 | - | log.Warn().Msg("cef: transcoding resource stream ended before producing data") | |||
| 207 | - | case errors.Is(err, io.EOF): | |||
| 208 | - | log.Debug().Msg("cef: transcoding resource stream reached EOF") | |||
| 209 | - | default: | |||
| 210 | - | log.Error().Err(err).Msg("cef: transcoding resource stream read failed") | |||
| 211 | - | } | |||
| 212 | - | return 0 // EOF or error | |||
| 213 | - | } | |||
| 214 | - | return 0 | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | - | // Cancel aborts the transcode session and releases resources. | |||
| 218 | - | func (rh *transcodingResourceHandler) Cancel() { | |||
| 219 | - | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |||
| 220 | - | totalBytes := rh.getTotalBytes() | |||
| 221 | - | rh.logger().Debug(). | |||
| 222 | - | Str("source_url", sourceURL). | |||
| 223 | - | Int64("total_bytes", totalBytes). | |||
| 224 | - | Msg("cef: transcoding resource stream canceled") | |||
| 225 | - | rh.mu.Lock() | |||
| 226 | - | session := rh.session | |||
| 227 | - | rh.mu.Unlock() | |||
| 228 | - | if session != nil { | |||
| 229 | - | if err := session.Close(); err != nil { | |||
| 230 | - | rh.logger().Warn(). | |||
| 231 | - | Str("source_url", sourceURL). | |||
| 232 | - | Err(err). | |||
| 233 | - | Msg("cef: transcoding resource stream close failed") | |||
| 234 | - | } | |||
| 235 | - | } | |||
| 236 | - | if rh.cancel != nil { | |||
| 237 | - | rh.cancel() | |||
| 238 | - | } | |||
| 239 | - | } | |||
| 240 | - | ||||
| 241 | - | // ProcessRequest is deprecated; Open is used instead. | |||
| 242 | - | func (rh *transcodingResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Callback) int32 { | |||
| 243 | - | return 0 | |||
| 244 | - | } | |||
| 245 | - | ||||
| 246 | - | // ReadResponse is deprecated; Read is used instead. | |||
| 247 | - | func (rh *transcodingResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 { | |||
| 248 | - | return 0 | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | // Skip is not used for streaming content. | |||
| 252 | - | func (rh *transcodingResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 { | |||
| 253 | - | return 0 | |||
| 254 | - | } | |||
| 255 | - | ||||
| 256 | - | // --------------------------------------------------------------------------- | |||
| 257 | - | // transcodingRequestHandler — ResourceRequestHandler that intercepts | |||
| 258 | - | // proprietary video responses and streaming manifests, then restarts them | |||
| 259 | - | // through the transcoder | |||
| 260 | - | // --------------------------------------------------------------------------- | |||
| 261 | - | ||||
| 262 | - | type transcodingRequestHandler struct { | |||
| 263 | - | transcoder port.MediaTranscoder | |||
| 264 | - | classifier MediaClassifier | |||
| 265 | - | transcodableURLs sync.Map // url string -> requestInfo | |||
| 266 | - | ctxf func() context.Context | |||
| 267 | - | } | |||
| 268 | - | ||||
| 269 | - | type requestInfo struct { | |||
| 270 | - | headers map[string]string | |||
| 271 | - | } | |||
| 272 | - | ||||
| 273 | - | // newTranscodingRequestHandler creates a ResourceRequestHandler that detects | |||
| 274 | - | // proprietary video containers and HLS/DASH manifest entrypoints, then | |||
| 275 | - | // restarts those requests through a GPU transcoding ResourceHandler. | |||
| 276 | - | func newTranscodingRequestHandler( | |||
| 277 | - | tc port.MediaTranscoder, classifier MediaClassifier, ctxf func() context.Context, | |||
| 278 | - | ) purecef.ResourceRequestHandler { | |||
| 279 | - | return purecef.NewResourceRequestHandler(&transcodingRequestHandler{ | |||
| 280 | - | transcoder: tc, | |||
| 281 | - | classifier: classifier, | |||
| 282 | - | ctxf: ctxf, | |||
| 283 | - | }) | |||
| 284 | - | } | |||
| 285 | - | ||||
| 286 | - | // OnResourceResponse inspects the MIME type and URL of every response. If it | |||
| 287 | - | // matches a proprietary video format or a streaming manifest entrypoint, it | |||
| 288 | - | // stores the request info and returns 1 to restart the request — at which | |||
| 289 | - | // point GetResourceHandler will provide the transcoding handler. | |||
| 290 | - | func (h *transcodingRequestHandler) OnResourceResponse( | |||
| 291 | - | _ purecef.Browser, _ purecef.Frame, request purecef.Request, response purecef.Response, | |||
| 292 | - | ) int32 { | |||
| 293 | - | if response == nil || request == nil { | |||
| 294 | - | return 0 | |||
| 295 | - | } | |||
| 296 | - | mimeType := response.GetMimeType() | |||
| 297 | - | url := request.GetURL() | |||
| 298 | - | ||||
| 299 | - | log := h.logger() | |||
| 300 | - | isProprietary, isAlreadyOpen, isManifest := classifyMediaResponse(h.classifier, url, mimeType) | |||
| 301 | - | if isProprietary || isAlreadyOpen || isManifest { | |||
| 302 | - | log.Debug(). | |||
| 303 | - | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). | |||
| 304 | - | Str("mime_type", mimeType). | |||
| 305 | - | Bool("proprietary_match", isProprietary). | |||
| 306 | - | Bool("already_open", isAlreadyOpen). | |||
| 307 | - | Bool("manifest_match", isManifest). | |||
| 308 | - | Msg("cef: OnResourceResponse observed media response") | |||
| 309 | - | } | |||
| 310 | - | ||||
| 311 | - | if isAlreadyOpen { | |||
| 312 | - | return 0 | |||
| 313 | - | } | |||
| 314 | - | ||||
| 315 | - | if !isProprietary && !isManifest { | |||
| 316 | - | return 0 | |||
| 317 | - | } | |||
| 318 | - | ||||
| 319 | - | info := buildRequestInfo(request) | |||
| 320 | - | h.transcodableURLs.Store(url, info) | |||
| 321 | - | reason := "proprietary-container" | |||
| 322 | - | if isManifest { | |||
| 323 | - | reason = "streaming-manifest" | |||
| 324 | - | } | |||
| 325 | - | log.Info(). | |||
| 326 | - | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). | |||
| 327 | - | Str("mime_type", mimeType). | |||
| 328 | - | Str("transcode_reason", reason). | |||
| 329 | - | Int("forwarded_header_count", len(info.headers)). | |||
| 330 | - | Msg("cef: media response marked for transcoding") | |||
| 331 | - | ||||
| 332 | - | return 1 // restart request | |||
| 333 | - | } | |||
| 334 | - | ||||
| 335 | - | // GetResourceHandler returns a transcoding ResourceHandler for URLs that were | |||
| 336 | - | // either identified eagerly by URL heuristics or marked by OnResourceResponse. | |||
| 337 | - | func (h *transcodingRequestHandler) GetResourceHandler( | |||
| 338 | - | _ purecef.Browser, _ purecef.Frame, request purecef.Request, | |||
| 339 | - | ) purecef.ResourceHandler { | |||
| 340 | - | if request == nil { | |||
| 341 | - | return nil | |||
| 342 | - | } | |||
| 343 | - | url := request.GetURL() | |||
| 344 | - | val, ok := h.transcodableURLs.LoadAndDelete(url) | |||
| 345 | - | info := requestInfo{} | |||
| 346 | - | eager := false | |||
| 347 | - | sourceURL := url | |||
| 348 | - | if ok { | |||
| 349 | - | cached, typeOK := val.(requestInfo) | |||
| 350 | - | if !typeOK { | |||
| 351 | - | return nil | |||
| 352 | - | } | |||
| 353 | - | info = cached | |||
| 354 | - | } else if syntheticSourceURL, referer, origin, synthetic := resolveTranscodeSource(h.classifier, url); synthetic { | |||
| 355 | - | eager = true | |||
| 356 | - | sourceURL = syntheticSourceURL | |||
| 357 | - | info = buildRequestInfo(request) | |||
| 358 | - | if referer != "" && info.headers["Referer"] == "" { | |||
| 359 | - | info.headers["Referer"] = referer | |||
| 360 | - | } | |||
| 361 | - | if origin != "" && info.headers["Origin"] == "" { | |||
| 362 | - | info.headers["Origin"] = origin | |||
| 363 | - | } | |||
| 364 | - | } else { | |||
| 365 | - | return nil | |||
| 366 | - | } | |||
| 367 | - | ||||
| 368 | - | parentCtx := context.Background() | |||
| 369 | - | if h.ctxf != nil { | |||
| 370 | - | if provided := h.ctxf(); provided != nil { | |||
| 371 | - | parentCtx = provided | |||
| 372 | - | } | |||
| 373 | - | } | |||
| 374 | - | ctx, cancel := context.WithTimeout(parentCtx, 5*time.Minute) | |||
| 375 | - | shortURL := logging.TruncateURL(url, maxTranscodingURLLength) | |||
| 376 | - | shortSourceURL := logging.TruncateURL(sourceURL, maxTranscodingURLLength) | |||
| 377 | - | h.logger().Info(). | |||
| 378 | - | Str("url", shortURL). | |||
| 379 | - | Str("source_url", shortSourceURL). | |||
| 380 | - | Bool("eager_match", eager). | |||
| 381 | - | Int("forwarded_header_count", len(info.headers)). | |||
| 382 | - | Msg("cef: GetResourceHandler returning transcoding resource handler") | |||
| 383 | - | return purecef.NewResourceHandler(&transcodingResourceHandler{ | |||
| 384 | - | transcoder: h.transcoder, | |||
| 385 | - | sourceURL: sourceURL, | |||
| 386 | - | headers: info.headers, | |||
| 387 | - | ctx: ctx, | |||
| 388 | - | cancel: cancel, | |||
| 389 | - | logf: h.logf, | |||
| 390 | - | }) | |||
| 391 | - | } | |||
| 392 | - | ||||
| 393 | - | func classifyMediaResponse(classifier MediaClassifier, rawURL, mimeType string) (bool, bool, bool) { | |||
| 394 | - | proprietary := classifier.IsProprietaryVideoMIME(mimeType) | |||
| 395 | - | alreadyOpen := classifier.IsOpenVideoMIME(mimeType) | |||
| 396 | - | manifest := !alreadyOpen && (classifier.IsStreamingManifestMIME(mimeType) || classifier.IsStreamingManifestURL(rawURL)) | |||
| 397 | - | return proprietary, alreadyOpen, manifest | |||
| 398 | - | } | |||
| 399 | - | ||||
| 400 | - | func resolveTranscodeSource(classifier MediaClassifier, rawURL string) (string, string, string, bool) { | |||
| 401 | - | if sourceURL, referer, origin, ok := classifier.ParseSyntheticTranscodeURL(rawURL); ok { | |||
| 402 | - | return sourceURL, referer, origin, true | |||
| 403 | - | } | |||
| 404 | - | if classifier.IsEagerTranscodeURL(rawURL) { | |||
| 405 | - | return rawURL, "", "", true | |||
| 406 | - | } | |||
| 407 | - | return "", "", "", false | |||
| 408 | - | } | |||
| 409 | - | ||||
| 410 | - | // --- No-op methods required by purecef.ResourceRequestHandler --- | |||
| 411 | - | ||||
| 412 | - | func (h *transcodingRequestHandler) GetCookieAccessFilter( | |||
| 413 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, | |||
| 414 | - | ) purecef.CookieAccessFilter { | |||
| 415 | - | return nil | |||
| 416 | - | } | |||
| 417 | - | ||||
| 418 | - | func (h *transcodingRequestHandler) OnBeforeResourceLoad( | |||
| 419 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Callback, | |||
| 420 | - | ) purecef.ReturnValue { | |||
| 421 | - | return purecef.ReturnValueRvContinue | |||
| 422 | - | } | |||
| 423 | - | ||||
| 424 | - | func (h *transcodingRequestHandler) OnResourceRedirect( | |||
| 425 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, _ uintptr, | |||
| 426 | - | ) { | |||
| 427 | - | } | |||
| 428 | - | ||||
| 429 | - | func (h *transcodingRequestHandler) GetResourceResponseFilter( | |||
| 430 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, | |||
| 431 | - | ) purecef.ResponseFilter { | |||
| 432 | - | return nil | |||
| 433 | - | } | |||
| 434 | - | ||||
| 435 | - | func (h *transcodingRequestHandler) OnResourceLoadComplete( | |||
| 436 | - | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ purecef.Response, | |||
| 437 | - | _ purecef.UrlrequestStatus, _ int64, | |||
| 438 | - | ) { | |||
| 439 | - | } | |||
| 440 | - | ||||
| 441 | - | func (h *transcodingRequestHandler) OnProtocolExecution(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _ *int32) { | |||
| 442 | - | } | |||
| 443 | - | ||||
| 444 | - | func buildRequestInfo(request purecef.Request) requestInfo { | |||
| 445 | - | headers := make(map[string]string) | |||
| 446 | - | if request == nil { | |||
| 447 | - | return requestInfo{headers: headers} | |||
| 448 | - | } | |||
| 449 | - | if cookie := request.GetHeaderByName("Cookie"); cookie != "" { | |||
| 450 | - | headers["Cookie"] = cookie | |||
| 451 | - | } | |||
| 452 | - | if auth := request.GetHeaderByName("Authorization"); auth != "" { | |||
| 453 | - | headers["Authorization"] = auth | |||
| 454 | - | } | |||
| 455 | - | if referer := request.GetHeaderByName("Referer"); referer != "" { | |||
| 456 | - | headers["Referer"] = referer | |||
| 457 | - | } | |||
| 458 | - | if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" { | |||
| 459 | - | headers["User-Agent"] = userAgent | |||
| 460 | - | } | |||
| 461 | - | if origin := request.GetHeaderByName("Origin"); origin != "" { | |||
| 462 | - | headers["Origin"] = origin | |||
| 463 | - | } | |||
| 464 | - | return requestInfo{headers: headers} | |||
| 465 | - | } | |||
| 466 | - | ||||
| 467 | - | func (h *transcodingRequestHandler) logger() *zerolog.Logger { | |||
| 468 | - | logger := h.logf() | |||
| 469 | - | return &logger | |||
| 470 | - | } | |||
| 471 | - | ||||
| 472 | - | func (h *transcodingRequestHandler) logf() zerolog.Logger { | |||
| 473 | - | ctx := context.Background() | |||
| 474 | - | if h != nil && h.ctxf != nil { | |||
| 475 | - | if provided := h.ctxf(); provided != nil { | |||
| 476 | - | ctx = provided | |||
| 477 | - | } | |||
| 478 | - | } | |||
| 479 | - | return logging.FromContext(ctx).With().Str("component", "cef-transcoding").Logger() | |||
| 480 | - | } | |||
| 481 | - | ||||
| 482 | - | func (rh *transcodingResourceHandler) logger() *zerolog.Logger { | |||
| 483 | - | if rh != nil && rh.logf != nil { | |||
| 484 | - | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |||
| 485 | - | logger := rh.logf().With().Str("source_url", sourceURL).Logger() | |||
| 486 | - | return &logger | |||
| 487 | - | } | |||
| 488 | - | logger := logging.FromContext(context.Background()).With().Str("component", "cef-transcoding").Logger() | |||
| 489 | - | return &logger | |||
| 490 | - | } | |||
D internal/infrastructure/cef/transcoding_handler_test.go +0 -49
| @@ -1,49 +0,0 @@ | |||||
| 1 | - | package cef | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "testing" | |||
| 5 | - | ||||
| 6 | - | "github.com/stretchr/testify/require" | |||
| 7 | - | ) | |||
| 8 | - | ||||
| 9 | - | func TestClassifyMediaResponse_UsesInjectedClassifier(t *testing.T) { | |||
| 10 | - | classifier := MediaClassifier{ | |||
| 11 | - | IsProprietaryVideoMIME: func(string) bool { return true }, | |||
| 12 | - | IsOpenVideoMIME: func(string) bool { return false }, | |||
| 13 | - | IsStreamingManifestMIME: func(string) bool { return false }, | |||
| 14 | - | IsStreamingManifestURL: func(string) bool { return false }, | |||
| 15 | - | }.normalize() | |||
| 16 | - | ||||
| 17 | - | proprietary, alreadyOpen, manifest := classifyMediaResponse(classifier, "https://cdn.example/video.mp4", "video/mp4") | |||
| 18 | - | require.True(t, proprietary) | |||
| 19 | - | require.False(t, alreadyOpen) | |||
| 20 | - | require.False(t, manifest) | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | func TestResolveTranscodeSource_UsesInjectedClassifier(t *testing.T) { | |||
| 24 | - | classifier := MediaClassifier{ | |||
| 25 | - | ParseSyntheticTranscodeURL: func(string) (string, string, string, bool) { | |||
| 26 | - | return "https://cdn.example/video.mp4", "https://example.com", "https://example.com", true | |||
| 27 | - | }, | |||
| 28 | - | IsEagerTranscodeURL: func(string) bool { return false }, | |||
| 29 | - | }.normalize() | |||
| 30 | - | ||||
| 31 | - | src, referer, origin, eager := resolveTranscodeSource(classifier, "https://dumber.invalid/__dumber__/transcode.webm?src=x") | |||
| 32 | - | require.True(t, eager) | |||
| 33 | - | require.Equal(t, "https://cdn.example/video.mp4", src) | |||
| 34 | - | require.Equal(t, "https://example.com", referer) | |||
| 35 | - | require.Equal(t, "https://example.com", origin) | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | func TestResolveTranscodeSource_UsesEagerFallback(t *testing.T) { | |||
| 39 | - | classifier := MediaClassifier{ | |||
| 40 | - | ParseSyntheticTranscodeURL: func(string) (string, string, string, bool) { return "", "", "", false }, | |||
| 41 | - | IsEagerTranscodeURL: func(string) bool { return true }, | |||
| 42 | - | }.normalize() | |||
| 43 | - | ||||
| 44 | - | src, referer, origin, eager := resolveTranscodeSource(classifier, "https://cdn.example/video.mp4") | |||
| 45 | - | require.True(t, eager) | |||
| 46 | - | require.Equal(t, "https://cdn.example/video.mp4", src) | |||
| 47 | - | require.Empty(t, referer) | |||
| 48 | - | require.Empty(t, origin) | |||
| 49 | - | } | |||
M internal/infrastructure/cef/webview_handlers.go +6 -22
| @@ -8,23 +8,20 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 10 | downloadutil "github.com/bnema/dumber/internal/domain/download" | 10 | downloadutil "github.com/bnema/dumber/internal/domain/download" | ||
| 11 | - | "github.com/bnema/dumber/internal/infrastructure/transcoder" | |||
| 12 | "github.com/bnema/dumber/internal/logging" | 11 | "github.com/bnema/dumber/internal/logging" | ||
| 13 | ) | 12 | ) | ||
| 14 | 13 | ||||
| 15 | // Console message markers used by injected JavaScript for log filtering. | 14 | // Console message markers used by injected JavaScript for log filtering. | ||
| 16 | const ( | 15 | const ( | ||
| 17 | - | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | 16 | + | consoleMarkerVideoDiag = "[VIDEO-DIAG]" |
| 18 | - | consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]" | 17 | + | consoleMarkerAutoCopy = "[AUTO-COPY]" |
| 19 | - | consoleMarkerAutoCopy = "[AUTO-COPY]" | |||
| 20 | ) | 18 | ) | ||
| 21 | 19 | ||||
| 22 | // handlerSet implements all CEF handler interfaces and dispatches events to the | 20 | // handlerSet implements all CEF handler interfaces and dispatches events to the | ||
| 23 | // owning WebView. A single struct is used so that the Client's Get*Handler | 21 | // owning WebView. A single struct is used so that the Client's Get*Handler | ||
| 24 | // methods can return the same receiver, avoiding extra allocations. | 22 | // methods can return the same receiver, avoiding extra allocations. | ||
| 25 | type handlerSet struct { | 23 | type handlerSet struct { | ||
| 26 | - | wv *WebView | 24 | + | wv *WebView |
| 27 | - | transcodingHandler purecef.ResourceRequestHandler | |||
| 28 | } | 25 | } | ||
| 29 | 26 | ||||
| 30 | // Compile-time interface checks. | 27 | // Compile-time interface checks. | ||
| @@ -495,7 +492,6 @@ func (h *handlerSet) OnConsoleMessage( | |||||
| 495 | ) int32 { | 492 | ) int32 { | ||
| 496 | if h.wv != nil && h.wv.ctx != nil && | 493 | if h.wv != nil && h.wv.ctx != nil && | ||
| 497 | (strings.Contains(message, consoleMarkerVideoDiag) || | 494 | (strings.Contains(message, consoleMarkerVideoDiag) || | ||
| 498 | - | strings.Contains(message, consoleMarkerRedditVideoPatch) || | |||
| 499 | strings.Contains(message, consoleMarkerAutoCopy)) { | 495 | strings.Contains(message, consoleMarkerAutoCopy)) { | ||
| 500 | log := logging.FromContext(h.wv.ctx).With(). | 496 | log := logging.FromContext(h.wv.ctx).With(). | ||
| 501 | Str("component", "cef-console"). | 497 | Str("component", "cef-console"). | ||
| @@ -928,7 +924,7 @@ func (h *handlerSet) OnBeforeBrowse(browser purecef.Browser, frame purecef.Frame | |||||
| 928 | } | 924 | } | ||
| 929 | 925 | ||||
| 930 | logging.FromContext(h.currentContext()).Debug(). | 926 | logging.FromContext(h.currentContext()).Debug(). | ||
| 931 | - | Str("url", logging.TruncateURL(url, maxTranscodingURLLength)). | 927 | + | Str("url", logging.TruncateURL(url, maxSchemeTruncatedURLLength)). |
| 932 | Msg("cef: forcing download for navigation") | 928 | Msg("cef: forcing download for navigation") | ||
| 933 | 929 | ||||
| 934 | host.StartDownload(url) | 930 | host.StartDownload(url) | ||
| @@ -993,21 +989,9 @@ func (h *handlerSet) OnOpenUrlfromTab( | |||||
| 993 | } | 989 | } | ||
| 994 | 990 | ||||
| 995 | func (h *handlerSet) GetResourceRequestHandler( | 991 | func (h *handlerSet) GetResourceRequestHandler( | ||
| 996 | - | _ purecef.Browser, _ purecef.Frame, request purecef.Request, | 992 | + | _ purecef.Browser, _ purecef.Frame, _ purecef.Request, |
| 997 | - | _, _ int32, _ string, disableDefaultHandling *int32, | 993 | + | _, _ int32, _ string, _ *int32, |
| 998 | ) purecef.ResourceRequestHandler { | 994 | ) purecef.ResourceRequestHandler { | ||
| 999 | - | if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil && | |||
| 1000 | - | transcoder.IsEagerTranscodeURL(request.GetURL()) { | |||
| 1001 | - | *disableDefaultHandling = 1 | |||
| 1002 | - | if h.wv != nil && h.wv.ctx != nil { | |||
| 1003 | - | logging.FromContext(h.wv.ctx).Info(). | |||
| 1004 | - | Str("url", logging.TruncateURL(request.GetURL(), maxTranscodingURLLength)). | |||
| 1005 | - | Msg("cef: disabled default handling for eager transcode candidate") | |||
| 1006 | - | } | |||
| 1007 | - | } | |||
| 1008 | - | if h.transcodingHandler != nil { | |||
| 1009 | - | return h.transcodingHandler | |||
| 1010 | - | } | |||
| 1011 | return nil | 995 | return nil | ||
| 1012 | } | 996 | } | ||
| 1013 | 997 | ||||
M internal/infrastructure/config/defaults.go +0 -12
| @@ -71,12 +71,6 @@ const ( | |||||
| 71 | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | 71 | defaultWebViewPoolPrewarmCount = 4 // WebViews to pre-create at startup | ||
| 72 | defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF | 72 | defaultCEFWindowlessFrameRate = 60 // OSR frame rate for CEF | ||
| 73 | 73 | ||||
| 74 | - | // Transcoding defaults | |||
| 75 | - | defaultTranscodingEnabled = false | |||
| 76 | - | defaultTranscodingHWAccel = "auto" | |||
| 77 | - | defaultTranscodingMaxConcurrent = 3 | |||
| 78 | - | defaultTranscodingQuality = "medium" | |||
| 79 | - | ||||
| 80 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | 74 | // Skia threading defaults (0 = unset, -1 = unset for GPU threads) | ||
| 81 | defaultSkiaCPUPaintingThreads = 0 | 75 | defaultSkiaCPUPaintingThreads = 0 | ||
| 82 | defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting | 76 | defaultSkiaGPUPaintingThreads = -1 // -1 means unset; 0 would disable GPU tile painting | ||
| @@ -324,12 +318,6 @@ func DefaultConfig() *Config { | |||||
| 324 | Downloads: DownloadsConfig{ | 318 | Downloads: DownloadsConfig{ | ||
| 325 | Path: "", // Empty = use XDG_DOWNLOAD_DIR or ~/Downloads | 319 | Path: "", // Empty = use XDG_DOWNLOAD_DIR or ~/Downloads | ||
| 326 | }, | 320 | }, | ||
| 327 | - | Transcoding: TranscodingConfig{ | |||
| 328 | - | Enabled: defaultTranscodingEnabled, | |||
| 329 | - | HWAccel: defaultTranscodingHWAccel, | |||
| 330 | - | MaxConcurrent: defaultTranscodingMaxConcurrent, | |||
| 331 | - | Quality: defaultTranscodingQuality, | |||
| 332 | - | }, | |||
| 333 | } | 321 | } | ||
| 334 | } | 322 | } | ||
| 335 | 323 | ||||
M internal/infrastructure/config/defaults_test.go +0 -4
| @@ -13,10 +13,6 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) { | |||||
| 13 | assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles) | 13 | assert.Equal(t, defaultMaxLogFiles, cfg.Logging.MaxFiles) | ||
| 14 | assert.False(t, cfg.Logging.CaptureGTKLogs) | 14 | assert.False(t, cfg.Logging.CaptureGTKLogs) | ||
| 15 | assert.False(t, cfg.Media.ShowDiagnosticsOnStartup) | 15 | assert.False(t, cfg.Media.ShowDiagnosticsOnStartup) | ||
| 16 | - | assert.False(t, cfg.Transcoding.Enabled) | |||
| 17 | - | assert.Equal(t, "auto", cfg.Transcoding.HWAccel) | |||
| 18 | - | assert.Equal(t, 3, cfg.Transcoding.MaxConcurrent) | |||
| 19 | - | assert.Equal(t, "medium", cfg.Transcoding.Quality) | |||
| 20 | 16 | ||||
| 21 | // Engine defaults (replaces old Performance/Privacy sections) | 17 | // Engine defaults (replaces old Performance/Privacy sections) | ||
| 22 | assert.Equal(t, "webkit", cfg.Engine.Type) | 18 | assert.Equal(t, "webkit", cfg.Engine.Type) | ||
M internal/infrastructure/config/loader.go +1 -8
| @@ -417,7 +417,6 @@ func (m *Manager) setDefaults() { | |||||
| 417 | m.setDebugDefaults(defaults) | 417 | m.setDebugDefaults(defaults) | ||
| 418 | m.setAppearanceDefaults(defaults) | 418 | m.setAppearanceDefaults(defaults) | ||
| 419 | m.setEngineDefaults(defaults) | 419 | m.setEngineDefaults(defaults) | ||
| 420 | - | m.setTranscodingDefaults(defaults) | |||
| 421 | m.setZoomAndScaleDefaults(defaults) | 420 | m.setZoomAndScaleDefaults(defaults) | ||
| 422 | m.setWorkspaceDefaults(defaults) | 421 | m.setWorkspaceDefaults(defaults) | ||
| 423 | m.setContentFilteringDefaults(defaults) | 422 | m.setContentFilteringDefaults(defaults) | ||
| @@ -611,13 +610,7 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||||
| 611 | m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold) | 610 | m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold) | ||
| 612 | } | 611 | } | ||
| 613 | 612 | ||||
| 614 | - | func (m *Manager) setTranscodingDefaults(defaults *Config) { | 613 | + | |
| 615 | - | tc := defaults.Transcoding | |||
| 616 | - | m.viper.SetDefault("transcoding.enabled", tc.Enabled) | |||
| 617 | - | m.viper.SetDefault("transcoding.hwaccel", tc.HWAccel) | |||
| 618 | - | m.viper.SetDefault("transcoding.max_concurrent", tc.MaxConcurrent) | |||
| 619 | - | m.viper.SetDefault("transcoding.quality", tc.Quality) | |||
| 620 | - | } | |||
| 621 | 614 | ||||
| 622 | // New returns a new default configuration instance. | 615 | // New returns a new default configuration instance. | ||
| 623 | // This is a convenience function for getting default config without the full manager. | 616 | // This is a convenience function for getting default config without the full manager. | ||
M internal/infrastructure/config/loader_test.go +0 -38
| @@ -1,13 +1,10 @@ | |||||
| 1 | package config | 1 | package config | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "os" | |||
| 5 | - | "path/filepath" | |||
| 6 | "testing" | 4 | "testing" | ||
| 7 | 5 | ||||
| 8 | "github.com/spf13/viper" | 6 | "github.com/spf13/viper" | ||
| 9 | "github.com/stretchr/testify/assert" | 7 | "github.com/stretchr/testify/assert" | ||
| 10 | - | "github.com/stretchr/testify/require" | |||
| 11 | ) | 8 | ) | ||
| 12 | 9 | ||||
| 13 | func TestSetEngineDefaults(t *testing.T) { | 10 | func TestSetEngineDefaults(t *testing.T) { | ||
| @@ -27,12 +24,6 @@ func TestSetEngineDefaults(t *testing.T) { | |||||
| 27 | assert.Equal(t, "auto", mgr.viper.GetString("engine.webkit.gl_rendering_mode")) | 24 | assert.Equal(t, "auto", mgr.viper.GetString("engine.webkit.gl_rendering_mode")) | ||
| 28 | assert.False(t, mgr.viper.GetBool("engine.webkit.disable_dmabuf_renderer")) | 25 | assert.False(t, mgr.viper.GetBool("engine.webkit.disable_dmabuf_renderer")) | ||
| 29 | 26 | ||||
| 30 | - | // Transcoding defaults | |||
| 31 | - | assert.False(t, mgr.viper.GetBool("transcoding.enabled")) | |||
| 32 | - | assert.Equal(t, "auto", mgr.viper.GetString("transcoding.hwaccel")) | |||
| 33 | - | assert.Equal(t, 3, mgr.viper.GetInt("transcoding.max_concurrent")) | |||
| 34 | - | assert.Equal(t, "medium", mgr.viper.GetString("transcoding.quality")) | |||
| 35 | - | ||||
| 36 | // Old sections should NOT be registered as defaults | 27 | // Old sections should NOT be registered as defaults | ||
| 37 | assert.Empty(t, mgr.viper.GetString("privacy.cookie_policy")) | 28 | assert.Empty(t, mgr.viper.GetString("privacy.cookie_policy")) | ||
| 38 | assert.Empty(t, mgr.viper.GetString("rendering.mode")) | 29 | assert.Empty(t, mgr.viper.GetString("rendering.mode")) | ||
| @@ -49,33 +40,4 @@ func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) { | |||||
| 49 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | 40 | assert.Equal(t, CookiePolicyAlways, cfg.Engine.CookiePolicy) | ||
| 50 | } | 41 | } | ||
| 51 | 42 | ||||
| 52 | - | func TestLoad_AppliesTranscodingDefaultsWhenSectionMissing(t *testing.T) { | |||
| 53 | - | t.Setenv("XDG_CONFIG_HOME", t.TempDir()) | |||
| 54 | - | t.Setenv("XDG_DATA_HOME", t.TempDir()) | |||
| 55 | - | t.Setenv("XDG_STATE_HOME", t.TempDir()) | |||
| 56 | - | t.Setenv("XDG_CACHE_HOME", t.TempDir()) | |||
| 57 | - | ||||
| 58 | - | configDir, err := GetConfigDir() | |||
| 59 | - | require.NoError(t, err) | |||
| 60 | - | ||||
| 61 | - | err = os.MkdirAll(configDir, dirPerm) | |||
| 62 | - | require.NoError(t, err) | |||
| 63 | 43 | ||||
| 64 | - | configPath := filepath.Join(configDir, "config.toml") | |||
| 65 | - | err = os.WriteFile(configPath, []byte(` | |||
| 66 | - | [engine] | |||
| 67 | - | type = "cef" | |||
| 68 | - | `), filePerm) | |||
| 69 | - | require.NoError(t, err) | |||
| 70 | - | ||||
| 71 | - | mgr, err := NewManager() | |||
| 72 | - | require.NoError(t, err) | |||
| 73 | - | require.NoError(t, mgr.Load()) | |||
| 74 | - | ||||
| 75 | - | cfg := mgr.Get() | |||
| 76 | - | require.NotNil(t, cfg) | |||
| 77 | - | assert.False(t, cfg.Transcoding.Enabled) | |||
| 78 | - | assert.Equal(t, "auto", cfg.Transcoding.HWAccel) | |||
| 79 | - | assert.Equal(t, 3, cfg.Transcoding.MaxConcurrent) | |||
| 80 | - | assert.Equal(t, "medium", cfg.Transcoding.Quality) | |||
| 81 | - | } | |||
M internal/infrastructure/config/schema.go +1 -18
| @@ -110,8 +110,6 @@ type Config struct { | |||||
| 110 | Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"` | 110 | Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"` | ||
| 111 | // Engine holds engine selection and unified engine options. | 111 | // Engine holds engine selection and unified engine options. | ||
| 112 | Engine EngineConfig `mapstructure:"engine" toml:"engine" yaml:"engine"` | 112 | Engine EngineConfig `mapstructure:"engine" toml:"engine" yaml:"engine"` | ||
| 113 | - | // Transcoding controls GPU-accelerated media transcoding for proprietary codecs. | |||
| 114 | - | Transcoding TranscodingConfig `mapstructure:"transcoding" yaml:"transcoding" toml:"transcoding"` | |||
| 115 | } | 113 | } | ||
| 116 | 114 | ||||
| 117 | // CookiePolicy controls cookie acceptance behavior. | 115 | // CookiePolicy controls cookie acceptance behavior. | ||
| @@ -384,19 +382,4 @@ type DownloadsConfig struct { | |||||
| 384 | Path string `mapstructure:"path" yaml:"path" toml:"path"` | 382 | Path string `mapstructure:"path" yaml:"path" toml:"path"` | ||
| 385 | } | 383 | } | ||
| 386 | 384 | ||||
| 387 | - | // TranscodingConfig controls GPU-accelerated media transcoding. | 385 | + | |
| 388 | - | // When enabled, proprietary video formats (H.264, HEVC) are transcoded | |||
| 389 | - | // to open codecs (VP9, AV1) that CEF can decode natively. | |||
| 390 | - | type TranscodingConfig struct { | |||
| 391 | - | // Enabled controls whether transcoding is active (default: false). | |||
| 392 | - | Enabled bool `mapstructure:"enabled" yaml:"enabled" toml:"enabled"` | |||
| 393 | - | // HWAccel selects the hardware acceleration API: "auto", "vaapi", or "nvenc". | |||
| 394 | - | // Default: "auto" (tries VAAPI first, then CUDA). | |||
| 395 | - | HWAccel string `mapstructure:"hwaccel" yaml:"hwaccel" toml:"hwaccel"` | |||
| 396 | - | // MaxConcurrent is the maximum number of simultaneous transcode sessions. | |||
| 397 | - | // Default: 3. | |||
| 398 | - | MaxConcurrent int `mapstructure:"max_concurrent" yaml:"max_concurrent" toml:"max_concurrent"` | |||
| 399 | - | // Quality controls the encode quality preset: "low", "medium", or "high". | |||
| 400 | - | // Default: "medium". | |||
| 401 | - | Quality string `mapstructure:"quality" yaml:"quality" toml:"quality"` | |||
| 402 | - | } | |||
M internal/infrastructure/config/schema_provider.go +6 -5
| @@ -1025,11 +1025,12 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||||
| 1025 | func (*SchemaProvider) getRuntimeKeys(defaults *Config) []entity.ConfigKeyInfo { | 1025 | func (*SchemaProvider) getRuntimeKeys(defaults *Config) []entity.ConfigKeyInfo { | ||
| 1026 | return []entity.ConfigKeyInfo{ | 1026 | return []entity.ConfigKeyInfo{ | ||
| 1027 | { | 1027 | { | ||
| 1028 | - | Key: "engine.cef.cef_dir", | 1028 | + | Key: "engine.cef.cef_dir", |
| 1029 | - | Type: "string", | 1029 | + | Type: "string", |
| 1030 | - | Default: defaults.Engine.CEF.CEFDir, | 1030 | + | Default: defaults.Engine.CEF.CEFDir, |
| 1031 | - | Description: "Custom CEF runtime directory containing libcef and Resources", | 1031 | + | Description: "Optional CEF runtime directory containing libcef.so and Resources; overrides the default " + |
| 1032 | - | Section: SectionRuntime, | 1032 | + | "runtime discovery order (CEF_DIR env, /usr/lib/cef, ~/.local/share/cef)", |
| 1033 | + | Section: SectionRuntime, | |||
| 1033 | }, | 1034 | }, | ||
| 1034 | { | 1035 | { | ||
| 1035 | Key: "engine.webkit.prefix", | 1036 | Key: "engine.webkit.prefix", | ||
M internal/infrastructure/config/writer_test.go +0 -22
| @@ -1,7 +1,6 @@ | |||||
| 1 | package config | 1 | package config | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "bytes" | |||
| 5 | "os" | 4 | "os" | ||
| 6 | "path/filepath" | 5 | "path/filepath" | ||
| 7 | "strings" | 6 | "strings" | ||
| @@ -44,27 +43,6 @@ func TestWriteConfigOrdered(t *testing.T) { | |||||
| 44 | } | 43 | } | ||
| 45 | } | 44 | } | ||
| 46 | 45 | ||||
| 47 | - | if !strings.Contains(string(content), "[transcoding]") { | |||
| 48 | - | t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content)) | |||
| 49 | - | } | |||
| 50 | - | // Match "enabled = false" specifically within the [transcoding] section to | |||
| 51 | - | // avoid false positives from other sections that may also have an enabled key. | |||
| 52 | - | transcodingIdx := bytes.Index(content, []byte("[transcoding]")) | |||
| 53 | - | if transcodingIdx < 0 { | |||
| 54 | - | t.Fatalf("expected transcoding section in generated config, got:\n%s", string(content)) | |||
| 55 | - | } | |||
| 56 | - | afterTranscoding := content[transcodingIdx:] | |||
| 57 | - | nextSection := bytes.Index(afterTranscoding[1:], []byte("\n[")) | |||
| 58 | - | var transcodingBlock []byte | |||
| 59 | - | if nextSection >= 0 { | |||
| 60 | - | transcodingBlock = afterTranscoding[:nextSection+1] | |||
| 61 | - | } else { | |||
| 62 | - | transcodingBlock = afterTranscoding | |||
| 63 | - | } | |||
| 64 | - | if !bytes.Contains(transcodingBlock, []byte("enabled = false")) { | |||
| 65 | - | t.Fatalf("expected enabled = false in [transcoding] section, got:\n%s", string(transcodingBlock)) | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | t.Logf("Found %d sections, all sorted", len(sections)) | 46 | t.Logf("Found %d sections, all sorted", len(sections)) | ||
| 69 | 47 | ||||
| 70 | // Print first 50 lines for debugging | 48 | // Print first 50 lines for debugging | ||
D internal/infrastructure/netguard/transcode.go +0 -52
| @@ -1,52 +0,0 @@ | |||||
| 1 | - | package netguard | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "net" | |||
| 5 | - | "net/netip" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | var blockedTranscodeIPPfxs = []netip.Prefix{ | |||
| 9 | - | netip.MustParsePrefix("0.0.0.0/8"), | |||
| 10 | - | netip.MustParsePrefix("10.0.0.0/8"), | |||
| 11 | - | netip.MustParsePrefix("100.64.0.0/10"), | |||
| 12 | - | netip.MustParsePrefix("127.0.0.0/8"), | |||
| 13 | - | netip.MustParsePrefix("169.254.0.0/16"), | |||
| 14 | - | netip.MustParsePrefix("172.16.0.0/12"), | |||
| 15 | - | netip.MustParsePrefix("192.0.0.0/24"), | |||
| 16 | - | netip.MustParsePrefix("192.0.2.0/24"), | |||
| 17 | - | netip.MustParsePrefix("192.168.0.0/16"), | |||
| 18 | - | netip.MustParsePrefix("198.18.0.0/15"), | |||
| 19 | - | netip.MustParsePrefix("198.51.100.0/24"), | |||
| 20 | - | netip.MustParsePrefix("203.0.113.0/24"), | |||
| 21 | - | netip.MustParsePrefix("224.0.0.0/4"), | |||
| 22 | - | netip.MustParsePrefix("240.0.0.0/4"), | |||
| 23 | - | netip.MustParsePrefix("::/128"), | |||
| 24 | - | netip.MustParsePrefix("::1/128"), | |||
| 25 | - | netip.MustParsePrefix("64:ff9b::/96"), | |||
| 26 | - | netip.MustParsePrefix("64:ff9b:1::/48"), | |||
| 27 | - | netip.MustParsePrefix("100::/64"), | |||
| 28 | - | netip.MustParsePrefix("2001::/32"), | |||
| 29 | - | netip.MustParsePrefix("2001:2::/48"), | |||
| 30 | - | netip.MustParsePrefix("2001:db8::/32"), | |||
| 31 | - | netip.MustParsePrefix("2002::/16"), | |||
| 32 | - | netip.MustParsePrefix("fc00::/7"), | |||
| 33 | - | netip.MustParsePrefix("fe80::/10"), | |||
| 34 | - | netip.MustParsePrefix("ff00::/8"), | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | // IsBlockedTranscodeIP reports whether ip belongs to a local, private, | |||
| 38 | - | // documentation, transition, or otherwise special-purpose range that a media | |||
| 39 | - | // transcoder must not fetch on behalf of web content. | |||
| 40 | - | func IsBlockedTranscodeIP(ip net.IP) bool { | |||
| 41 | - | addr, ok := netip.AddrFromSlice(ip) | |||
| 42 | - | if !ok { | |||
| 43 | - | return true | |||
| 44 | - | } | |||
| 45 | - | addr = addr.Unmap() | |||
| 46 | - | for _, prefix := range blockedTranscodeIPPfxs { | |||
| 47 | - | if prefix.Contains(addr) { | |||
| 48 | - | return true | |||
| 49 | - | } | |||
| 50 | - | } | |||
| 51 | - | return false | |||
| 52 | - | } | |||
D internal/infrastructure/netguard/transcode_test.go +0 -50
| @@ -1,50 +0,0 @@ | |||||
| 1 | - | package netguard | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "net" | |||
| 5 | - | "testing" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | func TestIsBlockedTranscodeIPRejectsSpecialPurposeRanges(t *testing.T) { | |||
| 9 | - | tests := []string{ | |||
| 10 | - | "127.0.0.1", | |||
| 11 | - | "10.0.0.1", | |||
| 12 | - | "172.16.0.1", | |||
| 13 | - | "192.168.1.1", | |||
| 14 | - | "100.64.0.1", | |||
| 15 | - | "198.18.0.1", | |||
| 16 | - | "192.0.2.1", | |||
| 17 | - | "169.254.169.254", | |||
| 18 | - | "0.0.0.0", | |||
| 19 | - | "64:ff9b::a00:1", | |||
| 20 | - | "2001::1", | |||
| 21 | - | "2001:db8::1", | |||
| 22 | - | "2002:0a00:0001::1", | |||
| 23 | - | "fc00::1", | |||
| 24 | - | "fe80::1", | |||
| 25 | - | "::ffff:127.0.0.1", | |||
| 26 | - | } | |||
| 27 | - | for _, rawIP := range tests { | |||
| 28 | - | t.Run(rawIP, func(t *testing.T) { | |||
| 29 | - | if !IsBlockedTranscodeIP(net.ParseIP(rawIP)) { | |||
| 30 | - | t.Fatalf("IsBlockedTranscodeIP(%s) = false, want true", rawIP) | |||
| 31 | - | } | |||
| 32 | - | }) | |||
| 33 | - | } | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | func TestIsBlockedTranscodeIPRejectsInvalidIP(t *testing.T) { | |||
| 37 | - | if !IsBlockedTranscodeIP(net.ParseIP("")) { | |||
| 38 | - | t.Fatal("IsBlockedTranscodeIP(invalid IP) = false, want true") | |||
| 39 | - | } | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | func TestIsBlockedTranscodeIPAllowsPublicAddress(t *testing.T) { | |||
| 43 | - | for _, rawIP := range []string{"93.184.216.34", "2606:4700:4700::1111"} { | |||
| 44 | - | t.Run(rawIP, func(t *testing.T) { | |||
| 45 | - | if IsBlockedTranscodeIP(net.ParseIP(rawIP)) { | |||
| 46 | - | t.Fatalf("IsBlockedTranscodeIP(%s) = true, want false", rawIP) | |||
| 47 | - | } | |||
| 48 | - | }) | |||
| 49 | - | } | |||
| 50 | - | } | |||
D internal/infrastructure/transcoder/audio.go +0 -417
| @@ -1,417 +0,0 @@ | |||||
| 1 | - | //go:build !js || !wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "errors" | |||
| 7 | - | "fmt" | |||
| 8 | - | "unsafe" | |||
| 9 | - | ||||
| 10 | - | "github.com/bnema/purego-ffmpeg/ffmpeg" | |||
| 11 | - | "github.com/rs/zerolog" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | // opusSampleRate is the standard output sample rate for Opus encoding. | |||
| 15 | - | // Opus supports 8, 12, 16, 24, and 48 kHz. 48 kHz is the canonical rate. | |||
| 16 | - | const opusSampleRate = 48000 | |||
| 17 | - | ||||
| 18 | - | // opusBitrate is the default Opus encoding bitrate (128 kbps stereo). | |||
| 19 | - | const opusBitrate = 128000 | |||
| 20 | - | ||||
| 21 | - | // opusFrameSize is the number of samples per Opus frame at 48 kHz. | |||
| 22 | - | // 960 samples = 20 ms at 48 kHz, which is the standard Opus frame duration. | |||
| 23 | - | const opusFrameSize = 960 | |||
| 24 | - | ||||
| 25 | - | func freeCodecContext(ctx *unsafe.Pointer) { | |||
| 26 | - | //nolint:gosec // FFmpeg expects AVCodecContext** for ownership release. | |||
| 27 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(ctx)) | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | func freeFrame(frame *unsafe.Pointer) { | |||
| 31 | - | //nolint:gosec // FFmpeg expects AVFrame** for ownership release. | |||
| 32 | - | ffmpeg.FrameFree(unsafe.Pointer(frame)) | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | func freeSwrContext(swr *unsafe.Pointer) { | |||
| 36 | - | //nolint:gosec // FFmpeg expects SwrContext** for ownership release. | |||
| 37 | - | ffmpeg.SwrFree(unsafe.Pointer(swr)) | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | func freePacket(pkt *unsafe.Pointer) { | |||
| 41 | - | //nolint:gosec // FFmpeg expects AVPacket** for ownership release. | |||
| 42 | - | ffmpeg.PacketFree(unsafe.Pointer(pkt)) | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | - | func swrConvert(swr, outFrame, inFrame unsafe.Pointer, outSamples, inSamples int32) int32 { | |||
| 46 | - | //nolint:gosec // FFmpeg expects raw AVFrame data pointers here. | |||
| 47 | - | return ffmpeg.SwrConvert( | |||
| 48 | - | swr, | |||
| 49 | - | unsafe.Pointer((*unsafe.Pointer)(outFrame)), outSamples, | |||
| 50 | - | unsafe.Pointer((*unsafe.Pointer)(inFrame)), inSamples, | |||
| 51 | - | ) | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | // audioTranscoder decodes an input audio stream (typically AAC) and | |||
| 55 | - | // re-encodes it to Opus, performing sample rate conversion if needed. | |||
| 56 | - | type audioTranscoder struct { | |||
| 57 | - | decoder unsafe.Pointer // AVCodecContext* for decoding | |||
| 58 | - | encoder unsafe.Pointer // AVCodecContext* for Opus encoding | |||
| 59 | - | resampler unsafe.Pointer // SwrContext* (nil if no resampling needed) | |||
| 60 | - | frame unsafe.Pointer // reusable AVFrame for resampled output | |||
| 61 | - | decFrame unsafe.Pointer // reusable AVFrame for decoded output | |||
| 62 | - | encPkt unsafe.Pointer // reusable AVPacket for encoded output | |||
| 63 | - | inStream int // input stream index in the source container | |||
| 64 | - | outStream int // output stream index in the muxer | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | // newAudioTranscoder sets up an AAC (or other) decoder and a libopus encoder. | |||
| 68 | - | // inCodecPar is the AVCodecParameters* from the input audio stream. | |||
| 69 | - | // outFmtCtx is the AVFormatContext* for the output container (used to | |||
| 70 | - | // add the output stream). inStreamIdx is the input stream index. | |||
| 71 | - | func newAudioTranscoder(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (*audioTranscoder, error) { | |||
| 72 | - | return newAudioTranscoderImpl(inCodecPar, outFmtCtx, inStreamIdx) | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | //nolint:gocyclo,funlen // FFmpeg decoder/encoder/resampler setup needs the necessary branches. | |||
| 76 | - | func newAudioTranscoderImpl(inCodecPar, outFmtCtx unsafe.Pointer, inStreamIdx int) (*audioTranscoder, error) { | |||
| 77 | - | // --- Decoder setup --- | |||
| 78 | - | codecID := ffmpeg.CodecParCodecID(inCodecPar) | |||
| 79 | - | dec := ffmpeg.CodecFindDecoder(codecID) | |||
| 80 | - | if dec == nil { | |||
| 81 | - | return nil, fmt.Errorf("audio decoder not found for codec ID %d", codecID) | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | decCtx := ffmpeg.CodecAllocContext3(dec) | |||
| 85 | - | if decCtx == nil { | |||
| 86 | - | return nil, errors.New("failed to allocate audio decoder context") | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | if ret := ffmpeg.CodecParametersToContext(decCtx, inCodecPar); ret < 0 { | |||
| 90 | - | freeCodecContext(&decCtx) | |||
| 91 | - | return nil, fmt.Errorf("failed to copy audio codec parameters to decoder: %d", ret) | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 { | |||
| 95 | - | freeCodecContext(&decCtx) | |||
| 96 | - | return nil, fmt.Errorf("failed to open audio decoder: %d", ret) | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | // --- Encoder setup (libopus) --- | |||
| 100 | - | enc := ffmpeg.CodecFindEncoderByName("libopus") | |||
| 101 | - | if enc == nil { | |||
| 102 | - | freeCodecContext(&decCtx) | |||
| 103 | - | return nil, errors.New("libopus encoder not found") | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | encCtx := ffmpeg.CodecAllocContext3(enc) | |||
| 107 | - | if encCtx == nil { | |||
| 108 | - | freeCodecContext(&decCtx) | |||
| 109 | - | return nil, errors.New("failed to allocate audio encoder context") | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | // libopus accepts s16 (interleaved) and flt (float interleaved). | |||
| 113 | - | // Planar formats (fltp) are NOT supported and cause encoder open to fail. | |||
| 114 | - | ffmpeg.CodecCtxSetSampleFmt(encCtx, int32(ffmpeg.SampleFmtFlt)) | |||
| 115 | - | ffmpeg.CodecCtxSetSampleRate(encCtx, opusSampleRate) | |||
| 116 | - | codecCtxSetBitRate(encCtx, opusBitrate) | |||
| 117 | - | ||||
| 118 | - | // Copy channel layout from decoder to encoder. | |||
| 119 | - | codecCtxCopyChLayout(encCtx, decCtx) | |||
| 120 | - | ||||
| 121 | - | // Set time base to 1/sample_rate for audio. | |||
| 122 | - | ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: 1, Den: opusSampleRate}) | |||
| 123 | - | ||||
| 124 | - | // Note: WebM container does not require AV_CODEC_FLAG_GLOBAL_HEADER for Opus. | |||
| 125 | - | ||||
| 126 | - | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | |||
| 127 | - | freeCodecContext(&encCtx) | |||
| 128 | - | freeCodecContext(&decCtx) | |||
| 129 | - | return nil, fmt.Errorf("failed to open audio encoder: %d", ret) | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | // --- Add output stream --- | |||
| 133 | - | outStreamPtr := ffmpeg.FormatNewStream(outFmtCtx, nil) | |||
| 134 | - | if outStreamPtr == nil { | |||
| 135 | - | freeCodecContext(&encCtx) | |||
| 136 | - | freeCodecContext(&decCtx) | |||
| 137 | - | return nil, errors.New("failed to create output audio stream") | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | outStreamWrap := ffmpeg.WrapStream(outStreamPtr) | |||
| 141 | - | outStreamIdx := int(outStreamWrap.Index()) | |||
| 142 | - | ||||
| 143 | - | // Copy encoder parameters to the output stream's codecpar. | |||
| 144 | - | if ret := ffmpeg.CodecParametersFromContext(outStreamWrap.Codecpar(), encCtx); ret < 0 { | |||
| 145 | - | freeCodecContext(&encCtx) | |||
| 146 | - | freeCodecContext(&decCtx) | |||
| 147 | - | return nil, fmt.Errorf("failed to copy audio encoder params to stream: %d", ret) | |||
| 148 | - | } | |||
| 149 | - | ||||
| 150 | - | // --- Resampler setup (if sample rates differ) --- | |||
| 151 | - | var swr unsafe.Pointer | |||
| 152 | - | var resampledFrame unsafe.Pointer | |||
| 153 | - | ||||
| 154 | - | inSampleRate := ffmpeg.CodecCtxSampleRate(decCtx) | |||
| 155 | - | inSampleFmt := ffmpeg.CodecCtxSampleFmt(decCtx) | |||
| 156 | - | needResample := inSampleRate != opusSampleRate || inSampleFmt != int32(ffmpeg.SampleFmtFlt) | |||
| 157 | - | ||||
| 158 | - | if needResample { | |||
| 159 | - | swr = ffmpeg.SwrAlloc() | |||
| 160 | - | if swr == nil { | |||
| 161 | - | freeCodecContext(&encCtx) | |||
| 162 | - | freeCodecContext(&decCtx) | |||
| 163 | - | return nil, errors.New("failed to allocate resampler") | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | - | // Configure resampler via av_opt_set on the SwrContext. | |||
| 167 | - | ffmpeg.AVOptSetInt(swr, "in_sample_rate", int64(inSampleRate), 0) | |||
| 168 | - | ffmpeg.AVOptSetInt(swr, "out_sample_rate", int64(opusSampleRate), 0) | |||
| 169 | - | ffmpeg.AVOptSet(swr, "in_sample_fmt", ffmpeg.GetSampleFmtName(inSampleFmt), 0) | |||
| 170 | - | ffmpeg.AVOptSet(swr, "out_sample_fmt", ffmpeg.GetSampleFmtName(int32(ffmpeg.SampleFmtFlt)), 0) | |||
| 171 | - | ||||
| 172 | - | // Modern FFmpeg (8.x) requires explicit channel layout configuration | |||
| 173 | - | // on the SwrContext. Without it, swr_init may succeed but the resampler | |||
| 174 | - | // operates with 0 channels and produces silence. Copy the decoder's | |||
| 175 | - | // channel layout to both in and out (we keep them identical — same | |||
| 176 | - | // number of channels, only sample rate/format changes). | |||
| 177 | - | swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrInChLayout) | |||
| 178 | - | swrSetChLayoutFromCodecCtx(swr, decCtx, offsetSwrOutChLayout) | |||
| 179 | - | ||||
| 180 | - | if ret := ffmpeg.SwrInit(swr); ret < 0 { | |||
| 181 | - | freeSwrContext(&swr) | |||
| 182 | - | freeCodecContext(&encCtx) | |||
| 183 | - | freeCodecContext(&decCtx) | |||
| 184 | - | return nil, fmt.Errorf("failed to init resampler: %d", ret) | |||
| 185 | - | } | |||
| 186 | - | ||||
| 187 | - | // Allocate a reusable output frame for resampled data. | |||
| 188 | - | resampledFrame = ffmpeg.FrameAlloc() | |||
| 189 | - | if resampledFrame == nil { | |||
| 190 | - | freeSwrContext(&swr) | |||
| 191 | - | freeCodecContext(&encCtx) | |||
| 192 | - | freeCodecContext(&decCtx) | |||
| 193 | - | return nil, errors.New("failed to allocate resampled frame") | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | frameSetFormat(resampledFrame, int32(ffmpeg.SampleFmtFlt)) | |||
| 197 | - | frameSetSampleRate(resampledFrame, opusSampleRate) | |||
| 198 | - | frameSetNbSamples(resampledFrame, opusFrameSize) | |||
| 199 | - | // Copy channel layout from decoder to the resampled frame. | |||
| 200 | - | codecCtxCopyChLayout(resampledFrame, decCtx) | |||
| 201 | - | } | |||
| 202 | - | ||||
| 203 | - | decFrame := ffmpeg.FrameAlloc() | |||
| 204 | - | if decFrame == nil { | |||
| 205 | - | if resampledFrame != nil { | |||
| 206 | - | freeFrame(&resampledFrame) | |||
| 207 | - | } | |||
| 208 | - | if swr != nil { | |||
| 209 | - | freeSwrContext(&swr) | |||
| 210 | - | } | |||
| 211 | - | freeCodecContext(&encCtx) | |||
| 212 | - | freeCodecContext(&decCtx) | |||
| 213 | - | return nil, errors.New("failed to allocate audio decode frame") | |||
| 214 | - | } | |||
| 215 | - | ||||
| 216 | - | encPkt := ffmpeg.PacketAlloc() | |||
| 217 | - | if encPkt == nil { | |||
| 218 | - | freeFrame(&decFrame) | |||
| 219 | - | if resampledFrame != nil { | |||
| 220 | - | freeFrame(&resampledFrame) | |||
| 221 | - | } | |||
| 222 | - | if swr != nil { | |||
| 223 | - | freeSwrContext(&swr) | |||
| 224 | - | } | |||
| 225 | - | freeCodecContext(&encCtx) | |||
| 226 | - | freeCodecContext(&decCtx) | |||
| 227 | - | return nil, errors.New("failed to allocate audio encode packet") | |||
| 228 | - | } | |||
| 229 | - | ||||
| 230 | - | return &audioTranscoder{ | |||
| 231 | - | decoder: decCtx, | |||
| 232 | - | encoder: encCtx, | |||
| 233 | - | resampler: swr, | |||
| 234 | - | frame: resampledFrame, | |||
| 235 | - | decFrame: decFrame, | |||
| 236 | - | encPkt: encPkt, | |||
| 237 | - | inStream: inStreamIdx, | |||
| 238 | - | outStream: outStreamIdx, | |||
| 239 | - | }, nil | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | - | // processPacket decodes an audio packet, optionally resamples, encodes | |||
| 243 | - | // to Opus, and writes the resulting packets to outFmtCtx. | |||
| 244 | - | func (a *audioTranscoder) processPacket(pkt, outFmtCtx unsafe.Pointer) error { | |||
| 245 | - | // Send packet to decoder. | |||
| 246 | - | sendRet := ffmpeg.CodecSendPacket(a.decoder, pkt) | |||
| 247 | - | if sendRet < 0 { | |||
| 248 | - | return fmt.Errorf("audio decode send packet: %d", sendRet) | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | frame := a.decFrame | |||
| 252 | - | encPkt := a.encPkt | |||
| 253 | - | ||||
| 254 | - | for { | |||
| 255 | - | recvRet := ffmpeg.CodecReceiveFrame(a.decoder, frame) | |||
| 256 | - | if recvRet < 0 { | |||
| 257 | - | // EAGAIN or EOF — no more frames from this packet. | |||
| 258 | - | break | |||
| 259 | - | } | |||
| 260 | - | ||||
| 261 | - | // Determine which frame to send to the encoder. | |||
| 262 | - | encFrame := frame | |||
| 263 | - | if a.resampler != nil { | |||
| 264 | - | encFrame = a.frame | |||
| 265 | - | ||||
| 266 | - | inSamples := frameNbSamples(frame) | |||
| 267 | - | ||||
| 268 | - | // Allocate buffer for output frame if needed. | |||
| 269 | - | frameSetNbSamples(a.frame, opusFrameSize) | |||
| 270 | - | if bufferRet := ffmpeg.FrameGetBuffer(a.frame, 0); bufferRet < 0 { | |||
| 271 | - | ffmpeg.FrameUnref(frame) | |||
| 272 | - | return fmt.Errorf("failed to allocate resampled frame buffer: %d", bufferRet) | |||
| 273 | - | } | |||
| 274 | - | ||||
| 275 | - | converted := swrConvert(a.resampler, a.frame, frame, opusFrameSize, inSamples) | |||
| 276 | - | if converted < 0 { | |||
| 277 | - | ffmpeg.FrameUnref(frame) | |||
| 278 | - | return fmt.Errorf("audio resample failed: %d", converted) | |||
| 279 | - | } | |||
| 280 | - | frameSetNbSamples(a.frame, converted) | |||
| 281 | - | ||||
| 282 | - | // Copy pts from decoded frame, rescaling for the new sample rate. | |||
| 283 | - | pts := framePts(frame) | |||
| 284 | - | if pts >= 0 { | |||
| 285 | - | decTB := ffmpeg.CodecCtxTimeBase(a.decoder) | |||
| 286 | - | encTB := ffmpeg.CodecCtxTimeBase(a.encoder) | |||
| 287 | - | frameSetPts(a.frame, ffmpeg.RescaleQ(pts, decTB, encTB)) | |||
| 288 | - | } | |||
| 289 | - | } | |||
| 290 | - | ||||
| 291 | - | // Send frame to encoder. | |||
| 292 | - | if sendFrameRet := ffmpeg.CodecSendFrame(a.encoder, encFrame); sendFrameRet < 0 { | |||
| 293 | - | ffmpeg.FrameUnref(frame) | |||
| 294 | - | return fmt.Errorf("audio encode send frame: %d", sendFrameRet) | |||
| 295 | - | } | |||
| 296 | - | ||||
| 297 | - | // Receive encoded packets. | |||
| 298 | - | for { | |||
| 299 | - | recvPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |||
| 300 | - | if recvPktRet < 0 { | |||
| 301 | - | break | |||
| 302 | - | } | |||
| 303 | - | ||||
| 304 | - | pktSetStreamIndex(encPkt, int32(a.outStream)) | |||
| 305 | - | ||||
| 306 | - | // Rescale timestamps from encoder time base to output stream time base. | |||
| 307 | - | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | |||
| 308 | - | outStreamWrap := ffmpeg.WrapStream(outStream) | |||
| 309 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | |||
| 310 | - | ||||
| 311 | - | if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 { | |||
| 312 | - | ffmpeg.PacketUnref(encPkt) | |||
| 313 | - | ffmpeg.FrameUnref(frame) | |||
| 314 | - | return fmt.Errorf("audio write frame: %d", wret) | |||
| 315 | - | } | |||
| 316 | - | ffmpeg.PacketUnref(encPkt) | |||
| 317 | - | } | |||
| 318 | - | ||||
| 319 | - | ffmpeg.FrameUnref(frame) | |||
| 320 | - | } | |||
| 321 | - | ||||
| 322 | - | return nil | |||
| 323 | - | } | |||
| 324 | - | ||||
| 325 | - | // flush drains remaining frames from the decoder and encoder. | |||
| 326 | - | // The logger parameter is used to report non-fatal resampling errors | |||
| 327 | - | // that occur during flush (we continue flushing rather than aborting). | |||
| 328 | - | func (a *audioTranscoder) flush(outFmtCtx unsafe.Pointer, logger zerolog.Logger) { | |||
| 329 | - | // Flush decoder by sending NULL packet. | |||
| 330 | - | ffmpeg.CodecSendPacket(a.decoder, nil) | |||
| 331 | - | ||||
| 332 | - | frame := a.decFrame | |||
| 333 | - | encPkt := a.encPkt | |||
| 334 | - | ||||
| 335 | - | // Drain decoded frames. | |||
| 336 | - | for { | |||
| 337 | - | recvRet := ffmpeg.CodecReceiveFrame(a.decoder, frame) | |||
| 338 | - | if recvRet < 0 { | |||
| 339 | - | break | |||
| 340 | - | } | |||
| 341 | - | ||||
| 342 | - | encFrame := frame | |||
| 343 | - | if a.resampler != nil { | |||
| 344 | - | encFrame = a.frame | |||
| 345 | - | inSamples := frameNbSamples(frame) | |||
| 346 | - | ||||
| 347 | - | frameSetNbSamples(a.frame, opusFrameSize) | |||
| 348 | - | if bufferRet := ffmpeg.FrameGetBuffer(a.frame, 0); bufferRet < 0 { | |||
| 349 | - | logger.Warn().Int32("ret", bufferRet).Msg("failed to allocate resampled frame buffer during flush") | |||
| 350 | - | ffmpeg.FrameUnref(frame) | |||
| 351 | - | continue | |||
| 352 | - | } | |||
| 353 | - | ||||
| 354 | - | converted := swrConvert(a.resampler, a.frame, frame, opusFrameSize, inSamples) | |||
| 355 | - | if converted < 0 { | |||
| 356 | - | logger.Warn().Int32("ret", converted).Msg("audio resample failed during flush") | |||
| 357 | - | ffmpeg.FrameUnref(frame) | |||
| 358 | - | continue | |||
| 359 | - | } | |||
| 360 | - | frameSetNbSamples(a.frame, converted) | |||
| 361 | - | } | |||
| 362 | - | ||||
| 363 | - | ffmpeg.CodecSendFrame(a.encoder, encFrame) | |||
| 364 | - | ||||
| 365 | - | for { | |||
| 366 | - | recvPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |||
| 367 | - | if recvPktRet < 0 { | |||
| 368 | - | break | |||
| 369 | - | } | |||
| 370 | - | pktSetStreamIndex(encPkt, int32(a.outStream)) | |||
| 371 | - | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | |||
| 372 | - | outStreamWrap := ffmpeg.WrapStream(outStream) | |||
| 373 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | |||
| 374 | - | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | |||
| 375 | - | ffmpeg.PacketUnref(encPkt) | |||
| 376 | - | } | |||
| 377 | - | ||||
| 378 | - | ffmpeg.FrameUnref(frame) | |||
| 379 | - | } | |||
| 380 | - | ||||
| 381 | - | // Flush encoder by sending NULL frame. | |||
| 382 | - | ffmpeg.CodecSendFrame(a.encoder, nil) | |||
| 383 | - | for { | |||
| 384 | - | flushPktRet := ffmpeg.CodecReceivePacket(a.encoder, encPkt) | |||
| 385 | - | if flushPktRet < 0 { | |||
| 386 | - | break | |||
| 387 | - | } | |||
| 388 | - | pktSetStreamIndex(encPkt, int32(a.outStream)) | |||
| 389 | - | outStream := ffmpeg.FmtCtxStream(outFmtCtx, a.outStream) | |||
| 390 | - | outStreamWrap := ffmpeg.WrapStream(outStream) | |||
| 391 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(a.encoder), outStreamWrap.TimeBase()) | |||
| 392 | - | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | |||
| 393 | - | ffmpeg.PacketUnref(encPkt) | |||
| 394 | - | } | |||
| 395 | - | } | |||
| 396 | - | ||||
| 397 | - | // close frees all FFmpeg resources owned by the audio transcoder. | |||
| 398 | - | func (a *audioTranscoder) close() { | |||
| 399 | - | if a.encPkt != nil { | |||
| 400 | - | freePacket(&a.encPkt) | |||
| 401 | - | } | |||
| 402 | - | if a.decFrame != nil { | |||
| 403 | - | freeFrame(&a.decFrame) | |||
| 404 | - | } | |||
| 405 | - | if a.frame != nil { | |||
| 406 | - | freeFrame(&a.frame) | |||
| 407 | - | } | |||
| 408 | - | if a.resampler != nil { | |||
| 409 | - | freeSwrContext(&a.resampler) | |||
| 410 | - | } | |||
| 411 | - | if a.encoder != nil { | |||
| 412 | - | freeCodecContext(&a.encoder) | |||
| 413 | - | } | |||
| 414 | - | if a.decoder != nil { | |||
| 415 | - | freeCodecContext(&a.decoder) | |||
| 416 | - | } | |||
| 417 | - | } | |||
D internal/infrastructure/transcoder/detect.go +0 -180
| @@ -1,180 +0,0 @@ | |||||
| 1 | - | package transcoder | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "mime" | |||
| 5 | - | "net/url" | |||
| 6 | - | "path" | |||
| 7 | - | "strings" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | // proprietaryVideoMIMEs lists MIME types for containers that typically carry | |||
| 11 | - | // proprietary codecs (H.264, HEVC, AAC). These require GPU transcoding to | |||
| 12 | - | // open formats (VP8/VP9/AV1 + Opus) for playback in browsers without | |||
| 13 | - | // proprietary codec support. | |||
| 14 | - | var proprietaryVideoMIMEs = map[string]bool{ | |||
| 15 | - | "video/mp4": true, // H.264/HEVC + AAC | |||
| 16 | - | "video/x-flv": true, // Flash Video (H.264 or Sorenson) | |||
| 17 | - | "video/3gpp": true, // 3GPP (H.264 + AAC) | |||
| 18 | - | "video/3gpp2": true, // 3GPP2 (H.264 + AAC) | |||
| 19 | - | "video/mp2t": true, // MPEG-TS (usually H.264) | |||
| 20 | - | "video/mpeg": true, // MPEG-1/2 | |||
| 21 | - | "video/x-msvideo": true, // AVI (often H.264) | |||
| 22 | - | "video/x-matroska": true, // MKV (often H.264/HEVC) | |||
| 23 | - | "video/quicktime": true, // MOV (H.264 + AAC) | |||
| 24 | - | "video/x-m4v": true, // M4V (H.264 + AAC) | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | // openVideoMIMEs lists MIME types for containers carrying open codecs | |||
| 28 | - | // (VP8/VP9/AV1 + Opus/Vorbis). These do not require transcoding. | |||
| 29 | - | var openVideoMIMEs = map[string]bool{ | |||
| 30 | - | "video/webm": true, // VP8/VP9/AV1 + Opus/Vorbis | |||
| 31 | - | "video/ogg": true, // Theora + Vorbis | |||
| 32 | - | } | |||
| 33 | - | ||||
| 34 | - | // streamingManifestMIMEs lists manifest/container MIME types that usually | |||
| 35 | - | // resolve to proprietary segment streams via HLS or DASH. | |||
| 36 | - | var streamingManifestMIMEs = map[string]bool{ | |||
| 37 | - | "application/vnd.apple.mpegurl": true, // RFC 8216 | |||
| 38 | - | "application/x-mpegurl": true, // common non-standard alias | |||
| 39 | - | "audio/x-mpegurl": true, // Go stdlib / legacy servers | |||
| 40 | - | "audio/mpegurl": true, // rare legacy alias | |||
| 41 | - | "application/dash+xml": true, | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | var proprietaryVideoExtensions = map[string]bool{ | |||
| 45 | - | ".mp4": true, | |||
| 46 | - | ".m4v": true, | |||
| 47 | - | ".mov": true, | |||
| 48 | - | ".mkv": true, | |||
| 49 | - | ".avi": true, | |||
| 50 | - | ".flv": true, | |||
| 51 | - | ".3gp": true, | |||
| 52 | - | ".3g2": true, | |||
| 53 | - | ".mpeg": true, | |||
| 54 | - | ".mpg": true, | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | const syntheticTranscodePath = "/__dumber__/transcode.webm" | |||
| 58 | - | ||||
| 59 | - | // IsProprietaryVideoMIME returns true if the MIME type indicates a video | |||
| 60 | - | // format that typically carries proprietary codecs requiring transcoding. | |||
| 61 | - | func IsProprietaryVideoMIME(contentType string) bool { | |||
| 62 | - | mediaType := parseMediaType(contentType) | |||
| 63 | - | return proprietaryVideoMIMEs[mediaType] | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | // IsOpenVideoMIME returns true if the MIME type indicates a video format | |||
| 67 | - | // with open codecs that do not require transcoding. | |||
| 68 | - | func IsOpenVideoMIME(contentType string) bool { | |||
| 69 | - | mediaType := parseMediaType(contentType) | |||
| 70 | - | return openVideoMIMEs[mediaType] | |||
| 71 | - | } | |||
| 72 | - | ||||
| 73 | - | // IsStreamingManifestMIME returns true for HLS/DASH manifest MIME types. | |||
| 74 | - | func IsStreamingManifestMIME(contentType string) bool { | |||
| 75 | - | mediaType := parseMediaType(contentType) | |||
| 76 | - | return streamingManifestMIMEs[mediaType] | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | // IsStreamingManifestURL returns true for URLs whose path indicates an HLS or | |||
| 80 | - | // DASH manifest entrypoint. | |||
| 81 | - | func IsStreamingManifestURL(rawURL string) bool { | |||
| 82 | - | if rawURL == "" { | |||
| 83 | - | return false | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | parsed, err := url.Parse(rawURL) | |||
| 87 | - | if err != nil { | |||
| 88 | - | return hasManifestExtension(rawURL) | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | return hasManifestExtension(parsed.Path) | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | // IsEagerTranscodeURL returns true for URLs that can be identified as | |||
| 95 | - | // transcodable before any response MIME is received. | |||
| 96 | - | func IsEagerTranscodeURL(rawURL string) bool { | |||
| 97 | - | if rawURL == "" { | |||
| 98 | - | return false | |||
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | if IsSyntheticTranscodeURL(rawURL) { | |||
| 102 | - | return true | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | parsed, err := url.Parse(rawURL) | |||
| 106 | - | if err != nil { | |||
| 107 | - | return hasEagerTranscodeExtension(rawURL) | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | return hasEagerTranscodeExtension(parsed.Path) | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | - | // IsSyntheticTranscodeURL returns true for same-origin synthetic media URLs | |||
| 114 | - | // that should be intercepted and resolved to an underlying source URL. | |||
| 115 | - | func IsSyntheticTranscodeURL(rawURL string) bool { | |||
| 116 | - | sourceURL, referer, origin, ok := ParseSyntheticTranscodeURL(rawURL) | |||
| 117 | - | _ = sourceURL | |||
| 118 | - | _ = referer | |||
| 119 | - | _ = origin | |||
| 120 | - | return ok | |||
| 121 | - | } | |||
| 122 | - | ||||
| 123 | - | // ParseSyntheticTranscodeURL extracts the underlying source URL and optional | |||
| 124 | - | // forwarded headers from a synthetic transcode URL. | |||
| 125 | - | func ParseSyntheticTranscodeURL(rawURL string) (sourceURL, referer, origin string, ok bool) { | |||
| 126 | - | if rawURL == "" { | |||
| 127 | - | return "", "", "", false | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | parsed, err := url.Parse(rawURL) | |||
| 131 | - | if err != nil { | |||
| 132 | - | return "", "", "", false | |||
| 133 | - | } | |||
| 134 | - | if parsed.Path != syntheticTranscodePath { | |||
| 135 | - | return "", "", "", false | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | - | sourceURL = strings.TrimSpace(parsed.Query().Get("src")) | |||
| 139 | - | if sourceURL == "" { | |||
| 140 | - | return "", "", "", false | |||
| 141 | - | } | |||
| 142 | - | sourceParsed, err := url.Parse(sourceURL) | |||
| 143 | - | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") { | |||
| 144 | - | return "", "", "", false | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | referer = strings.TrimSpace(parsed.Query().Get("referer")) | |||
| 148 | - | origin = strings.TrimSpace(parsed.Query().Get("origin")) | |||
| 149 | - | return sourceURL, referer, origin, true | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | func hasManifestExtension(value string) bool { | |||
| 153 | - | ext := strings.ToLower(path.Ext(value)) | |||
| 154 | - | return ext == ".m3u8" || ext == ".mpd" | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | func hasEagerTranscodeExtension(value string) bool { | |||
| 158 | - | ext := strings.ToLower(path.Ext(value)) | |||
| 159 | - | return hasManifestExtension(value) || proprietaryVideoExtensions[ext] | |||
| 160 | - | } | |||
| 161 | - | ||||
| 162 | - | // parseMediaType extracts the MIME type from a Content-Type header value, | |||
| 163 | - | // stripping parameters like charset or codecs. | |||
| 164 | - | func parseMediaType(contentType string) string { | |||
| 165 | - | if contentType == "" { | |||
| 166 | - | return "" | |||
| 167 | - | } | |||
| 168 | - | // mime.ParseMediaType handles the full RFC 2045 parsing, but is | |||
| 169 | - | // overkill and can fail on non-conforming values. A simple split | |||
| 170 | - | // on ";" is more resilient for our needs. | |||
| 171 | - | mediaType, _, _ := mime.ParseMediaType(contentType) | |||
| 172 | - | if mediaType != "" { | |||
| 173 | - | return strings.ToLower(mediaType) | |||
| 174 | - | } | |||
| 175 | - | // Fallback: trim parameters manually | |||
| 176 | - | if idx := strings.IndexByte(contentType, ';'); idx >= 0 { | |||
| 177 | - | contentType = contentType[:idx] | |||
| 178 | - | } | |||
| 179 | - | return strings.ToLower(strings.TrimSpace(contentType)) | |||
| 180 | - | } | |||
D internal/infrastructure/transcoder/detect_test.go +0 -254
| @@ -1,254 +0,0 @@ | |||||
| 1 | - | package transcoder | |||
| 2 | - | ||||
| 3 | - | import "testing" | |||
| 4 | - | ||||
| 5 | - | func TestIsProprietaryVideoMIME(t *testing.T) { | |||
| 6 | - | tests := []struct { | |||
| 7 | - | name string | |||
| 8 | - | contentType string | |||
| 9 | - | want bool | |||
| 10 | - | }{ | |||
| 11 | - | // Proprietary formats | |||
| 12 | - | {name: "mp4", contentType: "video/mp4", want: true}, | |||
| 13 | - | {name: "mp4 with codecs param", contentType: "video/mp4; codecs=\"avc1.42E01E\"", want: true}, | |||
| 14 | - | {name: "mp4 with charset", contentType: "video/mp4; charset=utf-8", want: true}, | |||
| 15 | - | {name: "flv", contentType: "video/x-flv", want: true}, | |||
| 16 | - | {name: "3gpp", contentType: "video/3gpp", want: true}, | |||
| 17 | - | {name: "3gpp2", contentType: "video/3gpp2", want: true}, | |||
| 18 | - | {name: "mpeg-ts", contentType: "video/mp2t", want: true}, | |||
| 19 | - | {name: "mpeg", contentType: "video/mpeg", want: true}, | |||
| 20 | - | {name: "avi", contentType: "video/x-msvideo", want: true}, | |||
| 21 | - | {name: "mkv", contentType: "video/x-matroska", want: true}, | |||
| 22 | - | {name: "quicktime", contentType: "video/quicktime", want: true}, | |||
| 23 | - | {name: "m4v", contentType: "video/x-m4v", want: true}, | |||
| 24 | - | ||||
| 25 | - | // Open formats (should be false) | |||
| 26 | - | {name: "webm", contentType: "video/webm", want: false}, | |||
| 27 | - | {name: "ogg", contentType: "video/ogg", want: false}, | |||
| 28 | - | ||||
| 29 | - | // Non-video (should be false) | |||
| 30 | - | {name: "audio mp3", contentType: "audio/mpeg", want: false}, | |||
| 31 | - | {name: "text html", contentType: "text/html", want: false}, | |||
| 32 | - | {name: "application json", contentType: "application/json", want: false}, | |||
| 33 | - | {name: "octet-stream", contentType: "application/octet-stream", want: false}, | |||
| 34 | - | {name: "empty", contentType: "", want: false}, | |||
| 35 | - | ||||
| 36 | - | // Case insensitivity | |||
| 37 | - | {name: "mp4 uppercase", contentType: "Video/MP4", want: true}, | |||
| 38 | - | {name: "webm uppercase", contentType: "Video/WebM", want: false}, | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | for _, tt := range tests { | |||
| 42 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 43 | - | got := IsProprietaryVideoMIME(tt.contentType) | |||
| 44 | - | if got != tt.want { | |||
| 45 | - | t.Errorf("IsProprietaryVideoMIME(%q) = %v, want %v", tt.contentType, got, tt.want) | |||
| 46 | - | } | |||
| 47 | - | }) | |||
| 48 | - | } | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | - | func TestIsOpenVideoMIME(t *testing.T) { | |||
| 52 | - | tests := []struct { | |||
| 53 | - | name string | |||
| 54 | - | contentType string | |||
| 55 | - | want bool | |||
| 56 | - | }{ | |||
| 57 | - | // Open formats | |||
| 58 | - | {name: "webm", contentType: "video/webm", want: true}, | |||
| 59 | - | {name: "webm with codecs", contentType: "video/webm; codecs=\"vp9,opus\"", want: true}, | |||
| 60 | - | {name: "ogg", contentType: "video/ogg", want: true}, | |||
| 61 | - | ||||
| 62 | - | // Proprietary formats (should be false) | |||
| 63 | - | {name: "mp4", contentType: "video/mp4", want: false}, | |||
| 64 | - | {name: "flv", contentType: "video/x-flv", want: false}, | |||
| 65 | - | {name: "mkv", contentType: "video/x-matroska", want: false}, | |||
| 66 | - | ||||
| 67 | - | // Non-video (should be false) | |||
| 68 | - | {name: "audio ogg", contentType: "audio/ogg", want: false}, | |||
| 69 | - | {name: "text html", contentType: "text/html", want: false}, | |||
| 70 | - | {name: "octet-stream", contentType: "application/octet-stream", want: false}, | |||
| 71 | - | {name: "empty", contentType: "", want: false}, | |||
| 72 | - | ||||
| 73 | - | // Case insensitivity | |||
| 74 | - | {name: "webm uppercase", contentType: "Video/WebM", want: true}, | |||
| 75 | - | {name: "ogg uppercase", contentType: "VIDEO/OGG", want: true}, | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | for _, tt := range tests { | |||
| 79 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 80 | - | got := IsOpenVideoMIME(tt.contentType) | |||
| 81 | - | if got != tt.want { | |||
| 82 | - | t.Errorf("IsOpenVideoMIME(%q) = %v, want %v", tt.contentType, got, tt.want) | |||
| 83 | - | } | |||
| 84 | - | }) | |||
| 85 | - | } | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | func TestIsStreamingManifestMIME(t *testing.T) { | |||
| 89 | - | tests := []struct { | |||
| 90 | - | name string | |||
| 91 | - | contentType string | |||
| 92 | - | want bool | |||
| 93 | - | }{ | |||
| 94 | - | {name: "hls apple", contentType: "application/vnd.apple.mpegURL", want: true}, | |||
| 95 | - | {name: "hls x-mpegurl", contentType: "application/x-mpegURL", want: true}, | |||
| 96 | - | {name: "hls audio x-mpegurl", contentType: "audio/x-mpegurl", want: true}, | |||
| 97 | - | {name: "hls audio mpegurl", contentType: "audio/mpegurl", want: true}, | |||
| 98 | - | {name: "dash", contentType: "application/dash+xml", want: true}, | |||
| 99 | - | {name: "dash with charset", contentType: "application/dash+xml; charset=utf-8", want: true}, | |||
| 100 | - | {name: "mp4", contentType: "video/mp4", want: false}, | |||
| 101 | - | {name: "json", contentType: "application/json", want: false}, | |||
| 102 | - | {name: "empty", contentType: "", want: false}, | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | for _, tt := range tests { | |||
| 106 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 107 | - | got := IsStreamingManifestMIME(tt.contentType) | |||
| 108 | - | if got != tt.want { | |||
| 109 | - | t.Errorf("IsStreamingManifestMIME(%q) = %v, want %v", tt.contentType, got, tt.want) | |||
| 110 | - | } | |||
| 111 | - | }) | |||
| 112 | - | } | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | func TestIsStreamingManifestURL(t *testing.T) { | |||
| 116 | - | tests := []struct { | |||
| 117 | - | name string | |||
| 118 | - | rawURL string | |||
| 119 | - | want bool | |||
| 120 | - | }{ | |||
| 121 | - | {name: "hls manifest", rawURL: "https://v.redd.it/abc/HLSPlaylist.m3u8?f=sd&v=1", want: true}, | |||
| 122 | - | {name: "dash manifest", rawURL: "https://example.com/video/manifest.mpd", want: true}, | |||
| 123 | - | {name: "uppercase extension", rawURL: "https://example.com/PLAYLIST.M3U8", want: true}, | |||
| 124 | - | {name: "segment ts", rawURL: "https://example.com/chunk.ts", want: false}, | |||
| 125 | - | {name: "plain mp4", rawURL: "https://example.com/video.mp4", want: false}, | |||
| 126 | - | {name: "empty", rawURL: "", want: false}, | |||
| 127 | - | {name: "invalid but obvious", rawURL: "not a url but playlist.m3u8", want: true}, | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | for _, tt := range tests { | |||
| 131 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 132 | - | got := IsStreamingManifestURL(tt.rawURL) | |||
| 133 | - | if got != tt.want { | |||
| 134 | - | t.Errorf("IsStreamingManifestURL(%q) = %v, want %v", tt.rawURL, got, tt.want) | |||
| 135 | - | } | |||
| 136 | - | }) | |||
| 137 | - | } | |||
| 138 | - | } | |||
| 139 | - | ||||
| 140 | - | func TestIsEagerTranscodeURL(t *testing.T) { | |||
| 141 | - | tests := []struct { | |||
| 142 | - | name string | |||
| 143 | - | rawURL string | |||
| 144 | - | want bool | |||
| 145 | - | }{ | |||
| 146 | - | {name: "hls manifest", rawURL: "https://v.redd.it/abc/HLSPlaylist.m3u8?f=sd&v=1", want: true}, | |||
| 147 | - | {name: "dash manifest", rawURL: "https://example.com/video/manifest.mpd", want: true}, | |||
| 148 | - | {name: "mp4 file", rawURL: "https://example.com/video.mp4", want: true}, | |||
| 149 | - | {name: "mov file", rawURL: "https://example.com/video.mov", want: true}, | |||
| 150 | - | {name: "mkv uppercase", rawURL: "https://example.com/VIDEO.MKV", want: true}, | |||
| 151 | - | {name: "hls segment", rawURL: "https://example.com/chunk.ts", want: false}, | |||
| 152 | - | {name: "webm open codec", rawURL: "https://example.com/video.webm", want: false}, | |||
| 153 | - | {name: "synthetic transcode", rawURL: "https://www.reddit.com/__dumber__/transcode.webm?src=https%3A%2F%2Fv.redd.it%2Fabc%2FHLSPlaylist.m3u8", want: true}, | |||
| 154 | - | {name: "empty", rawURL: "", want: false}, | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | - | for _, tt := range tests { | |||
| 158 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 159 | - | got := IsEagerTranscodeURL(tt.rawURL) | |||
| 160 | - | if got != tt.want { | |||
| 161 | - | t.Errorf("IsEagerTranscodeURL(%q) = %v, want %v", tt.rawURL, got, tt.want) | |||
| 162 | - | } | |||
| 163 | - | }) | |||
| 164 | - | } | |||
| 165 | - | } | |||
| 166 | - | ||||
| 167 | - | func TestParseSyntheticTranscodeURL(t *testing.T) { | |||
| 168 | - | tests := []struct { | |||
| 169 | - | name string | |||
| 170 | - | rawURL string | |||
| 171 | - | wantSrc string | |||
| 172 | - | wantRef string | |||
| 173 | - | wantOrig string | |||
| 174 | - | wantOK bool | |||
| 175 | - | }{ | |||
| 176 | - | { | |||
| 177 | - | name: "valid with all params", | |||
| 178 | - | rawURL: "https://www.reddit.com/__dumber__/transcode.webm?src=https%3A%2F%2Fv.redd.it%2Fabc%2FHLSPlaylist.m3u8&referer=https%3A%2F%2Fwww.reddit.com%2Fr%2FOpenAI&origin=https%3A%2F%2Fwww.reddit.com", | |||
| 179 | - | wantSrc: "https://v.redd.it/abc/HLSPlaylist.m3u8", | |||
| 180 | - | wantRef: "https://www.reddit.com/r/OpenAI", | |||
| 181 | - | wantOrig: "https://www.reddit.com", | |||
| 182 | - | wantOK: true, | |||
| 183 | - | }, | |||
| 184 | - | { | |||
| 185 | - | name: "valid without referer and origin", | |||
| 186 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |||
| 187 | - | wantSrc: "https://cdn.example.com/video.mp4", | |||
| 188 | - | wantRef: "", | |||
| 189 | - | wantOrig: "", | |||
| 190 | - | wantOK: true, | |||
| 191 | - | }, | |||
| 192 | - | { | |||
| 193 | - | name: "empty URL", | |||
| 194 | - | rawURL: "", | |||
| 195 | - | wantOK: false, | |||
| 196 | - | }, | |||
| 197 | - | { | |||
| 198 | - | name: "missing src parameter", | |||
| 199 | - | rawURL: "https://example.com/__dumber__/transcode.webm?referer=https%3A%2F%2Fexample.com", | |||
| 200 | - | wantOK: false, | |||
| 201 | - | }, | |||
| 202 | - | { | |||
| 203 | - | name: "wrong path", | |||
| 204 | - | rawURL: "https://example.com/other/path?src=https%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |||
| 205 | - | wantOK: false, | |||
| 206 | - | }, | |||
| 207 | - | { | |||
| 208 | - | name: "src with no host", | |||
| 209 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=not-a-url", | |||
| 210 | - | wantOK: false, | |||
| 211 | - | }, | |||
| 212 | - | { | |||
| 213 | - | name: "src with non-http scheme", | |||
| 214 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=ftp%3A%2F%2Fcdn.example.com%2Fvideo.mp4", | |||
| 215 | - | wantOK: false, | |||
| 216 | - | }, | |||
| 217 | - | { | |||
| 218 | - | name: "src is empty string", | |||
| 219 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=", | |||
| 220 | - | wantOK: false, | |||
| 221 | - | }, | |||
| 222 | - | { | |||
| 223 | - | name: "src is whitespace", | |||
| 224 | - | rawURL: "https://example.com/__dumber__/transcode.webm?src=%20%20", | |||
| 225 | - | wantOK: false, | |||
| 226 | - | }, | |||
| 227 | - | { | |||
| 228 | - | name: "unparseable URL", | |||
| 229 | - | rawURL: "://invalid", | |||
| 230 | - | wantOK: false, | |||
| 231 | - | }, | |||
| 232 | - | } | |||
| 233 | - | ||||
| 234 | - | for _, tt := range tests { | |||
| 235 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 236 | - | src, ref, orig, ok := ParseSyntheticTranscodeURL(tt.rawURL) | |||
| 237 | - | if ok != tt.wantOK { | |||
| 238 | - | t.Fatalf("ParseSyntheticTranscodeURL(%q) ok = %v, want %v", tt.rawURL, ok, tt.wantOK) | |||
| 239 | - | } | |||
| 240 | - | if !tt.wantOK { | |||
| 241 | - | return | |||
| 242 | - | } | |||
| 243 | - | if src != tt.wantSrc { | |||
| 244 | - | t.Errorf("sourceURL = %q, want %q", src, tt.wantSrc) | |||
| 245 | - | } | |||
| 246 | - | if ref != tt.wantRef { | |||
| 247 | - | t.Errorf("referer = %q, want %q", ref, tt.wantRef) | |||
| 248 | - | } | |||
| 249 | - | if orig != tt.wantOrig { | |||
| 250 | - | t.Errorf("origin = %q, want %q", orig, tt.wantOrig) | |||
| 251 | - | } | |||
| 252 | - | }) | |||
| 253 | - | } | |||
| 254 | - | } | |||
D internal/infrastructure/transcoder/ffmpeg_offsets.go +0 -166
| @@ -1,166 +0,0 @@ | |||||
| 1 | - | //go:build !js || !wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | // Offset-based accessors for FFmpeg struct fields not yet exposed | |||
| 6 | - | // by purego-ffmpeg. | |||
| 7 | - | // | |||
| 8 | - | // Validated against: | |||
| 9 | - | // FFmpeg 8.1 (n8.1) | |||
| 10 | - | // libavcodec 62.28.100 | |||
| 11 | - | // libavformat 62.12.100 | |||
| 12 | - | // libavutil 60.26.100 | |||
| 13 | - | // | |||
| 14 | - | // All offsets verified via offsetof() on the installed headers. | |||
| 15 | - | ||||
| 16 | - | import "unsafe" | |||
| 17 | - | ||||
| 18 | - | // --------------------------------------------------------------------------- | |||
| 19 | - | // AVPacket field offsets (FFmpeg 8.x, libavcodec 62) | |||
| 20 | - | // Layout: AVBufferRef* buf (0), int64 pts (8), int64 dts (16), | |||
| 21 | - | // uint8* data (24), int size (32), int stream_index (36), int flags (40) | |||
| 22 | - | // --------------------------------------------------------------------------- | |||
| 23 | - | ||||
| 24 | - | const ( | |||
| 25 | - | offsetPktStreamIndex = 36 | |||
| 26 | - | ) | |||
| 27 | - | ||||
| 28 | - | func pktStreamIndex(pkt unsafe.Pointer) int32 { | |||
| 29 | - | return *(*int32)(unsafe.Add(pkt, offsetPktStreamIndex)) | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | func pktSetStreamIndex(pkt unsafe.Pointer, idx int32) { | |||
| 33 | - | *(*int32)(unsafe.Add(pkt, offsetPktStreamIndex)) = idx | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | // --------------------------------------------------------------------------- | |||
| 37 | - | // AVFrame field offsets (FFmpeg 8.x, libavutil 60) | |||
| 38 | - | // Layout (relevant fields): | |||
| 39 | - | // data[8] at offset 0 (8 pointers = 64 bytes) | |||
| 40 | - | // linesize[8] at offset 64 (8 x int32 = 32 bytes) | |||
| 41 | - | // ... | |||
| 42 | - | // width at offset 104 | |||
| 43 | - | // height at offset 108 | |||
| 44 | - | // nb_samples at offset 112 | |||
| 45 | - | // format at offset 116 | |||
| 46 | - | // pts at offset 136 | |||
| 47 | - | // sample_rate at offset 180 | |||
| 48 | - | // hw_frames_ctx at offset 328 (AVBufferRef*) | |||
| 49 | - | // ch_layout at offset 384 (AVChannelLayout, 24 bytes) | |||
| 50 | - | // --------------------------------------------------------------------------- | |||
| 51 | - | ||||
| 52 | - | const ( | |||
| 53 | - | offsetFrameWidth = 104 | |||
| 54 | - | offsetFrameHeight = 108 | |||
| 55 | - | offsetFrameNbSamples = 112 | |||
| 56 | - | offsetFrameFormat = 116 | |||
| 57 | - | offsetFrameHWFramesCtx = 328 | |||
| 58 | - | offsetFramePts = 136 | |||
| 59 | - | offsetFrameSampleRate = 180 | |||
| 60 | - | ) | |||
| 61 | - | ||||
| 62 | - | func frameWidth(f unsafe.Pointer) int32 { | |||
| 63 | - | return *(*int32)(unsafe.Add(f, offsetFrameWidth)) | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | func frameSetWidth(f unsafe.Pointer, v int32) { | |||
| 67 | - | *(*int32)(unsafe.Add(f, offsetFrameWidth)) = v | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | func frameHeight(f unsafe.Pointer) int32 { | |||
| 71 | - | return *(*int32)(unsafe.Add(f, offsetFrameHeight)) | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | func frameSetHeight(f unsafe.Pointer, v int32) { | |||
| 75 | - | *(*int32)(unsafe.Add(f, offsetFrameHeight)) = v | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | func frameNbSamples(f unsafe.Pointer) int32 { | |||
| 79 | - | return *(*int32)(unsafe.Add(f, offsetFrameNbSamples)) | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | func frameSetNbSamples(f unsafe.Pointer, v int32) { | |||
| 83 | - | *(*int32)(unsafe.Add(f, offsetFrameNbSamples)) = v | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | func frameFormat(f unsafe.Pointer) int32 { | |||
| 87 | - | return *(*int32)(unsafe.Add(f, offsetFrameFormat)) | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | func frameSetFormat(f unsafe.Pointer, v int32) { | |||
| 91 | - | *(*int32)(unsafe.Add(f, offsetFrameFormat)) = v | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | func frameSetHWFramesCtx(f, v unsafe.Pointer) { | |||
| 95 | - | *(*unsafe.Pointer)(unsafe.Add(f, offsetFrameHWFramesCtx)) = v | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | - | func framePts(f unsafe.Pointer) int64 { | |||
| 99 | - | return *(*int64)(unsafe.Add(f, offsetFramePts)) | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | func frameSetPts(f unsafe.Pointer, v int64) { | |||
| 103 | - | *(*int64)(unsafe.Add(f, offsetFramePts)) = v | |||
| 104 | - | } | |||
| 105 | - | ||||
| 106 | - | func frameSetSampleRate(f unsafe.Pointer, v int32) { | |||
| 107 | - | *(*int32)(unsafe.Add(f, offsetFrameSampleRate)) = v | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | // --------------------------------------------------------------------------- | |||
| 111 | - | // AVCodecContext additional offsets (FFmpeg 8.x, libavcodec 62) | |||
| 112 | - | // --------------------------------------------------------------------------- | |||
| 113 | - | ||||
| 114 | - | const ( | |||
| 115 | - | offsetCodecCtxGopSize = 332 | |||
| 116 | - | offsetCodecCtxBitRate = 56 | |||
| 117 | - | offsetCodecCtxChLayout = 352 // AVChannelLayout struct (24 bytes) | |||
| 118 | - | ) | |||
| 119 | - | ||||
| 120 | - | func codecCtxSetGopSize(ctx unsafe.Pointer, v int32) { | |||
| 121 | - | *(*int32)(unsafe.Add(ctx, offsetCodecCtxGopSize)) = v | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | func codecCtxSetBitRate(ctx unsafe.Pointer, v int64) { | |||
| 125 | - | *(*int64)(unsafe.Add(ctx, offsetCodecCtxBitRate)) = v | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | // chLayoutCopy copies the 24-byte AVChannelLayout from src to dst. | |||
| 129 | - | // Both must point to the ch_layout field within their respective AVCodecContext. | |||
| 130 | - | // | |||
| 131 | - | //nolint:gosec // These offsets target fixed-size FFmpeg structs validated against headers. | |||
| 132 | - | func codecCtxCopyChLayout(dst, src unsafe.Pointer) { | |||
| 133 | - | dstLayout := unsafe.Add(dst, offsetCodecCtxChLayout) | |||
| 134 | - | srcLayout := unsafe.Add(src, offsetCodecCtxChLayout) | |||
| 135 | - | // AVChannelLayout is 24 bytes in FFmpeg 8.x. | |||
| 136 | - | copy( | |||
| 137 | - | unsafe.Slice((*byte)(dstLayout), channelLayoutSize), | |||
| 138 | - | unsafe.Slice((*byte)(srcLayout), channelLayoutSize), | |||
| 139 | - | ) | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | // --------------------------------------------------------------------------- | |||
| 143 | - | // SwrContext channel layout offsets (FFmpeg 8.x, libswresample) | |||
| 144 | - | // SwrContext is opaque; offsets verified by writing known AVChannelLayout | |||
| 145 | - | // values via av_opt_set_chlayout and scanning the resulting memory. | |||
| 146 | - | // --------------------------------------------------------------------------- | |||
| 147 | - | ||||
| 148 | - | const ( | |||
| 149 | - | channelLayoutSize = 24 | |||
| 150 | - | ||||
| 151 | - | offsetSwrInChLayout = 192 // AVChannelLayout (24 bytes) | |||
| 152 | - | offsetSwrOutChLayout = 216 // AVChannelLayout (24 bytes) | |||
| 153 | - | ) | |||
| 154 | - | ||||
| 155 | - | // swrCopyChLayoutFromCodecCtx copies an AVChannelLayout (24 bytes) from | |||
| 156 | - | // an AVCodecContext's ch_layout field into the SwrContext's in/out ch_layout. | |||
| 157 | - | // | |||
| 158 | - | //nolint:gosec // These offsets target fixed-size FFmpeg structs validated against headers. | |||
| 159 | - | func swrSetChLayoutFromCodecCtx(swr, codecCtx unsafe.Pointer, swrOffset int) { | |||
| 160 | - | dst := unsafe.Add(swr, swrOffset) | |||
| 161 | - | src := unsafe.Add(codecCtx, offsetCodecCtxChLayout) | |||
| 162 | - | copy( | |||
| 163 | - | unsafe.Slice((*byte)(dst), channelLayoutSize), | |||
| 164 | - | unsafe.Slice((*byte)(src), channelLayoutSize), | |||
| 165 | - | ) | |||
| 166 | - | } | |||
D internal/infrastructure/transcoder/http_client_test.go +0 -138
| @@ -1,138 +0,0 @@ | |||||
| 1 | - | //go:build !js || !wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "net" | |||
| 8 | - | "net/http" | |||
| 9 | - | "net/url" | |||
| 10 | - | "testing" | |||
| 11 | - | ||||
| 12 | - | "github.com/rs/zerolog" | |||
| 13 | - | ||||
| 14 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 15 | - | ) | |||
| 16 | - | ||||
| 17 | - | func TestValidateHTTPTranscodeURLRejectsPrivateTargets(t *testing.T) { | |||
| 18 | - | source, err := url.Parse("http://127.0.0.1/video.mp4") | |||
| 19 | - | if err != nil { | |||
| 20 | - | t.Fatal(err) | |||
| 21 | - | } | |||
| 22 | - | ||||
| 23 | - | err = validateHTTPTranscodeURL(context.Background(), source) | |||
| 24 | - | if err == nil { | |||
| 25 | - | t.Fatal("validateHTTPTranscodeURL() expected error for loopback target") | |||
| 26 | - | } | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) { | |||
| 30 | - | tests := []string{ | |||
| 31 | - | "100.64.0.1", | |||
| 32 | - | "198.18.0.1", | |||
| 33 | - | "192.0.2.1", | |||
| 34 | - | "64:ff9b::a00:1", | |||
| 35 | - | "2001::1", | |||
| 36 | - | "2001:db8::1", | |||
| 37 | - | "2002:0a00:0001::1", | |||
| 38 | - | } | |||
| 39 | - | for _, rawIP := range tests { | |||
| 40 | - | t.Run(rawIP, func(t *testing.T) { | |||
| 41 | - | if !isBlockedTranscodeIP(net.ParseIP(rawIP)) { | |||
| 42 | - | t.Fatalf("isBlockedTranscodeIP(%s) = false, want true", rawIP) | |||
| 43 | - | } | |||
| 44 | - | }) | |||
| 45 | - | } | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | func TestValidateHTTPTranscodeURLRejectsUnsupportedSchemes(t *testing.T) { | |||
| 49 | - | source, err := url.Parse("file:///etc/passwd") | |||
| 50 | - | if err != nil { | |||
| 51 | - | t.Fatal(err) | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | err = validateHTTPTranscodeURL(context.Background(), source) | |||
| 55 | - | if err == nil { | |||
| 56 | - | t.Fatal("validateHTTPTranscodeURL() expected error for unsupported scheme") | |||
| 57 | - | } | |||
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | func TestValidateHTTPTranscodeURLRejectsStreamingManifestURL(t *testing.T) { | |||
| 61 | - | source, err := url.Parse("https://93.184.216.34/playlist.m3u8") | |||
| 62 | - | if err != nil { | |||
| 63 | - | t.Fatal(err) | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | err = validateHTTPTranscodeURL(context.Background(), source) | |||
| 67 | - | if err == nil { | |||
| 68 | - | t.Fatal("validateHTTPTranscodeURL() expected error for streaming manifest") | |||
| 69 | - | } | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | - | func TestValidateHTTPTranscodeURLAllowsPublicIPAddress(t *testing.T) { | |||
| 73 | - | source, err := url.Parse("https://93.184.216.34/video.mp4") | |||
| 74 | - | if err != nil { | |||
| 75 | - | t.Fatal(err) | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | if err := validateHTTPTranscodeURL(context.Background(), source); err != nil { | |||
| 79 | - | t.Fatalf("validateHTTPTranscodeURL() unexpected error: %v", err) | |||
| 80 | - | } | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | func TestOpenInputFormatContextRejectsStreamingManifests(t *testing.T) { | |||
| 84 | - | p := newPipeline("test", port.HWCapabilities{}, "https://example.com/playlist.m3u8", nil, "", nil, zerolog.Nop()) | |||
| 85 | - | ||||
| 86 | - | _, _, err := p.openInputFormatContext(context.Background()) | |||
| 87 | - | if err == nil { | |||
| 88 | - | t.Fatal("openInputFormatContext() expected error for streaming manifest") | |||
| 89 | - | } | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | func TestIsStreamingManifestResponseDetectsContentType(t *testing.T) { | |||
| 93 | - | resp := &http.Response{Header: http.Header{"Content-Type": []string{"application/vnd.apple.mpegurl"}}} | |||
| 94 | - | if !isStreamingManifestResponse(resp) { | |||
| 95 | - | t.Fatal("isStreamingManifestResponse() = false, want true") | |||
| 96 | - | } | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | func TestValidateTranscodeRedirectRejectsPrivateTarget(t *testing.T) { | |||
| 100 | - | requestURL, err := url.Parse("http://10.0.0.1/private.mp4") | |||
| 101 | - | if err != nil { | |||
| 102 | - | t.Fatal(err) | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil) | |||
| 106 | - | if err == nil { | |||
| 107 | - | t.Fatal("validateTranscodeRedirect() expected error for private redirect target") | |||
| 108 | - | } | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | func TestValidateTranscodeRedirectRejectsTenHopRedirect(t *testing.T) { | |||
| 112 | - | requestURL, err := url.Parse("https://93.184.216.34/video.mp4") | |||
| 113 | - | if err != nil { | |||
| 114 | - | t.Fatal(err) | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | via := make([]*http.Request, 10) | |||
| 118 | - | for i := range via { | |||
| 119 | - | via[i] = &http.Request{URL: requestURL} | |||
| 120 | - | } | |||
| 121 | - | ||||
| 122 | - | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, via) | |||
| 123 | - | if err == nil { | |||
| 124 | - | t.Fatal("validateTranscodeRedirect() expected error for ten-hop redirect") | |||
| 125 | - | } | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | func TestValidateTranscodeRedirectRejectsStreamingManifestTarget(t *testing.T) { | |||
| 129 | - | requestURL, err := url.Parse("https://93.184.216.34/playlist.m3u8") | |||
| 130 | - | if err != nil { | |||
| 131 | - | t.Fatal(err) | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | - | err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil) | |||
| 135 | - | if err == nil { | |||
| 136 | - | t.Fatal("validateTranscodeRedirect() expected error for manifest redirect target") | |||
| 137 | - | } | |||
| 138 | - | } | |||
D internal/infrastructure/transcoder/hwprobe.go +0 -130
| @@ -1,130 +0,0 @@ | |||||
| 1 | - | //go:build !js || !wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "github.com/bnema/purego-ffmpeg/ffmpeg" | |||
| 7 | - | "github.com/rs/zerolog" | |||
| 8 | - | ||||
| 9 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | // usableEncoders lists hardware encoders that output open codecs | |||
| 13 | - | // CEF can decode natively (VP9, AV1). H.264/HEVC encoders exist | |||
| 14 | - | // but their output cannot be played by CEF's minimal build. | |||
| 15 | - | var usableEncoders = map[string]bool{ | |||
| 16 | - | "av1_vaapi": true, | |||
| 17 | - | "vp9_vaapi": true, | |||
| 18 | - | "av1_nvenc": true, | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | - | const hwAPINameVAAPI = "vaapi" | |||
| 22 | - | ||||
| 23 | - | // isUsableEncoder returns true only for hardware encoders whose output | |||
| 24 | - | // codec is open and decodable by CEF (AV1, VP9). | |||
| 25 | - | func isUsableEncoder(name string) bool { | |||
| 26 | - | return usableEncoders[name] | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | // hwProfile groups the encoder/decoder names to probe for a given API. | |||
| 30 | - | type hwProfile struct { | |||
| 31 | - | api string // "vaapi" or "nvenc" | |||
| 32 | - | deviceType int32 | |||
| 33 | - | encoders []string | |||
| 34 | - | decoders []string | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | var vaAPIProfile = hwProfile{ | |||
| 38 | - | api: hwAPINameVAAPI, | |||
| 39 | - | deviceType: int32(ffmpeg.HwdeviceTypeVaapi), | |||
| 40 | - | encoders: []string{"av1_vaapi", "vp9_vaapi", "h264_vaapi"}, | |||
| 41 | - | decoders: []string{"h264_vaapi", "hevc_vaapi"}, | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | var nvencProfile = hwProfile{ | |||
| 45 | - | api: "nvenc", | |||
| 46 | - | deviceType: int32(ffmpeg.HwdeviceTypeCuda), | |||
| 47 | - | // hevc_nvenc and h264_nvenc are probed for logging/diagnostic purposes | |||
| 48 | - | // but filtered as non-usable by isUsableEncoder (only open codecs like | |||
| 49 | - | // AV1 and VP9 are usable in CEF's minimal build). | |||
| 50 | - | encoders: []string{"av1_nvenc", "hevc_nvenc", "h264_nvenc"}, | |||
| 51 | - | decoders: []string{"h264_cuvid", "hevc_cuvid"}, | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | // ProbeGPU detects available GPU hardware encoding capabilities. | |||
| 55 | - | // hwaccelPref selects which APIs to try: "auto" (VAAPI then CUDA), | |||
| 56 | - | // "vaapi" (VAAPI only), or "nvenc" (CUDA only). | |||
| 57 | - | // Returns empty HWCapabilities if no compatible GPU encoder is found. | |||
| 58 | - | func ProbeGPU(hwaccelPref string, logger *zerolog.Logger) port.HWCapabilities { | |||
| 59 | - | if err := ffmpeg.Init(); err != nil { | |||
| 60 | - | logger.Warn().Err(err).Msg("ffmpeg init failed, GPU probe skipped") | |||
| 61 | - | return port.HWCapabilities{} | |||
| 62 | - | } | |||
| 63 | - | ||||
| 64 | - | var profiles []hwProfile | |||
| 65 | - | switch hwaccelPref { | |||
| 66 | - | case hwAPINameVAAPI: | |||
| 67 | - | profiles = []hwProfile{vaAPIProfile} | |||
| 68 | - | case "nvenc": | |||
| 69 | - | profiles = []hwProfile{nvencProfile} | |||
| 70 | - | default: // "auto" or unrecognized | |||
| 71 | - | profiles = []hwProfile{vaAPIProfile, nvencProfile} | |||
| 72 | - | } | |||
| 73 | - | ||||
| 74 | - | for _, p := range profiles { | |||
| 75 | - | caps := probeProfile(p, logger) | |||
| 76 | - | if len(caps.Encoders) > 0 { | |||
| 77 | - | return caps | |||
| 78 | - | } | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | logger.Info().Msg("no usable GPU encoder found, transcoding will be unavailable") | |||
| 82 | - | return port.HWCapabilities{} | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | // probeProfile attempts to open a hardware device and enumerate the | |||
| 86 | - | // available encoders/decoders for a single API profile. | |||
| 87 | - | func probeProfile(p hwProfile, logger *zerolog.Logger) port.HWCapabilities { | |||
| 88 | - | deviceCtx, err := ffmpeg.HWDeviceCtxCreate(p.deviceType, "") | |||
| 89 | - | if err != nil { | |||
| 90 | - | logger.Debug(). | |||
| 91 | - | Str("api", p.api). | |||
| 92 | - | Err(err). | |||
| 93 | - | Msg("hardware device not available") | |||
| 94 | - | return port.HWCapabilities{} | |||
| 95 | - | } | |||
| 96 | - | // Free the probing device context; a long-lived one will be | |||
| 97 | - | // created per transcode session (or shared across sessions). | |||
| 98 | - | defer ffmpeg.BufferUnref(&deviceCtx) | |||
| 99 | - | ||||
| 100 | - | logger.Info(). | |||
| 101 | - | Str("api", p.api). | |||
| 102 | - | Msg("hardware device opened for probing") | |||
| 103 | - | ||||
| 104 | - | var encoders []string | |||
| 105 | - | for _, name := range p.encoders { | |||
| 106 | - | if ffmpeg.CodecFindEncoderByName(name) != nil { | |||
| 107 | - | if isUsableEncoder(name) { | |||
| 108 | - | encoders = append(encoders, name) | |||
| 109 | - | logger.Info().Str("encoder", name).Msg("usable GPU encoder found") | |||
| 110 | - | } else { | |||
| 111 | - | logger.Debug().Str("encoder", name).Msg("encoder present but not usable (proprietary output)") | |||
| 112 | - | } | |||
| 113 | - | } | |||
| 114 | - | } | |||
| 115 | - | ||||
| 116 | - | var decoders []string | |||
| 117 | - | for _, name := range p.decoders { | |||
| 118 | - | if ffmpeg.CodecFindDecoderByName(name) != nil { | |||
| 119 | - | decoders = append(decoders, name) | |||
| 120 | - | logger.Debug().Str("decoder", name).Msg("GPU decoder found") | |||
| 121 | - | } | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | return port.HWCapabilities{ | |||
| 125 | - | API: p.api, | |||
| 126 | - | Device: "", // auto-detected by FFmpeg; reserved for future multi-GPU pinning | |||
| 127 | - | Encoders: encoders, | |||
| 128 | - | Decoders: decoders, | |||
| 129 | - | } | |||
| 130 | - | } | |||
D internal/infrastructure/transcoder/hwprobe_test.go +0 -41
| @@ -1,41 +0,0 @@ | |||||
| 1 | - | //go:build !js || !wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | import "testing" | |||
| 6 | - | ||||
| 7 | - | func TestIsUsableEncoder(t *testing.T) { | |||
| 8 | - | tests := []struct { | |||
| 9 | - | name string | |||
| 10 | - | encoder string | |||
| 11 | - | want bool | |||
| 12 | - | }{ | |||
| 13 | - | // Usable: open codec output that CEF can decode | |||
| 14 | - | {name: "av1_vaapi", encoder: "av1_vaapi", want: true}, | |||
| 15 | - | {name: "vp9_vaapi", encoder: "vp9_vaapi", want: true}, | |||
| 16 | - | {name: "av1_nvenc", encoder: "av1_nvenc", want: true}, | |||
| 17 | - | ||||
| 18 | - | // Not usable: proprietary codec output CEF cannot decode | |||
| 19 | - | {name: "h264_vaapi", encoder: "h264_vaapi", want: false}, | |||
| 20 | - | {name: "hevc_nvenc", encoder: "hevc_nvenc", want: false}, | |||
| 21 | - | {name: "h264_nvenc", encoder: "h264_nvenc", want: false}, | |||
| 22 | - | ||||
| 23 | - | // Software encoders are not usable either | |||
| 24 | - | {name: "libx264", encoder: "libx264", want: false}, | |||
| 25 | - | {name: "libsvtav1", encoder: "libsvtav1", want: false}, | |||
| 26 | - | {name: "libvpx-vp9", encoder: "libvpx-vp9", want: false}, | |||
| 27 | - | ||||
| 28 | - | // Edge cases | |||
| 29 | - | {name: "empty string", encoder: "", want: false}, | |||
| 30 | - | {name: "random string", encoder: "not_a_codec", want: false}, | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | for _, tt := range tests { | |||
| 34 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 35 | - | got := isUsableEncoder(tt.encoder) | |||
| 36 | - | if got != tt.want { | |||
| 37 | - | t.Errorf("isUsableEncoder(%q) = %v, want %v", tt.encoder, got, tt.want) | |||
| 38 | - | } | |||
| 39 | - | }) | |||
| 40 | - | } | |||
| 41 | - | } | |||
D internal/infrastructure/transcoder/pipeline.go +0 -920
| @@ -1,920 +0,0 @@ | |||||
| 1 | - | //go:build !js || !wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "errors" | |||
| 8 | - | "fmt" | |||
| 9 | - | "io" | |||
| 10 | - | "net" | |||
| 11 | - | "net/http" | |||
| 12 | - | "net/url" | |||
| 13 | - | "strings" | |||
| 14 | - | "time" | |||
| 15 | - | "unsafe" | |||
| 16 | - | ||||
| 17 | - | "github.com/bnema/purego" | |||
| 18 | - | "github.com/rs/zerolog" | |||
| 19 | - | ||||
| 20 | - | "github.com/bnema/purego-ffmpeg/ffmpeg" | |||
| 21 | - | ||||
| 22 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 23 | - | "github.com/bnema/dumber/internal/infrastructure/netguard" | |||
| 24 | - | ) | |||
| 25 | - | ||||
| 26 | - | // avioBufSize is the buffer size for custom AVIO contexts (32 KiB). | |||
| 27 | - | const avioBufSize = 32 * 1024 | |||
| 28 | - | ||||
| 29 | - | const ( | |||
| 30 | - | vaapiInitialPoolSize = 20 | |||
| 31 | - | minVideoBitrate = 500_000 | |||
| 32 | - | ) | |||
| 33 | - | ||||
| 34 | - | // pipeline orchestrates a single GPU transcode session. It reads from | |||
| 35 | - | // an HTTP source, decodes with a hardware decoder (or software fallback), | |||
| 36 | - | // encodes video to an open codec (AV1/VP9) via GPU, encodes audio to | |||
| 37 | - | // Opus via libopus, and muxes the result into WebM written to an | |||
| 38 | - | // io.PipeWriter. | |||
| 39 | - | type pipeline struct { | |||
| 40 | - | sessionID string | |||
| 41 | - | hwCaps port.HWCapabilities | |||
| 42 | - | sourceURL string | |||
| 43 | - | headers map[string]string | |||
| 44 | - | quality string | |||
| 45 | - | pw *io.PipeWriter | |||
| 46 | - | logger zerolog.Logger | |||
| 47 | - | ||||
| 48 | - | // Prevent GC of callback closures and uintptrs. | |||
| 49 | - | readCb uintptr | |||
| 50 | - | writeCb uintptr | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | // httpClient is used for fetching source media. It has connect/TLS/header | |||
| 54 | - | // timeouts but no overall Timeout so that long-lived streaming responses | |||
| 55 | - | // are not killed. The session context handles cancellation. | |||
| 56 | - | var httpClient = &http.Client{ | |||
| 57 | - | Transport: &http.Transport{ | |||
| 58 | - | DialContext: safeTranscodeDialContext, | |||
| 59 | - | TLSHandshakeTimeout: 10 * time.Second, | |||
| 60 | - | ResponseHeaderTimeout: 15 * time.Second, | |||
| 61 | - | }, | |||
| 62 | - | CheckRedirect: validateTranscodeRedirect, | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | func validateTranscodeRedirect(req *http.Request, via []*http.Request) error { | |||
| 66 | - | if len(via) >= 10 { | |||
| 67 | - | return fmt.Errorf("stopped after 10 redirects") | |||
| 68 | - | } | |||
| 69 | - | if err := validateHTTPTranscodeURL(req.Context(), req.URL); err != nil { | |||
| 70 | - | return fmt.Errorf("blocked redirect target: %w", err) | |||
| 71 | - | } | |||
| 72 | - | return nil | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) { | |||
| 76 | - | host, portValue, err := net.SplitHostPort(address) | |||
| 77 | - | if err != nil { | |||
| 78 | - | return nil, err | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | ips, err := resolveAllowedTranscodeIPs(ctx, host) | |||
| 82 | - | if err != nil { | |||
| 83 | - | return nil, err | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | dialer := &net.Dialer{Timeout: 10 * time.Second} | |||
| 87 | - | var lastErr error | |||
| 88 | - | for _, ip := range ips { | |||
| 89 | - | conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), portValue)) | |||
| 90 | - | if dialErr == nil { | |||
| 91 | - | return conn, nil | |||
| 92 | - | } | |||
| 93 | - | lastErr = dialErr | |||
| 94 | - | } | |||
| 95 | - | if lastErr != nil { | |||
| 96 | - | return nil, lastErr | |||
| 97 | - | } | |||
| 98 | - | return nil, fmt.Errorf("no allowed transcode source addresses for %s", host) | |||
| 99 | - | } | |||
| 100 | - | ||||
| 101 | - | func validateHTTPTranscodeURL(ctx context.Context, source *url.URL) error { | |||
| 102 | - | if source == nil || source.Host == "" { | |||
| 103 | - | return fmt.Errorf("invalid src") | |||
| 104 | - | } | |||
| 105 | - | if source.Scheme != "http" && source.Scheme != "https" { | |||
| 106 | - | return fmt.Errorf("unsupported src scheme") | |||
| 107 | - | } | |||
| 108 | - | if IsStreamingManifestURL(source.String()) { | |||
| 109 | - | return fmt.Errorf("streaming manifest transcoding is disabled") | |||
| 110 | - | } | |||
| 111 | - | _, err := resolveAllowedTranscodeIPs(ctx, source.Hostname()) | |||
| 112 | - | return err | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | func resolveAllowedTranscodeIPs(ctx context.Context, host string) ([]net.IP, error) { | |||
| 116 | - | host = strings.TrimSpace(host) | |||
| 117 | - | if host == "" { | |||
| 118 | - | return nil, fmt.Errorf("invalid src host") | |||
| 119 | - | } | |||
| 120 | - | if ip := net.ParseIP(host); ip != nil { | |||
| 121 | - | if isBlockedTranscodeIP(ip) { | |||
| 122 | - | return nil, fmt.Errorf("private src host not allowed") | |||
| 123 | - | } | |||
| 124 | - | return []net.IP{ip}, nil | |||
| 125 | - | } | |||
| 126 | - | ||||
| 127 | - | lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) | |||
| 128 | - | defer cancel() | |||
| 129 | - | addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host) | |||
| 130 | - | if err != nil || len(addrs) == 0 { | |||
| 131 | - | return nil, fmt.Errorf("src host could not be resolved") | |||
| 132 | - | } | |||
| 133 | - | ips := make([]net.IP, 0, len(addrs)) | |||
| 134 | - | for _, addr := range addrs { | |||
| 135 | - | if isBlockedTranscodeIP(addr.IP) { | |||
| 136 | - | return nil, fmt.Errorf("private src host not allowed") | |||
| 137 | - | } | |||
| 138 | - | ips = append(ips, addr.IP) | |||
| 139 | - | } | |||
| 140 | - | return ips, nil | |||
| 141 | - | } | |||
| 142 | - | ||||
| 143 | - | func isBlockedTranscodeIP(ip net.IP) bool { | |||
| 144 | - | return netguard.IsBlockedTranscodeIP(ip) | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | func isStreamingManifestResponse(resp *http.Response) bool { | |||
| 148 | - | if resp == nil { | |||
| 149 | - | return false | |||
| 150 | - | } | |||
| 151 | - | if resp.Request != nil && resp.Request.URL != nil && IsStreamingManifestURL(resp.Request.URL.String()) { | |||
| 152 | - | return true | |||
| 153 | - | } | |||
| 154 | - | contentType := strings.ToLower(resp.Header.Get("Content-Type")) | |||
| 155 | - | return strings.Contains(contentType, "mpegurl") || strings.Contains(contentType, "dash+xml") | |||
| 156 | - | } | |||
| 157 | - | ||||
| 158 | - | func newPipeline( | |||
| 159 | - | sessionID string, | |||
| 160 | - | hwCaps port.HWCapabilities, | |||
| 161 | - | sourceURL string, | |||
| 162 | - | headers map[string]string, | |||
| 163 | - | quality string, | |||
| 164 | - | pw *io.PipeWriter, | |||
| 165 | - | logger zerolog.Logger, | |||
| 166 | - | ) *pipeline { | |||
| 167 | - | return &pipeline{ | |||
| 168 | - | sessionID: sessionID, | |||
| 169 | - | hwCaps: hwCaps, | |||
| 170 | - | sourceURL: sourceURL, | |||
| 171 | - | headers: headers, | |||
| 172 | - | quality: quality, | |||
| 173 | - | pw: pw, | |||
| 174 | - | logger: logger, | |||
| 175 | - | } | |||
| 176 | - | } | |||
| 177 | - | ||||
| 178 | - | // run executes the full transcode pipeline in the calling goroutine. | |||
| 179 | - | // It closes pw when done (with error or nil). | |||
| 180 | - | func (p *pipeline) run(ctx context.Context) { | |||
| 181 | - | err := p.doRun(ctx) | |||
| 182 | - | if err != nil { | |||
| 183 | - | log := p.logger.With(). | |||
| 184 | - | Str("session_id", p.sessionID). | |||
| 185 | - | Str("source_url", p.sourceURL). | |||
| 186 | - | Logger() | |||
| 187 | - | if errors.Is(err, context.Canceled) { | |||
| 188 | - | log.Debug().Err(err).Msg("transcode pipeline canceled") | |||
| 189 | - | } else { | |||
| 190 | - | log.Error().Err(err).Msg("transcode pipeline failed") | |||
| 191 | - | } | |||
| 192 | - | _ = p.pw.CloseWithError(err) | |||
| 193 | - | } else { | |||
| 194 | - | p.logger.Debug(). | |||
| 195 | - | Str("session_id", p.sessionID). | |||
| 196 | - | Str("source_url", p.sourceURL). | |||
| 197 | - | Msg("transcode pipeline completed") | |||
| 198 | - | _ = p.pw.Close() | |||
| 199 | - | } | |||
| 200 | - | } | |||
| 201 | - | ||||
| 202 | - | // doRun contains the actual pipeline logic. Errors are returned, and | |||
| 203 | - | // the caller (run) closes the pipe writer accordingly. | |||
| 204 | - | func (p *pipeline) doRun(ctx context.Context) error { | |||
| 205 | - | return p.doRunImpl(ctx) | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | //nolint:gocyclo,funlen,gosec // FFmpeg pipeline setup/teardown uses necessary branching and unsafe C-API calls. | |||
| 209 | - | func (p *pipeline) doRunImpl(ctx context.Context) error { | |||
| 210 | - | // --- Step 1-3: Open the input using the appropriate strategy --- | |||
| 211 | - | inFmtCtx, closeInput, err := p.openInputFormatContext(ctx) | |||
| 212 | - | if err != nil { | |||
| 213 | - | return err | |||
| 214 | - | } | |||
| 215 | - | defer closeInput() | |||
| 216 | - | ||||
| 217 | - | // --- Step 4: Find stream info --- | |||
| 218 | - | if ret := ffmpeg.FormatFindStreamInfo(inFmtCtx, nil); ret < 0 { | |||
| 219 | - | return fmt.Errorf("find stream info: %d", ret) | |||
| 220 | - | } | |||
| 221 | - | ||||
| 222 | - | // --- Step 5: Select input video/audio streams --- | |||
| 223 | - | videoIdx, audioIdx, err := p.selectInputStreams(inFmtCtx) | |||
| 224 | - | if err != nil { | |||
| 225 | - | return err | |||
| 226 | - | } | |||
| 227 | - | ||||
| 228 | - | videoStream := ffmpeg.FmtCtxStream(inFmtCtx, videoIdx) | |||
| 229 | - | videoStreamWrap := ffmpeg.WrapStream(videoStream) | |||
| 230 | - | videoCodecPar := videoStreamWrap.Codecpar() | |||
| 231 | - | ||||
| 232 | - | // --- Step 6: Open video decoder --- | |||
| 233 | - | vidDecCtx, err := p.openVideoDecoder(videoCodecPar) | |||
| 234 | - | if err != nil { | |||
| 235 | - | return fmt.Errorf("open video decoder: %w", err) | |||
| 236 | - | } | |||
| 237 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 238 | - | defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidDecCtx)) | |||
| 239 | - | ||||
| 240 | - | // --- Step 7-8: Open video encoder with HW accel --- | |||
| 241 | - | hwDeviceCtx, vidEncCtx, err := p.openVideoEncoder(vidDecCtx) | |||
| 242 | - | if err != nil { | |||
| 243 | - | return fmt.Errorf("open video encoder: %w", err) | |||
| 244 | - | } | |||
| 245 | - | if hwDeviceCtx != nil { | |||
| 246 | - | defer ffmpeg.BufferUnref(&hwDeviceCtx) | |||
| 247 | - | } | |||
| 248 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 249 | - | defer ffmpeg.CodecFreeContext(unsafe.Pointer(&vidEncCtx)) | |||
| 250 | - | ||||
| 251 | - | // --- Step 9: Create output format context for WebM --- | |||
| 252 | - | var outFmtCtx unsafe.Pointer | |||
| 253 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer allocation for output contexts. | |||
| 254 | - | if ret := ffmpeg.FormatAllocOutputContext2(unsafe.Pointer(&outFmtCtx), nil, "webm", ""); ret < 0 { | |||
| 255 | - | return fmt.Errorf("alloc output context: %d", ret) | |||
| 256 | - | } | |||
| 257 | - | defer ffmpeg.FormatFreeContext(outFmtCtx) | |||
| 258 | - | ||||
| 259 | - | // --- Step 10: Create write AVIO callback --- | |||
| 260 | - | pw := p.pw | |||
| 261 | - | writeFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | |||
| 262 | - | if bufSize <= 0 { | |||
| 263 | - | return 0 | |||
| 264 | - | } | |||
| 265 | - | //nolint:gosec // FFmpeg supplies a valid callback buffer and explicit byte count here. | |||
| 266 | - | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | |||
| 267 | - | n, writeErr := pw.Write(goBuf) | |||
| 268 | - | if writeErr != nil { | |||
| 269 | - | return -1 | |||
| 270 | - | } | |||
| 271 | - | return int32(n) | |||
| 272 | - | } | |||
| 273 | - | p.writeCb = purego.NewCallback(writeFn) | |||
| 274 | - | ||||
| 275 | - | writeAVIO := ffmpeg.AvioAllocContext(avioBufSize, true, nil, 0, p.writeCb, 0) | |||
| 276 | - | if writeAVIO == nil { | |||
| 277 | - | return errors.New("failed to allocate write AVIO context") | |||
| 278 | - | } | |||
| 279 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |||
| 280 | - | defer ffmpeg.AvioContextFree(unsafe.Pointer(&writeAVIO)) | |||
| 281 | - | ||||
| 282 | - | ffmpeg.FmtCtxSetPB(outFmtCtx, writeAVIO) | |||
| 283 | - | ffmpeg.FmtCtxSetFlags(outFmtCtx, ffmpeg.FmtCtxFlags(outFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO) | |||
| 284 | - | ||||
| 285 | - | // --- Step 11: Add video output stream --- | |||
| 286 | - | outVideoStream := ffmpeg.FormatNewStream(outFmtCtx, nil) | |||
| 287 | - | if outVideoStream == nil { | |||
| 288 | - | return errors.New("failed to create output video stream") | |||
| 289 | - | } | |||
| 290 | - | outVideoStreamWrap := ffmpeg.WrapStream(outVideoStream) | |||
| 291 | - | outVideoIdx := int(outVideoStreamWrap.Index()) | |||
| 292 | - | ||||
| 293 | - | if ret := ffmpeg.CodecParametersFromContext(outVideoStreamWrap.Codecpar(), vidEncCtx); ret < 0 { | |||
| 294 | - | return fmt.Errorf("copy video encoder params to stream: %d", ret) | |||
| 295 | - | } | |||
| 296 | - | ||||
| 297 | - | // --- Step 11b: Add audio output stream (if audio present) --- | |||
| 298 | - | var audioTx *audioTranscoder | |||
| 299 | - | if audioIdx >= 0 { | |||
| 300 | - | audioStream := ffmpeg.FmtCtxStream(inFmtCtx, audioIdx) | |||
| 301 | - | audioStreamWrap := ffmpeg.WrapStream(audioStream) | |||
| 302 | - | audioCodecPar := audioStreamWrap.Codecpar() | |||
| 303 | - | ||||
| 304 | - | var txErr error | |||
| 305 | - | audioTx, txErr = newAudioTranscoder(audioCodecPar, outFmtCtx, audioIdx) | |||
| 306 | - | if txErr != nil { | |||
| 307 | - | // Audio transcoding failure is non-fatal; we proceed with video only. | |||
| 308 | - | audioTx = nil | |||
| 309 | - | audioIdx = -1 | |||
| 310 | - | } | |||
| 311 | - | } | |||
| 312 | - | // Use a closure so the defer sees the current value of audioTx at | |||
| 313 | - | // cleanup time. This allows us to close and nil-out audioTx mid-loop | |||
| 314 | - | // (e.g., on audio processing error) without double-closing. | |||
| 315 | - | defer func() { | |||
| 316 | - | if audioTx != nil { | |||
| 317 | - | audioTx.close() | |||
| 318 | - | } | |||
| 319 | - | }() | |||
| 320 | - | ||||
| 321 | - | // --- Step 12: Write header --- | |||
| 322 | - | if ret := ffmpeg.FormatWriteHeader(outFmtCtx, nil); ret < 0 { | |||
| 323 | - | return fmt.Errorf("write header: %d", ret) | |||
| 324 | - | } | |||
| 325 | - | ||||
| 326 | - | // --- Step 13: Main decode/encode loop --- | |||
| 327 | - | pkt := ffmpeg.PacketAlloc() | |||
| 328 | - | if pkt == nil { | |||
| 329 | - | return errors.New("failed to allocate read packet") | |||
| 330 | - | } | |||
| 331 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for packet allocations. | |||
| 332 | - | defer ffmpeg.PacketFree(unsafe.Pointer(&pkt)) | |||
| 333 | - | ||||
| 334 | - | frame := ffmpeg.FrameAlloc() | |||
| 335 | - | if frame == nil { | |||
| 336 | - | return errors.New("failed to allocate decode frame") | |||
| 337 | - | } | |||
| 338 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for frame allocations. | |||
| 339 | - | defer ffmpeg.FrameFree(unsafe.Pointer(&frame)) | |||
| 340 | - | ||||
| 341 | - | encPkt := ffmpeg.PacketAlloc() | |||
| 342 | - | if encPkt == nil { | |||
| 343 | - | return errors.New("failed to allocate encode packet") | |||
| 344 | - | } | |||
| 345 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for packet allocations. | |||
| 346 | - | defer ffmpeg.PacketFree(unsafe.Pointer(&encPkt)) | |||
| 347 | - | ||||
| 348 | - | for { | |||
| 349 | - | // Check context cancellation. | |||
| 350 | - | select { | |||
| 351 | - | case <-ctx.Done(): | |||
| 352 | - | return ctx.Err() | |||
| 353 | - | default: | |||
| 354 | - | } | |||
| 355 | - | ||||
| 356 | - | ret := ffmpeg.ReadFrame(inFmtCtx, pkt) | |||
| 357 | - | if ret < 0 { | |||
| 358 | - | // EOF or error — break to flush. | |||
| 359 | - | break | |||
| 360 | - | } | |||
| 361 | - | ||||
| 362 | - | streamIdx := int(pktStreamIndex(pkt)) | |||
| 363 | - | ||||
| 364 | - | switch { | |||
| 365 | - | case streamIdx == videoIdx: | |||
| 366 | - | if err := p.transcodeVideoPacket(pkt, frame, encPkt, vidDecCtx, vidEncCtx, outFmtCtx, outVideoIdx); err != nil { | |||
| 367 | - | ffmpeg.PacketUnref(pkt) | |||
| 368 | - | return fmt.Errorf("video transcode: %w", err) | |||
| 369 | - | } | |||
| 370 | - | ||||
| 371 | - | case audioTx != nil && streamIdx == audioIdx: | |||
| 372 | - | if err := audioTx.processPacket(pkt, outFmtCtx); err != nil { | |||
| 373 | - | // Audio errors are non-fatal: log warning, disable audio, | |||
| 374 | - | // and continue with video-only output. This matches the | |||
| 375 | - | // non-fatal handling of audio init failure above. | |||
| 376 | - | p.logger.Warn(). | |||
| 377 | - | Str("session_id", p.sessionID). | |||
| 378 | - | Err(err). | |||
| 379 | - | Msg("audio processing failed, continuing with video only") | |||
| 380 | - | audioTx.close() | |||
| 381 | - | audioTx = nil | |||
| 382 | - | audioIdx = -1 | |||
| 383 | - | } | |||
| 384 | - | } | |||
| 385 | - | ||||
| 386 | - | ffmpeg.PacketUnref(pkt) | |||
| 387 | - | } | |||
| 388 | - | ||||
| 389 | - | // --- Step 14: Flush decoders and encoders --- | |||
| 390 | - | // Flush video decoder. | |||
| 391 | - | ffmpeg.CodecSendPacket(vidDecCtx, nil) | |||
| 392 | - | for { | |||
| 393 | - | ret := ffmpeg.CodecReceiveFrame(vidDecCtx, frame) | |||
| 394 | - | if ret < 0 { | |||
| 395 | - | break | |||
| 396 | - | } | |||
| 397 | - | if err := p.encodeAndWriteVideoFrame(frame, encPkt, vidEncCtx, outFmtCtx, outVideoIdx); err != nil { | |||
| 398 | - | ffmpeg.FrameUnref(frame) | |||
| 399 | - | return fmt.Errorf("video flush: %w", err) | |||
| 400 | - | } | |||
| 401 | - | ffmpeg.FrameUnref(frame) | |||
| 402 | - | } | |||
| 403 | - | ||||
| 404 | - | // Flush video encoder. | |||
| 405 | - | ffmpeg.CodecSendFrame(vidEncCtx, nil) | |||
| 406 | - | for { | |||
| 407 | - | ret := ffmpeg.CodecReceivePacket(vidEncCtx, encPkt) | |||
| 408 | - | if ret < 0 { | |||
| 409 | - | break | |||
| 410 | - | } | |||
| 411 | - | pktSetStreamIndex(encPkt, int32(outVideoIdx)) | |||
| 412 | - | outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx) | |||
| 413 | - | outVidStreamWrap := ffmpeg.WrapStream(outVidStream) | |||
| 414 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(vidEncCtx), outVidStreamWrap.TimeBase()) | |||
| 415 | - | ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt) | |||
| 416 | - | ffmpeg.PacketUnref(encPkt) | |||
| 417 | - | } | |||
| 418 | - | ||||
| 419 | - | // Flush audio. | |||
| 420 | - | if audioTx != nil { | |||
| 421 | - | audioTx.flush(outFmtCtx, p.logger) | |||
| 422 | - | } | |||
| 423 | - | ||||
| 424 | - | // --- Step 15: Write trailer --- | |||
| 425 | - | if ret := ffmpeg.WriteTrailer(outFmtCtx); ret < 0 { | |||
| 426 | - | return fmt.Errorf("write trailer: %d", ret) | |||
| 427 | - | } | |||
| 428 | - | ||||
| 429 | - | return nil | |||
| 430 | - | } | |||
| 431 | - | ||||
| 432 | - | func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx, audioIdx int, err error) { | |||
| 433 | - | videoIdx = -1 | |||
| 434 | - | audioIdx = -1 | |||
| 435 | - | bestVideoPixels := int64(-1) | |||
| 436 | - | ||||
| 437 | - | streamCount := int(ffmpeg.FmtCtxNbStreams(inFmtCtx)) | |||
| 438 | - | for i := 0; i < streamCount; i++ { | |||
| 439 | - | stream := ffmpeg.FmtCtxStream(inFmtCtx, i) | |||
| 440 | - | if stream == nil { | |||
| 441 | - | continue | |||
| 442 | - | } | |||
| 443 | - | streamWrap := ffmpeg.WrapStream(stream) | |||
| 444 | - | codecPar := streamWrap.Codecpar() | |||
| 445 | - | if codecPar == nil { | |||
| 446 | - | continue | |||
| 447 | - | } | |||
| 448 | - | ||||
| 449 | - | switch ffmpeg.CodecParCodecType(codecPar) { | |||
| 450 | - | case ffmpeg.AVMEDIA_TYPE_VIDEO: | |||
| 451 | - | width := int64(ffmpeg.CodecParWidth(codecPar)) | |||
| 452 | - | height := int64(ffmpeg.CodecParHeight(codecPar)) | |||
| 453 | - | pixels := width * height | |||
| 454 | - | if videoIdx == -1 || pixels > bestVideoPixels { | |||
| 455 | - | videoIdx = i | |||
| 456 | - | bestVideoPixels = pixels | |||
| 457 | - | } | |||
| 458 | - | case ffmpeg.AVMEDIA_TYPE_AUDIO: | |||
| 459 | - | if audioIdx == -1 { | |||
| 460 | - | audioIdx = i | |||
| 461 | - | } | |||
| 462 | - | } | |||
| 463 | - | } | |||
| 464 | - | ||||
| 465 | - | if videoIdx < 0 { | |||
| 466 | - | return -1, audioIdx, errors.New("no video stream found in source") | |||
| 467 | - | } | |||
| 468 | - | ||||
| 469 | - | videoCodecPar := ffmpeg.WrapStream(ffmpeg.FmtCtxStream(inFmtCtx, videoIdx)).Codecpar() | |||
| 470 | - | log := p.logger.With(). | |||
| 471 | - | Str("session_id", p.sessionID). | |||
| 472 | - | Str("source_url", p.sourceURL). | |||
| 473 | - | Int("video_stream_index", videoIdx). | |||
| 474 | - | Int32("video_codec_id", ffmpeg.CodecParCodecID(videoCodecPar)). | |||
| 475 | - | Int32("video_width", ffmpeg.CodecParWidth(videoCodecPar)). | |||
| 476 | - | Int32("video_height", ffmpeg.CodecParHeight(videoCodecPar)). | |||
| 477 | - | Logger() | |||
| 478 | - | if audioIdx >= 0 { | |||
| 479 | - | audioCodecPar := ffmpeg.WrapStream(ffmpeg.FmtCtxStream(inFmtCtx, audioIdx)).Codecpar() | |||
| 480 | - | log.Info(). | |||
| 481 | - | Int("audio_stream_index", audioIdx). | |||
| 482 | - | Int32("audio_codec_id", ffmpeg.CodecParCodecID(audioCodecPar)). | |||
| 483 | - | Msg("transcode pipeline selected input streams") | |||
| 484 | - | } else { | |||
| 485 | - | log.Info().Msg("transcode pipeline selected input streams") | |||
| 486 | - | } | |||
| 487 | - | ||||
| 488 | - | return videoIdx, audioIdx, nil | |||
| 489 | - | } | |||
| 490 | - | ||||
| 491 | - | func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) { | |||
| 492 | - | if IsStreamingManifestURL(p.sourceURL) { | |||
| 493 | - | // FFmpeg resolves manifest redirects and segment URLs internally, bypassing | |||
| 494 | - | // the guarded Go HTTP client below. Keep untrusted transcode requests on | |||
| 495 | - | // custom I/O until manifests can be fetched and rewritten through the same | |||
| 496 | - | // SSRF protections. | |||
| 497 | - | return nil, nil, errors.New("streaming manifest transcoding is disabled") | |||
| 498 | - | } | |||
| 499 | - | return p.openCustomIOInputContext(ctx) | |||
| 500 | - | } | |||
| 501 | - | ||||
| 502 | - | func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer, func(), error) { | |||
| 503 | - | req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.sourceURL, http.NoBody) | |||
| 504 | - | if err != nil { | |||
| 505 | - | return nil, nil, fmt.Errorf("create request: %w", err) | |||
| 506 | - | } | |||
| 507 | - | for k, v := range p.headers { | |||
| 508 | - | req.Header.Set(k, v) | |||
| 509 | - | } | |||
| 510 | - | ||||
| 511 | - | resp, err := httpClient.Do(req) //nolint:bodyclose // closed by returned cleanup after FFmpeg finishes reading. | |||
| 512 | - | if err != nil { | |||
| 513 | - | return nil, nil, fmt.Errorf("fetch source: %w", err) | |||
| 514 | - | } | |||
| 515 | - | ||||
| 516 | - | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { | |||
| 517 | - | _ = resp.Body.Close() | |||
| 518 | - | return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode) | |||
| 519 | - | } | |||
| 520 | - | if isStreamingManifestResponse(resp) { | |||
| 521 | - | _ = resp.Body.Close() | |||
| 522 | - | return nil, nil, errors.New("streaming manifest transcoding is disabled") | |||
| 523 | - | } | |||
| 524 | - | ||||
| 525 | - | body := resp.Body | |||
| 526 | - | readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 { | |||
| 527 | - | if bufSize <= 0 { | |||
| 528 | - | return -1 | |||
| 529 | - | } | |||
| 530 | - | //nolint:gosec // FFmpeg supplies a valid callback buffer and explicit byte count here. | |||
| 531 | - | goBuf := unsafe.Slice((*byte)(buf), int(bufSize)) | |||
| 532 | - | // Retry up to 3 times for the edge case where body.Read returns | |||
| 533 | - | // n=0, err=nil (allowed by the io.Reader contract but unusual). | |||
| 534 | - | for tries := 0; tries < 3; tries++ { | |||
| 535 | - | n, readErr := body.Read(goBuf) | |||
| 536 | - | if n > 0 { | |||
| 537 | - | return int32(n) | |||
| 538 | - | } | |||
| 539 | - | if readErr != nil { | |||
| 540 | - | // Return AVERROR_EOF for EOF, generic error otherwise. | |||
| 541 | - | return averrorEOF | |||
| 542 | - | } | |||
| 543 | - | // n == 0 && err == nil: retry | |||
| 544 | - | } | |||
| 545 | - | return averrorEOF | |||
| 546 | - | } | |||
| 547 | - | p.readCb = purego.NewCallback(readFn) | |||
| 548 | - | ||||
| 549 | - | readAVIO := ffmpeg.AvioAllocContext(avioBufSize, false, nil, p.readCb, 0, 0) | |||
| 550 | - | if readAVIO == nil { | |||
| 551 | - | _ = resp.Body.Close() | |||
| 552 | - | return nil, nil, errors.New("failed to allocate read AVIO context") | |||
| 553 | - | } | |||
| 554 | - | ||||
| 555 | - | inFmtCtx := ffmpeg.FormatAllocContext() | |||
| 556 | - | if inFmtCtx == nil { | |||
| 557 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |||
| 558 | - | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |||
| 559 | - | _ = resp.Body.Close() | |||
| 560 | - | return nil, nil, errors.New("failed to allocate input format context") | |||
| 561 | - | } | |||
| 562 | - | ||||
| 563 | - | ffmpeg.FmtCtxSetPB(inFmtCtx, readAVIO) | |||
| 564 | - | ffmpeg.FmtCtxSetFlags(inFmtCtx, ffmpeg.FmtCtxFlags(inFmtCtx)|ffmpeg.AVFMT_FLAG_CUSTOM_IO) | |||
| 565 | - | ||||
| 566 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts. | |||
| 567 | - | if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, nil); ret < 0 { | |||
| 568 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |||
| 569 | - | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |||
| 570 | - | _ = resp.Body.Close() | |||
| 571 | - | ffmpeg.FormatFreeContext(inFmtCtx) | |||
| 572 | - | return nil, nil, fmt.Errorf("open input: %d", ret) | |||
| 573 | - | } | |||
| 574 | - | ||||
| 575 | - | cleanup := func() { | |||
| 576 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts. | |||
| 577 | - | ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx)) | |||
| 578 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for AVIO contexts. | |||
| 579 | - | ffmpeg.AvioContextFree(unsafe.Pointer(&readAVIO)) | |||
| 580 | - | _ = resp.Body.Close() | |||
| 581 | - | } | |||
| 582 | - | return inFmtCtx, cleanup, nil | |||
| 583 | - | } | |||
| 584 | - | ||||
| 585 | - | func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) { | |||
| 586 | - | codecID := ffmpeg.CodecParCodecID(codecPar) | |||
| 587 | - | ||||
| 588 | - | // Try HW decoder first. | |||
| 589 | - | var dec unsafe.Pointer | |||
| 590 | - | for _, name := range p.hwCaps.Decoders { | |||
| 591 | - | dec = ffmpeg.CodecFindDecoderByName(name) | |||
| 592 | - | if dec != nil { | |||
| 593 | - | break | |||
| 594 | - | } | |||
| 595 | - | } | |||
| 596 | - | ||||
| 597 | - | // Fallback to software decoder. | |||
| 598 | - | if dec == nil { | |||
| 599 | - | dec = ffmpeg.CodecFindDecoder(codecID) | |||
| 600 | - | } | |||
| 601 | - | if dec == nil { | |||
| 602 | - | return nil, fmt.Errorf("no decoder found for codec ID %d", codecID) | |||
| 603 | - | } | |||
| 604 | - | ||||
| 605 | - | decCtx := ffmpeg.CodecAllocContext3(dec) | |||
| 606 | - | if decCtx == nil { | |||
| 607 | - | return nil, errors.New("failed to allocate video decoder context") | |||
| 608 | - | } | |||
| 609 | - | ||||
| 610 | - | if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 { | |||
| 611 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 612 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |||
| 613 | - | return nil, fmt.Errorf("copy video codec params: %d", ret) | |||
| 614 | - | } | |||
| 615 | - | ||||
| 616 | - | if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 { | |||
| 617 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 618 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx)) | |||
| 619 | - | return nil, fmt.Errorf("open video decoder: %d", ret) | |||
| 620 | - | } | |||
| 621 | - | ||||
| 622 | - | return decCtx, nil | |||
| 623 | - | } | |||
| 624 | - | ||||
| 625 | - | // openVideoEncoder creates a hardware device context and encoder for | |||
| 626 | - | // the video stream. It uses the first available encoder from hwCaps.Encoders. | |||
| 627 | - | func (p *pipeline) openVideoEncoder(decCtx unsafe.Pointer) (hwDeviceCtx, encCtx unsafe.Pointer, err error) { | |||
| 628 | - | if len(p.hwCaps.Encoders) == 0 { | |||
| 629 | - | return nil, nil, errors.New("no GPU encoders available") | |||
| 630 | - | } | |||
| 631 | - | ||||
| 632 | - | // Create the HW device context. | |||
| 633 | - | var deviceType int32 | |||
| 634 | - | switch p.hwCaps.API { | |||
| 635 | - | case hwAPINameVAAPI: | |||
| 636 | - | deviceType = int32(ffmpeg.HwdeviceTypeVaapi) | |||
| 637 | - | case "nvenc": | |||
| 638 | - | deviceType = int32(ffmpeg.HwdeviceTypeCuda) | |||
| 639 | - | default: | |||
| 640 | - | return nil, nil, fmt.Errorf("unknown HW API: %s", p.hwCaps.API) | |||
| 641 | - | } | |||
| 642 | - | ||||
| 643 | - | hwDeviceCtx, hwErr := ffmpeg.HWDeviceCtxCreate(deviceType, p.hwCaps.Device) | |||
| 644 | - | if hwErr != nil { | |||
| 645 | - | return nil, nil, fmt.Errorf("create HW device: %w", hwErr) | |||
| 646 | - | } | |||
| 647 | - | ||||
| 648 | - | width := ffmpeg.CodecCtxWidth(decCtx) | |||
| 649 | - | height := ffmpeg.CodecCtxHeight(decCtx) | |||
| 650 | - | ||||
| 651 | - | var lastErr error | |||
| 652 | - | for _, name := range p.hwCaps.Encoders { | |||
| 653 | - | enc := ffmpeg.CodecFindEncoderByName(name) | |||
| 654 | - | if enc == nil { | |||
| 655 | - | continue | |||
| 656 | - | } | |||
| 657 | - | ||||
| 658 | - | encCtx, err = p.tryOpenVideoEncoder(decCtx, hwDeviceCtx, name, enc) | |||
| 659 | - | if err == nil { | |||
| 660 | - | p.logger.Info(). | |||
| 661 | - | Str("session_id", p.sessionID). | |||
| 662 | - | Str("source_url", p.sourceURL). | |||
| 663 | - | Str("encoder", name). | |||
| 664 | - | Str("api", p.hwCaps.API). | |||
| 665 | - | Int32("width", width). | |||
| 666 | - | Int32("height", height). | |||
| 667 | - | Msg("transcode pipeline selected video encoder") | |||
| 668 | - | return hwDeviceCtx, encCtx, nil | |||
| 669 | - | } | |||
| 670 | - | lastErr = err | |||
| 671 | - | p.logger.Warn(). | |||
| 672 | - | Str("session_id", p.sessionID). | |||
| 673 | - | Str("source_url", p.sourceURL). | |||
| 674 | - | Str("encoder", name). | |||
| 675 | - | Err(err). | |||
| 676 | - | Msg("transcode pipeline encoder candidate failed") | |||
| 677 | - | } | |||
| 678 | - | ||||
| 679 | - | ffmpeg.BufferUnref(&hwDeviceCtx) | |||
| 680 | - | if lastErr != nil { | |||
| 681 | - | return nil, nil, fmt.Errorf("no usable encoder codec found: %w", lastErr) | |||
| 682 | - | } | |||
| 683 | - | return nil, nil, errors.New("no usable encoder codec found") | |||
| 684 | - | } | |||
| 685 | - | ||||
| 686 | - | func (p *pipeline) tryOpenVideoEncoder(decCtx, hwDeviceCtx unsafe.Pointer, encoderName string, enc unsafe.Pointer) (unsafe.Pointer, error) { | |||
| 687 | - | encCtx := ffmpeg.CodecAllocContext3(enc) | |||
| 688 | - | if encCtx == nil { | |||
| 689 | - | return nil, errors.New("failed to allocate video encoder context") | |||
| 690 | - | } | |||
| 691 | - | ||||
| 692 | - | width := ffmpeg.CodecCtxWidth(decCtx) | |||
| 693 | - | height := ffmpeg.CodecCtxHeight(decCtx) | |||
| 694 | - | ffmpeg.CodecCtxSetWidth(encCtx, width) | |||
| 695 | - | ffmpeg.CodecCtxSetHeight(encCtx, height) | |||
| 696 | - | ||||
| 697 | - | framerate := ffmpeg.CodecCtxFramerate(decCtx) | |||
| 698 | - | if framerate.Num == 0 { | |||
| 699 | - | framerate = ffmpeg.AVRational{Num: 30, Den: 1} | |||
| 700 | - | } | |||
| 701 | - | ffmpeg.CodecCtxSetFramerate(encCtx, framerate) | |||
| 702 | - | ffmpeg.CodecCtxSetTimeBase(encCtx, ffmpeg.AVRational{Num: framerate.Den, Den: framerate.Num}) | |||
| 703 | - | ||||
| 704 | - | if p.hwCaps.API == hwAPINameVAAPI { | |||
| 705 | - | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtVaapi)) | |||
| 706 | - | } else { | |||
| 707 | - | ffmpeg.CodecCtxSetPixFmt(encCtx, int32(ffmpeg.PixFmtNv12)) | |||
| 708 | - | } | |||
| 709 | - | ||||
| 710 | - | gopSize := framerate.Num / framerate.Den * 2 | |||
| 711 | - | if gopSize <= 0 { | |||
| 712 | - | gopSize = 60 | |||
| 713 | - | } | |||
| 714 | - | codecCtxSetGopSize(encCtx, gopSize) | |||
| 715 | - | p.applyQualityPreset(encCtx, encoderName, width, height) | |||
| 716 | - | ||||
| 717 | - | hwDeviceRef := ffmpeg.BufferRef(hwDeviceCtx) | |||
| 718 | - | if hwDeviceRef == nil { | |||
| 719 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 720 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |||
| 721 | - | return nil, errors.New("failed to ref HW device context for encoder") | |||
| 722 | - | } | |||
| 723 | - | ffmpeg.CodecCtxSetHwDeviceCtx(encCtx, hwDeviceRef) | |||
| 724 | - | ||||
| 725 | - | if p.hwCaps.API == hwAPINameVAAPI { | |||
| 726 | - | framesRef := ffmpeg.HWFrameCtxAlloc(hwDeviceCtx) | |||
| 727 | - | if framesRef == nil { | |||
| 728 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 729 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |||
| 730 | - | return nil, errors.New("failed to allocate HW frames context") | |||
| 731 | - | } | |||
| 732 | - | ||||
| 733 | - | hwFramesData := ffmpeg.BufferRefData(framesRef) | |||
| 734 | - | if hwFramesData == nil { | |||
| 735 | - | ffmpeg.BufferUnref(&framesRef) | |||
| 736 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 737 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |||
| 738 | - | return nil, errors.New("failed to access HW frames context payload") | |||
| 739 | - | } | |||
| 740 | - | ||||
| 741 | - | ffmpeg.HWFramesCtxSetInitialPoolSize(hwFramesData, vaapiInitialPoolSize) | |||
| 742 | - | ffmpeg.HWFramesCtxSetFormat(hwFramesData, int32(ffmpeg.PixFmtVaapi)) | |||
| 743 | - | ffmpeg.HWFramesCtxSetSWFormat(hwFramesData, int32(ffmpeg.PixFmtNv12)) | |||
| 744 | - | ffmpeg.HWFramesCtxSetWidth(hwFramesData, width) | |||
| 745 | - | ffmpeg.HWFramesCtxSetHeight(hwFramesData, height) | |||
| 746 | - | ||||
| 747 | - | if ret := ffmpeg.HWFrameCtxInit(framesRef); ret < 0 { | |||
| 748 | - | ffmpeg.BufferUnref(&framesRef) | |||
| 749 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 750 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |||
| 751 | - | return nil, fmt.Errorf("init HW frames context: %d", ret) | |||
| 752 | - | } | |||
| 753 | - | ||||
| 754 | - | ffmpeg.CodecCtxSetHwFramesCtx(encCtx, framesRef) | |||
| 755 | - | } | |||
| 756 | - | ||||
| 757 | - | if ret := ffmpeg.CodecOpen2(encCtx, enc, nil); ret < 0 { | |||
| 758 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts. | |||
| 759 | - | ffmpeg.CodecFreeContext(unsafe.Pointer(&encCtx)) | |||
| 760 | - | return nil, fmt.Errorf("open video encoder %s: %d", encoderName, ret) | |||
| 761 | - | } | |||
| 762 | - | ||||
| 763 | - | return encCtx, nil | |||
| 764 | - | } | |||
| 765 | - | ||||
| 766 | - | // applyQualityPreset sets bitrate and encoder options based on the | |||
| 767 | - | // quality string ("low", "medium", "high"). | |||
| 768 | - | func (p *pipeline) applyQualityPreset(encCtx unsafe.Pointer, encoderName string, width, height int32) { | |||
| 769 | - | pixels := int64(width) * int64(height) | |||
| 770 | - | ||||
| 771 | - | // Base bitrate scaled to resolution (targeting 1080p baseline). | |||
| 772 | - | // low: ~1.5 Mbps at 1080p, medium: ~3 Mbps, high: ~6 Mbps. | |||
| 773 | - | var bitsPerPixel float64 | |||
| 774 | - | switch p.quality { | |||
| 775 | - | case "low": | |||
| 776 | - | bitsPerPixel = 0.07 | |||
| 777 | - | case "high": | |||
| 778 | - | bitsPerPixel = 0.30 | |||
| 779 | - | default: // "medium" | |||
| 780 | - | bitsPerPixel = 0.15 | |||
| 781 | - | } | |||
| 782 | - | ||||
| 783 | - | bitrate := int64(float64(pixels) * bitsPerPixel) | |||
| 784 | - | if bitrate < minVideoBitrate { | |||
| 785 | - | bitrate = minVideoBitrate // floor at 500 kbps | |||
| 786 | - | } | |||
| 787 | - | codecCtxSetBitRate(encCtx, bitrate) | |||
| 788 | - | ||||
| 789 | - | // Set encoder-specific options via av_opt_set. | |||
| 790 | - | if strings.HasSuffix(encoderName, "_vaapi") { | |||
| 791 | - | // VAAPI encoders use rc_mode for rate control. | |||
| 792 | - | ffmpeg.AVOptSet(encCtx, "rc_mode", "VBR", 0) | |||
| 793 | - | } else if strings.HasSuffix(encoderName, "_nvenc") { | |||
| 794 | - | // NVENC uses preset and rc. | |||
| 795 | - | switch p.quality { | |||
| 796 | - | case "low": | |||
| 797 | - | ffmpeg.AVOptSet(encCtx, "preset", "p4", 0) | |||
| 798 | - | case "high": | |||
| 799 | - | ffmpeg.AVOptSet(encCtx, "preset", "p7", 0) | |||
| 800 | - | default: | |||
| 801 | - | ffmpeg.AVOptSet(encCtx, "preset", "p5", 0) | |||
| 802 | - | } | |||
| 803 | - | ffmpeg.AVOptSet(encCtx, "rc", "vbr", 0) | |||
| 804 | - | } | |||
| 805 | - | } | |||
| 806 | - | ||||
| 807 | - | // transcodeVideoPacket decodes a video packet, encodes the resulting | |||
| 808 | - | // frames, and writes them to the output. | |||
| 809 | - | func (p *pipeline) transcodeVideoPacket(pkt, frame, encPkt, decCtx, encCtx, outFmtCtx unsafe.Pointer, outVideoIdx int) error { | |||
| 810 | - | ret := ffmpeg.CodecSendPacket(decCtx, pkt) | |||
| 811 | - | if ret < 0 { | |||
| 812 | - | return fmt.Errorf("video decode send packet: %d", ret) | |||
| 813 | - | } | |||
| 814 | - | ||||
| 815 | - | for { | |||
| 816 | - | ret = ffmpeg.CodecReceiveFrame(decCtx, frame) | |||
| 817 | - | if ret < 0 { | |||
| 818 | - | // EAGAIN or EOF. | |||
| 819 | - | break | |||
| 820 | - | } | |||
| 821 | - | ||||
| 822 | - | if err := p.encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx, outVideoIdx); err != nil { | |||
| 823 | - | ffmpeg.FrameUnref(frame) | |||
| 824 | - | return err | |||
| 825 | - | } | |||
| 826 | - | ffmpeg.FrameUnref(frame) | |||
| 827 | - | } | |||
| 828 | - | ||||
| 829 | - | return nil | |||
| 830 | - | } | |||
| 831 | - | ||||
| 832 | - | // encodeAndWriteVideoFrame sends a decoded frame to the encoder and | |||
| 833 | - | // writes any resulting packets to the output. | |||
| 834 | - | func (p *pipeline) encodeAndWriteVideoFrame(frame, encPkt, encCtx, outFmtCtx unsafe.Pointer, outVideoIdx int) error { | |||
| 835 | - | encFrame := frame | |||
| 836 | - | var cleanup func() | |||
| 837 | - | if p.hwCaps.API == hwAPINameVAAPI { | |||
| 838 | - | var err error | |||
| 839 | - | encFrame, cleanup, err = uploadVideoFrameToVAAPI(frame, encCtx) | |||
| 840 | - | if err != nil { | |||
| 841 | - | return err | |||
| 842 | - | } | |||
| 843 | - | defer cleanup() | |||
| 844 | - | } | |||
| 845 | - | ||||
| 846 | - | ret := ffmpeg.CodecSendFrame(encCtx, encFrame) | |||
| 847 | - | if ret < 0 { | |||
| 848 | - | return fmt.Errorf("video encode send frame: %d", ret) | |||
| 849 | - | } | |||
| 850 | - | ||||
| 851 | - | for { | |||
| 852 | - | ret = ffmpeg.CodecReceivePacket(encCtx, encPkt) | |||
| 853 | - | if ret < 0 { | |||
| 854 | - | break | |||
| 855 | - | } | |||
| 856 | - | ||||
| 857 | - | pktSetStreamIndex(encPkt, int32(outVideoIdx)) | |||
| 858 | - | ||||
| 859 | - | outVidStream := ffmpeg.FmtCtxStream(outFmtCtx, outVideoIdx) | |||
| 860 | - | outVidStreamWrap := ffmpeg.WrapStream(outVidStream) | |||
| 861 | - | ffmpeg.PacketRescaleTs(encPkt, ffmpeg.CodecCtxTimeBase(encCtx), outVidStreamWrap.TimeBase()) | |||
| 862 | - | ||||
| 863 | - | if wret := ffmpeg.InterleavedWriteFrame(outFmtCtx, encPkt); wret < 0 { | |||
| 864 | - | ffmpeg.PacketUnref(encPkt) | |||
| 865 | - | return fmt.Errorf("video write frame: %d", wret) | |||
| 866 | - | } | |||
| 867 | - | ffmpeg.PacketUnref(encPkt) | |||
| 868 | - | } | |||
| 869 | - | ||||
| 870 | - | return nil | |||
| 871 | - | } | |||
| 872 | - | ||||
| 873 | - | func uploadVideoFrameToVAAPI(frame, encCtx unsafe.Pointer) (unsafe.Pointer, func(), error) { | |||
| 874 | - | if frameFormat(frame) == int32(ffmpeg.PixFmtVaapi) { | |||
| 875 | - | return frame, func() {}, nil | |||
| 876 | - | } | |||
| 877 | - | ||||
| 878 | - | hwFramesCtx := ffmpeg.CodecCtxHwFramesCtx(encCtx) | |||
| 879 | - | if hwFramesCtx == nil { | |||
| 880 | - | return nil, nil, errors.New("video encoder missing hw_frames_ctx") | |||
| 881 | - | } | |||
| 882 | - | ||||
| 883 | - | hwFrame := ffmpeg.FrameAlloc() | |||
| 884 | - | if hwFrame == nil { | |||
| 885 | - | return nil, nil, errors.New("failed to allocate VAAPI frame") | |||
| 886 | - | } | |||
| 887 | - | ||||
| 888 | - | cleanup := func() { | |||
| 889 | - | if hwFrame != nil { | |||
| 890 | - | //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for frame allocations. | |||
| 891 | - | ffmpeg.FrameFree(unsafe.Pointer(&hwFrame)) | |||
| 892 | - | } | |||
| 893 | - | } | |||
| 894 | - | ||||
| 895 | - | frameSetFormat(hwFrame, int32(ffmpeg.PixFmtVaapi)) | |||
| 896 | - | frameSetWidth(hwFrame, frameWidth(frame)) | |||
| 897 | - | frameSetHeight(hwFrame, frameHeight(frame)) | |||
| 898 | - | frameSetPts(hwFrame, framePts(frame)) | |||
| 899 | - | ||||
| 900 | - | hwFramesRef := ffmpeg.BufferRef(hwFramesCtx) | |||
| 901 | - | if hwFramesRef == nil { | |||
| 902 | - | cleanup() | |||
| 903 | - | return nil, nil, errors.New("failed to ref encoder hw frames context") | |||
| 904 | - | } | |||
| 905 | - | frameSetHWFramesCtx(hwFrame, hwFramesRef) | |||
| 906 | - | ||||
| 907 | - | if ret := ffmpeg.HWFrameGetBuffer(hwFramesCtx, hwFrame, 0); ret < 0 { | |||
| 908 | - | cleanup() | |||
| 909 | - | return nil, nil, fmt.Errorf("allocate VAAPI frame buffer: %d", ret) | |||
| 910 | - | } | |||
| 911 | - | if ret := ffmpeg.HWFrameTransferData(hwFrame, frame, 0); ret < 0 { | |||
| 912 | - | cleanup() | |||
| 913 | - | return nil, nil, fmt.Errorf("upload frame to VAAPI: %d", ret) | |||
| 914 | - | } | |||
| 915 | - | ||||
| 916 | - | return hwFrame, cleanup, nil | |||
| 917 | - | } | |||
| 918 | - | ||||
| 919 | - | // averrorEOF is the AVERROR_EOF constant. | |||
| 920 | - | const averrorEOF = -('E' | 'O'<<8 | 'F'<<16 | ' '<<24) | |||
D internal/infrastructure/transcoder/pool.go +0 -111
| @@ -1,111 +0,0 @@ | |||||
| 1 | - | package transcoder | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "errors" | |||
| 6 | - | "io" | |||
| 7 | - | "sync" | |||
| 8 | - | "sync/atomic" | |||
| 9 | - | "time" | |||
| 10 | - | ||||
| 11 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | // Compile-time check: session implements port.TranscodeSession. | |||
| 15 | - | var _ port.TranscodeSession = (*session)(nil) | |||
| 16 | - | ||||
| 17 | - | // session wraps an io.PipeReader fed by a pipeline goroutine. | |||
| 18 | - | type session struct { | |||
| 19 | - | id string | |||
| 20 | - | pr *io.PipeReader | |||
| 21 | - | cancel context.CancelFunc | |||
| 22 | - | createdAt time.Time | |||
| 23 | - | lastRead atomic.Int64 // unix timestamp of last Read() call | |||
| 24 | - | } | |||
| 25 | - | ||||
| 26 | - | // Read delegates to the underlying pipe reader and records the timestamp. | |||
| 27 | - | func (s *session) Read(buf []byte) (int, error) { | |||
| 28 | - | n, err := s.pr.Read(buf) | |||
| 29 | - | if n > 0 { | |||
| 30 | - | s.lastRead.Store(time.Now().Unix()) | |||
| 31 | - | } | |||
| 32 | - | return n, err | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | // Close cancels the pipeline context and closes the pipe reader. | |||
| 36 | - | func (s *session) Close() error { | |||
| 37 | - | s.cancel() | |||
| 38 | - | return s.pr.Close() | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | // ContentType returns the MIME type of the transcoded output. | |||
| 42 | - | func (*session) ContentType() string { | |||
| 43 | - | return "video/webm" | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | - | // errPoolAtCapacity is returned when the session pool has reached its | |||
| 47 | - | // maximum number of concurrent sessions. | |||
| 48 | - | var errPoolAtCapacity = errors.New("transcoder: session pool at capacity") | |||
| 49 | - | ||||
| 50 | - | // sessionPool tracks active transcode sessions with a concurrency limit. | |||
| 51 | - | type sessionPool struct { | |||
| 52 | - | mu sync.RWMutex | |||
| 53 | - | sessions map[string]*session | |||
| 54 | - | maxConc int | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | // newSessionPool creates a pool with the given concurrency limit. | |||
| 58 | - | func newSessionPool(maxConcurrent int) *sessionPool { | |||
| 59 | - | return &sessionPool{ | |||
| 60 | - | sessions: make(map[string]*session), | |||
| 61 | - | maxConc: maxConcurrent, | |||
| 62 | - | } | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | // add registers a session in the pool. Returns errPoolAtCapacity if the | |||
| 66 | - | // pool is already at its maximum concurrency. | |||
| 67 | - | func (p *sessionPool) add(s *session) error { | |||
| 68 | - | p.mu.Lock() | |||
| 69 | - | defer p.mu.Unlock() | |||
| 70 | - | if len(p.sessions) >= p.maxConc { | |||
| 71 | - | return errPoolAtCapacity | |||
| 72 | - | } | |||
| 73 | - | p.sessions[s.id] = s | |||
| 74 | - | return nil | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | // remove unregisters a session from the pool. | |||
| 78 | - | func (p *sessionPool) remove(id string) { | |||
| 79 | - | p.mu.Lock() | |||
| 80 | - | defer p.mu.Unlock() | |||
| 81 | - | delete(p.sessions, id) | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | // count returns the number of active sessions. | |||
| 85 | - | func (p *sessionPool) count() int { | |||
| 86 | - | p.mu.RLock() | |||
| 87 | - | defer p.mu.RUnlock() | |||
| 88 | - | return len(p.sessions) | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | // closeAll cancels and closes every active session. It collects sessions | |||
| 92 | - | // under the lock, clears the map, then releases the lock before calling | |||
| 93 | - | // Close on each session to avoid holding the lock during potentially | |||
| 94 | - | // blocking I/O. | |||
| 95 | - | func (p *sessionPool) closeAll() { | |||
| 96 | - | p.mu.Lock() | |||
| 97 | - | snapshot := make([]*session, 0, len(p.sessions)) | |||
| 98 | - | for _, s := range p.sessions { | |||
| 99 | - | snapshot = append(snapshot, s) | |||
| 100 | - | } | |||
| 101 | - | // Clear the map while holding the lock. | |||
| 102 | - | for id := range p.sessions { | |||
| 103 | - | delete(p.sessions, id) | |||
| 104 | - | } | |||
| 105 | - | p.mu.Unlock() | |||
| 106 | - | ||||
| 107 | - | // Close sessions outside the lock. | |||
| 108 | - | for _, s := range snapshot { | |||
| 109 | - | _ = s.Close() | |||
| 110 | - | } | |||
| 111 | - | } | |||
D internal/infrastructure/transcoder/transcoder.go +0 -148
| @@ -1,148 +0,0 @@ | |||||
| 1 | - | //go:build !js || !wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "errors" | |||
| 8 | - | "fmt" | |||
| 9 | - | "io" | |||
| 10 | - | "time" | |||
| 11 | - | ||||
| 12 | - | "github.com/google/uuid" | |||
| 13 | - | "github.com/rs/zerolog" | |||
| 14 | - | ||||
| 15 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 16 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 17 | - | ) | |||
| 18 | - | ||||
| 19 | - | // Compile-time check: Transcoder implements port.MediaTranscoder. | |||
| 20 | - | var _ port.MediaTranscoder = (*Transcoder)(nil) | |||
| 21 | - | ||||
| 22 | - | // Transcoder implements port.MediaTranscoder using GPU-accelerated FFmpeg | |||
| 23 | - | // pipelines. It probes hardware capabilities at creation time and manages | |||
| 24 | - | // a bounded pool of concurrent transcode sessions. | |||
| 25 | - | type Transcoder struct { | |||
| 26 | - | cfg config.TranscodingConfig | |||
| 27 | - | hwCaps port.HWCapabilities | |||
| 28 | - | pool *sessionPool | |||
| 29 | - | logger zerolog.Logger | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | // New creates a Transcoder. It probes the GPU at creation time using the | |||
| 33 | - | // configured hardware acceleration preference. If no compatible GPU | |||
| 34 | - | // encoder is found, Available() will return false and Start() will fail. | |||
| 35 | - | func New(cfg config.TranscodingConfig, logger *zerolog.Logger) *Transcoder { | |||
| 36 | - | maxConc := cfg.MaxConcurrent | |||
| 37 | - | if maxConc <= 0 { | |||
| 38 | - | maxConc = 2 | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | // Work on a local copy to avoid mutating a potentially shared config. | |||
| 42 | - | localCfg := cfg | |||
| 43 | - | ||||
| 44 | - | if localCfg.Quality == "" { | |||
| 45 | - | localCfg.Quality = "medium" | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | hwaccel := localCfg.HWAccel | |||
| 49 | - | if hwaccel == "" { | |||
| 50 | - | hwaccel = "auto" | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | l := logger.With().Str("component", "transcoder").Logger() | |||
| 54 | - | ||||
| 55 | - | hwCaps := ProbeGPU(hwaccel, &l) | |||
| 56 | - | if len(hwCaps.Encoders) > 0 { | |||
| 57 | - | l.Info(). | |||
| 58 | - | Str("api", hwCaps.API). | |||
| 59 | - | Strs("encoders", hwCaps.Encoders). | |||
| 60 | - | Strs("decoders", hwCaps.Decoders). | |||
| 61 | - | Int("max_concurrent", maxConc). | |||
| 62 | - | Msg("GPU transcoding available") | |||
| 63 | - | } else { | |||
| 64 | - | l.Warn().Msg("no GPU encoder found, transcoding will be unavailable") | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | return &Transcoder{ | |||
| 68 | - | cfg: localCfg, | |||
| 69 | - | hwCaps: hwCaps, | |||
| 70 | - | pool: newSessionPool(maxConc), | |||
| 71 | - | logger: l, | |||
| 72 | - | } | |||
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | // Available returns true if a compatible GPU encoder was found during | |||
| 76 | - | // hardware probing. | |||
| 77 | - | func (t *Transcoder) Available() bool { | |||
| 78 | - | return len(t.hwCaps.Encoders) > 0 | |||
| 79 | - | } | |||
| 80 | - | ||||
| 81 | - | // Capabilities returns the detected GPU hardware info. | |||
| 82 | - | func (t *Transcoder) Capabilities() port.HWCapabilities { | |||
| 83 | - | return t.hwCaps | |||
| 84 | - | } | |||
| 85 | - | ||||
| 86 | - | // Start begins a transcode session. It spawns a goroutine running the | |||
| 87 | - | // FFmpeg pipeline and returns a TranscodeSession whose Read() streams | |||
| 88 | - | // the transcoded WebM output. The session is automatically removed from | |||
| 89 | - | // the pool when the pipeline completes or the context is canceled. | |||
| 90 | - | func (t *Transcoder) Start(ctx context.Context, sourceURL string, headers map[string]string) (port.TranscodeSession, error) { | |||
| 91 | - | if !t.Available() { | |||
| 92 | - | return nil, errors.New("transcoder: no GPU encoder available") | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | // Create a cancellable context for this session. | |||
| 96 | - | sessionCtx, cancel := context.WithCancel(ctx) | |||
| 97 | - | ||||
| 98 | - | pr, pw := io.Pipe() | |||
| 99 | - | ||||
| 100 | - | id := uuid.NewString() | |||
| 101 | - | s := &session{ | |||
| 102 | - | id: id, | |||
| 103 | - | pr: pr, | |||
| 104 | - | cancel: cancel, | |||
| 105 | - | createdAt: time.Now(), | |||
| 106 | - | } | |||
| 107 | - | s.lastRead.Store(time.Now().Unix()) | |||
| 108 | - | ||||
| 109 | - | // Check capacity and register before spawning the goroutine. | |||
| 110 | - | if err := t.pool.add(s); err != nil { | |||
| 111 | - | cancel() | |||
| 112 | - | _ = pr.Close() | |||
| 113 | - | _ = pw.Close() | |||
| 114 | - | return nil, fmt.Errorf("transcoder: %w", err) | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | p := newPipeline(id, t.hwCaps, sourceURL, headers, t.cfg.Quality, pw, t.logger) | |||
| 118 | - | ||||
| 119 | - | t.logger.Info(). | |||
| 120 | - | Str("session_id", id). | |||
| 121 | - | Str("source", sourceURL). | |||
| 122 | - | Int("active_sessions", t.pool.count()). | |||
| 123 | - | Msg("starting transcode session") | |||
| 124 | - | ||||
| 125 | - | go func() { | |||
| 126 | - | defer func() { | |||
| 127 | - | t.pool.remove(id) | |||
| 128 | - | t.logger.Info().Str("session_id", id).Msg("transcode session finished") | |||
| 129 | - | }() | |||
| 130 | - | defer func() { | |||
| 131 | - | if r := recover(); r != nil { | |||
| 132 | - | t.logger.Error().Str("session_id", id).Interface("panic", r).Msg("transcode session panicked") | |||
| 133 | - | _ = pw.CloseWithError(fmt.Errorf("transcode session panicked: %v", r)) | |||
| 134 | - | } | |||
| 135 | - | }() | |||
| 136 | - | p.run(sessionCtx) | |||
| 137 | - | }() | |||
| 138 | - | ||||
| 139 | - | return s, nil | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | // Close shuts down all active transcode sessions. | |||
| 143 | - | func (t *Transcoder) Close() { | |||
| 144 | - | t.logger.Info(). | |||
| 145 | - | Int("active_sessions", t.pool.count()). | |||
| 146 | - | Msg("closing all transcode sessions") | |||
| 147 | - | t.pool.closeAll() | |||
| 148 | - | } | |||
D internal/infrastructure/transcoder/transcoder_wasm.go +0 -41
| @@ -1,41 +0,0 @@ | |||||
| 1 | - | //go:build js && wasm | |||
| 2 | - | ||||
| 3 | - | package transcoder | |||
| 4 | - | ||||
| 5 | - | import ( | |||
| 6 | - | "context" | |||
| 7 | - | "errors" | |||
| 8 | - | ||||
| 9 | - | "github.com/rs/zerolog" | |||
| 10 | - | ||||
| 11 | - | "github.com/bnema/dumber/internal/application/port" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | // Compile-time check: Transcoder implements port.MediaTranscoder. | |||
| 15 | - | var _ port.MediaTranscoder = (*Transcoder)(nil) | |||
| 16 | - | ||||
| 17 | - | // Transcoder is unavailable in js/wasm builds because FFmpeg is not available. | |||
| 18 | - | type Transcoder struct{} | |||
| 19 | - | ||||
| 20 | - | // New creates an unavailable Transcoder for js/wasm builds. | |||
| 21 | - | func New(_ any, _ *zerolog.Logger) *Transcoder { | |||
| 22 | - | return &Transcoder{} | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | // Available reports whether transcoding is available. | |||
| 26 | - | func (*Transcoder) Available() bool { | |||
| 27 | - | return false | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | // Capabilities returns empty hardware capabilities for js/wasm builds. | |||
| 31 | - | func (*Transcoder) Capabilities() port.HWCapabilities { | |||
| 32 | - | return port.HWCapabilities{} | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | // Start always fails for js/wasm builds. | |||
| 36 | - | func (*Transcoder) Start(_ context.Context, _ string, _ map[string]string) (port.TranscodeSession, error) { | |||
| 37 | - | return nil, errors.New("transcoder: FFmpeg unavailable in js/wasm") | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | // Close is a no-op for js/wasm builds. | |||
| 41 | - | func (*Transcoder) Close() {} | |||