feat(media): add MediaDiagnostics port and CheckMediaUseCase
- commit
- 39be2ea07fa743dbf722496456c70d0bd6739ae7
- author
- brice · · 2025-12-18 18:27 UTC
- parent
- 214a5d3
- signature
- unsigned
3 files · +352 -0
A internal/application/port/media.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | // Package port defines interfaces for external dependencies. | |
| 2 | + | package port | |
| 3 | + | ||
| 4 | + | import "context" | |
| 5 | + | ||
| 6 | + | // MediaDiagnosticsResult contains hardware acceleration detection results. | |
| 7 | + | type MediaDiagnosticsResult struct { | |
| 8 | + | // GStreamer availability | |
| 9 | + | GStreamerAvailable bool | |
| 10 | + | ||
| 11 | + | // GStreamer plugins | |
| 12 | + | HasVAPlugin bool // gst-plugins-bad VA (modern stateless decoders) | |
| 13 | + | HasVAAPIPlugin bool // gstreamer-vaapi (legacy) | |
| 14 | + | HasNVCodecPlugin bool // nvcodec for NVIDIA | |
| 15 | + | ||
| 16 | + | // Detected hardware decoders | |
| 17 | + | AV1Decoders []string | |
| 18 | + | H264Decoders []string | |
| 19 | + | H265Decoders []string | |
| 20 | + | VP9Decoders []string | |
| 21 | + | ||
| 22 | + | // VA-API info | |
| 23 | + | VAAPIAvailable bool | |
| 24 | + | VAAPIDriver string | |
| 25 | + | VAAPIVersion string | |
| 26 | + | ||
| 27 | + | // Summary | |
| 28 | + | HWAccelAvailable bool | |
| 29 | + | AV1HWAvailable bool | |
| 30 | + | Warnings []string | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // MediaDiagnostics provides video playback capability detection. | |
| 34 | + | type MediaDiagnostics interface { | |
| 35 | + | // RunDiagnostics checks GStreamer plugins and VA-API availability. | |
| 36 | + | RunDiagnostics(ctx context.Context) *MediaDiagnosticsResult | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | // Package port defines interfaces for external dependencies. | |||
| 2 | + | package port | |||
| 3 | + | ||||
| 4 | + | import "context" | |||
| 5 | + | ||||
| 6 | + | // MediaDiagnosticsResult contains hardware acceleration detection results. | |||
| 7 | + | type MediaDiagnosticsResult struct { | |||
| 8 | + | // GStreamer availability | |||
| 9 | + | GStreamerAvailable bool | |||
| 10 | + | ||||
| 11 | + | // GStreamer plugins | |||
| 12 | + | HasVAPlugin bool // gst-plugins-bad VA (modern stateless decoders) | |||
| 13 | + | HasVAAPIPlugin bool // gstreamer-vaapi (legacy) | |||
| 14 | + | HasNVCodecPlugin bool // nvcodec for NVIDIA | |||
| 15 | + | ||||
| 16 | + | // Detected hardware decoders | |||
| 17 | + | AV1Decoders []string | |||
| 18 | + | H264Decoders []string | |||
| 19 | + | H265Decoders []string | |||
| 20 | + | VP9Decoders []string | |||
| 21 | + | ||||
| 22 | + | // VA-API info | |||
| 23 | + | VAAPIAvailable bool | |||
| 24 | + | VAAPIDriver string | |||
| 25 | + | VAAPIVersion string | |||
| 26 | + | ||||
| 27 | + | // Summary | |||
| 28 | + | HWAccelAvailable bool | |||
| 29 | + | AV1HWAvailable bool | |||
| 30 | + | Warnings []string | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // MediaDiagnostics provides video playback capability detection. | |||
| 34 | + | type MediaDiagnostics interface { | |||
| 35 | + | // RunDiagnostics checks GStreamer plugins and VA-API availability. | |||
| 36 | + | RunDiagnostics(ctx context.Context) *MediaDiagnosticsResult | |||
| 37 | + | } | |||
A internal/application/usecase/check_media.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | // Package usecase contains application business logic. | |
| 2 | + | package usecase | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // CheckMediaUseCase validates media playback requirements at startup. | |
| 13 | + | type CheckMediaUseCase struct { | |
| 14 | + | diagnostics port.MediaDiagnostics | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // NewCheckMediaUseCase creates a new CheckMediaUseCase. | |
| 18 | + | func NewCheckMediaUseCase(diagnostics port.MediaDiagnostics) *CheckMediaUseCase { | |
| 19 | + | return &CheckMediaUseCase{ | |
| 20 | + | diagnostics: diagnostics, | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // CheckMediaInput contains options for the media check. | |
| 25 | + | type CheckMediaInput struct { | |
| 26 | + | ShowDiagnostics bool // Log diagnostics warnings | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // CheckMediaOutput contains the result of the media check. | |
| 30 | + | type CheckMediaOutput struct { | |
| 31 | + | GStreamerAvailable bool | |
| 32 | + | HWAccelAvailable bool | |
| 33 | + | AV1HWAvailable bool | |
| 34 | + | Warnings []string | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Execute checks media playback requirements. | |
| 38 | + | // Returns error if GStreamer is not installed (fatal). | |
| 39 | + | // Returns warnings for missing hardware acceleration (non-fatal). | |
| 40 | + | func (uc *CheckMediaUseCase) Execute(ctx context.Context, input CheckMediaInput) (*CheckMediaOutput, error) { | |
| 41 | + | log := logging.FromContext(ctx) | |
| 42 | + | ||
| 43 | + | result := uc.diagnostics.RunDiagnostics(ctx) | |
| 44 | + | ||
| 45 | + | // GStreamer is required - fail early if not installed | |
| 46 | + | if !result.GStreamerAvailable { | |
| 47 | + | return nil, fmt.Errorf("GStreamer not installed - video playback requires GStreamer. Install: gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Log warnings if requested | |
| 51 | + | if input.ShowDiagnostics { | |
| 52 | + | for _, warning := range result.Warnings { | |
| 53 | + | log.Warn().Str("component", "media").Msg(warning) | |
| 54 | + | } | |
| 55 | + | if result.AV1HWAvailable { | |
| 56 | + | log.Info().Str("component", "media").Msg("AV1 hardware decoding available (preferred codec)") | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | return &CheckMediaOutput{ | |
| 61 | + | GStreamerAvailable: result.GStreamerAvailable, | |
| 62 | + | HWAccelAvailable: result.HWAccelAvailable, | |
| 63 | + | AV1HWAvailable: result.AV1HWAvailable, | |
| 64 | + | Warnings: result.Warnings, | |
| 65 | + | }, nil | |
| 66 | + | } | |
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | // Package usecase contains application business logic. | |||
| 2 | + | package usecase | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // CheckMediaUseCase validates media playback requirements at startup. | |||
| 13 | + | type CheckMediaUseCase struct { | |||
| 14 | + | diagnostics port.MediaDiagnostics | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // NewCheckMediaUseCase creates a new CheckMediaUseCase. | |||
| 18 | + | func NewCheckMediaUseCase(diagnostics port.MediaDiagnostics) *CheckMediaUseCase { | |||
| 19 | + | return &CheckMediaUseCase{ | |||
| 20 | + | diagnostics: diagnostics, | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // CheckMediaInput contains options for the media check. | |||
| 25 | + | type CheckMediaInput struct { | |||
| 26 | + | ShowDiagnostics bool // Log diagnostics warnings | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // CheckMediaOutput contains the result of the media check. | |||
| 30 | + | type CheckMediaOutput struct { | |||
| 31 | + | GStreamerAvailable bool | |||
| 32 | + | HWAccelAvailable bool | |||
| 33 | + | AV1HWAvailable bool | |||
| 34 | + | Warnings []string | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Execute checks media playback requirements. | |||
| 38 | + | // Returns error if GStreamer is not installed (fatal). | |||
| 39 | + | // Returns warnings for missing hardware acceleration (non-fatal). | |||
| 40 | + | func (uc *CheckMediaUseCase) Execute(ctx context.Context, input CheckMediaInput) (*CheckMediaOutput, error) { | |||
| 41 | + | log := logging.FromContext(ctx) | |||
| 42 | + | ||||
| 43 | + | result := uc.diagnostics.RunDiagnostics(ctx) | |||
| 44 | + | ||||
| 45 | + | // GStreamer is required - fail early if not installed | |||
| 46 | + | if !result.GStreamerAvailable { | |||
| 47 | + | return nil, fmt.Errorf("GStreamer not installed - video playback requires GStreamer. Install: gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Log warnings if requested | |||
| 51 | + | if input.ShowDiagnostics { | |||
| 52 | + | for _, warning := range result.Warnings { | |||
| 53 | + | log.Warn().Str("component", "media").Msg(warning) | |||
| 54 | + | } | |||
| 55 | + | if result.AV1HWAvailable { | |||
| 56 | + | log.Info().Str("component", "media").Msg("AV1 hardware decoding available (preferred codec)") | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | return &CheckMediaOutput{ | |||
| 61 | + | GStreamerAvailable: result.GStreamerAvailable, | |||
| 62 | + | HWAccelAvailable: result.HWAccelAvailable, | |||
| 63 | + | AV1HWAvailable: result.AV1HWAvailable, | |||
| 64 | + | Warnings: result.Warnings, | |||
| 65 | + | }, nil | |||
| 66 | + | } | |||
A internal/infrastructure/media/diagnostics.go +249 -0
| @@ -0,0 +1,249 @@ | |||
| 1 | + | // Package media provides video playback diagnostics and hardware acceleration detection. | |
| 2 | + | package media | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "os" | |
| 7 | + | "os/exec" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/logging" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Adapter implements port.MediaDiagnostics using system tools. | |
| 15 | + | type Adapter struct{} | |
| 16 | + | ||
| 17 | + | // New creates a new media diagnostics adapter. | |
| 18 | + | func New() port.MediaDiagnostics { | |
| 19 | + | return &Adapter{} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // RunDiagnostics checks GStreamer plugins and VA-API availability. | |
| 23 | + | func (a *Adapter) RunDiagnostics(ctx context.Context) *port.MediaDiagnosticsResult { | |
| 24 | + | log := logging.FromContext(ctx) | |
| 25 | + | result := &port.MediaDiagnosticsResult{} | |
| 26 | + | ||
| 27 | + | // Check GStreamer plugins via gst-inspect-1.0 | |
| 28 | + | a.checkGStreamerPlugins(ctx, result) | |
| 29 | + | ||
| 30 | + | // Check VA-API via vainfo | |
| 31 | + | a.checkVAAPI(ctx, result) | |
| 32 | + | ||
| 33 | + | // Determine overall hardware accel availability | |
| 34 | + | result.HWAccelAvailable = result.HasVAPlugin || result.HasVAAPIPlugin || result.HasNVCodecPlugin | |
| 35 | + | result.AV1HWAvailable = len(result.AV1Decoders) > 0 | |
| 36 | + | ||
| 37 | + | // Generate user-friendly warnings | |
| 38 | + | a.generateWarnings(result) | |
| 39 | + | ||
| 40 | + | log.Info(). | |
| 41 | + | Bool("hw_accel", result.HWAccelAvailable). | |
| 42 | + | Bool("av1_hw", result.AV1HWAvailable). | |
| 43 | + | Bool("va_plugin", result.HasVAPlugin). | |
| 44 | + | Bool("vaapi_plugin", result.HasVAAPIPlugin). | |
| 45 | + | Bool("nvcodec_plugin", result.HasNVCodecPlugin). | |
| 46 | + | Str("vaapi_driver", result.VAAPIDriver). | |
| 47 | + | Msg("media diagnostics complete") | |
| 48 | + | ||
| 49 | + | return result | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // checkGStreamerPlugins detects available GStreamer decoder plugins. | |
| 53 | + | func (a *Adapter) checkGStreamerPlugins(ctx context.Context, r *port.MediaDiagnosticsResult) { | |
| 54 | + | log := logging.FromContext(ctx) | |
| 55 | + | ||
| 56 | + | // Check if gst-inspect-1.0 is available | |
| 57 | + | gstInspect, err := exec.LookPath("gst-inspect-1.0") | |
| 58 | + | if err != nil { | |
| 59 | + | log.Warn().Msg("gst-inspect-1.0 not found - GStreamer not installed, video playback will fail") | |
| 60 | + | r.GStreamerAvailable = false | |
| 61 | + | return | |
| 62 | + | } | |
| 63 | + | r.GStreamerAvailable = true | |
| 64 | + | ||
| 65 | + | // Check 'va' plugin (modern stateless decoders in gst-plugins-bad) | |
| 66 | + | if out, err := exec.CommandContext(ctx, gstInspect, "va").Output(); err == nil { | |
| 67 | + | r.HasVAPlugin = true | |
| 68 | + | a.parseVADecoders(string(out), r) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // Check 'vaapi' plugin (legacy gstreamer-vaapi) | |
| 72 | + | if out, err := exec.CommandContext(ctx, gstInspect, "vaapi").Output(); err == nil { | |
| 73 | + | r.HasVAAPIPlugin = true | |
| 74 | + | a.parseVAAPIDecoders(string(out), r) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Check 'nvcodec' plugin (NVIDIA) | |
| 78 | + | if out, err := exec.CommandContext(ctx, gstInspect, "nvcodec").Output(); err == nil { | |
| 79 | + | r.HasNVCodecPlugin = true | |
| 80 | + | a.parseNVCodecDecoders(string(out), r) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | log.Debug(). | |
| 84 | + | Bool("va", r.HasVAPlugin). | |
| 85 | + | Bool("vaapi", r.HasVAAPIPlugin). | |
| 86 | + | Bool("nvcodec", r.HasNVCodecPlugin). | |
| 87 | + | Msg("gstreamer plugins checked") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // parseVADecoders extracts decoder names from gst-inspect-1.0 va output. | |
| 91 | + | func (a *Adapter) parseVADecoders(output string, r *port.MediaDiagnosticsResult) { | |
| 92 | + | lines := strings.Split(output, "\n") | |
| 93 | + | for _, line := range lines { | |
| 94 | + | line = strings.TrimSpace(line) | |
| 95 | + | // Format: " vah264dec: VA-API H.264 Decoder" | |
| 96 | + | if strings.HasPrefix(line, "vaav1dec") { | |
| 97 | + | r.AV1Decoders = append(r.AV1Decoders, "vaav1dec") | |
| 98 | + | } | |
| 99 | + | if strings.HasPrefix(line, "vah264dec") { | |
| 100 | + | r.H264Decoders = append(r.H264Decoders, "vah264dec") | |
| 101 | + | } | |
| 102 | + | if strings.HasPrefix(line, "vah265dec") { | |
| 103 | + | r.H265Decoders = append(r.H265Decoders, "vah265dec") | |
| 104 | + | } | |
| 105 | + | if strings.HasPrefix(line, "vavp9dec") { | |
| 106 | + | r.VP9Decoders = append(r.VP9Decoders, "vavp9dec") | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // parseVAAPIDecoders extracts decoder names from gst-inspect-1.0 vaapi output. | |
| 112 | + | func (a *Adapter) parseVAAPIDecoders(output string, r *port.MediaDiagnosticsResult) { | |
| 113 | + | lines := strings.Split(output, "\n") | |
| 114 | + | for _, line := range lines { | |
| 115 | + | line = strings.TrimSpace(line) | |
| 116 | + | // Format: " vaapih264dec: VA-API H264 decoder" | |
| 117 | + | if strings.HasPrefix(line, "vaapiav1dec") { | |
| 118 | + | r.AV1Decoders = append(r.AV1Decoders, "vaapiav1dec") | |
| 119 | + | } | |
| 120 | + | if strings.HasPrefix(line, "vaapih264dec") { | |
| 121 | + | r.H264Decoders = append(r.H264Decoders, "vaapih264dec") | |
| 122 | + | } | |
| 123 | + | if strings.HasPrefix(line, "vaapih265dec") { | |
| 124 | + | r.H265Decoders = append(r.H265Decoders, "vaapih265dec") | |
| 125 | + | } | |
| 126 | + | if strings.HasPrefix(line, "vaapivp9dec") { | |
| 127 | + | r.VP9Decoders = append(r.VP9Decoders, "vaapivp9dec") | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // parseNVCodecDecoders extracts decoder names from gst-inspect-1.0 nvcodec output. | |
| 133 | + | func (a *Adapter) parseNVCodecDecoders(output string, r *port.MediaDiagnosticsResult) { | |
| 134 | + | lines := strings.Split(output, "\n") | |
| 135 | + | for _, line := range lines { | |
| 136 | + | line = strings.TrimSpace(line) | |
| 137 | + | // Format: " nvav1dec: NVDEC AV1 Decoder" | |
| 138 | + | if strings.HasPrefix(line, "nvav1dec") { | |
| 139 | + | r.AV1Decoders = append(r.AV1Decoders, "nvav1dec") | |
| 140 | + | } | |
| 141 | + | if strings.HasPrefix(line, "nvh264dec") { | |
| 142 | + | r.H264Decoders = append(r.H264Decoders, "nvh264dec") | |
| 143 | + | } | |
| 144 | + | if strings.HasPrefix(line, "nvh265dec") { | |
| 145 | + | r.H265Decoders = append(r.H265Decoders, "nvh265dec") | |
| 146 | + | } | |
| 147 | + | if strings.HasPrefix(line, "nvvp9dec") { | |
| 148 | + | r.VP9Decoders = append(r.VP9Decoders, "nvvp9dec") | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // checkVAAPI detects VA-API driver and version. | |
| 154 | + | func (a *Adapter) checkVAAPI(ctx context.Context, r *port.MediaDiagnosticsResult) { | |
| 155 | + | log := logging.FromContext(ctx) | |
| 156 | + | ||
| 157 | + | // Check LIBVA_DRIVER_NAME environment first | |
| 158 | + | if driver := os.Getenv("LIBVA_DRIVER_NAME"); driver != "" { | |
| 159 | + | r.VAAPIDriver = driver | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // Run vainfo for detailed information | |
| 163 | + | vainfo, err := exec.LookPath("vainfo") | |
| 164 | + | if err != nil { | |
| 165 | + | log.Debug().Msg("vainfo not found, skipping VA-API driver detection") | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | out, err := exec.CommandContext(ctx, vainfo).CombinedOutput() | |
| 170 | + | if err != nil { | |
| 171 | + | log.Debug().Err(err).Msg("vainfo failed") | |
| 172 | + | return | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | output := string(out) | |
| 176 | + | r.VAAPIAvailable = true | |
| 177 | + | ||
| 178 | + | // Parse driver name and version from vainfo output | |
| 179 | + | for _, line := range strings.Split(output, "\n") { | |
| 180 | + | line = strings.TrimSpace(line) | |
| 181 | + | ||
| 182 | + | // Extract driver from "vainfo: Driver version: ..." | |
| 183 | + | if strings.Contains(line, "Driver version:") { | |
| 184 | + | lowerLine := strings.ToLower(line) | |
| 185 | + | if strings.Contains(lowerLine, "radeonsi") || strings.Contains(lowerLine, "radeon") { | |
| 186 | + | r.VAAPIDriver = "radeonsi" | |
| 187 | + | } else if strings.Contains(lowerLine, "i965") { | |
| 188 | + | r.VAAPIDriver = "i965" | |
| 189 | + | } else if strings.Contains(lowerLine, "ihd") || strings.Contains(lowerLine, "intel") { | |
| 190 | + | r.VAAPIDriver = "iHD" | |
| 191 | + | } else if strings.Contains(lowerLine, "nvidia") { | |
| 192 | + | r.VAAPIDriver = "nvidia" | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // Extract VA-API version from "vainfo: VA-API version: X.XX" | |
| 197 | + | if strings.Contains(line, "VA-API version:") { | |
| 198 | + | parts := strings.Split(line, ":") | |
| 199 | + | if len(parts) >= 2 { | |
| 200 | + | r.VAAPIVersion = strings.TrimSpace(parts[len(parts)-1]) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | log.Debug(). | |
| 206 | + | Bool("available", r.VAAPIAvailable). | |
| 207 | + | Str("driver", r.VAAPIDriver). | |
| 208 | + | Str("version", r.VAAPIVersion). | |
| 209 | + | Msg("vaapi status") | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // generateWarnings creates user-friendly warning messages. | |
| 213 | + | func (a *Adapter) generateWarnings(r *port.MediaDiagnosticsResult) { | |
| 214 | + | // Critical: GStreamer not installed | |
| 215 | + | if !r.GStreamerAvailable { | |
| 216 | + | r.Warnings = append(r.Warnings, | |
| 217 | + | "CRITICAL: GStreamer not installed. Video playback will not work!") | |
| 218 | + | r.Warnings = append(r.Warnings, | |
| 219 | + | "Install: gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav") | |
| 220 | + | return // No point checking other things | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | if !r.HWAccelAvailable { | |
| 224 | + | r.Warnings = append(r.Warnings, | |
| 225 | + | "No hardware video acceleration detected. Video will use software decoding (higher CPU).") | |
| 226 | + | r.Warnings = append(r.Warnings, | |
| 227 | + | "Install gst-plugins-bad for VA decoders, plus VA-API driver for your GPU:") | |
| 228 | + | r.Warnings = append(r.Warnings, | |
| 229 | + | " AMD: libva-mesa-driver | Intel: intel-media-driver | NVIDIA: nvidia-vaapi-driver") | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // AV1 is the preferred codec - warn if not available | |
| 233 | + | if !r.AV1HWAvailable && r.HWAccelAvailable { | |
| 234 | + | r.Warnings = append(r.Warnings, | |
| 235 | + | "AV1 hardware decoder not found. AV1 streams will use software decoding.") | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // Check for essential codecs for Twitch | |
| 239 | + | if len(r.H264Decoders) == 0 && len(r.VP9Decoders) == 0 { | |
| 240 | + | r.Warnings = append(r.Warnings, | |
| 241 | + | "No H.264 or VP9 hardware decoders found. Twitch/YouTube may have degraded performance.") | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // Prefer modern VA plugin over legacy VAAPI | |
| 245 | + | if r.HasVAAPIPlugin && !r.HasVAPlugin { | |
| 246 | + | r.Warnings = append(r.Warnings, | |
| 247 | + | "Using legacy gstreamer-vaapi. Consider gst-plugins-bad for newer VA stateless decoders.") | |
| 248 | + | } | |
| 249 | + | } | |
| @@ -0,0 +1,249 @@ | |||||
| 1 | + | // Package media provides video playback diagnostics and hardware acceleration detection. | |||
| 2 | + | package media | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "os" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 11 | + | "github.com/bnema/dumber/internal/logging" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Adapter implements port.MediaDiagnostics using system tools. | |||
| 15 | + | type Adapter struct{} | |||
| 16 | + | ||||
| 17 | + | // New creates a new media diagnostics adapter. | |||
| 18 | + | func New() port.MediaDiagnostics { | |||
| 19 | + | return &Adapter{} | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // RunDiagnostics checks GStreamer plugins and VA-API availability. | |||
| 23 | + | func (a *Adapter) RunDiagnostics(ctx context.Context) *port.MediaDiagnosticsResult { | |||
| 24 | + | log := logging.FromContext(ctx) | |||
| 25 | + | result := &port.MediaDiagnosticsResult{} | |||
| 26 | + | ||||
| 27 | + | // Check GStreamer plugins via gst-inspect-1.0 | |||
| 28 | + | a.checkGStreamerPlugins(ctx, result) | |||
| 29 | + | ||||
| 30 | + | // Check VA-API via vainfo | |||
| 31 | + | a.checkVAAPI(ctx, result) | |||
| 32 | + | ||||
| 33 | + | // Determine overall hardware accel availability | |||
| 34 | + | result.HWAccelAvailable = result.HasVAPlugin || result.HasVAAPIPlugin || result.HasNVCodecPlugin | |||
| 35 | + | result.AV1HWAvailable = len(result.AV1Decoders) > 0 | |||
| 36 | + | ||||
| 37 | + | // Generate user-friendly warnings | |||
| 38 | + | a.generateWarnings(result) | |||
| 39 | + | ||||
| 40 | + | log.Info(). | |||
| 41 | + | Bool("hw_accel", result.HWAccelAvailable). | |||
| 42 | + | Bool("av1_hw", result.AV1HWAvailable). | |||
| 43 | + | Bool("va_plugin", result.HasVAPlugin). | |||
| 44 | + | Bool("vaapi_plugin", result.HasVAAPIPlugin). | |||
| 45 | + | Bool("nvcodec_plugin", result.HasNVCodecPlugin). | |||
| 46 | + | Str("vaapi_driver", result.VAAPIDriver). | |||
| 47 | + | Msg("media diagnostics complete") | |||
| 48 | + | ||||
| 49 | + | return result | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // checkGStreamerPlugins detects available GStreamer decoder plugins. | |||
| 53 | + | func (a *Adapter) checkGStreamerPlugins(ctx context.Context, r *port.MediaDiagnosticsResult) { | |||
| 54 | + | log := logging.FromContext(ctx) | |||
| 55 | + | ||||
| 56 | + | // Check if gst-inspect-1.0 is available | |||
| 57 | + | gstInspect, err := exec.LookPath("gst-inspect-1.0") | |||
| 58 | + | if err != nil { | |||
| 59 | + | log.Warn().Msg("gst-inspect-1.0 not found - GStreamer not installed, video playback will fail") | |||
| 60 | + | r.GStreamerAvailable = false | |||
| 61 | + | return | |||
| 62 | + | } | |||
| 63 | + | r.GStreamerAvailable = true | |||
| 64 | + | ||||
| 65 | + | // Check 'va' plugin (modern stateless decoders in gst-plugins-bad) | |||
| 66 | + | if out, err := exec.CommandContext(ctx, gstInspect, "va").Output(); err == nil { | |||
| 67 | + | r.HasVAPlugin = true | |||
| 68 | + | a.parseVADecoders(string(out), r) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // Check 'vaapi' plugin (legacy gstreamer-vaapi) | |||
| 72 | + | if out, err := exec.CommandContext(ctx, gstInspect, "vaapi").Output(); err == nil { | |||
| 73 | + | r.HasVAAPIPlugin = true | |||
| 74 | + | a.parseVAAPIDecoders(string(out), r) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // Check 'nvcodec' plugin (NVIDIA) | |||
| 78 | + | if out, err := exec.CommandContext(ctx, gstInspect, "nvcodec").Output(); err == nil { | |||
| 79 | + | r.HasNVCodecPlugin = true | |||
| 80 | + | a.parseNVCodecDecoders(string(out), r) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | log.Debug(). | |||
| 84 | + | Bool("va", r.HasVAPlugin). | |||
| 85 | + | Bool("vaapi", r.HasVAAPIPlugin). | |||
| 86 | + | Bool("nvcodec", r.HasNVCodecPlugin). | |||
| 87 | + | Msg("gstreamer plugins checked") | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // parseVADecoders extracts decoder names from gst-inspect-1.0 va output. | |||
| 91 | + | func (a *Adapter) parseVADecoders(output string, r *port.MediaDiagnosticsResult) { | |||
| 92 | + | lines := strings.Split(output, "\n") | |||
| 93 | + | for _, line := range lines { | |||
| 94 | + | line = strings.TrimSpace(line) | |||
| 95 | + | // Format: " vah264dec: VA-API H.264 Decoder" | |||
| 96 | + | if strings.HasPrefix(line, "vaav1dec") { | |||
| 97 | + | r.AV1Decoders = append(r.AV1Decoders, "vaav1dec") | |||
| 98 | + | } | |||
| 99 | + | if strings.HasPrefix(line, "vah264dec") { | |||
| 100 | + | r.H264Decoders = append(r.H264Decoders, "vah264dec") | |||
| 101 | + | } | |||
| 102 | + | if strings.HasPrefix(line, "vah265dec") { | |||
| 103 | + | r.H265Decoders = append(r.H265Decoders, "vah265dec") | |||
| 104 | + | } | |||
| 105 | + | if strings.HasPrefix(line, "vavp9dec") { | |||
| 106 | + | r.VP9Decoders = append(r.VP9Decoders, "vavp9dec") | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // parseVAAPIDecoders extracts decoder names from gst-inspect-1.0 vaapi output. | |||
| 112 | + | func (a *Adapter) parseVAAPIDecoders(output string, r *port.MediaDiagnosticsResult) { | |||
| 113 | + | lines := strings.Split(output, "\n") | |||
| 114 | + | for _, line := range lines { | |||
| 115 | + | line = strings.TrimSpace(line) | |||
| 116 | + | // Format: " vaapih264dec: VA-API H264 decoder" | |||
| 117 | + | if strings.HasPrefix(line, "vaapiav1dec") { | |||
| 118 | + | r.AV1Decoders = append(r.AV1Decoders, "vaapiav1dec") | |||
| 119 | + | } | |||
| 120 | + | if strings.HasPrefix(line, "vaapih264dec") { | |||
| 121 | + | r.H264Decoders = append(r.H264Decoders, "vaapih264dec") | |||
| 122 | + | } | |||
| 123 | + | if strings.HasPrefix(line, "vaapih265dec") { | |||
| 124 | + | r.H265Decoders = append(r.H265Decoders, "vaapih265dec") | |||
| 125 | + | } | |||
| 126 | + | if strings.HasPrefix(line, "vaapivp9dec") { | |||
| 127 | + | r.VP9Decoders = append(r.VP9Decoders, "vaapivp9dec") | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // parseNVCodecDecoders extracts decoder names from gst-inspect-1.0 nvcodec output. | |||
| 133 | + | func (a *Adapter) parseNVCodecDecoders(output string, r *port.MediaDiagnosticsResult) { | |||
| 134 | + | lines := strings.Split(output, "\n") | |||
| 135 | + | for _, line := range lines { | |||
| 136 | + | line = strings.TrimSpace(line) | |||
| 137 | + | // Format: " nvav1dec: NVDEC AV1 Decoder" | |||
| 138 | + | if strings.HasPrefix(line, "nvav1dec") { | |||
| 139 | + | r.AV1Decoders = append(r.AV1Decoders, "nvav1dec") | |||
| 140 | + | } | |||
| 141 | + | if strings.HasPrefix(line, "nvh264dec") { | |||
| 142 | + | r.H264Decoders = append(r.H264Decoders, "nvh264dec") | |||
| 143 | + | } | |||
| 144 | + | if strings.HasPrefix(line, "nvh265dec") { | |||
| 145 | + | r.H265Decoders = append(r.H265Decoders, "nvh265dec") | |||
| 146 | + | } | |||
| 147 | + | if strings.HasPrefix(line, "nvvp9dec") { | |||
| 148 | + | r.VP9Decoders = append(r.VP9Decoders, "nvvp9dec") | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // checkVAAPI detects VA-API driver and version. | |||
| 154 | + | func (a *Adapter) checkVAAPI(ctx context.Context, r *port.MediaDiagnosticsResult) { | |||
| 155 | + | log := logging.FromContext(ctx) | |||
| 156 | + | ||||
| 157 | + | // Check LIBVA_DRIVER_NAME environment first | |||
| 158 | + | if driver := os.Getenv("LIBVA_DRIVER_NAME"); driver != "" { | |||
| 159 | + | r.VAAPIDriver = driver | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // Run vainfo for detailed information | |||
| 163 | + | vainfo, err := exec.LookPath("vainfo") | |||
| 164 | + | if err != nil { | |||
| 165 | + | log.Debug().Msg("vainfo not found, skipping VA-API driver detection") | |||
| 166 | + | return | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | out, err := exec.CommandContext(ctx, vainfo).CombinedOutput() | |||
| 170 | + | if err != nil { | |||
| 171 | + | log.Debug().Err(err).Msg("vainfo failed") | |||
| 172 | + | return | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | output := string(out) | |||
| 176 | + | r.VAAPIAvailable = true | |||
| 177 | + | ||||
| 178 | + | // Parse driver name and version from vainfo output | |||
| 179 | + | for _, line := range strings.Split(output, "\n") { | |||
| 180 | + | line = strings.TrimSpace(line) | |||
| 181 | + | ||||
| 182 | + | // Extract driver from "vainfo: Driver version: ..." | |||
| 183 | + | if strings.Contains(line, "Driver version:") { | |||
| 184 | + | lowerLine := strings.ToLower(line) | |||
| 185 | + | if strings.Contains(lowerLine, "radeonsi") || strings.Contains(lowerLine, "radeon") { | |||
| 186 | + | r.VAAPIDriver = "radeonsi" | |||
| 187 | + | } else if strings.Contains(lowerLine, "i965") { | |||
| 188 | + | r.VAAPIDriver = "i965" | |||
| 189 | + | } else if strings.Contains(lowerLine, "ihd") || strings.Contains(lowerLine, "intel") { | |||
| 190 | + | r.VAAPIDriver = "iHD" | |||
| 191 | + | } else if strings.Contains(lowerLine, "nvidia") { | |||
| 192 | + | r.VAAPIDriver = "nvidia" | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // Extract VA-API version from "vainfo: VA-API version: X.XX" | |||
| 197 | + | if strings.Contains(line, "VA-API version:") { | |||
| 198 | + | parts := strings.Split(line, ":") | |||
| 199 | + | if len(parts) >= 2 { | |||
| 200 | + | r.VAAPIVersion = strings.TrimSpace(parts[len(parts)-1]) | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | log.Debug(). | |||
| 206 | + | Bool("available", r.VAAPIAvailable). | |||
| 207 | + | Str("driver", r.VAAPIDriver). | |||
| 208 | + | Str("version", r.VAAPIVersion). | |||
| 209 | + | Msg("vaapi status") | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // generateWarnings creates user-friendly warning messages. | |||
| 213 | + | func (a *Adapter) generateWarnings(r *port.MediaDiagnosticsResult) { | |||
| 214 | + | // Critical: GStreamer not installed | |||
| 215 | + | if !r.GStreamerAvailable { | |||
| 216 | + | r.Warnings = append(r.Warnings, | |||
| 217 | + | "CRITICAL: GStreamer not installed. Video playback will not work!") | |||
| 218 | + | r.Warnings = append(r.Warnings, | |||
| 219 | + | "Install: gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav") | |||
| 220 | + | return // No point checking other things | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | if !r.HWAccelAvailable { | |||
| 224 | + | r.Warnings = append(r.Warnings, | |||
| 225 | + | "No hardware video acceleration detected. Video will use software decoding (higher CPU).") | |||
| 226 | + | r.Warnings = append(r.Warnings, | |||
| 227 | + | "Install gst-plugins-bad for VA decoders, plus VA-API driver for your GPU:") | |||
| 228 | + | r.Warnings = append(r.Warnings, | |||
| 229 | + | " AMD: libva-mesa-driver | Intel: intel-media-driver | NVIDIA: nvidia-vaapi-driver") | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | // AV1 is the preferred codec - warn if not available | |||
| 233 | + | if !r.AV1HWAvailable && r.HWAccelAvailable { | |||
| 234 | + | r.Warnings = append(r.Warnings, | |||
| 235 | + | "AV1 hardware decoder not found. AV1 streams will use software decoding.") | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | // Check for essential codecs for Twitch | |||
| 239 | + | if len(r.H264Decoders) == 0 && len(r.VP9Decoders) == 0 { | |||
| 240 | + | r.Warnings = append(r.Warnings, | |||
| 241 | + | "No H.264 or VP9 hardware decoders found. Twitch/YouTube may have degraded performance.") | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // Prefer modern VA plugin over legacy VAAPI | |||
| 245 | + | if r.HasVAAPIPlugin && !r.HasVAPlugin { | |||
| 246 | + | r.Warnings = append(r.Warnings, | |||
| 247 | + | "Using legacy gstreamer-vaapi. Consider gst-plugins-bad for newer VA stateless decoders.") | |||
| 248 | + | } | |||
| 249 | + | } | |||