Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b9ba3ed
Support additional metadata in SlimBrowse style menus
michaelherger Jun 8, 2025
67c7c95
Merge branch 'public/9.1' into SlimBrowse-Metadata
darrell-k Jun 12, 2025
2dfa8e3
some extra tags
darrell-k Jun 12, 2025
c121804
Merge pull request #1407 from darrell-k/SlimBrowse-Metadata
michaelherger Jun 13, 2025
7321391
Merge branch 'public/9.1' into SlimBrowse-Metadata
michaelherger Jun 13, 2025
8f1bce8
Make tags param available to plugin
darrell-k Jun 20, 2025
65ced03
Merge pull request #1408 from darrell-k/SlimBrowse-Metadata
michaelherger Jun 21, 2025
8d87904
Add album metadata to top level of response
darrell-k Jun 22, 2025
56fecda
always include hasMetadata elemet if present
darrell-k Jun 23, 2025
c52c784
Merge pull request #1409 from darrell-k/SlimBrowse-Metadata
michaelherger Jun 24, 2025
1acb37d
add title/version/titleFlags elements
darrell-k Jun 24, 2025
db2a861
Merge branch 'LMS-Community:SlimBrowse-Metadata' into SlimBrowse-Meta…
darrell-k Jun 24, 2025
efb9ad3
Merge pull request #1411 from darrell-k/SlimBrowse-Metadata
michaelherger Jun 24, 2025
f893a97
Merge branch 'public/9.1' into SlimBrowse-Metadata
darrell-k Jul 26, 2025
01b8dda
Merge branch 'public/9.1' into SlimBrowse-Metadata
darrell-k Sep 8, 2025
838de42
Merge branch 'public/9.1' into SlimBrowse-Metadata
darrell-k Oct 10, 2025
636ca11
initial commit
darrell-k Oct 17, 2025
b456403
Merge branch 'public/9.1' into remote-service-enhancement
darrell-k Nov 7, 2025
945f601
further WIP
darrell-k Nov 7, 2025
0e8f819
Merge branch 'public/9.1' into SlimBrowse-Metadata
darrell-k Nov 9, 2025
8440393
Merge branch 'SlimBrowse-Metadata' into remote-service-enhancement
darrell-k Nov 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 98 additions & 18 deletions Slim/Control/Queries.pm
Original file line number Diff line number Diff line change
Expand Up @@ -4740,6 +4740,8 @@ sub titlesQuery {
my $ignoreWorkTracks = $request->getParam('ignore_work_tracks');
my $performance = $request->getParam('performance');
my $onlyAlbumYears = $request->getParam('only_album_years');
my $remoteAlbumId = $request->getParam('remote_album_id');
my $onlineService = $request->getParam('service');

# did we have override on the defaults?
# note that this is not equivalent to
Expand Down Expand Up @@ -4795,6 +4797,8 @@ sub titlesQuery {
workId => $workID,
libraryId => $libraryID,
onlyAlbumYears=> $onlyAlbumYears,
remoteAlbumId => $remoteAlbumId,
onlineService => $onlineService,
limit => sub {
$count = shift;

Expand All @@ -4813,26 +4817,67 @@ sub titlesQuery {

$count += 0;

my $loopname = 'titles_loop';
# this is the count of items in this part of the request (e.g., menu 100 200)
# not to be confused with $count, which is the count of the entire list
my $chunkCount = 0;
# is it a remote album that's not in the database?
my $handler;
if ( !scalar @{$itemOrder} && $remoteAlbumId && $onlineService ) {
my $url = $onlineService . ':album:' . $remoteAlbumId;
$handler = Slim::Player::ProtocolHandlers->handlerForURL($url);
}
#---
# this change gets rendered by git diff in a very confusing way. All we're doing is adding this "if" and indenting
# the existing code in an "else".
if ( $handler && $handler->can('getAlbumTracks') ) {
# we have an online service handler that can return album tracks in the expected format

$request->setStatusProcessing();
$handler->getAlbumTracks(sub{
($items, $itemOrder, $totalCount) = @_;

if ( scalar @{$itemOrder} ) {
my $loopname = 'titles_loop';
my $chunkCount = 0;

for my $trackId ( @{$itemOrder} ) {
my $item = $items->{$trackId};
if ( scalar @{$itemOrder} ) {

_addSong($request, $loopname, $chunkCount, $item, $tags);
for my $trackId ( @{$itemOrder} ) {
my $item = $items->{$trackId};

$chunkCount++;
}
_addSong($request, $loopname, $chunkCount, $item, $tags);

$chunkCount++;
}

}

$request->addResult('count', $totalCount);

$request->setStatusDone();
}, $request->client, $remoteAlbumId);

}
#---
else {

$request->addResult('count', $totalCount);
my $loopname = 'titles_loop';
# this is the count of items in this part of the request (e.g., menu 100 200)
# not to be confused with $count, which is the count of the entire list
my $chunkCount = 0;

$request->setStatusDone();
if ( scalar @{$itemOrder} ) {

for my $trackId ( @{$itemOrder} ) {
my $item = $items->{$trackId};

_addSong($request, $loopname, $chunkCount, $item, $tags);

$chunkCount++;
}

}

$request->addResult('count', $totalCount);

$request->setStatusDone();
}
}


Expand Down Expand Up @@ -5823,17 +5868,33 @@ sub _songData {
$song = $client->currentSongForUrl($url);
}

my $service;
if ( $isRemote ) {
my $handler = Slim::Player::ProtocolHandlers->handlerForURL($url);

if ( $handler && $handler->can('getMetadataFor') ) {
$service = (split(/:/, $url))[0];
# Don't modify source data
$remoteMeta = Storable::dclone(
$handler->getMetadataFor( $request->client, $url )
);

# if the artist is in the database, use their local id. If not, use the remote service id multiplied by -1
# so clients can distinguish between the two possibilities.
#-------------------------------------------------------------------------------------
### !!! Needs extra thought here: what if the remote service artist id is not numeric?
#-------------------------------------------------------------------------------------
my @extArtistIds = split /,/, $remoteMeta->{artistId};
my @artistIds;
foreach (@extArtistIds) {
my $artistObj = Slim::Schema->rs("Contributor")->search( extid => "$service:artist:$_" )->single;
push @artistIds, $artistObj ? $artistObj->id : $_ * -1;
}
$remoteMeta->{artistId} = join ',', @artistIds;

$remoteMeta->{a} = $remoteMeta->{artist};
$remoteMeta->{A} = $remoteMeta->{artist};
$remoteMeta->{e} = $remoteMeta->{albumId};
$remoteMeta->{E} = $remoteMeta->{extid};
$remoteMeta->{l} = $remoteMeta->{album};
$remoteMeta->{i} = $remoteMeta->{disc};
Expand Down Expand Up @@ -5877,6 +5938,7 @@ sub _songData {

$returnHash{'id'} = $track->id;
$returnHash{'title'} = $remoteMeta->{title} || $track->title;
$returnHash{'service_id'} = $service if $service;
my %seen;

# loop so that stuff is returned in the order given...
Expand Down Expand Up @@ -5906,6 +5968,15 @@ sub _songData {
elsif ($tag eq 'b') {
$returnHash{work} = $remoteMeta->{$tag};
$returnHash{composer} = $remoteMeta->{composer} if $remoteMeta->{composer};
# if the composer is in the database, use their local id. If not, use the remote service id multiplied by -1
# so clients can distinguish between the two possibilities.
#-------------------------------------------------------------------------------------
### !!! Needs extra thought here: what if the remote service composer id is not numeric?
#-------------------------------------------------------------------------------------
if ( $remoteMeta->{composerId} ) {
my $composerObj = Slim::Schema->rs("Contributor")->search( extid => "$service:artist:" . $remoteMeta->{composerId} )->single;
$returnHash{composer_ids} = $composerObj ? $composerObj->id . "" : $remoteMeta->{composerId} * -1 . "";
}
}

# Special case for 2: at track level, triggers addition of the play queue context $addedFromWork
Expand All @@ -5917,6 +5988,7 @@ sub _songData {
elsif ($tag eq 'A' || $tag eq 'S') {
if ( my $meta = $remoteMeta->{$tag} ) {
$returnHash{artist} = $meta;
$returnHash{artist_ids} = $remoteMeta->{artistId} if $remoteMeta->{artistId};
next;
}
elsif ( $track->isa('Slim::Schema::RemoteTrack')) {
Expand Down Expand Up @@ -6005,6 +6077,7 @@ sub _songData {
}
# we might need to proxy the image request to resize it
elsif ($tag eq 'K' && $value) {
$returnHash{baseImage} = URI::Escape::uri_escape_utf8($value);
$value = proxiedImage($value);
}

Expand Down Expand Up @@ -6289,12 +6362,6 @@ sub _getTagDataForTracks {
}
}

if ( my $libraryId = Slim::Music::VirtualLibraries->getRealId($args->{libraryId}) ) {
$sql .= 'JOIN library_track ON library_track.track = tracks.id ';
push @{$w}, 'library_track.library = ?';
push @{$p}, $libraryId;
}

# Some helper functions to setup joins with less code
my $join_genre_track = sub {
if ( $sql !~ /JOIN genre_track/ ) {
Expand Down Expand Up @@ -6336,6 +6403,19 @@ sub _getTagDataForTracks {
}
};

if ( $args->{remoteAlbumId} && $args->{onlineService} ) {
# allow retrieval of track by remote album id.
my $remoteAlbumId = $args->{remoteAlbumId};
my $onlineService = $args->{onlineService};
$join_albums->();
push @{$w}, 'albums.extid = ?';
push @{$p}, $onlineService . ':album:' . $remoteAlbumId;
} elsif ( my $libraryId = Slim::Music::VirtualLibraries->getRealId($args->{libraryId}) ) {
$sql .= 'JOIN library_track ON library_track.track = tracks.id ';
push @{$w}, 'library_track.library = ?';
push @{$p}, $libraryId;
}

if ( my $year = $args->{year} ) {
push @{$w}, 'tracks.year = ?';
push @{$p}, $year;
Expand Down
68 changes: 65 additions & 3 deletions Slim/Control/XMLBrowser.pm
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,25 @@ use constant CACHE_TIME => 3600; # how long to cache browse sessions
my $log = logger('formats.xml');
my $prefs = preferences('server');

# variable of same name in Slim::Control::Queries is source of truth
# used to return raw metadata for clients who request tags
my %colMap = (
# "publisher" is used in Podcasts
a => ['artist','publisher'],
A => 'artists',
b => ['work','composer'],
d => ['secs','duration'],
g => 'genre',
G => 'genres',
i => 'discnum',
k => 'description',
l => ['album','version','remoteAlbumId'],
q => 'disccount',
t => ['tracknum','title','titleFlags'],
# "date" is being used in Podcast episodes
'y' => ['year','date'],
);

sub cliQuery {
my ( $query, $feed, $request, $expires, $forceTitle ) = @_;

Expand Down Expand Up @@ -310,6 +329,7 @@ sub _cliQuery_done {
my $menu = $request->getParam('menu');
my $url = $request->getParam('url');
my $trackId = $request->getParam('track_id');
my $tags = $request->getParam('tags');

# menu/jive mgmt
my $menuMode = defined $menu;
Expand Down Expand Up @@ -496,6 +516,8 @@ sub _cliQuery_done {
my $pt = $subFeed->{passthrough} || [];
my %args = (params => $feed->{'query'}, isControl => 1);

$args{'tags'} = $tags if $tags;

if (defined $search && $subFeed->{type} && ($subFeed->{type} eq 'search' || defined $subFeed->{'searchParam'})) {
$args{'search'} = $search;
}
Expand Down Expand Up @@ -549,7 +571,7 @@ sub _cliQuery_done {
($subFeed->{'type'} && $subFeed->{'type'} eq 'audio') ||
$subFeed->{'enclosure'} ||
# Bug 17385 - rss feeds include description at non leaf levels
($subFeed->{'description'} && $subFeed->{'type'} && $subFeed->{'type'} ne 'rss')
($subFeed->{'description'} && $subFeed->{'type'} && $subFeed->{'type'} ne 'rss' && ($subFeed->{'hasMetadata'} || '') ne 'podcast')
)
) {

Expand Down Expand Up @@ -673,7 +695,7 @@ sub _cliQuery_done {
&& (defined $subFeed->{'name'} || defined $subFeed->{'title'})
&& ($method !~ /all/)) {

my $title = $subFeed->{'name'} || $subFeed->{'title'};
my $title = $subFeed->{'album'} && $subFeed->{'artist'} ? $subFeed->{'title'} : $subFeed->{'name'};
my $url = $subFeed->{'url'};

# Podcast enclosures
Expand All @@ -699,6 +721,8 @@ sub _cliQuery_done {
bitrate => $subFeed->{'bitrate'},
cover => $subFeed->{'cover'} || $subFeed->{'image'} || $subFeed->{'icon'} || $request->getParam('icon'),
year => $subFeed->{'year'},
album => $subFeed->{'album'},
artist => $subFeed->{'artist'},
} );

$client->execute([ 'playlist', $method, $url ]);
Expand Down Expand Up @@ -1372,6 +1396,35 @@ sub _cliQuery_done {
delete $hash{'style'} if $hash{'style'} && $hash{'style'} eq 'itemNoAction';
}

if ( $item->{hasMetadata} && (my $tags = $request->getParam('tags')) ) {
my $metadata = {
type => $item->{hasMetadata},
};

foreach my $tag (split(//, $tags)) {
if (my $mapping = $colMap{$tag}) {
$mapping = [$mapping] unless ref $mapping;
foreach my $map (@$mapping) {
if (my $value = $item->{$map}) {
$metadata->{$map} = $value;
}
}
}
}

# some itmes (basically line1, line2) we add always, if available
$metadata->{'name'} ||= $item->{'name'} if defined $item->{'name'} && !$metadata->{'title'};
$metadata->{'description'} ||= $item->{'description'} if defined $item->{'description'};

# convert unix timestamps to human readable time
$metadata->{'date'} = localtime($metadata->{'date'}) if $metadata->{'date'} =~ /\d{10}/;

# add formatted duration
$metadata->{'duration'} ||= Slim::Utils::DateTime::secsToMMSS($metadata->{'secs'}) if $metadata->{'secs'};

$hash{'metadata'} = $metadata;
}

$hash{'textkey'} = $item->{textkey} if defined $item->{textkey};

$request->setResultLoopHash($loopname, $cnt, \%hash);
Expand Down Expand Up @@ -1410,7 +1463,6 @@ sub _cliQuery_done {

$hash{hasitems} = $hasItems;
}

$request->setResultLoopHash($loopname, $cnt, \%hash);
}
$cnt++;
Expand All @@ -1421,6 +1473,16 @@ sub _cliQuery_done {

$request->addResult('count', $totalCount);

if ( my $meta = $subFeed->{'hasMetadata'} ) {
$request->addResult('hasMetadata', $meta);
if ( $meta eq 'album' ) {
$request->addResult('year', $subFeed->{'year'});
$request->addResult('album', $subFeed->{'album'});
$request->addResult('artist', $subFeed->{'artist'});
$request->addResult('genre', $subFeed->{'genre'});
}
}

if ($menuMode) {

if ($request->getResult('base')) {
Expand Down
32 changes: 27 additions & 5 deletions Slim/Formats/XML.pm
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,8 @@ sub _parseOPMLOutline {

my $url = $itemXML->{'url'} || $itemXML->{'URL'} || $itemXML->{'xmlUrl'};

next if $url && $url =~ IS_TUNEIN_RE && $itemXML && ref $itemXML && $itemXML->{key} && $itemXML->{key} eq 'unavailable';
my $isTuneIn = $url && $url =~ IS_TUNEIN_RE;
next if $isTuneIn && $itemXML && ref $itemXML && $itemXML->{key} && $itemXML->{key} eq 'unavailable';

# Some programs, such as OmniOutliner put garbage in the URL.
if ($url) {
Expand All @@ -620,12 +621,33 @@ sub _parseOPMLOutline {
# Pull in all attributes we find
my %attrs;
for my $attr ( keys %{$itemXML} ) {
next if $attr =~ /^(?:text|type|URL|xmlUrl|outline)$/i;
$attrs{$attr} = $itemXML->{$attr};
}
next if $attr =~ /^(?:text|type|URL|xmlUrl|outline)$/i;
$attrs{$attr} = $itemXML->{$attr};
}

push @items, {
if ( $isTuneIn && $itemXML->{type} ) {
my $type = $itemXML->{type} || '';
my $item = $itemXML->{item} || '';

my $defaults = sub {
$attrs{'hasMetadata'} = $_[0];
$attrs{'title'} = unescapeAndTrim($itemXML->{'text'}),
$attrs{'description'} = unescapeAndTrim($itemXML->{'subtext'}),
};

if ($type eq 'audio' && $item eq 'topic' && ($itemXML->{'stream_type'} || '') eq 'download') {
$defaults->('episode');
$attrs{'secs'} = $itemXML->{'topic_duration'} || 0;
}
elsif ($type eq 'audio') {
$defaults->('station');
}
elsif ($type eq 'link' && $item eq 'show') {
$defaults->('podcast');
}
}

push @items, {
# compatable with INPUT.Choice, which expects 'name' and 'value'
'name' => unescapeAndTrim( $itemXML->{'text'} ),
'value' => $url || $itemXML->{'text'},
Expand Down
Loading