fix(jellycompat): improve audio track selection and display titles (#240)
- preferredAudioStreamIndex: when default track is TrueHD/MLP and device profile doesn't explicitly list that codec, falls back to best compat track (EAC3 > AC3 > DTS > AAC…), same language preferred. Prevents Android TV clients receiving undecoded lossless passthrough streams. - audioTrackDisplayTitle: structured display title (e.g. "English - EAC3 5.1") replacing the raw firstNonEmpty fallback. Suppresses generic embedded labels (Stereo, Surround) that add no information. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Sonnet 4.6
parent
652b5d1e9f
commit
eafa22a3c3
@@ -553,7 +553,7 @@ func (h *PlaybackHandler) buildPlaybackSource(
|
||||
allowVideoCopy := boolDefault(req.AllowVideoStreamCopy, true)
|
||||
allowAudioCopy := boolDefault(req.AllowAudioStreamCopy, true)
|
||||
|
||||
audioIndex := defaultAudioStreamIndex(version)
|
||||
audioIndex := preferredAudioStreamIndex(version, profile)
|
||||
subtitleIndex := defaultSubtitleStreamIndex(version)
|
||||
selectedAudioIndex := audioIndex
|
||||
if req.AudioStreamIndex != nil && isValidCompatAudioStreamIndex(version, int(*req.AudioStreamIndex)) {
|
||||
@@ -716,7 +716,7 @@ func buildMediaStreamsWithSelection(routeItemID, mediaSourceID string, version c
|
||||
Codec: strings.ToLower(track.Codec),
|
||||
Language: track.Language,
|
||||
TimeBase: "1/1000",
|
||||
DisplayTitle: firstNonEmpty(track.Title, track.EmbeddedTitle, track.Language, track.Codec),
|
||||
DisplayTitle: audioTrackDisplayTitle(track),
|
||||
Title: firstNonEmpty(track.Title, track.EmbeddedTitle),
|
||||
IsDefault: isDefault,
|
||||
IsExternal: false,
|
||||
@@ -788,6 +788,120 @@ func defaultAudioStreamIndex(version catalog.FileVersion) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
// losslessPassthroughCodecs are audio codecs that require dedicated hardware
|
||||
// passthrough (AV receiver or decoder chip) and cannot be decoded by
|
||||
// software-only players like ExoPlayer on most Android TV devices.
|
||||
var losslessPassthroughCodecs = map[string]bool{
|
||||
"truehd": true,
|
||||
"mlp": true,
|
||||
}
|
||||
|
||||
// compatFallbackCodecs are broadly supported audio codecs suitable for
|
||||
// software decoding. Lower index = higher preference.
|
||||
var compatFallbackCodecRank = map[string]int{
|
||||
"eac3": 1,
|
||||
"ac3": 2,
|
||||
"dts": 3,
|
||||
"aac": 4,
|
||||
"flac": 5,
|
||||
"opus": 6,
|
||||
"vorbis": 7,
|
||||
"mp3": 8,
|
||||
"pcm_s16le": 9,
|
||||
"pcm_s24le": 10,
|
||||
}
|
||||
|
||||
// preferredAudioStreamIndex returns the best audio stream index for the given
|
||||
// device profile. When the default audio track is a lossless passthrough codec
|
||||
// (TrueHD, MLP) and the profile does not explicitly list that codec as
|
||||
// supported, it selects the most compatible fallback track (same language
|
||||
// preferred). This prevents Android TV and similar clients from receiving a
|
||||
// TrueHD stream they cannot decode when an AC3/EAC3 fallback is present.
|
||||
func preferredAudioStreamIndex(version catalog.FileVersion, profile DeviceProfile) *int {
|
||||
defaultIdx := defaultAudioStreamIndex(version)
|
||||
if defaultIdx == nil {
|
||||
return defaultIdx
|
||||
}
|
||||
|
||||
defaultTrackIdx := *defaultIdx - len(version.VideoTracks)
|
||||
if defaultTrackIdx < 0 || defaultTrackIdx >= len(version.AudioTracks) {
|
||||
return defaultIdx
|
||||
}
|
||||
defaultTrack := version.AudioTracks[defaultTrackIdx]
|
||||
if !losslessPassthroughCodecs[normalizeCompatToken(defaultTrack.Codec)] {
|
||||
return defaultIdx // not a passthrough codec; keep default
|
||||
}
|
||||
|
||||
// Check whether the profile explicitly lists this lossless codec as
|
||||
// supported in any DirectPlayProfile. An empty AudioCodec field is a
|
||||
// wildcard that many clients use to mean "try anything" — but for
|
||||
// lossless passthrough codecs we cannot assume the device can actually
|
||||
// decode them, so we do not treat wildcard as explicit support.
|
||||
defaultCodec := normalizeCompatToken(defaultTrack.Codec)
|
||||
for _, p := range profile.DirectPlayProfiles {
|
||||
if !matchesVideoType(p.Type) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(p.AudioCodec) == "" {
|
||||
continue // wildcard — not explicit support for lossless
|
||||
}
|
||||
for part := range strings.SplitSeq(p.AudioCodec, ",") {
|
||||
if normalizeCompatToken(part) == defaultCodec {
|
||||
return defaultIdx // profile explicitly supports this lossless codec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Profile does not explicitly support this lossless codec. Find the best
|
||||
// compatible fallback with the same language as the default track.
|
||||
defaultLang := strings.ToLower(strings.TrimSpace(defaultTrack.Language))
|
||||
|
||||
bestIdx := -1
|
||||
bestRank := 0
|
||||
|
||||
for i, track := range version.AudioTracks {
|
||||
rank, ok := compatFallbackCodecRank[normalizeCompatToken(track.Codec)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lang := strings.ToLower(strings.TrimSpace(track.Language))
|
||||
sameLang := lang == defaultLang
|
||||
// Prefer same-language tracks; within each group prefer lower rank number.
|
||||
if bestIdx == -1 {
|
||||
bestIdx = i
|
||||
bestRank = rank
|
||||
if sameLang {
|
||||
bestRank = -rank // negative signals same-language preference
|
||||
}
|
||||
} else {
|
||||
currentSameLang := bestRank < 0
|
||||
if sameLang && !currentSameLang {
|
||||
// Upgrade from cross-language to same-language.
|
||||
bestIdx = i
|
||||
bestRank = -rank
|
||||
} else if sameLang == currentSameLang {
|
||||
// Same language group — pick lower rank (higher quality).
|
||||
effectiveRank := rank
|
||||
effectiveBest := bestRank
|
||||
if sameLang {
|
||||
effectiveRank = -rank
|
||||
effectiveBest = bestRank // already negative
|
||||
}
|
||||
if effectiveRank < effectiveBest {
|
||||
bestIdx = i
|
||||
bestRank = effectiveRank
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestIdx >= 0 {
|
||||
idx := len(version.VideoTracks) + bestIdx
|
||||
return &idx
|
||||
}
|
||||
return defaultIdx
|
||||
}
|
||||
|
||||
func effectiveCompatAudioStreamIndex(source PlaybackMediaSource) *int {
|
||||
if source.SelectedAudioStreamIndex != nil && isValidCompatAudioStreamIndex(source.Version, *source.SelectedAudioStreamIndex) {
|
||||
return intPtr(*source.SelectedAudioStreamIndex)
|
||||
@@ -954,6 +1068,79 @@ func parseCompatFrameRate(raw string) float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func audioTrackDisplayTitle(track models.AudioTrack) string {
|
||||
lang := compatLanguageName(track.Language)
|
||||
codec := audioCodecDisplayName(track.Codec)
|
||||
channels := audioChannelsDisplayName(track.Channels)
|
||||
title := strings.TrimSpace(codec + " " + channels)
|
||||
if lang != "" {
|
||||
title = lang + " - " + title
|
||||
}
|
||||
// Append embedded title only when it adds info beyond codec/channels (e.g. "Commentary").
|
||||
embedded := strings.TrimSpace(firstNonEmpty(track.Title, track.EmbeddedTitle))
|
||||
if !isGenericAudioLabel(embedded) {
|
||||
title += " - " + embedded
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
func audioCodecDisplayName(codec string) string {
|
||||
switch normalizeCompatToken(codec) {
|
||||
case "truehd":
|
||||
return "TrueHD"
|
||||
case "mlp":
|
||||
return "MLP"
|
||||
case "ac3":
|
||||
return "AC3"
|
||||
case "eac3":
|
||||
return "EAC3"
|
||||
case "dts":
|
||||
return "DTS"
|
||||
case "dtshd", "dtshd_ma":
|
||||
return "DTS-HD MA"
|
||||
case "aac":
|
||||
return "AAC"
|
||||
case "mp3":
|
||||
return "MP3"
|
||||
case "flac":
|
||||
return "FLAC"
|
||||
case "opus":
|
||||
return "Opus"
|
||||
case "vorbis":
|
||||
return "Vorbis"
|
||||
case "pcms16le", "pcms24le", "pcms32le", "pcmf32le":
|
||||
return "PCM"
|
||||
default:
|
||||
return strings.ToUpper(strings.TrimSpace(codec))
|
||||
}
|
||||
}
|
||||
|
||||
func audioChannelsDisplayName(channels int) string {
|
||||
switch channels {
|
||||
case 1:
|
||||
return "Mono"
|
||||
case 2:
|
||||
return "Stereo"
|
||||
case 6:
|
||||
return "5.1"
|
||||
case 8:
|
||||
return "7.1"
|
||||
default:
|
||||
if channels > 0 {
|
||||
return fmt.Sprintf("%d ch", channels)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isGenericAudioLabel(s string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "", "stereo", "mono", "5.1", "7.1", "surround", "5.1 surround", "7.1 surround":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func compatSubtitleDisplayTitle(track catalog.VersionSubtitleTrack) string {
|
||||
base := compatLanguageName(track.Language)
|
||||
tags := make([]string, 0, 4)
|
||||
|
||||
Reference in New Issue
Block a user