Files
memby/server/internal/emby/device_profile.go
T

275 lines
8.4 KiB
Go
Raw Normal View History

2026-08-02 22:10:19 +12:00
// Playback profile generation is adapted from Wholphin's DeviceProfileUtils.kt,
// itself derived from Jellyfin Android TV.
//
// Wholphin: https://github.com/damontecres/Wholphin
// Jellyfin Android TV: https://github.com/jellyfin/jellyfin-androidtv
2026-08-10 12:23:23 +12:00
// Moonfin audio profile: https://github.com/Moonfin-Client/Moonfin-Core
2026-08-02 22:10:19 +12:00
//
// Modifications Copyright (C) 2026 Memby contributors
// SPDX-License-Identifier: GPL-2.0-only
package emby
import "strconv"
2026-08-10 12:23:23 +12:00
var alwaysDecodableAudioCodecs = []string{
"aac", "mp3", "flac", "opus", "vorbis", "pcm_s16le", "pcm_s24le",
}
2026-08-02 22:10:19 +12:00
// PlaybackCapabilities is the Android decoder evidence captured for one TV session.
// Zero values deliberately describe the legacy H.264-safe profile.
type PlaybackCapabilities struct {
2026-08-10 12:23:23 +12:00
H264Profiles []string
H264Level int
H264High10Level int
H264MaxWidth int
H264MaxHeight int
HEVC bool
HEVCMain bool
HEVCMain10 bool
HEVCMainLevel int
HEVCMain10Level int
HEVCMaxWidth int
HEVCMaxHeight int
HEVCHDR10 bool
HEVCHDR10Plus bool
HEVCDolbyVision bool
AudioProfileV1 bool
AudioPassthrough map[string]bool
AudioDecode map[string]bool
AudioMaxChannels int
2026-08-02 22:10:19 +12:00
}
func androidTVDeviceProfile(capabilities PlaybackCapabilities) map[string]any {
videoCodecs := directPlayVideoCodecs(capabilities.HEVC)
2026-08-10 12:23:23 +12:00
audioCodecs := directPlayAudioCodecs(capabilities)
transcodeAudio := transcodeAudioCodecs(capabilities)
2026-08-02 22:10:19 +12:00
return map[string]any{
"Name": "Memby Android TV", "SupportedMediaTypes": "Video",
"DirectPlayProfiles": []map[string]string{
{
"Container": "mkv,mp4,m4v,mov,ts,mpegts", "VideoCodec": videoCodecs,
2026-08-10 12:23:23 +12:00
"AudioCodec": audioCodecs, "Type": "Video",
2026-08-02 22:10:19 +12:00
},
},
"TranscodingProfiles": []map[string]string{
{
// AllowVideoStreamCopy lets Emby keep a supported H.264/HEVC track and
// convert only incompatible audio or subtitles into an HLS stream.
2026-08-10 12:23:23 +12:00
"Container": "ts", "VideoCodec": videoCodecs, "AudioCodec": transcodeAudio,
2026-08-02 22:10:19 +12:00
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
},
},
2026-08-10 12:23:23 +12:00
"CodecProfiles": append(videoCodecProfiles(capabilities), audioCodecProfile(capabilities)),
2026-08-02 22:10:19 +12:00
"SubtitleProfiles": androidTVSubtitleProfiles(),
}
}
2026-08-10 12:23:23 +12:00
func directPlayAudioCodecs(capabilities PlaybackCapabilities) string {
if !capabilities.AudioProfileV1 {
return "aac,mp3"
}
codecs := append([]string{}, alwaysDecodableAudioCodecs...)
for _, codec := range []string{"ac3", "eac3", "atmos", "dts", "dts_hd", "truehd"} {
if capabilities.AudioPassthrough[codec] || capabilities.AudioDecode[codec] {
switch codec {
case "dts":
codecs = appendUnique(codecs, "dts", "dca")
case "dts_hd":
codecs = appendUnique(codecs, "dts", "dca", "dtshd")
case "truehd":
codecs = appendUnique(codecs, "truehd", "mlp")
case "atmos":
codecs = appendUnique(codecs, "eac3")
default:
codecs = appendUnique(codecs, codec)
}
}
}
return joinComma(codecs)
}
func transcodeAudioCodecs(capabilities PlaybackCapabilities) string {
codecs := []string{}
if capabilities.AudioPassthrough["eac3"] || capabilities.AudioPassthrough["atmos"] {
codecs = append(codecs, "eac3")
}
if capabilities.AudioPassthrough["ac3"] {
codecs = append(codecs, "ac3")
}
return joinComma(appendUnique(codecs, "aac", "mp3"))
}
func audioCodecProfile(capabilities PlaybackCapabilities) map[string]any {
channels := capabilities.AudioMaxChannels
if channels < 2 {
channels = 2
}
return map[string]any{
"Type": "VideoAudio", "Codec": "",
"Conditions": []map[string]any{
profileCondition("LessThanEqual", "AudioChannels", strconv.Itoa(channels)),
},
}
}
func appendUnique(values []string, additions ...string) []string {
for _, addition := range additions {
found := false
for _, value := range values {
if value == addition {
found = true
break
}
}
if !found {
values = append(values, addition)
}
}
return values
}
func joinComma(values []string) string {
result := ""
for _, value := range values {
if result != "" {
result += ","
}
result += value
}
return result
}
2026-08-10 07:11:14 +12:00
// forceH264TranscodeProfile turns decoder recovery into an actual codec change. Merely
// removing DirectPlayProfiles is insufficient: Emby may otherwise stream-copy HEVC into
// the ordinary HLS transcoding profile and hand the failing decoder the same video again.
func forceH264TranscodeProfile(profile map[string]any) {
profile["DirectPlayProfiles"] = []map[string]string{}
profile["TranscodingProfiles"] = []map[string]string{
{
"Container": "ts", "VideoCodec": "h264", "AudioCodec": "aac",
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
},
}
profiles, _ := profile["CodecProfiles"].([]map[string]any)
h264Profiles := make([]map[string]any, 0, len(profiles))
for _, codecProfile := range profiles {
if codec, _ := codecProfile["Codec"].(string); codec == "h264" {
h264Profiles = append(h264Profiles, codecProfile)
}
}
profile["CodecProfiles"] = h264Profiles
}
2026-08-02 22:10:19 +12:00
func androidTVSubtitleProfiles() []map[string]string {
return []map[string]string{
{"Format": "srt", "Method": "External"},
{"Format": "subrip", "Method": "External"},
{"Format": "ass", "Method": "External"},
{"Format": "ssa", "Method": "External"},
{"Format": "vtt", "Method": "External"},
{"Format": "webvtt", "Method": "External"},
{"Format": "mov_text", "Method": "External"},
{"Format": "tx3g", "Method": "External"},
{"Format": "pgs", "Method": "Encode"},
{"Format": "pgssub", "Method": "Encode"},
{"Format": "sup", "Method": "Encode"},
{"Format": "vobsub", "Method": "Encode"},
{"Format": "dvdsub", "Method": "Encode"},
}
}
func videoCodecProfiles(capabilities PlaybackCapabilities) []map[string]any {
profiles := []map[string]any{}
if len(capabilities.H264Profiles) > 0 {
profiles = append(profiles, codecProfile("h264",
[]map[string]any{profileCondition("EqualsAny", "VideoProfile", joinProfiles(capabilities.H264Profiles))}, nil))
}
profiles = appendLevelProfile(
profiles, "h264", capabilities.H264Level, "baseline|constrained baseline|main|high",
)
profiles = appendLevelProfile(profiles, "h264", capabilities.H264High10Level, "high 10")
profiles = appendResolutionProfile(
profiles, "h264", capabilities.H264MaxWidth, capabilities.H264MaxHeight,
)
if !capabilities.HEVC {
return profiles
}
hevcProfiles := []string{}
if capabilities.HEVCMain {
hevcProfiles = append(hevcProfiles, "main")
}
if capabilities.HEVCMain10 {
hevcProfiles = append(hevcProfiles, "main 10")
}
if len(hevcProfiles) > 0 {
profiles = append(profiles, codecProfile("hevc",
[]map[string]any{profileCondition("EqualsAny", "VideoProfile", joinProfiles(hevcProfiles))}, nil))
}
profiles = appendLevelProfile(profiles, "hevc", capabilities.HEVCMainLevel, "main")
profiles = appendLevelProfile(profiles, "hevc", capabilities.HEVCMain10Level, "main 10")
profiles = appendResolutionProfile(
profiles, "hevc", capabilities.HEVCMaxWidth, capabilities.HEVCMaxHeight,
)
return profiles
}
func appendLevelProfile(profiles []map[string]any, codec string, level int, applyTo string) []map[string]any {
if level <= 0 {
return profiles
}
condition := "Equals"
if containsPipe(applyTo) {
condition = "EqualsAny"
}
return append(profiles, codecProfile(codec,
[]map[string]any{profileCondition("LessThanEqual", "VideoLevel", strconv.Itoa(level))},
[]map[string]any{profileCondition(condition, "VideoProfile", applyTo)}))
}
func appendResolutionProfile(profiles []map[string]any, codec string, width, height int) []map[string]any {
if width <= 0 || height <= 0 {
return profiles
}
return append(profiles, codecProfile(codec, []map[string]any{
profileCondition("LessThanEqual", "Width", strconv.Itoa(width)),
profileCondition("LessThanEqual", "Height", strconv.Itoa(height)),
}, nil))
}
func codecProfile(codec string, conditions, applyConditions []map[string]any) map[string]any {
profile := map[string]any{
"Type": "Video", "Codec": codec, "Conditions": conditions,
}
if len(applyConditions) > 0 {
profile["ApplyConditions"] = applyConditions
}
return profile
}
func profileCondition(condition, property, value string) map[string]any {
return map[string]any{
"Condition": condition, "Property": property, "Value": value, "IsRequired": false,
}
}
func joinProfiles(values []string) string {
result := ""
for _, value := range values {
if result != "" {
result += "|"
}
result += value
}
return result
}
func containsPipe(value string) bool {
for _, char := range value {
if char == '|' {
return true
}
}
return false
}