Skip to content

UPnP/DLNA Media Renderer: On-the-fly bitrate detection and Icy Metadata support - #1661

Open
stsichler wants to merge 2 commits into
LMS-Community:public/9.2from
stsichler:feature/upnp-bitrate-on-the-fly
Open

stsichler wants to merge 2 commits into
LMS-Community:public/9.2from
stsichler:feature/upnp-bitrate-on-the-fly

Conversation

@stsichler

@stsichler stsichler commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

A proper bitrate value is crucial for seeking, so this improves bitrate heuristic for LPCM & WAV and when no bitrate is sent by the server at all, we do an on-the-fly bitrate detection using Audio::Scan->scan_fh() on the first 128kB of data (or more when a lengthy MP3v2 tag, like a large cover image, is detected).

Unfortunately, we cannot directly use parseAudioStream() here, because it would create its own connection (which would be rejected by e.g. pa-dlna) and currently does not support HTTP/1.1 chunked transfer encoding, so it required an implementation that is embedded into _sysread() instead.

We also improve metadata handling here, so that this also adds support for Icy Metadata, although it is not part of the UPnP/DLNA spec, because some Media Servers (like AirMusic) are sending it.

In addition, canSeek() now honors the DLNA.ORG_OP seekable flag (in DIDL-Lite) instead of always assuming Range support.

Note that this also required a minor change to Slim::Music::Info::setRemoteMetadata() (for cosmetic reasons), because setRemoteMetadata() supposed that all bitrates that are not typical MP3-bitrates are VBR, which is in fact very buggy.
We solve this by adding support for an optional vbr_scale metadata attribute which is now preferred over guessing.

Tested with:

  • BubbleUPnP 4.6.5.1
  • AirMusic 3.4.3
  • Hi-Fi Cast 1.135
  • foobar2000 2.25.10
  • pa-dlna 1.2

Audio formats tested:

  • audio/wav
  • audio/L16
  • audio/mpeg

Signed-off-by: Stefan Sichler <stsichler@web.de>
Comment thread Slim/Music/Info.pm
Comment on lines +455 to +457
$attr->{VBR_SCALE} = exists $meta->{vbr_scale}
? $meta->{vbr_scale}
: ( exists $cbr{ $meta->{bitrate} } ) ? undef : 1;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vbrScale currently is checked for the exact value of 1 in

sub buildPrettyBitRate {
my ( $class, $bitrate, $vbrScale, $format ) = @_;
if ($bitrate) {
my $mode = '';
if (Slim::Music::Info::isLossy($format) ) { # only relevant for lossy formats
$mode = (defined $vbrScale && $vbrScale == 1) ? ' VBR' : ' CBR';
}
return sprintf( "%d", ($bitrate / 1000) ) . Slim::Utils::Strings::string('KBPS') . $mode;
}
return 0;
}
. Your change would break that.

@SamInPgh - is there any reason why you are expecting the exact value of 1 there? Couldn't we accept any truthy value?

Can that value ever be 0 at all? Otherwise this statement could be simplified to something like:

$attr->{VBR_SCALE} = $meta->{vbr_scale} || !$cbr{ $meta->{bitrate} } || undef;

@stsichler stsichler Sep 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your change would break that.

I don't think that my change breaks anything here, because all locations in the code that currently call setRemoteMetadata() build up the EDIT: $meta parameter set in-place and none of them currently provides the vbr_scale metadata key at all.
It is only the code added here in UPnP/DLNA Media Renderer by now that actually sets it and it does so by only either setting it to EDIT: undef or don't provide it at all.

is there any reason why you are expecting the exact value of 1 there?

This question is a pretty good one:
what's the actual meaning of the vbr_scale track attribute / column in the track database? Is it a boolean or is it a scale value?

Maybe I should instead resolve that by renaming the freshly added vbr_scale attribute here to something different like vbr to clearly state that it is boolean-only here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the edit of my previous post. I wrote $attr, but was actually talking of $meta.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I crawled through the code now and these are my findings:
the VBR_SCALE track attribute is traditionally a string in the database (see vbr_scale definition in the schema), not a boolean, but all places currently setting it (including the local track scanners) either set it to 1, 0 or leave it undefined.
So it is actually used as a bool, not a scale value.

@SamInPgh SamInPgh Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SamInPgh - is there any reason why you are expecting the exact value of 1 there? Couldn't we accept any truthy value?

Theoretically, yes.

Can that value ever be 0 at all?

Yes. As currently used, it can be 0, 1 or undefined

See @stsichler's comments, which accurately describe how the vbr_scale attribute is defined and used.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SamInPgh related question (but somewhat off-topic - sorry): Slim::Formats::Flac defines vbr_scale = 1 for FLAC. Isn't this wrong?

$tags->{VBR_SCALE} = 1;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slim::Formats::Flac defines vbr_scale = 1 for FLAC. Isn't this wrong?

AFAIK, Flac is always VBR, so I think that this is correct.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry for being thick. @stsichler am I finally getting it, understanding you don't want this change to transport the actual VBR value, but still only a flag? Just read it from $meta (if available) instead of looking it up in the vbr table?

Yes, vbr_scale is a flag. And the only purpose of this change is to bypass that MP3-targeted heuristic via "table of known CBR-rates" when the caller of the function knows it better.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slim::Formats::Flac defines vbr_scale = 1 for FLAC. Isn't this wrong?

AFAIK, Flac is always VBR, so I think that this is correct.

What he said. ⬆️

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification!

@stsichler
stsichler force-pushed the feature/upnp-bitrate-on-the-fly branch from 14232fc to c88293c Compare September 11, 2026 17:12
@stsichler

Copy link
Copy Markdown
Contributor Author

Note: I just added adaptive size of buffered data in the on-the-fly bitrate scan to support MP3s with large cover images (same logic as in Slim::Utils::Scanner::Remote::parseAudioStream())

@stsichler

Copy link
Copy Markdown
Contributor Author

@michaelherger It turned out that it requires a small fix in HTML/EN/html/SqueezeJS/Base.js to also properly get the UI updated when a track's bitrate changed because of the on-the-fly scan implemented here after playback has already started.
Do you want me to also add this here or as a separate PR?

@stsichler
stsichler force-pushed the feature/upnp-bitrate-on-the-fly branch from c88293c to 4fd89f2 Compare September 11, 2026 17:39
# enough data buffered (or stream ended) - scan once and stop
${*$self}{'_bitrateScan'} = 0;

my ( $bitrate, $vbr ) = eval { $scan->{formatClass}->scanBitrate( $scan->{fh}, $scan->{url} ) };

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm... After further digging into the code, I'm unsure whether it would be better to instead directly call Audio::Scan->scan_fh() here, bypassing setting of further track attributes like TITLE and/or caching a cover image, which is done in scanBitrate(). That's something I wasn't really expecting anyway here...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you only ever scan local files?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. This scans the first 128kB (or more) of remote tracks that are copied to a local temporary file meanwhile the file is received from the DLNA server for playback. So it's an on-the-fly scan.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead directly call Audio::Scan->scan_fh()

Okay, I now have a working version directly using scan_fh() instead of scanBitrate() and this seems to be the better solution, because:

  • scan_fh() really only determines bitrate, vbr and duration, instead of also extracting title and cover artwork
  • I'm able to add support for all formats that are currently advertised as sink formats, namely mp3, wav, aif, ogg, ops and flc.

I'll put this PR on hold because I'd love to hear your option about this first, before I rebase it onto the scan_fh()-based version.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my imagination is poor - I could give better feedback if I had code to look at. Would you have the commit somewhere? Even a temporary PR in draft mode?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you have the commit somewhere?

Yes, see this commit in my fork

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you'd replace scanBitrate() with using your own code around the same scan_fh() as in scanBitrate() for better control? Or because scanBitrate() was not available for all formats? If the latter was the case, I'd rather see those missing implementations added. Otherwise we'll just end up with partially redundant, but not completely identical implementations - which I'd try to avoid.

@stsichler stsichler Sep 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you'd replace scanBitrate() with using your own code around the same scan_fh() as in scanBitrate() for better control? Or because scanBitrate() was not available for all formats? If the latter was the case, I'd rather see those missing implementations added.

These are exactly my thoughts, too.

The primary reason for this is better control: The current scanBitrate() implementations of MP3, Ogg and Opus do more than their name suggests and not only scan the bitrate, but do also (re-)set title information to "<title> BY <artist> FROM <album>" and do also add a cover to the cache, both of this cannot be switched off..
But the DLNA servers I tested do set title, artist, album (and cover) already correctly (by the DIDL-Lite info) and it is only the bitrate that's missing.

The scanBitrate() implementation for WAV/AIFF, on the other hand, is simply missing, but could be added, of course.

So, maybe the best alternative solution would be to add a parameter to scanBitrate() (like $bitrateOnly) to be able to really do a bitrate-only scan, but this would require a more invasive change, because it's in use in Slim::Utils::Scanner::Remote::parseRemoteHeader() / scanUrl().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, after looking more closely at the alternatives, I think that directly using Audio::Scan->scan_fh() here is the best compromise, because the code duplication is minimal enough and the use case is a bit different from scanBitrate().
So, I've re-based this PR to the scan_fh()-based version.

@michaelherger

Copy link
Copy Markdown
Member

@michaelherger It turned out that it requires a small fix in HTML/EN/html/SqueezeJS/Base.js to also properly get the UI updated when a track's bitrate changed because of the on-the-fly scan implemented here after playback has already started. Do you want me to also add this here or as a separate PR?

What would that change be?

@stsichler

Copy link
Copy Markdown
Contributor Author

What would that change be?

I just pushed it here as separate commit for easy review.

@stsichler
stsichler marked this pull request as draft September 13, 2026 13:31
@michaelherger

Copy link
Copy Markdown
Member

I just pushed it here as separate commit for easy review.

Could you please test without the fix, but with latest 9.2 and the experimental WebSocket plugin enabled? You might have to wipe your browser's cache, or make sure you force-reload the page. The websocket should push changes to Default skin as soon as they happen.

Would the current version (without WebSocket support) not update after a few seconds? The idea at the time was to have one really light weight request to see whether something important has changed. Adding parameters to that request kind of defeats the purpose of that light call.

@stsichler
stsichler force-pushed the feature/upnp-bitrate-on-the-fly branch from 401d419 to 4fd89f2 Compare September 15, 2026 08:43
@stsichler

Copy link
Copy Markdown
Contributor Author

Could you please test without the fix, but with latest 9.2 and the experimental WebSocket plugin enabled?

You're right! It seems that it's working without having to patch HTML/EN/html/SqueezeJS/Base.js, so I removed this commit again from the PR.

(I previously tested a patched 9.1 installation without WebSocket)

A proper bitrate value is crucial for seeking, so this improves
bitrate heuristic for LPCM & WAV and when no bitrate is sent by
the server at all, do an on-the-fly bitrate detection using
Audio::Scan->scan_fh() on the first 128kB of data (or more when
a lengthy MP3v2 tag, like a large cover image, is detected).

This also adds support for Icy Metadata, although it is not part
of the UPnP/DLNA spec, because some Media Servers (like AirMusic)
are sending it.

In addition, canSeek() now honors the DLNA.ORG_OP seekable flag
instead of always assuming Range support.

**Parts of this code are generated using AI.**

Signed-off-by: Stefan Sichler <stsichler@web.de>
@stsichler
stsichler force-pushed the feature/upnp-bitrate-on-the-fly branch from 4fd89f2 to a4df36e Compare September 18, 2026 08:59
@stsichler
stsichler marked this pull request as ready for review September 18, 2026 09:06
@michaelherger

Copy link
Copy Markdown
Member

I think I missed an important detail: this Protocol Handler is used to stream from one of those UPnP servers to a Squeezebox?

@stsichler

Copy link
Copy Markdown
Contributor Author

I think I missed an important detail: this Protocol Handler is used to stream from one of those UPnP servers to a Squeezebox?

Yes. Exactly.


main::DEBUGLOG && $log->is_debug && $log->debug( 'Metadata returned for ' . $meta->{title} );
# support Icy Metadata updates
my $title = Slim::Music::Info::getCurrentTitle( $client, $url );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the right thing to do? I believe getMetadataFor() should return as bare naked data as possible. But getCurrentTitle() would format according to title formatting settings - which could include an album name or other metadata, depending on the user's settings. I might be wrong, but please double check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants