From ca0200985504b480f9a664e929f64dec53a6e36b Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 18 May 2026 13:47:49 +0300 Subject: Add playback hints endpoint for client codec/container decisions Co-Authored-By: Claude Sonnet 4.6 --- player-server/internal/service/playback_hints.go | 155 +++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 player-server/internal/service/playback_hints.go (limited to 'player-server/internal/service/playback_hints.go') diff --git a/player-server/internal/service/playback_hints.go b/player-server/internal/service/playback_hints.go new file mode 100644 index 0000000..5abbacc --- /dev/null +++ b/player-server/internal/service/playback_hints.go @@ -0,0 +1,155 @@ +package service + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "codeberg.org/snonux/player/internal/model" +) + +// PlaybackHint contains codec/container metadata needed by a client to decide +// whether to play natively or request a transcoded variant. No actual +// transcoding takes place — the hint is derived purely from existing DB fields. +type PlaybackHint struct { + StreamURL string `json:"stream_url"` + Container string `json:"container"` + VideoCodec string `json:"video_codec"` + AudioCodec string `json:"audio_codec"` + DurationSeconds float64 `json:"duration_seconds"` + FileSizeBytes int64 `json:"file_size_bytes"` + Width int `json:"width"` + Height int `json:"height"` + Bitrate int `json:"bitrate"` + NeedsTranscode bool `json:"needs_transcode"` +} + +// PlaybackHintsService returns playback hints for a media item. +type PlaybackHintsService interface { + // GetPlaybackHint returns playback hints for a media item visible to the user. + GetPlaybackHint(ctx context.Context, mediaID, userID int64) (*PlaybackHint, error) +} + +// Compile-time check: playbackHintsService implements PlaybackHintsService. +var _ PlaybackHintsService = (*playbackHintsService)(nil) + +// playbackHintsService is the concrete implementation of PlaybackHintsService. +type playbackHintsService struct { + helper *accessHelper +} + +// NewPlaybackHintsService creates a PlaybackHintsService backed by an accessHelper. +func NewPlaybackHintsService(helper *accessHelper) *playbackHintsService { + return &playbackHintsService{helper: helper} +} + +// GetPlaybackHint fetches a media item, verifies access, and assembles hint fields. +// It performs no I/O beyond the DB lookup inside verifyAccess. +func (s *playbackHintsService) GetPlaybackHint(ctx context.Context, mediaID, userID int64) (*PlaybackHint, error) { + media, err := s.helper.verifyAccess(ctx, mediaID, userID) + if err != nil { + return nil, fmt.Errorf("verify access: %w", err) + } + + return buildPlaybackHint(media), nil +} + +// buildPlaybackHint assembles a PlaybackHint from Media fields without any I/O. +// It splits the stored codec string into separate video/audio components and +// determines whether the file is likely to require transcoding. +func buildPlaybackHint(media *model.Media) *PlaybackHint { + streamURL := fmt.Sprintf("/api/v1/media/%d/stream", media.ID) + container := containerFromPath(media.FileName) + videoCodec, audioCodec := splitCodecs(media.Codec) + + return &PlaybackHint{ + StreamURL: streamURL, + Container: container, + VideoCodec: videoCodec, + AudioCodec: audioCodec, + DurationSeconds: media.Duration, + FileSizeBytes: media.FileSizeBytes, + Width: media.Width, + Height: media.Height, + Bitrate: media.Bitrate, + NeedsTranscode: needsTranscode(container, videoCodec, audioCodec), + } +} + +// containerFromPath extracts the lowercase file extension (without dot) from a filename. +func containerFromPath(filename string) string { + ext := strings.TrimPrefix(filepath.Ext(filename), ".") + return strings.ToLower(ext) +} + +// splitCodecs splits a codec string of the form "video/audio" or "codec" into +// separate video and audio components, normalised to lowercase. When only one +// component is present, it is treated as the video codec and audio is left empty. +func splitCodecs(codec string) (videoCodec, audioCodec string) { + parts := strings.SplitN(codec, "/", 2) + if len(parts) == 2 { + return strings.ToLower(strings.TrimSpace(parts[0])), strings.ToLower(strings.TrimSpace(parts[1])) + } + return strings.ToLower(strings.TrimSpace(codec)), "" +} + +// nativeContainers lists containers that web browsers and common native players +// can play without transcoding. +var nativeContainers = map[string]bool{ + "mp4": true, + "webm": true, + "ogg": true, + "mp3": true, + "m4a": true, + "wav": true, + "aac": true, + "opus": true, +} + +// nativeVideoCodecs lists video codecs that can be played natively by most clients. +// Codecs absent from this map (e.g. wmv, mpeg2, xvid) trigger needsTranscode=true. +var nativeVideoCodecs = map[string]bool{ + "h264": true, + "avc": true, // synonym used by some probers + "avc1": true, + "vp8": true, + "vp9": true, + "av1": true, + "hevc": true, // supported natively on Apple platforms + "h265": true, // synonym for hevc + "theora": true, +} + +// nativeAudioCodecs lists audio codecs that can be played natively by most clients. +// Codecs absent from this map (e.g. flac, ac3, dts) trigger needsTranscode=true. +// Empty string (no audio track) is handled by the if-guard in needsTranscode. +var nativeAudioCodecs = map[string]bool{ + "aac": true, + "mp3": true, + "opus": true, + "vorbis": true, +} + +// needsTranscode returns true when the container, video codec, or audio codec +// is unlikely to play natively without transcoding. +// Containers like .mkv and codecs like flac, wmv, mpeg2 are flagged as true. +// Inputs are expected to already be lowercase (as returned by splitCodecs). +// This is a best-effort heuristic — no actual transcoding occurs here. +func needsTranscode(container, videoCodec, audioCodec string) bool { + if !nativeContainers[container] { + return true + } + + // Non-empty video codec that is not in the native list requires transcode. + if videoCodec != "" && !nativeVideoCodecs[videoCodec] { + return true + } + + // Audio codec: flac and other exotic codecs require transcode. + if audioCodec != "" && !nativeAudioCodecs[audioCodec] { + return true + } + + return false +} -- cgit v1.2.3