0.2.42 - DTS surround sound support
This commit is contained in:
@@ -481,6 +481,8 @@ func sessionPlaybackCapabilities(sess store.Session) emby.PlaybackCapabilities {
|
||||
capabilities.HEVCHDR10Plus = true
|
||||
case "video_hevc_dolby_vision":
|
||||
capabilities.HEVCDolbyVision = true
|
||||
case "audio_passthrough_v1":
|
||||
capabilities.AudioProfileV1 = true
|
||||
default:
|
||||
parsePlaybackCapabilityValue(value, &capabilities)
|
||||
}
|
||||
@@ -508,6 +510,9 @@ func (s *Server) effectivePlaybackCapabilities(
|
||||
}
|
||||
|
||||
func parsePlaybackCapabilityValue(value string, capabilities *emby.PlaybackCapabilities) {
|
||||
if parseAudioCapability(value, capabilities) {
|
||||
return
|
||||
}
|
||||
parseIntCapability(value, "video_h264_level_", &capabilities.H264Level)
|
||||
parseIntCapability(value, "video_h264_high10_level_", &capabilities.H264High10Level)
|
||||
parseIntCapability(value, "video_hevc_main_level_", &capabilities.HEVCMainLevel)
|
||||
@@ -520,6 +525,51 @@ func parsePlaybackCapabilityValue(value string, capabilities *emby.PlaybackCapab
|
||||
)
|
||||
}
|
||||
|
||||
func parseAudioCapability(value string, capabilities *emby.PlaybackCapabilities) bool {
|
||||
const prefix = "audio_"
|
||||
if !strings.HasPrefix(value, prefix) {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(value, "audio_max_channels_") {
|
||||
parsed, err := strconv.Atoi(strings.TrimPrefix(value, "audio_max_channels_"))
|
||||
if err == nil && parsed >= 2 && parsed <= 8 {
|
||||
capabilities.AudioMaxChannels = parsed
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, suffix := range []string{"_passthrough", "_decode"} {
|
||||
if !strings.HasSuffix(value, suffix) {
|
||||
continue
|
||||
}
|
||||
codec := strings.TrimSuffix(strings.TrimPrefix(value, prefix), suffix)
|
||||
if !validAudioCapabilityCodec(codec) {
|
||||
return true
|
||||
}
|
||||
if suffix == "_passthrough" {
|
||||
if capabilities.AudioPassthrough == nil {
|
||||
capabilities.AudioPassthrough = map[string]bool{}
|
||||
}
|
||||
capabilities.AudioPassthrough[codec] = true
|
||||
} else {
|
||||
if capabilities.AudioDecode == nil {
|
||||
capabilities.AudioDecode = map[string]bool{}
|
||||
}
|
||||
capabilities.AudioDecode[codec] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validAudioCapabilityCodec(codec string) bool {
|
||||
switch codec {
|
||||
case "ac3", "eac3", "atmos", "dts", "dts_hd", "truehd":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseIntCapability(value, prefix string, destination *int) {
|
||||
if !strings.HasPrefix(value, prefix) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
func TestSessionPlaybackCapabilitiesParsesAudioEvidence(t *testing.T) {
|
||||
capabilities := sessionPlaybackCapabilities(store.Session{ClientCapabilities: []string{
|
||||
"audio_passthrough_v1",
|
||||
"audio_eac3_passthrough",
|
||||
"audio_truehd_decode",
|
||||
"audio_max_channels_8",
|
||||
"audio_unknown_passthrough",
|
||||
}})
|
||||
if !capabilities.AudioProfileV1 || !capabilities.AudioPassthrough["eac3"] {
|
||||
t.Fatalf("passthrough evidence = %#v", capabilities)
|
||||
}
|
||||
if !capabilities.AudioDecode["truehd"] || capabilities.AudioMaxChannels != 8 {
|
||||
t.Fatalf("decode evidence = %#v", capabilities)
|
||||
}
|
||||
if capabilities.AudioPassthrough["unknown"] {
|
||||
t.Fatalf("unknown audio codec was accepted: %#v", capabilities.AudioPassthrough)
|
||||
}
|
||||
}
|
||||
@@ -52,3 +52,43 @@ func TestForcedTranscodeProfileCannotStreamCopyHEVC(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioProfileAdvertisesDecodeAndPassthroughWithoutReencodingVideo(t *testing.T) {
|
||||
profile := androidTVDeviceProfile(PlaybackCapabilities{
|
||||
HEVC: true, AudioProfileV1: true, AudioMaxChannels: 8,
|
||||
AudioPassthrough: map[string]bool{"ac3": true, "eac3": true},
|
||||
AudioDecode: map[string]bool{
|
||||
"ac3": true, "eac3": true, "atmos": true, "dts": true,
|
||||
"dts_hd": true, "truehd": true,
|
||||
},
|
||||
})
|
||||
direct := profile["DirectPlayProfiles"].([]map[string]string)[0]
|
||||
for _, codec := range []string{"flac", "opus", "ac3", "eac3", "dts", "dtshd", "truehd"} {
|
||||
if !containsCommaValue(direct["AudioCodec"], codec) {
|
||||
t.Fatalf("direct audio codecs %q missing %q", direct["AudioCodec"], codec)
|
||||
}
|
||||
}
|
||||
transcode := profile["TranscodingProfiles"].([]map[string]string)[0]
|
||||
if transcode["VideoCodec"] != "h264,hevc" || transcode["AudioCodec"] != "eac3,ac3,aac,mp3" {
|
||||
t.Fatalf("audio conversion profile = %#v", transcode)
|
||||
}
|
||||
profiles := profile["CodecProfiles"].([]map[string]any)
|
||||
audio := profiles[len(profiles)-1]
|
||||
conditions := audio["Conditions"].([]map[string]any)
|
||||
if audio["Type"] != "VideoAudio" || conditions[0]["Value"] != "8" {
|
||||
t.Fatalf("audio constraints = %#v", audio)
|
||||
}
|
||||
}
|
||||
|
||||
func containsCommaValue(values, wanted string) bool {
|
||||
start := 0
|
||||
for i := 0; i <= len(values); i++ {
|
||||
if i == len(values) || values[i] == ',' {
|
||||
if values[start:i] == wanted {
|
||||
return true
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//
|
||||
// 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
|
||||
@@ -10,49 +11,134 @@ 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
|
||||
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": "aac,mp3", "Type": "Video",
|
||||
"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": "aac",
|
||||
"Container": "ts", "VideoCodec": videoCodecs, "AudioCodec": transcodeAudio,
|
||||
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
|
||||
},
|
||||
},
|
||||
"CodecProfiles": videoCodecProfiles(capabilities),
|
||||
"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.
|
||||
|
||||
Reference in New Issue
Block a user