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

283 lines
8.9 KiB
Go

// 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
// Moonfin audio profile: https://github.com/Moonfin-Client/Moonfin-Core
//
// Modifications Copyright (C) 2026 Memby contributors
// SPDX-License-Identifier: GPL-2.0-only
package emby
import "strconv"
// deviceProfileName is what Emby records against a playback session, and it appears in
// the dashboard's device and playback lists. It is the client identity on the wire, not
// the product name — it read as "Memby Android TV" there, which is exactly the name that
// has no business travelling to somebody else's logs — and it must match the literal the
// television sends on the direct path (DeviceProfile.embyAndroidTv), or one set playing
// both ways appears twice.
const deviceProfileName = "MbyATV"
var alwaysDecodableAudioCodecs = []string{
"aac", "mp3", "flac", "opus", "vorbis", "pcm_s16le", "pcm_s24le",
}
// PlaybackCapabilities is the Android decoder evidence captured for one TV session.
// Zero values deliberately describe the legacy H.264-safe profile.
type PlaybackCapabilities struct {
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
}
func androidTVDeviceProfile(capabilities PlaybackCapabilities) map[string]any {
videoCodecs := directPlayVideoCodecs(capabilities.HEVC)
audioCodecs := directPlayAudioCodecs(capabilities)
transcodeAudio := transcodeAudioCodecs(capabilities)
return map[string]any{
"Name": deviceProfileName, "SupportedMediaTypes": "Video",
"DirectPlayProfiles": []map[string]string{
{
"Container": "mkv,mp4,m4v,mov,ts,mpegts", "VideoCodec": videoCodecs,
"AudioCodec": audioCodecs, "Type": "Video",
},
},
"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.
"Container": "ts", "VideoCodec": videoCodecs, "AudioCodec": transcodeAudio,
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
},
},
"CodecProfiles": append(videoCodecProfiles(capabilities), audioCodecProfile(capabilities)),
"SubtitleProfiles": androidTVSubtitleProfiles(),
}
}
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
}
// 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
}
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
}