283 lines
7.4 KiB
Go
283 lines
7.4 KiB
Go
package jellycompat
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/catalog"
|
|
)
|
|
|
|
// DeviceProfile captures the subset of Jellyfin client capabilities the
|
|
// compat layer needs for direct-play, remux, and transcode negotiation.
|
|
type DeviceProfile struct {
|
|
Name string `json:"Name,omitempty"`
|
|
MaxStreamingBitrate int64 `json:"MaxStreamingBitrate,omitempty"`
|
|
DirectPlayProfiles []DirectPlayProfile `json:"DirectPlayProfiles,omitempty"`
|
|
TranscodingProfiles []TranscodingProfile `json:"TranscodingProfiles,omitempty"`
|
|
}
|
|
|
|
type DirectPlayProfile struct {
|
|
Type string `json:"Type,omitempty"`
|
|
Container string `json:"Container,omitempty"`
|
|
VideoCodec string `json:"VideoCodec,omitempty"`
|
|
AudioCodec string `json:"AudioCodec,omitempty"`
|
|
}
|
|
|
|
type TranscodingProfile struct {
|
|
Type string `json:"Type,omitempty"`
|
|
Container string `json:"Container,omitempty"`
|
|
Protocol string `json:"Protocol,omitempty"`
|
|
Context string `json:"Context,omitempty"`
|
|
VideoCodec string `json:"VideoCodec,omitempty"`
|
|
AudioCodec string `json:"AudioCodec,omitempty"`
|
|
}
|
|
|
|
// DeviceProfileStore keeps the last reported device profile per compat token.
|
|
type DeviceProfileStore struct {
|
|
mu sync.RWMutex
|
|
profiles map[string]storedDeviceProfile
|
|
ttl time.Duration
|
|
now func() time.Time
|
|
}
|
|
|
|
type storedDeviceProfile struct {
|
|
profile DeviceProfile
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// NewDeviceProfileStore creates a new in-memory device profile store.
|
|
func NewDeviceProfileStore(ttl time.Duration, now func() time.Time) *DeviceProfileStore {
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
if ttl <= 0 {
|
|
ttl = 6 * time.Hour
|
|
}
|
|
return &DeviceProfileStore{
|
|
profiles: make(map[string]storedDeviceProfile),
|
|
ttl: ttl,
|
|
now: now,
|
|
}
|
|
}
|
|
|
|
// Put stores a device profile for a compat session token.
|
|
func (s *DeviceProfileStore) Put(token string, profile DeviceProfile) {
|
|
if token == "" {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.profiles[token] = storedDeviceProfile{
|
|
profile: profile,
|
|
expiresAt: s.now().Add(s.ttl),
|
|
}
|
|
}
|
|
|
|
// Get returns the last device profile reported for a compat session token.
|
|
func (s *DeviceProfileStore) Get(token string) (DeviceProfile, bool) {
|
|
s.mu.RLock()
|
|
entry, ok := s.profiles[token]
|
|
s.mu.RUnlock()
|
|
if !ok {
|
|
return DeviceProfile{}, false
|
|
}
|
|
if !entry.expiresAt.After(s.now()) {
|
|
s.Delete(token)
|
|
return DeviceProfile{}, false
|
|
}
|
|
return entry.profile, true
|
|
}
|
|
|
|
// Delete removes a stored device profile.
|
|
func (s *DeviceProfileStore) Delete(token string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.profiles, token)
|
|
}
|
|
|
|
// HasData reports whether the profile contains usable capability information.
|
|
func (p DeviceProfile) HasData() bool {
|
|
return strings.TrimSpace(p.Name) != "" ||
|
|
p.MaxStreamingBitrate > 0 ||
|
|
len(p.DirectPlayProfiles) > 0 ||
|
|
len(p.TranscodingProfiles) > 0
|
|
}
|
|
|
|
// SupportsDirectPlay reports whether a version can be served as-is.
|
|
func (p DeviceProfile) SupportsDirectPlay(version catalog.FileVersion) bool {
|
|
if len(p.DirectPlayProfiles) == 0 {
|
|
return true
|
|
}
|
|
for _, profile := range p.DirectPlayProfiles {
|
|
if !matchesVideoType(profile.Type) {
|
|
continue
|
|
}
|
|
if matchesCSV(profile.Container, version.Container) &&
|
|
matchesCSV(profile.VideoCodec, version.CodecVideo) &&
|
|
matchesCSV(profile.AudioCodec, version.CodecAudio) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SupportsDirectStream reports whether a version can be remuxed without a full
|
|
// video transcode.
|
|
func (p DeviceProfile) SupportsDirectStream(version catalog.FileVersion) bool {
|
|
if len(p.DirectPlayProfiles) == 0 {
|
|
return true
|
|
}
|
|
for _, profile := range p.DirectPlayProfiles {
|
|
if !matchesVideoType(profile.Type) {
|
|
continue
|
|
}
|
|
if matchesCSV(profile.VideoCodec, version.CodecVideo) &&
|
|
matchesCSV(profile.AudioCodec, version.CodecAudio) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SupportsTranscoding reports whether the client advertises HLS transcoding.
|
|
func (p DeviceProfile) SupportsTranscoding(version catalog.FileVersion) bool {
|
|
if len(p.TranscodingProfiles) == 0 {
|
|
return true
|
|
}
|
|
for _, profile := range p.TranscodingProfiles {
|
|
if !matchesVideoType(profile.Type) {
|
|
continue
|
|
}
|
|
if protocol := strings.ToLower(strings.TrimSpace(profile.Protocol)); protocol != "" && protocol != "hls" {
|
|
continue
|
|
}
|
|
if !matchesCSV(profile.VideoCodec, "h264") {
|
|
continue
|
|
}
|
|
if !matchesCSV(profile.AudioCodec, "aac") {
|
|
continue
|
|
}
|
|
if profile.Container != "" && !matchesCSV(profile.Container, "ts") && !matchesCSV(profile.Container, "mpegts") {
|
|
continue
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// DefaultDeviceProfile is a permissive fallback when a client has not reported
|
|
// capabilities yet.
|
|
func DefaultDeviceProfile() DeviceProfile {
|
|
return DeviceProfile{
|
|
Name: "generic",
|
|
DirectPlayProfiles: []DirectPlayProfile{
|
|
{Type: "Video"},
|
|
},
|
|
TranscodingProfiles: []TranscodingProfile{
|
|
{Type: "Video", Protocol: "hls", Container: "ts", VideoCodec: "h264", AudioCodec: "aac"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// SupportsVideoCodecForDirectStream reports whether the client can accept the
|
|
// source video codec for a remux-style stream, regardless of whether the audio
|
|
// codec must be transcoded separately.
|
|
func (p DeviceProfile) SupportsVideoCodecForDirectStream(version catalog.FileVersion) bool {
|
|
if len(p.DirectPlayProfiles) == 0 {
|
|
return true
|
|
}
|
|
for _, profile := range p.DirectPlayProfiles {
|
|
if !matchesVideoType(profile.Type) {
|
|
continue
|
|
}
|
|
if matchesCSV(profile.VideoCodec, version.CodecVideo) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SupportsAudioCodecForDirectStream reports whether the client can accept the
|
|
// source audio codec in a remux-style stream, regardless of container.
|
|
func (p DeviceProfile) SupportsAudioCodecForDirectStream(version catalog.FileVersion) bool {
|
|
if len(p.DirectPlayProfiles) == 0 {
|
|
return true
|
|
}
|
|
for _, profile := range p.DirectPlayProfiles {
|
|
if !matchesVideoType(profile.Type) {
|
|
continue
|
|
}
|
|
if matchesCSV(profile.AudioCodec, version.CodecAudio) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// decodeDeviceProfile extracts a device profile from either a wrapped
|
|
// Jellyfin request body or a direct DeviceProfile payload.
|
|
func decodeDeviceProfile(r io.Reader) (DeviceProfile, error) {
|
|
body, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return DeviceProfile{}, err
|
|
}
|
|
if len(strings.TrimSpace(string(body))) == 0 {
|
|
return DeviceProfile{}, nil
|
|
}
|
|
|
|
var wrapper struct {
|
|
DeviceProfile json.RawMessage `json:"DeviceProfile"`
|
|
}
|
|
if err := json.Unmarshal(body, &wrapper); err == nil && len(wrapper.DeviceProfile) > 0 {
|
|
var profile DeviceProfile
|
|
if err := json.Unmarshal(wrapper.DeviceProfile, &profile); err != nil {
|
|
return DeviceProfile{}, err
|
|
}
|
|
return profile, nil
|
|
}
|
|
|
|
var profile DeviceProfile
|
|
if err := json.Unmarshal(body, &profile); err != nil {
|
|
return DeviceProfile{}, err
|
|
}
|
|
return profile, nil
|
|
}
|
|
|
|
func matchesVideoType(raw string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "", "*", "video":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func matchesCSV(raw, want string) bool {
|
|
if strings.TrimSpace(raw) == "" || raw == "*" {
|
|
return true
|
|
}
|
|
want = normalizeCompatToken(want)
|
|
for part := range strings.SplitSeq(raw, ",") {
|
|
if normalizeCompatToken(part) == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalizeCompatToken(raw string) string {
|
|
token := strings.ToLower(strings.TrimSpace(raw))
|
|
switch token {
|
|
case "ts":
|
|
return "mpegts"
|
|
case "x-matroska":
|
|
return "mkv"
|
|
default:
|
|
return token
|
|
}
|
|
}
|