summaryrefslogtreecommitdiff
path: root/player-server/internal/service/playback_hints.go
blob: 5abbacc3bab05122a451bf39b21c65a597a82aa8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
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
}