diff --git a/Slim/Control/XMLBrowser.pm b/Slim/Control/XMLBrowser.pm index 29f91ef8c5c..40a0c20a474 100644 --- a/Slim/Control/XMLBrowser.pm +++ b/Slim/Control/XMLBrowser.pm @@ -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'], + q => 'disccount', + t => ['tracknum','title','titleFlags'], + # "date" is being used in Podcast episodes + y => ['year','date'], +); + sub cliQuery { my ( $query, $feed, $request, $expires, $forceTitle ) = @_; @@ -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; @@ -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; } @@ -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') ) ) { @@ -1372,6 +1394,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 +1461,6 @@ sub _cliQuery_done { $hash{hasitems} = $hasItems; } - $request->setResultLoopHash($loopname, $cnt, \%hash); } $cnt++; @@ -1421,6 +1471,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')) { 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) + +