From b9ba3ed40e0542365ea2a6f150243246ec4f865b Mon Sep 17 00:00:00 2001 From: Michael Herger Date: Sun, 8 Jun 2025 09:36:27 +0200 Subject: [PATCH 1/6] Support additional metadata in SlimBrowse style menus See discussion in https://forums.lyrion.org/forum/developer-forums/developers/1771548-improving-online-service-integration * allow SlimBrowse client to request a number of `tags` as in `songinfo` query etc. * provide raw metadata in a new `metadata` element of the SlimBrowse response * POC implementation for the Radio and Podcast plugins Signed-off-by: Michael Herger --- Slim/Control/XMLBrowser.pm | 49 ++++++++++++++++++++++++-- Slim/Formats/XML.pm | 32 ++++++++++++++--- Slim/Menu/BrowseLibrary.pm | 4 +-- Slim/Plugin/InternetRadio/Plugin.pm | 1 - Slim/Plugin/Podcast/GPodder.pm | 3 ++ Slim/Plugin/Podcast/Parser.pm | 4 +++ Slim/Plugin/Podcast/Plugin.pm | 2 ++ Slim/Plugin/Podcast/PodcastIndex.pm | 7 ++++ Slim/Plugin/SongScanner/Plugin.pm | 10 ++---- Slim/Utils/DateTime.pm | 5 +++ SlimBrowse Metadata.md | 54 +++++++++++++++++++++++++++++ 11 files changed, 154 insertions(+), 17 deletions(-) create mode 100644 SlimBrowse Metadata.md diff --git a/Slim/Control/XMLBrowser.pm b/Slim/Control/XMLBrowser.pm index 29f91ef8c5c..ea0f72e8edb 100644 --- a/Slim/Control/XMLBrowser.pm +++ b/Slim/Control/XMLBrowser.pm @@ -38,6 +38,23 @@ 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 = ( + g => 'genres', + # "publisher" is used in Podcasts + a => ['artist','publisher'], + A => 'artists', + l => 'album', + d => ['secs','duration'], + # i => 'tracks.disc', + # q => 'albums.discc', + t => 'tracknum', + # "date" is being used in Podcast episodes + y => ['year','date'], + k => 'description', +); + sub cliQuery { my ( $query, $feed, $request, $expires, $forceTitle ) = @_; @@ -549,7 +566,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') ) ) { @@ -1372,6 +1389,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); @@ -1410,7 +1456,6 @@ sub _cliQuery_done { $hash{hasitems} = $hasItems; } - $request->setResultLoopHash($loopname, $cnt, \%hash); } $cnt++; diff --git a/Slim/Formats/XML.pm b/Slim/Formats/XML.pm index cd0f458e435..61731c0d11e 100644 --- a/Slim/Formats/XML.pm +++ b/Slim/Formats/XML.pm @@ -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) { @@ -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'}, diff --git a/Slim/Menu/BrowseLibrary.pm b/Slim/Menu/BrowseLibrary.pm index 645737c8e22..35d92c14dc7 100644 --- a/Slim/Menu/BrowseLibrary.pm +++ b/Slim/Menu/BrowseLibrary.pm @@ -1856,7 +1856,7 @@ sub _tracks { $_->{'ct'} = $_->{'type'}; if (my $secs = $_->{'duration'}) { $_->{'secs'} = $secs; - $_->{'duration'} = sprintf('%d:%02d', int($secs / 60), $secs % 60); + $_->{'duration'} = Slim::Utils::DateTime::secsToMMSS($secs); } $_->{'discc'} = delete $_->{'disccount'} if defined $_->{'disccount'}; $_->{'fs'} = $_->{'filesize'}; @@ -2232,7 +2232,7 @@ sub _playlistTracks { $_->{'ct'} = $_->{'type'}; if (my $secs = $_->{'duration'}) { $_->{'secs'} = $secs; - $_->{'duration'} = sprintf('%d:%02d', int($secs / 60), $secs % 60); + $_->{'duration'} = Slim::Utils::DateTime::secsToMMSS($secs); } $_->{'discc'} = delete $_->{'disccount'} if defined $_->{'disccount'}; $_->{'fs'} = $_->{'filesize'}; diff --git a/Slim/Plugin/InternetRadio/Plugin.pm b/Slim/Plugin/InternetRadio/Plugin.pm index 847ff111e8f..0fdfb7d66cc 100644 --- a/Slim/Plugin/InternetRadio/Plugin.pm +++ b/Slim/Plugin/InternetRadio/Plugin.pm @@ -204,7 +204,6 @@ sub setFeed { \$localFeed = \$_[1] } $subclass->initPlugin(); } -# Some TuneIn-specific code to add formats param if Alien is installed sub radiotimeFeed { my ( $class, $feed, $client ) = @_; diff --git a/Slim/Plugin/Podcast/GPodder.pm b/Slim/Plugin/Podcast/GPodder.pm index 3523b388510..607b3252271 100644 --- a/Slim/Plugin/Podcast/GPodder.pm +++ b/Slim/Plugin/Podcast/GPodder.pm @@ -26,6 +26,9 @@ sub getFeedsIterator { image => $feed->{$image}, description => $feed->{description}, author => $feed->{author}, + hasMetadata => 'podcast', + title => $feed->{title}, + publisher => $feed->{author}, }; }; } diff --git a/Slim/Plugin/Podcast/Parser.pm b/Slim/Plugin/Podcast/Parser.pm index e8e84fb80ba..e2ab4e44b0e 100644 --- a/Slim/Plugin/Podcast/Parser.pm +++ b/Slim/Plugin/Podcast/Parser.pm @@ -137,6 +137,10 @@ sub parse { elsif ($duration) { $item->{line2} = $item->{line2} ? $item->{line2} . ' (' . $duration . ')' : $duration; } + + $item->{hasMetadata} = 'episode'; + $item->{secs} ||= $item->{duration}; + $item->{'date'} = $item->{pubdate}; } $feed->{nocache} = 1; diff --git a/Slim/Plugin/Podcast/Plugin.pm b/Slim/Plugin/Podcast/Plugin.pm index c1b4455f956..79f66cb9dd8 100644 --- a/Slim/Plugin/Podcast/Plugin.pm +++ b/Slim/Plugin/Podcast/Plugin.pm @@ -183,6 +183,8 @@ sub handleFeed { parser => 'Slim::Plugin::Podcast::Parser', image => $image || __PACKAGE__->_pluginDataFor('icon'), playlist => $url, + hasMetadata => 'podcast', + title => $_->{name}, }; # if pre-cached feed data is missing, initiate retrieval diff --git a/Slim/Plugin/Podcast/PodcastIndex.pm b/Slim/Plugin/Podcast/PodcastIndex.pm index 3f5029f476e..e8003d291a6 100644 --- a/Slim/Plugin/Podcast/PodcastIndex.pm +++ b/Slim/Plugin/Podcast/PodcastIndex.pm @@ -75,6 +75,9 @@ sub getFeedsIterator { description => $feed->{description}, author => $feed->{author}, language => $feed->{language}, + hasMetadata => 'podcast', + title => $feed->{title}, + publisher => $feed->{author}, }; }; } @@ -124,6 +127,10 @@ sub newsHandler { image => $item->{image} || $item->{feedImage}, date => $item->{datePublished}, type => 'audio', + hasMetadata => 'episode', + title => $item->{title}, + description => $item->{description}, + secs => $item->{duration}, }; } diff --git a/Slim/Plugin/SongScanner/Plugin.pm b/Slim/Plugin/SongScanner/Plugin.pm index 9ec97a7640b..49961d8c81a 100644 --- a/Slim/Plugin/SongScanner/Plugin.pm +++ b/Slim/Plugin/SongScanner/Plugin.pm @@ -102,15 +102,11 @@ my %modeParams = ( sub _formatTime { my $seconds = shift; - my $hrs = int($seconds / 3600); - my $mins = int(($seconds % 3600) / 60); - my $secs = $seconds % 60; - - if ($hrs) { - return sprintf("%d:%02d:%02d", $hrs, $mins, $secs); + if (int($seconds / 3600)) { + return Slim::Utils::DateTime::timeFormat($seconds); } else { - return sprintf("%02d:%02d", $mins, $secs); + return Slim::Utils::DateTime::secsToMMSS($seconds); } } diff --git a/Slim/Utils/DateTime.pm b/Slim/Utils/DateTime.pm index 0813372eb3d..035c7280b3d 100644 --- a/Slim/Utils/DateTime.pm +++ b/Slim/Utils/DateTime.pm @@ -131,6 +131,11 @@ sub timeFormat { ); } +sub secsToMMSS { + my $secs = shift || 0; + return sprintf('%d:%02d', int($secs / 60), $secs % 60); +} + =head2 fracSecToMinSec( $seconds ) Turns seconds into min:sec diff --git a/SlimBrowse Metadata.md b/SlimBrowse Metadata.md new file mode 100644 index 00000000000..6e7dfbe45ae --- /dev/null +++ b/SlimBrowse Metadata.md @@ -0,0 +1,54 @@ +# SlimBrowse Metadata + +The following is a summary of the metadata returned by the XMLBrowser based CLI commands - if requested and available. +In order to request raw metadata, add the `tags:...` parameter to those SlimBrowse queries. The results may vary +depending on the service providing the data. Eg. Podcasts on TuneIn don't have a publishing date, but it's embedded +in the description. And they don't provide an author or publisher. + +## Albums +* hasMetadata `album` +* album (name) +* artist +* artists +* genre +* year + +## Artists +* hasMetadata `artist` +* artist + +## Tracks +* hasMetadata `track` +* title +* album (name) +* artist (name) +* artists +* tracknum +* duration +* year + +## Playlists +* hasMetadata: `playlist` +* title +* description + +## Radio Station +* hasMetadata: `station` +* name +* description + +## Podcasts +* hasMetadata `podcast` +* title +* publisher +* description + +## Podcast Episodes +* hasMetadata `episode` +* title +* podcast (the show's name if available) +* description +* duration +* date (of the episode's publishing) + + From 2dfa8e3ee1664927dc1b100d70cd94abaf058bc1 Mon Sep 17 00:00:00 2001 From: darrell-k Date: Thu, 12 Jun 2025 20:43:58 +0100 Subject: [PATCH 2/6] some extra tags Signed-off-by: darrell-k --- Slim/Control/XMLBrowser.pm | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Slim/Control/XMLBrowser.pm b/Slim/Control/XMLBrowser.pm index ea0f72e8edb..0f90ea60771 100644 --- a/Slim/Control/XMLBrowser.pm +++ b/Slim/Control/XMLBrowser.pm @@ -41,18 +41,20 @@ 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 = ( - g => 'genres', # "publisher" is used in Podcasts a => ['artist','publisher'], A => 'artists', - l => 'album', + b => ['work','composer'], d => ['secs','duration'], - # i => 'tracks.disc', - # q => 'albums.discc', + g => 'genre', + G => 'genres', + i => 'discnum', + k => 'description', + l => 'album', + q => 'disccount', t => 'tracknum', # "date" is being used in Podcast episodes y => ['year','date'], - k => 'description', ); sub cliQuery { From 8f1bce85c2c761758b2d53d8edd6d300c3280084 Mon Sep 17 00:00:00 2001 From: darrell-k Date: Fri, 20 Jun 2025 19:20:05 +0100 Subject: [PATCH 3/6] Make tags param available to plugin Signed-off-by: darrell-k --- Slim/Control/XMLBrowser.pm | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Slim/Control/XMLBrowser.pm b/Slim/Control/XMLBrowser.pm index 0f90ea60771..9770e79e7de 100644 --- a/Slim/Control/XMLBrowser.pm +++ b/Slim/Control/XMLBrowser.pm @@ -329,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; @@ -515,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; } From 8d8790460d10a832707060351bdf9c4719e10d0f Mon Sep 17 00:00:00 2001 From: darrell-k Date: Sun, 22 Jun 2025 18:58:15 +0100 Subject: [PATCH 4/6] Add album metadata to top level of response Signed-off-by: darrell-k --- Slim/Control/XMLBrowser.pm | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Slim/Control/XMLBrowser.pm b/Slim/Control/XMLBrowser.pm index 9770e79e7de..abf8360ed19 100644 --- a/Slim/Control/XMLBrowser.pm +++ b/Slim/Control/XMLBrowser.pm @@ -813,6 +813,7 @@ sub _cliQuery_done { main::INFOLOG && $log->info("Get items."); my $items = $subFeed->{'items'}; +#Slim::Utils::Log::logError("DK \$items=" . Data::Dump::dump($items)); my $count = $subFeed->{'total'};; $count ||= defined $items ? scalar @$items : 0; @@ -1468,9 +1469,18 @@ sub _cliQuery_done { } } +#Slim::Utils::Log::logError("DK \$subFeed=" . Data::Dump::dump($subFeed)); $request->addResult('count', $totalCount); + if ( $subFeed->{'hasMetadata'} && $subFeed->{'hasMetadata'} eq 'album' ) { + $request->addResult('year', $subFeed->{'year'}); + $request->addResult('album', $subFeed->{'album'}); + $request->addResult('artist', $subFeed->{'artist'}); + $request->addResult('genre', $subFeed->{'genre'}); + $request->addResult('hasMetadata', $subFeed->{'hasMetadata'}); + } + if ($menuMode) { if ($request->getResult('base')) { @@ -1548,6 +1558,7 @@ sub _cliQuery_done { } # ENDIF $isItemQuery $request->setStatusDone(); +#Slim::Utils::Log::logError("DK \$request=" . Data::Dump::dump($request)); } From 56fecda47c96608aa26c6ef70ab38bed3af294c4 Mon Sep 17 00:00:00 2001 From: darrell-k Date: Mon, 23 Jun 2025 17:32:27 +0100 Subject: [PATCH 5/6] always include hasMetadata elemet if present Signed-off-by: darrell-k --- Slim/Control/XMLBrowser.pm | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Slim/Control/XMLBrowser.pm b/Slim/Control/XMLBrowser.pm index abf8360ed19..119438017ac 100644 --- a/Slim/Control/XMLBrowser.pm +++ b/Slim/Control/XMLBrowser.pm @@ -813,7 +813,6 @@ sub _cliQuery_done { main::INFOLOG && $log->info("Get items."); my $items = $subFeed->{'items'}; -#Slim::Utils::Log::logError("DK \$items=" . Data::Dump::dump($items)); my $count = $subFeed->{'total'};; $count ||= defined $items ? scalar @$items : 0; @@ -1469,16 +1468,17 @@ sub _cliQuery_done { } } -#Slim::Utils::Log::logError("DK \$subFeed=" . Data::Dump::dump($subFeed)); $request->addResult('count', $totalCount); - if ( $subFeed->{'hasMetadata'} && $subFeed->{'hasMetadata'} eq 'album' ) { - $request->addResult('year', $subFeed->{'year'}); - $request->addResult('album', $subFeed->{'album'}); - $request->addResult('artist', $subFeed->{'artist'}); - $request->addResult('genre', $subFeed->{'genre'}); - $request->addResult('hasMetadata', $subFeed->{'hasMetadata'}); + 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) { @@ -1558,7 +1558,6 @@ sub _cliQuery_done { } # ENDIF $isItemQuery $request->setStatusDone(); -#Slim::Utils::Log::logError("DK \$request=" . Data::Dump::dump($request)); } From 1acb37d7552182bda0b0f48cbe18dd8d17cf1444 Mon Sep 17 00:00:00 2001 From: darrell-k Date: Tue, 24 Jun 2025 19:18:47 +0100 Subject: [PATCH 6/6] add title/version/titleFlags elements Signed-off-by: darrell-k --- Slim/Control/XMLBrowser.pm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Slim/Control/XMLBrowser.pm b/Slim/Control/XMLBrowser.pm index 119438017ac..40a0c20a474 100644 --- a/Slim/Control/XMLBrowser.pm +++ b/Slim/Control/XMLBrowser.pm @@ -50,9 +50,9 @@ my %colMap = ( G => 'genres', i => 'discnum', k => 'description', - l => 'album', + l => ['album','version'], q => 'disccount', - t => 'tracknum', + t => ['tracknum','title','titleFlags'], # "date" is being used in Podcast episodes y => ['year','date'], );