275 lines
8.4 KiB
Go
275 lines
8.4 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"
|
|
|
|
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": "Memby Android TV", "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
|
|
}
|