├── .github └── workflows │ └── release.yml ├── .gitignore ├── API.pm ├── API ├── Async.pm ├── Auth.pm └── Sync.pm ├── HTML └── EN │ └── plugins │ └── TIDAL │ ├── auth.html │ ├── html │ ├── emblem.png │ ├── featured_MTL_svg_trophy.png │ ├── home.png │ ├── mix_MTL_svg_stream.png │ ├── moods_MTL_icon_celebration.png │ ├── personal.png │ └── tidal_MTL_svg_tidal.png │ └── settings.html ├── Importer.pm ├── InfoMenu.pm ├── LastMix.pm ├── Plugin.pm ├── ProtocolHandler.pm ├── README.md ├── Settings.pm ├── Settings └── Auth.pm ├── install.xml ├── repo ├── release.pl └── repo.xml ├── strings.txt └── tidal_icons.svg /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Checkout Repository 12 | uses: actions/checkout@v4 13 | 14 | - name: Set Version 15 | id: set-version 16 | run: 17 | echo "version=`egrep -o "version>(.*)> $GITHUB_OUTPUT 18 | 19 | - name: Zip Repository 20 | id: zip 21 | run: zip TIDAL -r API *.pm HTML Settings install.xml strings.txt README.md 22 | 23 | - name: Setup Perl 24 | run: | 25 | sudo apt-get install -y libxml-simple-perl libdigest-sha-perl 26 | 27 | - name: Update SHA and Version in repo.xml 28 | id: tag 29 | run: | 30 | url="https://github.com/${{ github.repository }}/releases/download/${{ steps.set-version.outputs.version }}" 31 | perl repo/release.pl repo/repo.xml ${{ steps.set-version.outputs.version }} TIDAL.zip $url 32 | 33 | - name: Update Repository 34 | run: | 35 | git config user.name github-actions 36 | git config user.email github-actions@github.com 37 | git add repo/repo.xml 38 | git commit -m "Update repo.xml for release ${{ steps.set-version.outputs.version }}" 39 | git push origin HEAD:main 40 | 41 | - name: Create Release 42 | id: create_release 43 | uses: softprops/action-gh-release@v1 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | with: 47 | tag_name: ${{ steps.set-version.outputs.version }} 48 | name: Version ${{ steps.set-version.outputs.version }} 49 | body: TIDAL plugin release 50 | draft: false 51 | prerelease: false 52 | files: TIDAL.zip 53 | 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .local 2 | repo/repo.xml 3 | -------------------------------------------------------------------------------- /API.pm: -------------------------------------------------------------------------------- 1 | package Plugins::TIDAL::API; 2 | 3 | use strict; 4 | use Exporter::Lite; 5 | 6 | use Slim::Utils::Cache; 7 | use Slim::Utils::Log; 8 | use Slim::Utils::Prefs; 9 | 10 | our @EXPORT_OK = qw(AURL BURL KURL LURL SCOPES GRANT_TYPE_DEVICE DEFAULT_LIMIT MAX_LIMIT PLAYLIST_LIMIT DEFAULT_TTL DYNAMIC_TTL USER_CONTENT_TTL); 11 | 12 | use constant AURL => 'https://auth.tidal.com'; 13 | use constant BURL => 'https://api.tidal.com/v1'; 14 | use constant KURL => 'https://gist.githubusercontent.com/yaronzz/48d01f5a24b4b7b37f19443977c22cd6/raw/5a91ced856f06fe226c1c72996685463393a9d00/tidal-api-key.json'; 15 | use constant LURL => 'https://listen.tidal.com/v2'; 16 | use constant IURL => 'http://resources.tidal.com/images/'; 17 | use constant SCOPES => 'r_usr+w_usr'; 18 | use constant GRANT_TYPE_DEVICE => 'urn:ietf:params:oauth:grant-type:device_code'; 19 | 20 | use constant DEFAULT_LIMIT => 100; 21 | use constant PLAYLIST_LIMIT => 50; 22 | use constant MAX_LIMIT => 5000; 23 | 24 | use constant DEFAULT_TTL => 86400; 25 | use constant DYNAMIC_TTL => 3600; 26 | use constant USER_CONTENT_TTL => 300; 27 | 28 | use constant IMAGE_SIZES => { 29 | album => '1280x1280', 30 | track => '1280x1280', 31 | artist => '750x750', 32 | user => '600x600', 33 | mood => '684x684', 34 | genre => '640x426', 35 | playlist => '1080x720', 36 | playlistSquare => '1080x1080', 37 | }; 38 | 39 | use constant SOUND_QUALITY => { 40 | LOW => 'mp4', 41 | HIGH => 'mp4', 42 | LOSSLESS => 'flc', 43 | HI_RES => 'flc', 44 | }; 45 | 46 | my $cache = Slim::Utils::Cache->new; 47 | my $log = logger('plugin.tidal'); 48 | my $prefs = preferences('plugin.tidal'); 49 | 50 | sub getSomeUserId { 51 | my $accounts = $prefs->get('accounts'); 52 | 53 | my ($account) = keys %$accounts; 54 | 55 | return $account; 56 | } 57 | 58 | sub getUserdata { 59 | my ($class, $userId) = @_; 60 | 61 | return unless $userId; 62 | 63 | my $accounts = $prefs->get('accounts') || return; 64 | 65 | return $accounts->{$userId}; 66 | } 67 | 68 | sub getCountryCode { 69 | my ($class, $userId) = @_; 70 | my $userdata = $class->getUserdata($userId) || {}; 71 | return uc($prefs->get('countryCode') || $userdata->{countryCode} || 'US'); 72 | } 73 | 74 | sub getFormat { 75 | return SOUND_QUALITY->{$prefs->get('quality')}; 76 | } 77 | 78 | sub getImageUrl { 79 | my ($class, $data, $usePlaceholder, $type) = @_; 80 | 81 | if ( my $coverId = $data->{cover} || $data->{image} || $data->{squareImage} || $data->{picture} || ($data->{album} && $data->{album}->{cover}) ) { 82 | 83 | return $data->{cover} = $coverId if $coverId =~ /^https?:/; 84 | 85 | $type ||= $class->typeOfItem($data); 86 | my $iconSize; 87 | 88 | if ($type eq 'playlist' && $data->{squareImage}) { 89 | $coverId = $data->{squareImage}; 90 | $iconSize ||= IMAGE_SIZES->{playlistSquare}; 91 | } 92 | 93 | $iconSize ||= IMAGE_SIZES->{$type}; 94 | 95 | if ($iconSize) { 96 | $coverId =~ s/-/\//g; 97 | $data->{cover} = IURL . $coverId . "/$iconSize.jpg"; 98 | } 99 | else { 100 | delete $data->{cover}; 101 | } 102 | } 103 | elsif (my $images = $data->{mixImages}) { 104 | if (ref $images eq 'ARRAY') { 105 | $images = { map { substr($_->{size}, 0, 1) => $_ } @$images }; 106 | } 107 | 108 | my $image = $images->{L} || $images->{M} || $images->{S}; 109 | $data->{cover} = $image->{url} if $image; 110 | } 111 | elsif (my $images = $data->{images}) { 112 | my $image = $images->{MEDIUM} || $images->{SMALL} || $images->{LARGE}; 113 | $data->{cover} = $image->{url} if $image; 114 | } 115 | 116 | return $data->{cover} || (!main::SCANNER && $usePlaceholder && Plugins::TIDAL::Plugin->_pluginDataFor('icon')); 117 | } 118 | 119 | sub typeOfItem { 120 | my ($class, $item) = @_; 121 | 122 | return '' unless ref $item; 123 | 124 | if ( $item->{type} && $item->{type} =~ /(?:EXTURL|VIDEO)/ ) {} 125 | elsif ( defined $item->{hasPlaylists} && $item->{path} ) { 126 | return 'category'; 127 | } 128 | elsif ( ($item->{type} && $item->{type} =~ /(?:ALBUM|EP|SINGLE)/) || ($item->{releaseDate} && defined $item->{numberOfTracks}) ) { 129 | return 'album'; 130 | } 131 | # playlist items can be of various types: USER, EDITORIAL etc., but they should have a numberOfTracks element 132 | elsif ( $item->{type} && defined $item->{numberOfTracks} && ($item->{created} || $item->{creator} || $item->{creators} || $item->{publicPlaylist} || $item->{lastUpdated}) ) { 133 | return 'playlist'; 134 | } 135 | elsif ( (defined $item->{mixNumber} && $item->{artists}) || defined $item->{mixType} || $item->{type} =~ /_MIX\b/ ) { 136 | return 'mix' 137 | } 138 | # only artists have names? Others have titles? 139 | elsif ( $item->{name} ) { 140 | return 'artist'; 141 | } 142 | # tracks? 143 | elsif ( !$item->{type} || defined $item->{duration}) { 144 | return 'track'; 145 | } 146 | elsif ( main::INFOLOG ) { 147 | $log->warn('unknown tidal item type: ' . Data::Dump::dump($item)); 148 | Slim::Utils::Log::logBacktrace(''); 149 | } 150 | 151 | return ''; 152 | } 153 | 154 | sub cacheTrackMetadata { 155 | my ($class, $tracks) = @_; 156 | 157 | return [] unless $tracks; 158 | 159 | return [ grep { $_ } map { 160 | my $entry = $_; 161 | $entry = $entry->{item} if $entry->{item}; 162 | 163 | if ($entry->{allowStreaming} || ! defined $entry->{streamReady}) { 164 | my $oldMeta = $cache->get( 'tidal_meta_' . $entry->{id}); 165 | $oldMeta = {} unless ref $oldMeta; 166 | 167 | my $icon = $class->getImageUrl($entry, 'usePlaceholder', 'track'); 168 | my $artist = $entry->{artist}; 169 | ($artist) = grep { $_->{type} eq 'MAIN' || $_->{main} } @{$entry->{artists}} unless $artist; 170 | 171 | # consolidate metadata in case parsing of stream came first (huh?) 172 | my $meta = { 173 | %$oldMeta, 174 | id => $entry->{id}, 175 | title => $entry->{title}, 176 | artist => $artist, 177 | artists => $entry->{artists}, 178 | album => $entry->{album}->{title}, 179 | album_id => $entry->{album}->{id}, 180 | duration => $entry->{duration}, 181 | icon => $icon, 182 | cover => $icon, 183 | replay_gain => $entry->{replayGain} || 0, 184 | peak => $entry->{peak}, 185 | disc => $entry->{volumeNumber}, 186 | tracknum => $entry->{trackNumber}, 187 | url => $entry->{url}, 188 | }; 189 | 190 | # cache track metadata aggressively 191 | $cache->set( 'tidal_meta_' . $entry->{id}, $meta, time() + 90 * 86400); 192 | 193 | $meta; 194 | } 195 | } @$tracks ]; 196 | } 197 | 198 | sub getHumanReadableName { 199 | my ($class, $profile) = @_; 200 | return $profile->{nickname} || $profile->{firstName} || $profile->{fullName} || $profile->{username}; 201 | } 202 | 203 | 1; 204 | -------------------------------------------------------------------------------- /API/Async.pm: -------------------------------------------------------------------------------- 1 | package Plugins::TIDAL::API::Async; 2 | 3 | use strict; 4 | use base qw(Slim::Utils::Accessor); 5 | 6 | use Async::Util; 7 | use Data::URIEncode qw(complex_to_query); 8 | use Date::Parse qw(str2time); 9 | use MIME::Base64 qw(encode_base64); 10 | use JSON::XS::VersionOneAndTwo; 11 | use List::Util qw(min maxstr reduce); 12 | 13 | use Slim::Networking::SimpleAsyncHTTP; 14 | use Slim::Utils::Cache; 15 | use Slim::Utils::Log; 16 | use Slim::Utils::Prefs; 17 | use Slim::Utils::Strings qw(string); 18 | 19 | use Plugins::TIDAL::API qw(BURL LURL DEFAULT_LIMIT PLAYLIST_LIMIT MAX_LIMIT DEFAULT_TTL DYNAMIC_TTL USER_CONTENT_TTL); 20 | 21 | use constant CAN_MORE_HTTP_VERBS => Slim::Networking::SimpleAsyncHTTP->can('delete'); 22 | 23 | { 24 | __PACKAGE__->mk_accessor( rw => qw( 25 | client 26 | userId 27 | updatedPlaylists 28 | ) ); 29 | 30 | __PACKAGE__->mk_accessor( hash => qw( 31 | updatedFavorites 32 | ) ); 33 | } 34 | 35 | my $cache = Slim::Utils::Cache->new(); 36 | my $log = logger('plugin.tidal'); 37 | my $prefs = preferences('plugin.tidal'); 38 | my $serverPrefs = preferences('server'); 39 | 40 | my %apiClients; 41 | 42 | sub new { 43 | my ($class, $args) = @_; 44 | 45 | if (!$args->{client} && !$args->{userId}) { 46 | return; 47 | } 48 | 49 | my $client = $args->{client}; 50 | my $userId = $args->{userId} || $prefs->client($client)->get('userId') || return; 51 | 52 | if (my $apiClient = $apiClients{$userId}) { 53 | return $apiClient; 54 | } 55 | 56 | my $self = $apiClients{$userId} = $class->SUPER::new(); 57 | $self->client($client); 58 | $self->userId($userId); 59 | 60 | return $self; 61 | } 62 | 63 | sub search { 64 | my ($self, $cb, $args) = @_; 65 | 66 | my $type = $args->{type} || ''; 67 | $type = "/$type" if $type && $type !~ m{^/}; 68 | 69 | $self->_get('/search' . $type, sub { 70 | my $result = shift; 71 | 72 | my $items = $args->{type} ? $result->{items} : $result if $result && ref $result; 73 | $items = Plugins::TIDAL::API->cacheTrackMetadata($items) if $args->{type} =~ /tracks/; 74 | 75 | $cb->($items); 76 | }, { 77 | _ttl => $args->{ttl} || DYNAMIC_TTL, 78 | limit => $args->{limit}, 79 | query => $args->{search} 80 | }); 81 | } 82 | 83 | sub track { 84 | my ($self, $cb, $id) = @_; 85 | 86 | $self->_get("/tracks/$id", sub { 87 | my $track = shift; 88 | ($track) = @{ Plugins::TIDAL::API->cacheTrackMetadata([$track]) } if $track; 89 | $cb->($track); 90 | }); 91 | } 92 | 93 | sub getArtist { 94 | my ($self, $cb, $id) = @_; 95 | 96 | $self->_get("/artists/$id", sub { 97 | $cb->($_[0] || {}); 98 | }); 99 | } 100 | 101 | sub similarArtists { 102 | my ($self, $cb, $id) = @_; 103 | 104 | $self->_get("/artists/$id/similar", sub { 105 | my $result = shift; 106 | my $items = $result->{items} if $result; 107 | $cb->($items || []); 108 | }, { 109 | limit => MAX_LIMIT, 110 | _ttl => 3600, 111 | _personal => 1 112 | }); 113 | } 114 | 115 | sub artistAlbums { 116 | my ($self, $cb, $id, $type) = @_; 117 | 118 | $self->_get("/artists/$id/albums", sub { 119 | my $artist = shift; 120 | my $albums = $self->_filterAlbums($artist->{items}) if $artist; 121 | $cb->($albums || []); 122 | },{ 123 | limit => MAX_LIMIT, 124 | filter => $type || 'ALBUMS' 125 | }); 126 | } 127 | 128 | sub artistTopTracks { 129 | my ($self, $cb, $id) = @_; 130 | 131 | $self->_get("/artists/$id/toptracks", sub { 132 | my $artist = shift; 133 | my $tracks = Plugins::TIDAL::API->cacheTrackMetadata($artist->{items}) if $artist; 134 | $cb->($tracks || []); 135 | },{ 136 | limit => MAX_LIMIT, 137 | }); 138 | } 139 | 140 | sub trackRadio { 141 | my ($self, $cb, $id) = @_; 142 | 143 | $self->_get("/tracks/$id/radio", sub { 144 | my $result = shift; 145 | my $tracks = Plugins::TIDAL::API->cacheTrackMetadata($result->{items}) if $result; 146 | $cb->($tracks || []); 147 | },{ 148 | limit => MAX_LIMIT, 149 | _ttl => 3600, 150 | _personal => 1 151 | }); 152 | } 153 | 154 | # try to remove duplicates 155 | sub _filterAlbums { 156 | my ($self, $albums) = @_; 157 | 158 | my $explicitAlbumHandling = $prefs->get('explicitAlbumHandling') || {}; 159 | 160 | my (%seen, %explicit, %nonExplicit); 161 | my $wantsExplicit = $explicitAlbumHandling->{$self->userId} || 0; 162 | my $wantsNonExplicit = !$wantsExplicit || $explicitAlbumHandling->{$self->userId} == 2; 163 | my $wantsBoth = $wantsExplicit && $wantsNonExplicit; 164 | 165 | my $explicitFilter = $wantsBoth 166 | ? sub { 1 } # we want explicit and non explicit 167 | : $wantsExplicit 168 | ? sub { $_[0] || !$explicit{$_[1]} } # we only want the non-explicit version, unless there's none, in which case we use explicit version 169 | : sub { !$_[0] || !$nonExplicit{$_[1]} }; # the opposite of the above: we prefer explicit over non-explicit, if both are available 170 | 171 | return [ grep { 172 | my $fingerprint = $_->{fingerprint}; 173 | 174 | scalar (grep /^LOSSLESS$/, @{$_->{mediaMetadata}->{tags} || []}) 175 | && $explicitFilter->($_->{explicit}, $fingerprint) 176 | && !$seen{$fingerprint}++ 177 | } map { 178 | my $item = $_; 179 | my $fingerprint = join(':', $item->{artist}->{id}, $item->{title}, $item->{numberOfTracks}, ($wantsBoth ? $item->{explicit} : undef)); 180 | 181 | $explicit{$fingerprint} ||= $_->{explicit}; 182 | $nonExplicit{$fingerprint} ||= !$_->{explicit}; 183 | 184 | $item->{fingerprint} = $fingerprint; 185 | $item; 186 | } @{$albums || []} ]; 187 | } 188 | 189 | sub featured { 190 | my ($self, $cb) = @_; 191 | $self->_get("/featured", $cb); 192 | } 193 | 194 | sub home { 195 | my ($self, $cb) = @_; 196 | 197 | $self->homeFeed(sub { 198 | my $items = shift; 199 | 200 | $self->page(sub { 201 | my ($homeItems) = @_; 202 | 203 | # de-duplicate the menu items 204 | my %titles = map { $_->{title} => 1 } grep { $_->{title} } @$items; 205 | push @$items, grep { !$titles{$_->{title}} } @{$homeItems || []}; 206 | 207 | $cb->($items); 208 | }, 'pages/home'); 209 | }); 210 | } 211 | 212 | sub homeFeed { 213 | my ($self, $cb) = @_; 214 | 215 | _getTZOffset(sub { 216 | my ($timeOffset) = @_; 217 | 218 | $timeOffset = undef unless "$timeOffset" =~ m{^[-+]?\d{1,2}:\d{2}$}; 219 | 220 | $self->_get(LURL . '/home/feed/static', sub { 221 | my $page = shift; 222 | my $items = $page->{items} || []; 223 | 224 | foreach my $item (@$items) { 225 | $item->{items} = [ map { 226 | $_->{data}->{title} ||= $_->{data}->{titleTextInfo}->{text} if $_->{data}->{titleTextInfo}; 227 | $_->{data}; 228 | } @{$item->{items} || []} ]; 229 | } 230 | 231 | $cb->($items || []); 232 | }, { 233 | _ttl => DYNAMIC_TTL, 234 | _personal => 1, 235 | deviceType => 'BROWSER', 236 | platform => 'WEB', 237 | locale => lc($serverPrefs->get('language')), 238 | timeOffset => $timeOffset, 239 | }, { 240 | 'x-tidal-client-version' => '2025.4.15', 241 | }); 242 | }); 243 | } 244 | 245 | sub page { 246 | my ($self, $cb, $path, $limit) = @_; 247 | 248 | $self->_get("/$path", sub { 249 | my $page = shift; 250 | 251 | my $items = []; 252 | # flatten down all modules as they seem to be only one per row 253 | push @$items, @{$_->{modules}} foreach (@{$page->{rows}}); 254 | 255 | $cb->($items || []); 256 | }, { 257 | _ttl => DYNAMIC_TTL, 258 | _personal => 1, 259 | deviceType => 'BROWSER', 260 | limit => $limit || DEFAULT_LIMIT, 261 | locale => lc($serverPrefs->get('language')), 262 | } ); 263 | } 264 | 265 | sub dataPage { 266 | my ($self, $cb, $path, $limit) = @_; 267 | 268 | $self->_get("/$path", sub { 269 | my $page = shift; 270 | 271 | my $items = $page->{items}; 272 | 273 | $cb->($items || []); 274 | }, { 275 | _ttl => DYNAMIC_TTL, 276 | _personal => 1, 277 | _page => PLAYLIST_LIMIT, 278 | deviceType => 'BROWSER', 279 | limit => $limit || DEFAULT_LIMIT, 280 | locale => lc($serverPrefs->get('language')), 281 | } ); 282 | } 283 | 284 | sub featuredItem { 285 | my ($self, $cb, $args) = @_; 286 | 287 | my $id = $args->{id}; 288 | my $type = $args->{type}; 289 | 290 | return $cb->() unless $id && $type; 291 | 292 | $self->_get("/featured/$id/$type", sub { 293 | my $items = shift; 294 | my $tracks = $items->{items} if $items; 295 | $tracks = Plugins::TIDAL::API->cacheTrackMetadata($tracks) if $tracks && $type eq 'tracks'; 296 | 297 | $cb->($tracks || []); 298 | }); 299 | } 300 | 301 | sub myMixes { 302 | my ($self, $cb) = @_; 303 | 304 | $self->_get("/mixes/daily/track", sub { 305 | $cb->(@_); 306 | }, { 307 | limit => MAX_LIMIT, 308 | _ttl => 3600, 309 | _personal => 1, 310 | }); 311 | } 312 | 313 | sub mix { 314 | my ($self, $cb, $id) = @_; 315 | 316 | $self->_get("/mixes/$id/items", sub { 317 | my $mix = shift; 318 | 319 | my $tracks = Plugins::TIDAL::API->cacheTrackMetadata([ map { 320 | $_->{item} 321 | } grep { 322 | $_->{type} && $_->{type} eq 'track' 323 | } @{$mix->{items} || []} ]) if $mix; 324 | 325 | $cb->($tracks || []); 326 | }, { 327 | limit => MAX_LIMIT, 328 | _ttl => 3600, 329 | _personal => 1 330 | }); 331 | } 332 | 333 | sub album { 334 | my ($self, $cb, $id) = @_; 335 | 336 | $self->_get("/albums/$id", sub { 337 | my $album = shift; 338 | $cb->($album); 339 | },{ 340 | limit => MAX_LIMIT 341 | }); 342 | } 343 | 344 | sub albumTracks { 345 | my ($self, $cb, $id) = @_; 346 | 347 | $self->_get("/albums/$id/tracks", sub { 348 | my $album = shift; 349 | my $tracks = $album->{items} if $album; 350 | $tracks = Plugins::TIDAL::API->cacheTrackMetadata($tracks) if $tracks; 351 | 352 | $cb->($tracks || []); 353 | },{ 354 | limit => MAX_LIMIT 355 | }); 356 | } 357 | 358 | sub genres { 359 | my ($self, $cb) = @_; 360 | $self->_get('/genres', $cb); 361 | } 362 | 363 | sub genreByType { 364 | my ($self, $cb, $genre, $type) = @_; 365 | 366 | $self->_get("/genres/$genre/$type", sub { 367 | my $results = shift; 368 | my $items = $results->{items} if $results; 369 | $items = Plugins::TIDAL::API->cacheTrackMetadata($items) if $items && $type eq 'tracks'; 370 | $cb->($items || []); 371 | }); 372 | } 373 | 374 | sub moods { 375 | my ($self, $cb) = @_; 376 | $self->_get('/moods', $cb); 377 | } 378 | 379 | sub moodPlaylists { 380 | my ($self, $cb, $mood) = @_; 381 | 382 | $self->_get("/moods/$mood/playlists", sub { 383 | $cb->(@_); 384 | },{ 385 | limit => MAX_LIMIT, 386 | }); 387 | } 388 | 389 | # see comment on Plugin::GetFavoritesPlaylists 390 | =comment 391 | sub userPlaylists { 392 | my ($self, $cb, $userId) = @_; 393 | 394 | $userId ||= $self->userId; 395 | 396 | $self->_get("/users/$userId/playlists", sub { 397 | my $result = shift; 398 | my $items = $result->{items} if $result; 399 | 400 | $cb->($items); 401 | },{ 402 | limit => MAX_LIMIT, 403 | _ttl => 300, 404 | }) 405 | } 406 | =cut 407 | 408 | sub playlistData { 409 | my ($self, $cb, $uuid) = @_; 410 | 411 | $self->_get("/playlists/$uuid", sub { 412 | my $playlist = shift; 413 | $cb->($playlist); 414 | }, { _ttl => DYNAMIC_TTL } ); 415 | } 416 | 417 | sub playlist { 418 | my ($self, $cb, $uuid) = @_; 419 | 420 | # we need to verify that the playlist has not been invalidated 421 | my $cacheKey = 'tidal_playlist_refresh_' . $uuid; 422 | my $refresh = $cache->get($cacheKey); 423 | 424 | $self->_get("/playlists/$uuid/items", sub { 425 | my $result = shift; 426 | 427 | my $items = Plugins::TIDAL::API->cacheTrackMetadata([ map { 428 | $_->{item} 429 | } grep { 430 | $_->{type} && $_->{type} eq 'track' 431 | } @{$result->{items} || []} ]) if $result; 432 | 433 | $cache->remove($cacheKey) if $refresh; 434 | 435 | $cb->($items || []); 436 | },{ 437 | _ttl => DYNAMIC_TTL, 438 | _refresh => $refresh, 439 | limit => MAX_LIMIT, 440 | }); 441 | } 442 | 443 | # User collections can be large - but have a known last updated timestamp. 444 | # Playlist are more complicated as the list might have changed but also the 445 | # content might have changed. We know if the list of favorite playlists has 446 | # changed and if the content (tbc) of user-created playlists has changed. 447 | # So if the list has changed, we re-read it and iterate playlists to see 448 | # and flag the updated ones so that cache is refreshed next time we access 449 | # these (note that we don't re-read the items, just the playlist). 450 | # But that does not do much good when the list have not changed, we can 451 | # only wait for the playlist's cache ttl to expire (1 day) 452 | # For users-created playlists, the situtation is better because we know that 453 | # the content of at least one has changed, so we re-read and invalidate them 454 | # as describes above, but because we have a flag for content update, changes 455 | # are detected immediately, regardless of cache. 456 | 457 | sub getFavorites { 458 | my ($self, $cb, $type, $refresh) = @_; 459 | 460 | return $cb->() unless $type; 461 | 462 | my $userId = $self->userId || return $cb->(); 463 | my $cacheKey = "tidal_favs_$type:$userId"; 464 | 465 | # verify if that type has been updated and force refresh (don't confuse adding 466 | # a playlist to favorites with changing the *content* of a playlist) 467 | $refresh ||= $self->updatedFavorites($type); 468 | $self->updatedFavorites($type, 0); 469 | 470 | my $lookupSub = sub { 471 | my $timestamp = shift; 472 | 473 | $self->_get("/users/$userId/favorites/$type", sub { 474 | my $result = shift; 475 | 476 | my $items = [ map { $_->{item} } @{$result->{items} || []} ] if $result; 477 | $items = Plugins::TIDAL::API->cacheTrackMetadata($items) if $items && $type eq 'tracks'; 478 | 479 | # verify if playlists need to be invalidated 480 | if (defined $timestamp && $type eq 'playlists') { 481 | foreach my $playlist (@$items) { 482 | next unless str2time($playlist->{lastUpdated}) > $timestamp; 483 | main::INFOLOG && $log->is_info && $log->info("Invalidating playlist $playlist->{uuid}"); 484 | # the invalidation flag lives longer than the playlist cache itself 485 | $cache->set('tidal_playlist_refresh_' . $playlist->{uuid}, DEFAULT_TTL); 486 | } 487 | } 488 | 489 | $cache->set($cacheKey, { 490 | items => $items, 491 | timestamp => time(), 492 | }, '1M') if $items; 493 | 494 | $cb->($items); 495 | },{ 496 | _nocache => 1, 497 | limit => MAX_LIMIT, 498 | }); 499 | }; 500 | 501 | # use cached data unless the collection has changed 502 | my $cached = $cache->get($cacheKey); 503 | if (ref $cached && ref $cached->{items}) { 504 | # don't bother verifying timestamp unless we're sure we need to 505 | return $cb->($cached->{items}) unless $refresh; 506 | 507 | $self->getLatestCollectionTimestamp(sub { 508 | my ($timestamp, $fullset) = @_; 509 | 510 | # we re-check more than what we should if updatePlaylist has changed, as we could 511 | # limit to user-made playlist. But that does not cost much to check them all 512 | if ($timestamp > $cached->{timestamp} || ($type eq 'playlists' && $fullset->{updatedPlaylists} > $cached->{timestamp})) { 513 | main::INFOLOG && $log->is_info && $log->info("Favorites of type '$type' has changed - updating"); 514 | $lookupSub->($cached->{timestamp}); 515 | } 516 | else { 517 | main::INFOLOG && $log->is_info && $log->info("Favorites of type '$type' has not changed - using cached results"); 518 | $cb->($cached->{items}); 519 | } 520 | }, $type); 521 | } 522 | else { 523 | $lookupSub->(); 524 | } 525 | } 526 | 527 | sub getCollectionPlaylists { 528 | my ($self, $cb, $refresh) = @_; 529 | 530 | my $userId = $self->userId || return $cb->(); 531 | my $cacheKey = "tidal_playlists:$userId"; 532 | 533 | $refresh ||= $self->updatedPlaylists(); 534 | $self->updatedPlaylists(0); 535 | 536 | my $lookupSub = sub { 537 | my $timestamp = shift; 538 | 539 | $self->_get("/users/$userId/playlistsAndFavoritePlaylists", sub { 540 | my $result = shift; 541 | 542 | my $items = [ map { $_->{playlist} } @{$result->{items} || []} ] if $result; 543 | 544 | foreach my $playlist (@$items) { 545 | next unless str2time($playlist->{lastUpdated}) > $timestamp; 546 | main::INFOLOG && $log->is_info && $log->info("Invalidating playlist $playlist->{uuid}"); 547 | $cache->set('tidal_playlist_refresh_' . $playlist->{uuid}, DEFAULT_TTL); 548 | } 549 | 550 | $cache->set($cacheKey, { 551 | items => $items, 552 | timestamp => time(), 553 | }, '1M') if $items; 554 | 555 | $cb->($items); 556 | },{ 557 | _nocache => 1, 558 | # yes, this is the ONLY API THAT HAS A DIFFERENT PAGE LIMIT 559 | _page => PLAYLIST_LIMIT, 560 | limit => MAX_LIMIT, 561 | }); 562 | }; 563 | 564 | my $cached = $cache->get($cacheKey); 565 | if (ref $cached && ref $cached->{items}) { 566 | return $cb->($cached->{items}) unless $refresh; 567 | 568 | $self->getLatestCollectionTimestamp(sub { 569 | my ($timestamp, $fullset) = @_; 570 | 571 | if ($timestamp > $cached->{timestamp} || $fullset->{updatedPlaylists} > $cached->{timestamp}) { 572 | main::INFOLOG && $log->is_info && $log->info("Collection of playlists has changed - updating"); 573 | $lookupSub->($cached->{timestamp}); 574 | } 575 | else { 576 | main::INFOLOG && $log->is_info && $log->info("Collection of playlists has not changed - using cached results"); 577 | $cb->($cached->{items}); 578 | } 579 | }, 'playlists'); 580 | } 581 | else { 582 | $lookupSub->(); 583 | } 584 | } 585 | 586 | sub getLatestCollectionTimestamp { 587 | my ($self, $cb, $type) = @_; 588 | 589 | my $userId = $self->userId || return $cb->(); 590 | 591 | $self->_get("/users/$userId/favorites", sub { 592 | my $result = shift; 593 | my $key = 'updatedFavorite' . ucfirst($type || ''); 594 | $result->{$_} = (str2time($result->{$_}) || 0) foreach (keys %$result); 595 | $cb->( $result->{$key}, $result ); 596 | }, { _nocache => 1 }); 597 | } 598 | 599 | sub updateFavorite { 600 | my ($self, $cb, $action, $type, $id) = @_; 601 | 602 | my $userId = $self->userId; 603 | my $key = $type ne 'playlist' ? $type . 'Ids' : 'uuids'; 604 | $type .= 's'; 605 | 606 | # make favorites has updated 607 | $self->updatedFavorites($type, 1); 608 | $self->updatedPlaylists(1) if $type eq 'playlist'; 609 | 610 | if ($action eq 'add') { 611 | 612 | my $params = { 613 | $key => $id, 614 | onArtifactNotFound => 'SKIP', 615 | }; 616 | 617 | my $headers = { 'Content-Type' => 'application/x-www-form-urlencoded' }; 618 | 619 | $self->_post("/users/$userId/favorites/$type", 620 | sub { $cb->() }, 621 | $params, 622 | $headers, 623 | ); 624 | } 625 | else { 626 | $self->_delete("/users/$userId/favorites/$type/$id", 627 | sub { $cb->() }, 628 | ); 629 | } 630 | } 631 | 632 | sub updatePlaylist { 633 | my ($self, $cb, $action, $uuid, $trackIdOrIndex) = @_; 634 | 635 | # mark that playlist as need to be refreshed. After the DEFAULT_TTL 636 | # the _get will also have forgotten it, no need to cache beyond 637 | $cache->set('tidal_playlist_refresh_' . $uuid, DEFAULT_TTL); 638 | 639 | # we need an etag, so we need to do a request of one, not cached! 640 | $self->_get("/playlists/$uuid/items", 641 | sub { 642 | my ($result, $response) = @_; 643 | my $eTag = $response->headers->header('etag'); 644 | $eTag =~ s/"//g; 645 | 646 | # and yes, you're not dreaming, the Tidal API does not allow you to delete 647 | # a track in a playlist by it's id, you need to provide the item's *index* 648 | # in the list... OMG 649 | if ($action eq 'add') { 650 | my $params = { 651 | trackIds => $trackIdOrIndex, 652 | onDupes => 'SKIP', 653 | onArtifactNotFound => 'SKIP', 654 | }; 655 | 656 | my %headers = ( 657 | 'Content-Type' => 'application/x-www-form-urlencoded', 658 | ); 659 | $headers{'If-None-Match'} = $eTag if $eTag; 660 | 661 | $self->_post("/playlists/$uuid/items", 662 | sub { $cb->() }, 663 | $params, 664 | \%headers, 665 | ); 666 | } 667 | else { 668 | my %headers; 669 | $headers{'If-None-Match'} = $eTag if $eTag; 670 | 671 | $self->_delete("/playlists/$uuid/items/$trackIdOrIndex", 672 | sub { $cb->() }, 673 | {}, 674 | \%headers, 675 | ); 676 | } 677 | }, { 678 | _nocache => 1, 679 | limit => 1, 680 | } 681 | ); 682 | } 683 | 684 | sub getTrackUrl { 685 | my ($self, $cb, $id, $params) = @_; 686 | 687 | $params->{_nocache} = 1; 688 | 689 | $self->_get('/tracks/' . $id . '/playbackinfopostpaywall', sub { 690 | $cb->(@_); 691 | }, $params); 692 | } 693 | 694 | sub getToken { 695 | my ( $self, $cb ) = @_; 696 | 697 | my $userId = $self->userId; 698 | my $token = $cache->get("tidal_at_$userId"); 699 | 700 | return $cb->($token) if $token; 701 | 702 | Plugins::TIDAL::API::Auth->refreshToken($cb, $self->userId); 703 | } 704 | 705 | my $_getTZOffset; 706 | sub _getTZOffset { 707 | my ($cb) = @_; 708 | 709 | $_getTZOffset ||= Slim::Utils::DateTime->can('getTZOffsetHHMM') 710 | ? \&Slim::Utils::DateTime::getTZOffsetHHMM 711 | : sub { $_[0]->() }; 712 | 713 | $_getTZOffset->($cb); 714 | } 715 | 716 | 717 | sub _get { 718 | my ( $self, $url, $cb, $params, $headers ) = @_; 719 | $self->_call($url, $cb, $params, $headers); 720 | } 721 | 722 | sub _post { 723 | my ( $self, $url, $cb, $params, $headers ) = @_; 724 | $params ||= {}; 725 | $params->{_method} = 'post'; 726 | $params->{_nocache} = 1; 727 | $self->_call($url, $cb, $params, $headers); 728 | } 729 | 730 | sub _delete { if (CAN_MORE_HTTP_VERBS) { 731 | my ( $self, $url, $cb, $params, $headers ) = @_; 732 | $params ||= {}; 733 | $params->{_method} = 'delete'; 734 | $params->{_nocache} = 1; 735 | $self->_call($url, $cb, $params, $headers); 736 | } else { 737 | $log->error('Your LMS does not support the DELETE http verb yet - please update!'); 738 | return $_[2]->(); 739 | } } 740 | 741 | sub _call { 742 | my ( $self, $url, $cb, $params, $headers ) = @_; 743 | 744 | $self->getToken(sub { 745 | my ($token) = @_; 746 | 747 | if (!$token) { 748 | my $error = $1 || 'NO_ACCESS_TOKEN'; 749 | $error = 'NO_ACCESS_TOKEN' if $error !~ /429/; 750 | 751 | $cb->({ 752 | error => 'Did not get a token ' . $error, 753 | }); 754 | } 755 | else { 756 | $params ||= {}; 757 | $params->{countryCode} ||= Plugins::TIDAL::API->getCountryCode($self->userId); 758 | 759 | $headers ||= {}; 760 | $headers->{Authorization} = 'Bearer ' . $token; 761 | 762 | my $ttl = delete $params->{_ttl} || DEFAULT_TTL; 763 | my $noCache = delete $params->{_nocache}; 764 | my $refresh = delete $params->{_refresh}; 765 | my $personalCache = delete $params->{_personal} ? ($self->userId . ':') : ''; 766 | my $method = delete $params->{_method} || 'get'; 767 | my $pageSize = delete $params->{_page} || DEFAULT_LIMIT; 768 | 769 | $params->{limit} ||= DEFAULT_LIMIT; 770 | 771 | while (my ($k, $v) = each %$params) { 772 | $params->{$k} = Slim::Utils::Unicode::utf8toLatin1Transliterate($v); 773 | } 774 | 775 | my $cacheKey = "tidal_resp:$url:$personalCache" . join(':', map { 776 | $_ . $params->{$_} 777 | } sort grep { 778 | $_ !~ /^_/ 779 | } keys %$params) unless $noCache; 780 | 781 | main::INFOLOG && $log->is_info && $log->info("Using cache key '$cacheKey'") unless $noCache; 782 | 783 | my $maxLimit = 0; 784 | if ($params->{limit} > $pageSize) { 785 | $maxLimit = $params->{limit}; 786 | $params->{limit} = $pageSize; 787 | } 788 | 789 | # TODO - make sure we don't pass any of the keys prefixed with an underscore! 790 | my $query = complex_to_query($params); 791 | 792 | if (!$noCache && !$refresh && (my $cached = $cache->get($cacheKey))) { 793 | main::INFOLOG && $log->is_info && $log->info("Returning cached data for $url?$query"); 794 | main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($cached)); 795 | 796 | return $cb->($cached); 797 | } 798 | 799 | main::INFOLOG && $log->is_info && $log->info("$method $url?$query"); 800 | 801 | my $http = Slim::Networking::SimpleAsyncHTTP->new( 802 | sub { 803 | my $response = shift; 804 | 805 | my $result = eval { from_json($response->content) } if $response->content; 806 | 807 | $@ && $log->error($@); 808 | main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($result)); 809 | 810 | if ($maxLimit && $result && ref $result eq 'HASH' && $maxLimit >= $result->{totalNumberOfItems} && $result->{totalNumberOfItems} - $pageSize > 0) { 811 | my $remaining = $result->{totalNumberOfItems} - $pageSize; 812 | main::INFOLOG && $log->is_info && $log->info("We need to page to get $remaining more results"); 813 | 814 | my @offsets; 815 | my $offset = $pageSize; 816 | my $maxOffset = min($maxLimit, MAX_LIMIT, $result->{totalNumberOfItems}); 817 | do { 818 | push @offsets, $offset; 819 | $offset += $pageSize; 820 | } while ($offset < $maxOffset); 821 | 822 | # restore some of the initial params 823 | $params->{_nocache} = $noCache; 824 | $params->{_personal} = $personalCache; 825 | $params->{_refresh} = $refresh; 826 | $params->{_method} = $method; 827 | 828 | if (scalar @offsets) { 829 | Async::Util::amap( 830 | inputs => \@offsets, 831 | action => sub { 832 | my ($input, $acb) = @_; 833 | $self->_call($url, sub { 834 | # only return the first argument, the second would be considered an error 835 | $acb->($_[0]); 836 | }, { 837 | %$params, 838 | offset => $input, 839 | }); 840 | }, 841 | at_a_time => 4, 842 | cb => sub { 843 | my ($results, $error) = @_; 844 | 845 | foreach (@$results) { 846 | next unless ref $_ && $_->{items}; 847 | push @{$result->{items}}, @{$_->{items}}; 848 | } 849 | 850 | $cache->set($cacheKey, $result, $ttl) unless $noCache; 851 | 852 | $cb->($result); 853 | } 854 | ); 855 | 856 | return; 857 | } 858 | } 859 | 860 | $cache->set($cacheKey, $result, $ttl) unless $noCache; 861 | 862 | $cb->($result, $response); 863 | }, 864 | sub { 865 | my ($http, $error) = @_; 866 | 867 | $log->warn("Error: $error"); 868 | main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($http)); 869 | 870 | $cb->(); 871 | }, 872 | { 873 | cache => ($method eq 'get' && !$noCache && !$personalCache) ? 1 : 0, 874 | } 875 | ); 876 | 877 | $url = BURL . $url unless $url =~ m{^https?://}; 878 | if ($method eq 'post') { 879 | $http->$method($url, %$headers, $query); 880 | } 881 | else { 882 | $http->$method("$url?$query", %$headers); 883 | } 884 | } 885 | }); 886 | } 887 | 888 | 1; -------------------------------------------------------------------------------- /API/Auth.pm: -------------------------------------------------------------------------------- 1 | package Plugins::TIDAL::API::Auth; 2 | 3 | use strict; 4 | use Data::URIEncode qw(complex_to_query); 5 | use JSON::XS::VersionOneAndTwo; 6 | use MIME::Base64 qw(encode_base64); 7 | 8 | use Slim::Networking::SimpleAsyncHTTP; 9 | use Slim::Utils::Cache; 10 | use Slim::Utils::Log; 11 | use Slim::Utils::Prefs; 12 | use Slim::Utils::Strings qw(string); 13 | 14 | use Plugins::TIDAL::API qw(AURL KURL SCOPES GRANT_TYPE_DEVICE); 15 | 16 | use constant TOKEN_PATH => '/v1/oauth2/token'; 17 | 18 | my $cache = Slim::Utils::Cache->new(); 19 | my $log = logger('plugin.tidal'); 20 | my $prefs = preferences('plugin.tidal'); 21 | 22 | my (%deviceCodes, $cid, $sec); 23 | 24 | sub init { 25 | my $class = shift; 26 | $cid = $prefs->get('cid'); 27 | $sec = $prefs->get('sec'); 28 | 29 | $class->_fetchKs() unless $cid && $sec; 30 | } 31 | 32 | sub initDeviceFlow { 33 | my ($class, $cb) = @_; 34 | 35 | $class->_call('/v1/oauth2/device_authorization', $cb, { 36 | scope => SCOPES 37 | }); 38 | } 39 | 40 | sub pollDeviceAuth { 41 | my ($class, $args, $cb) = @_; 42 | 43 | my $deviceCode = $args->{deviceCode} || return $cb->(); 44 | 45 | $deviceCodes{$deviceCode} ||= $args; 46 | $args->{expiry} ||= time() + $args->{expiresIn}; 47 | $args->{cb} ||= $cb if $cb; 48 | 49 | _delayedPollDeviceAuth($deviceCode, $args); 50 | } 51 | 52 | sub _delayedPollDeviceAuth { 53 | my ($deviceCode, $args) = @_; 54 | 55 | Slim::Utils::Timers::killTimers($deviceCode, \&_delayedPollDeviceAuth); 56 | 57 | if ($deviceCodes{$deviceCode} && time() <= $args->{expiry}) { 58 | __PACKAGE__->_call(TOKEN_PATH, sub { 59 | my $result = shift; 60 | 61 | if ($result) { 62 | if ($result->{error}) { 63 | Slim::Utils::Timers::setTimer($deviceCode, time() + ($args->{interval} || 2), \&_delayedPollDeviceAuth, $args); 64 | return; 65 | } 66 | else { 67 | _storeTokens($result) 68 | } 69 | 70 | delete $deviceCodes{$deviceCode}; 71 | } 72 | 73 | $args->{cb}->($result) if $args->{cb}; 74 | },{ 75 | scope => SCOPES, 76 | grant_type => GRANT_TYPE_DEVICE, 77 | device_code => $deviceCode, 78 | }); 79 | 80 | return; 81 | } 82 | 83 | # we have timed out 84 | main::INFOLOG && $log->is_info && $log->info("we have timed out polling for an access token"); 85 | delete $deviceCodes{$deviceCode}; 86 | 87 | return $args->{cb}->() if $args->{cb}; 88 | 89 | $log->error('no callback defined?!?'); 90 | } 91 | 92 | sub cancelDeviceAuth { 93 | my ($class, $deviceCode) = @_; 94 | 95 | return unless $deviceCode; 96 | 97 | Slim::Utils::Timers::killTimers($deviceCode, \&_delayedPollDeviceAuth); 98 | delete $deviceCodes{$deviceCode}; 99 | } 100 | 101 | sub _fetchKs { 102 | my ($class) = @_; 103 | 104 | Slim::Networking::SimpleAsyncHTTP->new( 105 | sub { 106 | my $response = shift; 107 | 108 | my $result = eval { from_json($response->content) }; 109 | 110 | $@ && $log->error($@); 111 | main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($result)); 112 | 113 | my $keys = $result->{keys}; 114 | 115 | if ($keys) { 116 | $keys = [ sort { 117 | $b->{valid} <=> $a->{valid} 118 | } grep { 119 | $_->{cid} && $_->{sec} && $_->{valid} 120 | } map { 121 | { 122 | cid => $_->{clientId}, 123 | sec => $_->{clientSecret}, 124 | valid => $_->{valid} =~ /true/i ? 1 : 0 125 | } 126 | } grep { 127 | $_->{formats} =~ /Normal/ 128 | } @$keys ]; 129 | 130 | if (my $key = shift @$keys) { 131 | $cid = $key->{cid}; 132 | $sec = $key->{sec}; 133 | $prefs->set('cid', $cid); 134 | $prefs->set('sec', $sec); 135 | } 136 | } 137 | }, 138 | sub { 139 | my ($http, $error) = @_; 140 | 141 | $log->warn("Error: $error"); 142 | main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($http)); 143 | } 144 | )->get(KURL); 145 | } 146 | 147 | sub refreshToken { 148 | my ( $class, $cb, $userId ) = @_; 149 | 150 | my $accounts = $prefs->get('accounts') || {}; 151 | my $profile = $accounts->{$userId}; 152 | 153 | if ( $profile && (my $refreshToken = $profile->{refreshToken}) ) { 154 | $class->_call(TOKEN_PATH, sub { 155 | $cb->(_storeTokens(@_)); 156 | },{ 157 | grant_type => 'refresh_token', 158 | refresh_token => $refreshToken, 159 | }); 160 | } 161 | else { 162 | $log->error('Did find neither access nor refresh token. Please re-authenticate.'); 163 | # TODO expose warning on client 164 | $cb->(); 165 | } 166 | } 167 | 168 | sub _storeTokens { 169 | my ($result) = @_; 170 | 171 | if ($result->{user} && $result->{user_id} && $result->{access_token}) { 172 | my $accounts = $prefs->get('accounts'); 173 | 174 | my $userId = $result->{user_id}; 175 | 176 | # have token expire a little early 177 | $cache->set("tidal_at_$userId", $result->{access_token}, $result->{expires_in} - 300); 178 | 179 | $result->{user}->{refreshToken} = $result->{refresh_token} if $result->{refresh_token}; 180 | my %account = (%{$accounts->{$userId} || {}}, %{$result->{user}}); 181 | $accounts->{$userId} = \%account; 182 | 183 | $prefs->set('accounts', $accounts); 184 | } 185 | 186 | return $result->{access_token}; 187 | } 188 | 189 | sub _call { 190 | my ( $class, $url, $cb, $params ) = @_; 191 | 192 | my $bearer = encode_base64(sprintf('%s:%s', $cid, $sec)); 193 | $bearer =~ s/\s//g; 194 | 195 | $params->{client_id} ||= $cid; 196 | 197 | Slim::Networking::SimpleAsyncHTTP->new( 198 | sub { 199 | my $response = shift; 200 | 201 | main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($response)); 202 | my $result = eval { from_json($response->content) }; 203 | 204 | $@ && $log->error($@); 205 | main::INFOLOG && $log->is_info && $log->info(Data::Dump::dump($result)); 206 | 207 | $cb->($result); 208 | }, 209 | sub { 210 | my ($http, $error) = @_; 211 | 212 | $log->error("Error: $error"); 213 | main::INFOLOG && $log->is_info && !$log->is_debug && $log->info(Data::Dump::dump($http->contentRef)); 214 | main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($http)); 215 | 216 | $cb->({ 217 | error => $error || 'failed auth request' 218 | }); 219 | }, 220 | { 221 | timeout => 15, 222 | cache => 0, 223 | expiry => 0, 224 | } 225 | )->post(AURL . $url, 226 | 'Content-Type' => 'application/x-www-form-urlencoded', 227 | 'Authorization' => 'Basic ' . $bearer, 228 | complex_to_query($params), 229 | ); 230 | } 231 | 232 | 1; -------------------------------------------------------------------------------- /API/Sync.pm: -------------------------------------------------------------------------------- 1 | package Plugins::TIDAL::API::Sync; 2 | 3 | use strict; 4 | use Data::URIEncode qw(complex_to_query); 5 | use Date::Parse qw(str2time); 6 | use JSON::XS::VersionOneAndTwo; 7 | use List::Util qw(min); 8 | 9 | use Slim::Networking::SimpleSyncHTTP; 10 | use Slim::Utils::Cache; 11 | use Slim::Utils::Log; 12 | 13 | use Plugins::TIDAL::API qw(BURL DEFAULT_LIMIT MAX_LIMIT PLAYLIST_LIMIT); 14 | 15 | my $cache = Slim::Utils::Cache->new(); 16 | my $log = logger('plugin.tidal'); 17 | 18 | sub getFavorites { 19 | my ($class, $userId, $type) = @_; 20 | 21 | my $result = $class->_get("/users/$userId/favorites/$type", $userId); 22 | 23 | my $items = [ map { 24 | my $item = $_; 25 | $item->{item}->{added} = str2time(delete $item->{created}) if $item->{created}; 26 | $item->{item}->{cover} = Plugins::TIDAL::API->getImageUrl($item->{item}); 27 | 28 | foreach (qw(adSupportedStreamReady allowStreaming audioModes audioQuality copyright djReady explicit 29 | mediaMetadata numberOfVideos popularity premiumStreamingOnly stemReady streamReady 30 | streamStartDate upc url version vibrantColor videoCover 31 | )) { 32 | delete $item->{item}->{$_}; 33 | } 34 | 35 | $item->{item} ; 36 | } @{$result->{items} || []} ] if $result; 37 | 38 | return $items; 39 | } 40 | 41 | sub albumTracks { 42 | my ($class, $userId, $id) = @_; 43 | 44 | my $album = $class->_get("/albums/$id/tracks", $userId); 45 | my $tracks = $album->{items} if $album; 46 | $tracks = Plugins::TIDAL::API->cacheTrackMetadata($tracks) if $tracks; 47 | 48 | return $tracks; 49 | } 50 | 51 | sub collectionPlaylists { 52 | my ($class, $userId) = @_; 53 | 54 | my $result = $class->_get("/users/$userId/playlistsAndFavoritePlaylists", $userId, { _page => PLAYLIST_LIMIT }); 55 | $result = [ map { $_->{playlist} } @{$result->{items} || []} ] if $result; 56 | 57 | my $items = [ map { 58 | $_->{added} = str2time(delete $_->{created}) if $_->{created}; 59 | $_->{cover} = Plugins::TIDAL::API->getImageUrl($_); 60 | $_; 61 | } @$result ] if $result && @$result; 62 | 63 | return $items; 64 | } 65 | 66 | sub playlist { 67 | my ($class, $userId, $uuid) = @_; 68 | 69 | my $playlist = $class->_get("/playlists/$uuid/items", $userId); 70 | my $tracks = $playlist->{items} if $playlist; 71 | $tracks = Plugins::TIDAL::API->cacheTrackMetadata($tracks) if $tracks; 72 | 73 | return $tracks; 74 | } 75 | 76 | sub getArtist { 77 | my ($class, $userId, $id) = @_; 78 | 79 | my $artist = $class->_get("/artists/$id", $userId); 80 | $artist->{cover} = Plugins::TIDAL::API->getImageUrl($artist) if $artist && $artist->{picture}; 81 | return $artist; 82 | } 83 | 84 | sub _get { 85 | my ( $class, $url, $userId, $params ) = @_; 86 | 87 | $userId ||= Plugins::TIDAL::API->getSomeUserId(); 88 | my $token = $cache->get("tidal_at_$userId"); 89 | my $pageSize = delete $params->{_page} || DEFAULT_LIMIT; 90 | 91 | if (!$token) { 92 | $log->error("Failed to get token for $userId"); 93 | return; 94 | } 95 | 96 | $params ||= {}; 97 | $params->{countryCode} ||= Plugins::TIDAL::API->getCountryCode($userId); 98 | $params->{limit} = min($pageSize, $params->{limit} || DEFAULT_LIMIT); 99 | 100 | my $query = complex_to_query($params); 101 | 102 | main::INFOLOG && $log->is_info && $log->info("Getting $url?$query"); 103 | 104 | my $response = Slim::Networking::SimpleSyncHTTP->new({ 105 | timeout => 15, 106 | cache => 1, 107 | expiry => 86400, 108 | })->get(BURL . "$url?$query", 'Authorization' => 'Bearer ' . $token); 109 | 110 | if ($response->code == 200) { 111 | my $result = eval { from_json($response->content) }; 112 | 113 | $@ && $log->error($@); 114 | main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($result)); 115 | 116 | if (ref $result eq 'HASH' && $result->{items} && $result->{totalNumberOfItems}) { 117 | my $maxItems = min(MAX_LIMIT, $result->{totalNumberOfItems}); 118 | my $offset = ($params->{offset} || 0) + $pageSize; 119 | 120 | if ($maxItems > $offset) { 121 | my $remaining = $result->{totalNumberOfItems} - $offset; 122 | main::INFOLOG && $log->is_info && $log->info("We need to page to get $remaining more results"); 123 | 124 | my $moreResult = $class->_get($url, $userId, { 125 | %$params, 126 | offset => $offset, 127 | }); 128 | 129 | if ($moreResult && ref $moreResult && $moreResult->{items}) { 130 | push @{$result->{items}}, @{$moreResult->{items}}; 131 | } 132 | } 133 | } 134 | 135 | return $result; 136 | } 137 | else { 138 | $log->error("Request failed for $url/$query: " . $response->code); 139 | main::INFOLOG && $log->info(Data::Dump::dump($response)); 140 | } 141 | 142 | return; 143 | } 144 | 145 | 1; -------------------------------------------------------------------------------- /HTML/EN/plugins/TIDAL/auth.html: -------------------------------------------------------------------------------- 1 | [% IF useExtJS && deviceCode; extJsScripts = BLOCK %] 2 | 21 | [% END; ELSIF deviceCode %] 22 | 34 | [% END %] 35 | [% PROCESS settings/header.html %] 36 | 37 | [% WRAPPER setting title="PLUGIN_TIDAL_AUTH" desc="" %] 38 |
[% "PLUGIN_TIDAL_FOLLOW_LINK" | string %]
39 |
[% followAuthLink %]
40 |
41 | 42 | 43 |
44 | [% END %] 45 | 46 | [% PROCESS settings/footer.html %] 47 | -------------------------------------------------------------------------------- /HTML/EN/plugins/TIDAL/html/emblem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelherger/lms-plugin-tidal/dd9aa06f174668633d1fa16bad7bdddbbc15dda2/HTML/EN/plugins/TIDAL/html/emblem.png -------------------------------------------------------------------------------- /HTML/EN/plugins/TIDAL/html/featured_MTL_svg_trophy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelherger/lms-plugin-tidal/dd9aa06f174668633d1fa16bad7bdddbbc15dda2/HTML/EN/plugins/TIDAL/html/featured_MTL_svg_trophy.png -------------------------------------------------------------------------------- /HTML/EN/plugins/TIDAL/html/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelherger/lms-plugin-tidal/dd9aa06f174668633d1fa16bad7bdddbbc15dda2/HTML/EN/plugins/TIDAL/html/home.png -------------------------------------------------------------------------------- /HTML/EN/plugins/TIDAL/html/mix_MTL_svg_stream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelherger/lms-plugin-tidal/dd9aa06f174668633d1fa16bad7bdddbbc15dda2/HTML/EN/plugins/TIDAL/html/mix_MTL_svg_stream.png -------------------------------------------------------------------------------- /HTML/EN/plugins/TIDAL/html/moods_MTL_icon_celebration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelherger/lms-plugin-tidal/dd9aa06f174668633d1fa16bad7bdddbbc15dda2/HTML/EN/plugins/TIDAL/html/moods_MTL_icon_celebration.png -------------------------------------------------------------------------------- /HTML/EN/plugins/TIDAL/html/personal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelherger/lms-plugin-tidal/dd9aa06f174668633d1fa16bad7bdddbbc15dda2/HTML/EN/plugins/TIDAL/html/personal.png -------------------------------------------------------------------------------- /HTML/EN/plugins/TIDAL/html/tidal_MTL_svg_tidal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/michaelherger/lms-plugin-tidal/dd9aa06f174668633d1fa16bad7bdddbbc15dda2/HTML/EN/plugins/TIDAL/html/tidal_MTL_svg_tidal.png -------------------------------------------------------------------------------- /HTML/EN/plugins/TIDAL/settings.html: -------------------------------------------------------------------------------- 1 | [% title = "PLUGIN_TIDAL_NAME" %] 2 | [% PROCESS settings/header.html %] 3 | [% IF credentials; WRAPPER setting desc="" %] 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | [% FOREACH creds = credentials %] 12 | [% accountName = creds.name; accountId = creds.id %] 13 | 14 | 15 | 21 | 28 | 31 | 32 | [% END %] 33 |
[% "PLUGIN_TIDAL_ACCOUNT" | string %][% "COLON" | string %][% "PLUGIN_TIDAL_IMPORT_LIBRARY"| string %][% "COLON" | string %][% "PLUGIN_TIDAL_EXPLICIT_ALBUMS"| string %][% "COLON" | string %]
[% accountName | html %] 16 | 20 | 22 | 27 | 29 | 30 |
34 | [% END; END %] 35 | 36 | [% WRAPPER setting title="" desc="" %] 37 | 38 | [% END %] 39 | 40 | [% WRAPPER setting title="PLUGIN_TIDAL_QUALITY" desc="PLUGIN_TIDAL_QUALITY_DESC" %] 41 | 46 | [% END %] 47 | 48 | [% WRAPPER setting title="PLUGIN_TIDAL_COUNTRY_CODE" desc="PLUGIN_TIDAL_COUNTRY_CODE_DESC" %] 49 | 50 | [% END %] 51 | [% PROCESS settings/footer.html %] 52 | -------------------------------------------------------------------------------- /Importer.pm: -------------------------------------------------------------------------------- 1 | package Plugins::TIDAL::Importer; 2 | 3 | use strict; 4 | 5 | use base qw(Slim::Plugin::OnlineLibraryBase); 6 | 7 | use Date::Parse qw(str2time); 8 | 9 | use Slim::Utils::Cache; 10 | use Slim::Utils::Log; 11 | use Slim::Utils::Prefs; 12 | use Slim::Utils::Progress; 13 | use Slim::Utils::Strings qw(string); 14 | 15 | my $cache = Slim::Utils::Cache->new(); 16 | my $log = logger('plugin.tidal'); 17 | my $prefs = preferences('plugin.tidal'); 18 | 19 | my ($ct, $splitChar); 20 | 21 | sub startScan { if (main::SCANNER) { 22 | my ($class) = @_; 23 | 24 | require Plugins::TIDAL::API::Sync; 25 | $ct = Plugins::TIDAL::API::getFormat(); 26 | $splitChar = substr(preferences('server')->get('splitList'), 0, 1); 27 | 28 | my $accounts = _enabledAccounts(); 29 | 30 | if (scalar keys %$accounts) { 31 | my $playlistsOnly = Slim::Music::Import->scanPlaylistsOnly(); 32 | 33 | $class->initOnlineTracksTable(); 34 | 35 | if (!$playlistsOnly) { 36 | $class->scanAlbums($accounts); 37 | $class->scanArtists($accounts); 38 | } 39 | 40 | if (!$class->can('ignorePlaylists') || !$class->ignorePlaylists) { 41 | $class->scanPlaylists($accounts); 42 | } 43 | 44 | $class->deleteRemovedTracks(); 45 | $cache->set('tidal_library_last_scan', time(), '1y'); 46 | } 47 | 48 | Slim::Music::Import->endImporter($class); 49 | } } 50 | 51 | sub scanAlbums { if (main::SCANNER) { 52 | my ($class, $accounts) = @_; 53 | 54 | my $progress = Slim::Utils::Progress->new({ 55 | 'type' => 'importer', 56 | 'name' => 'plugin_tidal_albums', 57 | 'total' => 1, 58 | 'every' => 1, 59 | }); 60 | 61 | while (my ($accountName, $userId) = each %$accounts) { 62 | my %missingAlbums; 63 | 64 | main::INFOLOG && $log->is_info && $log->info("Reading albums... " . $accountName); 65 | $progress->update(string('PLUGIN_TIDAL_PROGRESS_READ_ALBUMS', $accountName)); 66 | 67 | my $albums = Plugins::TIDAL::API::Sync->getFavorites($userId, 'albums') || []; 68 | $progress->total(scalar @$albums); 69 | 70 | foreach my $album (@$albums) { 71 | my $albumDetails = $cache->get('tidal_album_with_tracks_' . $album->{id}); 72 | 73 | if ( ref $albumDetails && $albumDetails->{tracks} && ref $albumDetails->{tracks}) { 74 | $progress->update($album->{title}); 75 | 76 | $class->storeTracks([ 77 | map { _prepareTrack($albumDetails, $_) } @{ $albumDetails->{tracks} } 78 | ], undef, $accountName); 79 | 80 | main::SCANNER && Slim::Schema->forceCommit; 81 | } 82 | else { 83 | $missingAlbums{$album->{id}} = $album; 84 | } 85 | } 86 | 87 | while ( my ($albumId, $album) = each %missingAlbums ) { 88 | $progress->update($album->{title}); 89 | 90 | $album->{tracks} = Plugins::TIDAL::API::Sync->albumTracks($userId, $albumId); 91 | 92 | if (!$album->{tracks}) { 93 | $log->warn("Didn't receive tracks for $album->{title}/$album->{id}"); 94 | next; 95 | } 96 | 97 | $cache->set('tidal_album_with_tracks_' . $albumId, $album, '3M'); 98 | 99 | $class->storeTracks([ 100 | map { _prepareTrack($album, $_) } @{ $album->{tracks} } 101 | ], undef, $accountName); 102 | 103 | main::SCANNER && Slim::Schema->forceCommit; 104 | } 105 | } 106 | 107 | $progress->final(); 108 | main::SCANNER && Slim::Schema->forceCommit; 109 | } } 110 | 111 | sub scanArtists { if (main::SCANNER) { 112 | my ($class, $accounts) = @_; 113 | 114 | my $progress = Slim::Utils::Progress->new({ 115 | 'type' => 'importer', 116 | 'name' => 'plugin_tidal_artists', 117 | 'total' => 1, 118 | 'every' => 1, 119 | }); 120 | 121 | while (my ($accountName, $userId) = each %$accounts) { 122 | main::INFOLOG && $log->is_info && $log->info("Reading artists... " . $accountName); 123 | $progress->update(string('PLUGIN_TIDAL_PROGRESS_READ_ARTISTS', $accountName)); 124 | 125 | my $artists = Plugins::TIDAL::API::Sync->getFavorites($userId, 'artists') || []; 126 | 127 | $progress->total($progress->total + scalar @$artists); 128 | 129 | foreach my $artist (@$artists) { 130 | my $name = $artist->{name}; 131 | 132 | $progress->update($name); 133 | main::SCANNER && Slim::Schema->forceCommit; 134 | 135 | Slim::Schema::Contributor->add({ 136 | 'artist' => $class->normalizeContributorName($name), 137 | 'extid' => 'tidal:artist:' . $artist->{id}, 138 | }); 139 | 140 | _cacheArtistPictureUrl($artist, '3M'); 141 | } 142 | } 143 | 144 | $progress->final(); 145 | main::SCANNER && Slim::Schema->forceCommit; 146 | } } 147 | 148 | sub scanPlaylists { if (main::SCANNER) { 149 | my ($class, $accounts) = @_; 150 | 151 | my $dbh = Slim::Schema->dbh(); 152 | my $insertTrackInTempTable_sth = $dbh->prepare_cached("INSERT OR IGNORE INTO online_tracks (url) VALUES (?)") if !$main::wipe; 153 | 154 | my $progress = Slim::Utils::Progress->new({ 155 | 'type' => 'importer', 156 | 'name' => 'plugin_tidal_playlists', 157 | 'total' => 0, 158 | 'every' => 1, 159 | }); 160 | 161 | main::INFOLOG && $log->is_info && $log->info("Removing playlists..."); 162 | $progress->update(string('PLAYLIST_DELETED_PROGRESS'), $progress->done); 163 | my $deletePlaylists_sth = $dbh->prepare_cached("DELETE FROM tracks WHERE url LIKE 'tidal://playlist:%'"); 164 | $deletePlaylists_sth->execute(); 165 | 166 | while (my ($accountName, $userId) = each %$accounts) { 167 | $progress->update(string('PLUGIN_TIDAL_PROGRESS_READ_PLAYLISTS', $accountName), $progress->done); 168 | 169 | main::INFOLOG && $log->is_info && $log->info("Reading playlists for $accountName..."); 170 | my $playlists = Plugins::TIDAL::API::Sync->collectionPlaylists($userId) || []; 171 | 172 | $progress->total($progress->total + @$playlists); 173 | 174 | my $prefix = 'TIDAL' . string('COLON') . ' '; 175 | 176 | main::INFOLOG && $log->is_info && $log->info(sprintf("Importing tracks for %s playlists...", scalar @$playlists)); 177 | foreach my $playlist (@{$playlists || []}) { 178 | my $uuid = $playlist->{uuid} or next; 179 | 180 | my $tracks = Plugins::TIDAL::API::Sync->playlist($userId, $uuid); 181 | 182 | $progress->update($accountName . string('COLON') . ' ' . $playlist->{title}); 183 | Slim::Schema->forceCommit; 184 | 185 | my $url = "tidal://playlist:$uuid"; 186 | 187 | my $playlistObj = Slim::Schema->updateOrCreate({ 188 | url => $url, 189 | playlist => 1, 190 | integrateRemote => 1, 191 | attributes => { 192 | TITLE => $prefix . $playlist->{title}, 193 | COVER => $playlist->{cover}, 194 | AUDIO => 1, 195 | EXTID => $url, 196 | CONTENT_TYPE => 'ssp' 197 | }, 198 | }); 199 | 200 | my @trackIds = map { "tidal://$_->{id}.$ct" } @$tracks; 201 | 202 | $playlistObj->setTracks(\@trackIds) if $playlistObj && scalar @trackIds; 203 | $insertTrackInTempTable_sth && $insertTrackInTempTable_sth->execute($url); 204 | } 205 | 206 | Slim::Schema->forceCommit; 207 | } 208 | 209 | $progress->final(); 210 | Slim::Schema->forceCommit; 211 | } } 212 | 213 | sub getArtistPicture { if (main::SCANNER) { 214 | my ($class, $id) = @_; 215 | 216 | my $url = $cache->get('tidal_artist_image' . $id); 217 | 218 | return $url if $url; 219 | 220 | $id =~ s/tidal:artist://; 221 | 222 | require Plugins::TIDAL::API::Sync; 223 | my $artist = Plugins::TIDAL::API::Sync->getArtist(undef, $id) || {}; 224 | 225 | if ($artist->{cover}) { 226 | _cacheArtistPictureUrl($artist, '3M'); 227 | return $artist->{cover}; 228 | } 229 | 230 | return; 231 | } } 232 | 233 | my $previousArtistId = ''; 234 | sub _cacheArtistPictureUrl { 235 | my ($artist, $ttl) = @_; 236 | 237 | if ($artist->{cover} && $artist->{id} ne $previousArtistId) { 238 | $cache->set('tidal_artist_image' . 'tidal:artist:' . $artist->{id}, $artist->{cover}, $ttl || '3M'); 239 | $previousArtistId = $artist->{id}; 240 | } 241 | } 242 | 243 | sub trackUriPrefix { 'tidal://' } 244 | 245 | # This code is not run in the scanner, but in LMS 246 | sub needsUpdate { if (!main::SCANNER) { 247 | my ($class, $cb) = @_; 248 | 249 | my $lastScanTime = $cache->get('tidal_library_last_scan') || return $cb->(1); 250 | my @keys = qw(updatedFavoriteArtists updatedFavoriteTracks updatedFavoriteAlbums); 251 | push @keys, qw(updatedFavoritePlaylists updatedPlaylists) unless $class->ignorePlaylists; 252 | 253 | Async::Util::achain( 254 | steps => [ map { 255 | my $userId = $_; 256 | my $api = Plugins::TIDAL::API::Async->new({ userId => $userId }); 257 | 258 | my @tasks = ( 259 | sub { 260 | my ($input, $acb) = @_; 261 | return $acb->($input) if $input; 262 | 263 | $api->getLatestCollectionTimestamp(sub { 264 | my (undef, $timestamp) = @_; 265 | 266 | foreach (@keys) { 267 | return $acb->(1) if $timestamp->{$_} > $lastScanTime; 268 | } 269 | 270 | return $acb->(0); 271 | }); 272 | } 273 | ); 274 | 275 | if (!$class->ignorePlaylists) { 276 | push @tasks, sub { 277 | my ($input, $acb) = @_; 278 | return $acb->($input) if $input; 279 | 280 | $api->getCollectionPlaylists(sub { 281 | my $playlists = shift; 282 | 283 | foreach (@$playlists) { 284 | return $acb->(1) if str2time($_->{item}->{lastUpdated}) > $lastScanTime; 285 | } 286 | 287 | return $acb->(0); 288 | }, 1); 289 | }; 290 | } 291 | 292 | @tasks; 293 | } values %{_enabledAccounts()} ], 294 | cb => sub { 295 | my ($result, $error) = @_; 296 | $cb->($result && !$error); 297 | } 298 | ); 299 | } } 300 | 301 | sub _enabledAccounts { 302 | my $accounts = $prefs->get('accounts'); 303 | my $dontImportAccounts = $prefs->get('dontImportAccounts'); 304 | my $enabledAccounts = {}; 305 | 306 | while (my ($id, $account) = each %$accounts) { 307 | $enabledAccounts->{$account->{nickname} || $account->{username}} = $id unless $dontImportAccounts->{$id} 308 | } 309 | 310 | return $enabledAccounts; 311 | } 312 | 313 | my %roleMap = ( 314 | MAIN => 'TRACKARTIST', 315 | FEATURED => 'TRACKARTIST', 316 | COMPOSER => 'COMPOSER', 317 | CONDUCTOR => 'CONDUCTOR', 318 | ); 319 | 320 | sub _prepareTrack { 321 | my ($album, $track) = @_; 322 | 323 | $ct ||= Plugins::TIDAL::API::getFormat(); 324 | my $url = 'tidal://' . $track->{id} . ".$ct"; 325 | 326 | my $trackData = { 327 | url => $url, 328 | TITLE => $track->{title}, 329 | ARTIST => $track->{artist}->{name}, 330 | ARTIST_EXTID => 'tidal:artist:' . $track->{artist}->{id}, 331 | ALBUM => $album->{title}, 332 | ALBUM_EXTID => 'tidal:album:' . $album->{id}, 333 | TRACKNUM => $track->{tracknum}, 334 | GENRE => 'TIDAL', 335 | DISC => $track->{disc}, 336 | DISCC => $album->{numberOfVolumes} || 1, 337 | SECS => $track->{duration}, 338 | YEAR => substr($album->{releaseDate} || '', 0, 4), 339 | COVER => $album->{cover}, 340 | AUDIO => 1, 341 | EXTID => $url, 342 | TIMESTAMP => $album->{added}, 343 | CONTENT_TYPE => $ct, 344 | LOSSLESS => $ct eq 'flc' ? 1 : 0, 345 | RELEASETYPE => $album->{type}, 346 | REPLAYGAIN_ALBUM_GAIN => $track->{albumReplayGain}, 347 | REPLAYGAIN_ALBUM_PEAK => $track->{albumPeakAmplitude}, 348 | REPLAYGAIN_TRACK_GAIN => $track->{trackReplayGain} || $track->{replayGain}, 349 | REPLAYGAIN_TRACK_PEAK => $track->{trackPeakAmplitude} || $track->{peak}, 350 | }; 351 | 352 | my %contributors; 353 | foreach (grep { $_->{name} ne $track->{artist}->{name} && $_->{type} } @{ $track->{artists} }) { 354 | my $type = $roleMap{$_->{type}}; 355 | my $name = $_->{name}; 356 | 357 | my $contributorsList = $contributors{$type} ||= []; 358 | next if scalar grep { /$name/ } @$contributorsList; 359 | push @$contributorsList, $name; 360 | } 361 | 362 | 363 | $splitChar ||= substr(preferences('server')->get('splitList'), 0, 1); 364 | foreach my $type (values %roleMap) { 365 | my $contributorsList = delete $contributors{$type} || []; 366 | next unless scalar @$contributorsList; 367 | 368 | $trackData->{$type} = join($splitChar, @$contributorsList); 369 | } 370 | 371 | return $trackData; 372 | } 373 | 374 | 1; 375 | -------------------------------------------------------------------------------- /InfoMenu.pm: -------------------------------------------------------------------------------- 1 | package Plugins::TIDAL::InfoMenu; 2 | 3 | use strict; 4 | use Tie::Cache::LRU; 5 | 6 | use Slim::Utils::Log; 7 | use Slim::Utils::Prefs; 8 | use Slim::Utils::Strings qw(cstring); 9 | 10 | use Plugins::TIDAL::API::Async; 11 | use Plugins::TIDAL::Plugin; 12 | 13 | my $log = logger('plugin.tidal'); 14 | my $prefs = preferences('plugin.tidal'); 15 | 16 | # see note on memorizing feeds for different dispatches 17 | my %rootFeeds; 18 | tie %rootFeeds, 'Tie::Cache::LRU', 64; 19 | 20 | sub init { 21 | my $class = shift; 22 | 23 | Slim::Control::Request::addDispatch( [ 'tidal_info', 'items', '_index', '_quantity' ], [ 1, 1, 1, \&menuInfoWeb ] ); 24 | Slim::Control::Request::addDispatch( [ 'tidal_action', 'items', '_index', '_quantity' ], [ 1, 1, 1, \&menuAction ] ); 25 | Slim::Control::Request::addDispatch( [ 'tidal_action', '_action' ], [ 1, 1, 1, \&menuAction ] ); 26 | Slim::Control::Request::addDispatch( [ 'tidal_browse', 'items' ], [ 1, 1, 1, \&menuBrowse ] ); 27 | Slim::Control::Request::addDispatch( [ 'tidal_browse', 'playlist', '_method' ], [ 1, 1, 1, \&menuBrowse ] ); 28 | } 29 | 30 | sub menuInfoWeb { 31 | my $request = shift; 32 | 33 | # be careful that type must be artistS|albumS|playlistS|trackS 34 | my $type = $request->getParam('type'); 35 | my $id = $request->getParam('id'); 36 | 37 | $request->addParam('_index', 0); 38 | $request->addParam('_quantity', 10); 39 | 40 | # we can't get the response live, we must be called back by cliQuery to 41 | # call it back ourselves 42 | Slim::Control::XMLBrowser::cliQuery('tidal_info', sub { 43 | my ($client, $cb, $args) = @_; 44 | 45 | my $api = Plugins::TIDAL::Plugin::getAPIHandler($client); 46 | 47 | my $subInfo = sub { 48 | my $favorites = shift || []; 49 | my $action; 50 | 51 | if ($type eq 'playlist') { 52 | $action = (grep { $_->{uuid} eq $id } @$favorites) ? 'remove' : 'add'; 53 | } else { 54 | $action = (grep { $_->{id} == $id && ($type =~ /$_->{type}/i || !$_->{type}) } @$favorites) ? 'remove' : 'add'; 55 | } 56 | 57 | my $title = $action eq 'add' ? cstring($client, 'PLUGIN_TIDAL_ADD_TO_FAVORITES') : cstring($client, 'PLUGIN_TIDAL_REMOVE_FROM_FAVORITES'); 58 | my $items = []; 59 | 60 | if ($request->getParam('menu')) { 61 | push @$items, { 62 | type => 'link', 63 | name => $title, 64 | isContextMenu => 1, 65 | refresh => 1, 66 | jive => { 67 | nextWindow => 'parent', 68 | actions => { 69 | go => { 70 | player => 0, 71 | cmd => [ 'tidal_action', $action ], 72 | params => { type => $type, id => $id } 73 | } 74 | }, 75 | }, 76 | }; 77 | 78 | # only tracks can be added to playlist 79 | push @$items, { 80 | type => 'link', 81 | name => cstring($client, 'PLUGIN_TIDAL_ADD_TO_PLAYLIST'), 82 | itemActions => { 83 | items => { 84 | command => ['tidal_action', 'items' ], 85 | fixedParams => { action => 'add_to_playlist', id => $id }, 86 | }, 87 | }, 88 | } if $type eq 'track'; 89 | } else { 90 | push @$items, { 91 | type => 'link', 92 | name => $title, 93 | url => sub { 94 | my ($client, $ucb) = @_; 95 | $api->updateFavorite( sub { 96 | _completed($client, $ucb); 97 | }, $action, $type, $id ); 98 | }, 99 | }; 100 | 101 | # only add tracks to playlists 102 | push @$items, { 103 | type => 'link', 104 | name => cstring($client, 'PLUGIN_TIDAL_ADD_TO_PLAYLIST'), 105 | url => \&addToPlaylist, 106 | passthrough => [ { id => $id } ], 107 | } if $type eq 'track'; 108 | } 109 | 110 | my $method; 111 | 112 | if ( $type eq 'track' ) { 113 | $method = \&_menuTrackInfo; 114 | } elsif ( $type eq 'album' ) { 115 | $method = \&_menuAlbumInfo; 116 | } elsif ( $type eq 'artist' ) { 117 | $method = \&_menuArtistInfo; 118 | } elsif ( $type eq 'playlist' ) { 119 | $method = \&_menuPlaylistInfo; 120 | =comment 121 | } elsif ( $type eq 'podcast' ) { 122 | $method = \&_menuPodcastInfo; 123 | } elsif ( $type eq 'episode' ) { 124 | $method = \&_menuEpisodeInfo; 125 | =cut 126 | } 127 | 128 | $method->( $api, $items, sub { 129 | my ($icon, $entry) = @_; 130 | 131 | # we need to add favorites for cliQuery to add them 132 | $entry = Plugins::TIDAL::Plugin::_renderItem($client, $entry, { addArtistToTitle => 1 }); 133 | my $favorites = Slim::Control::XMLBrowser::_favoritesParams($entry) || {}; 134 | $favorites->{favorites_icon} = $favorites->{icon} if $favorites; 135 | $cb->( { 136 | type => 'opml', 137 | %$favorites, 138 | image => $icon, 139 | items => $items, 140 | # do we need this one? 141 | name => $entry->{name} || $entry->{title}, 142 | } ); 143 | }, $args->{params}); 144 | 145 | }; 146 | 147 | if ($type eq 'playlist') { 148 | $api->getCollectionPlaylists( $subInfo ); 149 | } else { 150 | $api->getFavorites( $subInfo, $type . 's' ); 151 | } 152 | 153 | }, $request ); 154 | } 155 | 156 | sub addToPlaylist { 157 | my ($client, $cb, undef, $params) = @_; 158 | 159 | my $api = Plugins::TIDAL::Plugin::getAPIHandler($client); 160 | 161 | $api->getCollectionPlaylists( sub { 162 | my $items = []; 163 | 164 | foreach my $item ( @{$_[0] || {}} ) { 165 | # only present playlist that we have the right to modify 166 | next if $item->{creator}->{id} ne $api->userId; 167 | 168 | # we don't have to create a special RPC menu/action, we could simply let the 169 | # XML menu play, but the exit of the action is less user friendly as we land 170 | # on "complete" page like for Web::XMLBrowser 171 | 172 | if ($params->{menu}) { 173 | push @$items, { 174 | type => 'link', 175 | name => $item->{title}, 176 | isContextMenu => 1, 177 | refresh => 1, 178 | image => Plugins::TIDAL::API->getImageUrl($item, 'usePlaceholder'), 179 | jive => { 180 | nextWindow => 'grandparent', 181 | actions => { 182 | go => { 183 | player => 0, 184 | cmd => [ 'tidal_action', 'add_track' ], 185 | params => { id => $params->{id}, playlistId => $item->{uuid} }, 186 | } 187 | }, 188 | }, 189 | }; 190 | } 191 | else { 192 | push @$items, { 193 | name => $item->{title}, 194 | url => sub { 195 | my ($client, $cb, $args, $params) = @_; 196 | $api->updatePlaylist( sub { 197 | _completed($client, $cb); 198 | }, 'add', $params->{uuid}, $params->{trackId} ); 199 | }, 200 | image => Plugins::TIDAL::API->getImageUrl($item, 'usePlaceholder'), 201 | passthrough => [ { trackId => $params->{id}, uuid => $item->{uuid} } ], 202 | }; 203 | } 204 | } 205 | 206 | $cb->( { items => $items } ); 207 | } ); 208 | } 209 | 210 | sub menuAction { 211 | my $request = shift; 212 | 213 | my $itemId = $request->getParam('item_id'); 214 | 215 | # if we are re-drilling, no need to search, just get our anchor/root 216 | if ( defined $itemId ) { 217 | my ($key) = $itemId =~ /([^\.]+)/; 218 | if (my $cached = ${$rootFeeds{$key} || \''}) { 219 | main::INFOLOG && $log->is_info && $log->info("re-drilling using cache key: $key"); 220 | Slim::Control::XMLBrowser::cliQuery('tidal_action', $cached, $request); 221 | return; 222 | } 223 | } 224 | 225 | my $entity = $request->getRequest(1); 226 | my $id = $request->getParam('id'); 227 | 228 | # can be an action through a sub-feed (items) that needs to be displayed first, 229 | # so we to be ready to re-drill, or can be a direct action 230 | if ($entity eq 'items') { 231 | my $action = $request->getParam('action'); 232 | main::INFOLOG && $log->is_info && $log->info("JSON RPC query items with action $action"); 233 | 234 | # we assume that only one controller wants to use a client at the same time 235 | my $key = $request->client->id =~ s/://gr; 236 | $request->addParam('item_id', $key); 237 | 238 | # only items 'action' for now is to add to playlist 239 | if ($action =~ /add_to_playlist/ ) { 240 | Slim::Control::XMLBrowser::cliQuery( 'tidal_action', sub { 241 | my ($client, $cb, $args) = @_; 242 | 243 | addToPlaylist($client, sub { 244 | my $feed = $_[0]; 245 | $rootFeeds{$key} = \$feed; 246 | $cb->($feed); 247 | }, undef, { menu => $request->getParam('menu'), id => $id } ); 248 | }, $request ); 249 | } 250 | } else { 251 | my $api = Plugins::TIDAL::Plugin::getAPIHandler($request->client); 252 | my $action = $request->getParam('_action'); 253 | 254 | main::INFOLOG && $log->is_info && $log->info("JSON RPC action $action for $id"); 255 | 256 | if ($action eq 'remove_track' ) { 257 | my $playlistId = $request->getParam('playlistId'); 258 | my $index = $request->getParam('index'); 259 | $api->updatePlaylist( sub { }, 'del', $playlistId, $index ); 260 | } elsif ($action eq 'add_track' ) { 261 | # this is only used if we have a direct RPC menu set in addToPlaylist 262 | my $playlistId = $request->getParam('playlistId'); 263 | $api->updatePlaylist( sub { }, 'add', $playlistId, $id ); 264 | } else { 265 | my $type = $request->getParam('type'); 266 | $api->updateFavorite( sub { }, $action, $type, $id ); 267 | } 268 | } 269 | } 270 | 271 | sub menuBrowse { 272 | my $request = shift; 273 | 274 | my $client = $request->client; 275 | 276 | my $itemId = $request->getParam('item_id'); 277 | my $type = $request->getParam('type'); 278 | my $id = $request->getParam('id'); 279 | 280 | $request->addParam('_index', 0); 281 | # TODO: why do we need to set that 282 | $request->addParam('_quantity', 200); 283 | 284 | main::INFOLOG && $log->is_info && $log->info("Browsing for item_id:$itemId or type:$type:$id"); 285 | 286 | # if we are descending, no need to search, just get our root 287 | if ( defined $itemId ) { 288 | my ($key) = $itemId =~ /([^\.]+)/; 289 | if (my $cached = ${$rootFeeds{$key} || \''}) { 290 | main::INFOLOG && $log->is_info && $log->info("re-drilling using cache key: $key"); 291 | Slim::Control::XMLBrowser::cliQuery('tidal_browse', $cached, $request); 292 | return; 293 | } 294 | } 295 | 296 | # this key will prefix each action's hierarchy that JSON will sent us which 297 | # allows us to find back our root feed. During drill-down, that prefix is 298 | # removed and XMLBrowser descends the feed. 299 | # ideally, we would like to not have to do that but that means we leave some 300 | # breadcrums *before* we arrive here, in the _renderXXX family but I don't 301 | # know how so we have to build our own "fake" dispatch just for that 302 | # we only need to do that when we have to redescend further that hierarchy, 303 | # not when it's one shot and we assume that only one controller wants to use 304 | # a client at the same time 305 | my $key = $client->id =~ s/://gr; 306 | $request->addParam('item_id', $key); 307 | 308 | Slim::Control::XMLBrowser::cliQuery('tidal_browse', sub { 309 | my ($client, $cb, $args) = @_; 310 | 311 | if ( $type eq 'album' ) { 312 | 313 | Plugins::TIDAL::Plugin::getAlbum($client, sub { 314 | my $feed = $_[0]; 315 | $rootFeeds{$key} = \$feed; 316 | $cb->($feed); 317 | }, $args, { id => $id } ); 318 | 319 | } elsif ( $type eq 'artist' ) { 320 | 321 | Plugins::TIDAL::Plugin::getAPIHandler($client)->getArtist(sub { 322 | my $feed = Plugins::TIDAL::Plugin::_renderItem( $client, $_[0] ) if $_[0]; 323 | $rootFeeds{$key} = \$feed; 324 | # no need to add any action, the root 'tidal_browse' is memorized and cliQuery 325 | # will provide us with item_id hierarchy. All we need is to know where our root 326 | # by prefixing item_id with a min 8-digits length hex string 327 | $cb->($feed); 328 | }, $id ); 329 | 330 | } elsif ( $type eq 'playlist' ) { 331 | 332 | Plugins::TIDAL::Plugin::getAPIHandler($client)->playlist(sub { 333 | my $items = [ map { Plugins::TIDAL::Plugin::_renderItem( $client, $_) } @{$_[0]} ] if $_[0]; 334 | # don't memorize the feed as we won't redescend into it (maybe we'll use 'M' again) 335 | $cb->( { items => $items } ); 336 | }, $id ); 337 | 338 | } elsif ( $type eq 'track' ) { 339 | 340 | # track must be in cache, no memorizing 341 | my $cache = Slim::Utils::Cache->new; 342 | my $track = Plugins::TIDAL::Plugin::_renderItem( $client, $cache->get('tidal_meta_' . $id), { addArtistToTitle => 1 } ); 343 | $cb->( { items => [ $track ] } ); 344 | 345 | } elsif ( $type eq 'track_mix' ) { 346 | 347 | Plugins::TIDAL::Plugin::getAPIHandler($client)->trackRadio(sub { 348 | my $feed = { }; 349 | $feed->{items} = [ map { Plugins::TIDAL::Plugin::_renderItem( $client, $_) } @{$_[0]} ] if $_[0]; 350 | # memorize feed as we will drill-down again 351 | $rootFeeds{$key} = \$feed; 352 | $cb->($feed); 353 | }, $id); 354 | =comment 355 | } elsif ( $type eq 'podcast' ) { 356 | 357 | # we need to re-acquire the podcast itself 358 | Plugins::TIDAL::Plugin::getAPIHandler($client)->podcast(sub { 359 | my $podcast = shift; 360 | Plugins::TIDAL::Plugin::getPodcastEpisodes($client, $cb, $args, { 361 | id => $id, 362 | podcast => $podcast, 363 | } ); 364 | }, $id ); 365 | 366 | } elsif ( $type eq 'episode' ) { 367 | 368 | # episode must be in cache, no memorizing 369 | my $cache = Slim::Utils::Cache->new; 370 | my $episode = Plugins::TIDAL::Plugin::_renderItem( $client, $cache->get('tidal_episode_meta_' . $id) ); 371 | $cb->( { items => [$episode] }); 372 | =cut 373 | } 374 | }, $request ); 375 | } 376 | 377 | sub _menuBase { 378 | my ($client, $type, $id, $params) = @_; 379 | 380 | my $items = []; 381 | 382 | push @$items, ( 383 | _menuAdd($client, $type, $id, 'add', 'ADD_TO_END', $params->{menu}), 384 | _menuAdd($client, $type, $id, 'insert', 'PLAY_NEXT', $params->{menu}), 385 | _menuPlay($client, $type, $id, $params->{menu}), 386 | ) if $params->{useContextMenu} || $params->{feedMode}; 387 | 388 | return $items; 389 | } 390 | 391 | sub _menuAdd { 392 | my ($client, $type, $id, $cmd, $title, $menuMode) = @_; 393 | 394 | my $actions = { 395 | items => { 396 | command => [ 'tidal_browse', 'playlist', $cmd ], 397 | fixedParams => { type => $type, id => $id }, 398 | }, 399 | }; 400 | 401 | $actions->{'play'} = $actions->{'items'}; 402 | $actions->{'add'} = $actions->{'items'}; 403 | 404 | return { 405 | itemActions => $actions, 406 | nextWindow => 'parent', 407 | type => $menuMode ? 'text' : 'link', 408 | playcontrol => $cmd, 409 | name => cstring($client, $title), 410 | }; 411 | } 412 | 413 | sub _menuPlay { 414 | my ($client, $type, $id, $menuMode) = @_; 415 | 416 | my $actions = { 417 | items => { 418 | command => [ 'tidal_browse', 'playlist', 'load' ], 419 | fixedParams => { type => $type, id => $id }, 420 | }, 421 | }; 422 | 423 | $actions->{'play'} = $actions->{'items'}; 424 | 425 | return { 426 | itemActions => $actions, 427 | nextWindow => 'nowPlaying', 428 | type => $menuMode ? 'text' : 'link', 429 | playcontrol => 'play', 430 | name => cstring($client, 'PLAY'), 431 | }; 432 | } 433 | 434 | sub _menuTrackInfo { 435 | my ($api, $items, $cb, $params) = @_; 436 | 437 | my $cache = Slim::Utils::Cache->new; 438 | my $id = $params->{id}; 439 | 440 | # if we are here, the metadata of the track is cached 441 | my $track = $cache->get("tidal_meta_$id"); 442 | $log->error("metadata not cached for $id") && return [] unless $track && ref $track; 443 | 444 | # play/add/add_next options except for skins that don't want it 445 | my $base = _menuBase($api->client, 'track', $id, $params); 446 | push @$items, @$base if @$base; 447 | 448 | # if we have a playlist id, then we might remove that track from playlist 449 | if ($params->{playlistId} ) { 450 | if ($params->{menu}) { 451 | push @$items, { 452 | type => 'link', 453 | name => cstring($api->client, 'PLUGIN_TIDAL_REMOVE_FROM_PLAYLIST'), 454 | isContextMenu => 1, 455 | refresh => 1, 456 | jive => { 457 | nextWindow => 'parent', 458 | actions => { 459 | go => { 460 | player => 0, 461 | cmd => [ 'tidal_action', 'remove_track' ], 462 | params => { index => $params->{index}, playlistId => $params->{playlistId} }, 463 | } 464 | }, 465 | }, 466 | } 467 | } else { 468 | push @$items, { 469 | type => 'link', 470 | name => cstring($api->client, 'PLUGIN_TIDAL_REMOVE_FROM_PLAYLIST'), 471 | url => sub { 472 | my ($client, $cb, $args, $params) = @_; 473 | $api->updatePlaylist( sub { 474 | _completed($api->client, $cb); 475 | }, 'del', $params->{playlistId}, $params->{index} ); 476 | }, 477 | passthrough => [ $params ], 478 | } 479 | } 480 | } 481 | 482 | push @$items, ( { 483 | type => 'link', 484 | name => $track->{album}, 485 | label => 'ALBUM', 486 | itemActions => { 487 | items => { 488 | command => ['tidal_browse', 'items'], 489 | fixedParams => { type => 'album', id => $track->{album_id} }, 490 | }, 491 | }, 492 | }, { 493 | type => 'link', 494 | name => $track->{artist}->{name}, 495 | label => 'ARTIST', 496 | itemActions => { 497 | items => { 498 | command => ['tidal_browse', 'items'], 499 | fixedParams => { type => 'artist', id => $track->{artist}->{id} }, 500 | }, 501 | }, 502 | }, { 503 | name => cstring($api->client, 'PLUGIN_TIDAL_TRACK_MIX'), 504 | type => 'playlist', 505 | url => \&Plugins::TIDAL::Plugin::getTrackRadio, 506 | passthrough => [{ id => $id }], 507 | itemActions => { 508 | items => { 509 | command => ['tidal_browse', 'items'], 510 | fixedParams => { type => 'track_mix', id => $id }, 511 | }, 512 | play => { 513 | command => ['tidal_browse', 'playlist', 'load'], 514 | fixedParams => { type => 'track_mix', id => $id }, 515 | }, 516 | add => { 517 | command => ['tidal_browse', 'playlist', 'add'], 518 | fixedParams => { type => 'track_mix', id => $id }, 519 | }, 520 | insert => { 521 | command => ['tidal_browse', 'playlist', 'insert'], 522 | fixedParams => { type => 'track_mix', id => $id }, 523 | }, 524 | }, 525 | }, { 526 | type => 'text', 527 | name => sprintf('%s:%02s', int($track->{duration} / 60), $track->{duration} % 60), 528 | label => 'LENGTH', 529 | }, { 530 | type => 'text', 531 | name => $track->{url}, 532 | label => 'URL', 533 | parseURLs => 1 534 | } ); 535 | 536 | $cb->($track->{cover}, $track); 537 | } 538 | 539 | sub _menuAlbumInfo { 540 | my ($api, $items, $cb, $params) = @_; 541 | 542 | my $id = $params->{id}; 543 | 544 | $api->album( sub { 545 | my $album = shift; 546 | 547 | # play/add/add_next options except for skins that don't want it 548 | my $base = _menuBase($api->client, 'album', $id, $params); 549 | push @$items, @$base if @$base; 550 | 551 | push @$items, ( { 552 | type => 'playlist', 553 | name => $album->{artist}->{name} || $album->{artists}->[0]->{name},, 554 | label => 'ARTIST', 555 | itemActions => { 556 | items => { 557 | command => ['tidal_browse', 'items'], 558 | fixedParams => { type => 'artist', id => $album->{artist}->{id} }, 559 | }, 560 | }, 561 | }, { 562 | type => 'text', 563 | name => $album->{numberOfTracks} || 0, 564 | label => 'TRACK_NUMBER', 565 | }, { 566 | type => 'text', 567 | name => substr($album->{releaseDate}, 0, 4), 568 | label => 'YEAR', 569 | }, { 570 | type => 'text', 571 | name => sprintf('%s:%02s', int($album->{duration} / 60), $album->{duration} % 60), 572 | label => 'LENGTH', 573 | }, { 574 | type => 'text', 575 | name => $album->{url}, 576 | label => 'URL', 577 | parseURLs => 1 578 | } ); 579 | 580 | my $icon = Plugins::TIDAL::API->getImageUrl($album, 'usePlaceholder'); 581 | $cb->($icon, $album); 582 | 583 | }, $id ); 584 | } 585 | 586 | sub _menuArtistInfo { 587 | my ($api, $items, $cb, $params) = @_; 588 | 589 | my $id = $params->{id}; 590 | 591 | $api->getArtist( sub { 592 | my $artist = shift; 593 | 594 | push @$items, ( { 595 | type => 'link', 596 | name => $artist->{name}, 597 | url => 'N/A', 598 | label => 'ARTIST', 599 | itemActions => { 600 | items => { 601 | command => ['tidal_browse', 'items'], 602 | fixedParams => { type => 'artist', id => $artist->{id} }, 603 | }, 604 | }, 605 | }, { 606 | type => 'text', 607 | name => $artist->{url}, 608 | label => 'URL', 609 | parseURLs => 1 610 | } ); 611 | 612 | my $icon = Plugins::TIDAL::API->getImageUrl($artist, 'usePlaceholder'); 613 | $cb->($icon, $artist); 614 | 615 | }, $id ); 616 | } 617 | 618 | sub _menuPlaylistInfo { 619 | my ($api, $items, $cb, $params) = @_; 620 | 621 | my $id = $params->{id}; 622 | 623 | $api->playlistData( sub { 624 | my $playlist = shift; 625 | 626 | # play/add/add_next options except for skins that don't want it 627 | my $base = _menuBase($api->client, 'playlist', $id, $params); 628 | push @$items, @$base if @$base; 629 | 630 | push @$items, ( { 631 | type => 'text', 632 | name => $playlist->{title}, 633 | label => 'ALBUM', 634 | }, { 635 | type => 'text', 636 | name => $playlist->{numberOfTracks} || 0, 637 | label => 'TRACK_NUMBER', 638 | }, { 639 | type => 'text', 640 | name => substr($playlist->{created}, 0, 4), 641 | label => 'YEAR', 642 | }, { 643 | type => 'text', 644 | name => sprintf('%02s:%02s:%02s', int($playlist->{duration} / 3600), int(($playlist->{duration} % 3600)/ 60), $playlist->{duration} % 60), 645 | label => 'LENGTH', 646 | }, { 647 | type => 'text', 648 | name => $playlist->{url}, 649 | label => 'URL', 650 | parseURLs => 1 651 | } ); 652 | 653 | my $icon = Plugins::TIDAL::API->getImageUrl($playlist, 'usePlaceholder'); 654 | $cb->($icon, $playlist); 655 | 656 | }, $id ); 657 | } 658 | 659 | =comment 660 | sub _menuPodcastInfo { 661 | my ($api, $items, $cb, $params) = @_; 662 | 663 | my $id = $params->{id}; 664 | 665 | $api->podcast( sub { 666 | my $podcast = shift; 667 | 668 | # play/add/add_next options except for skins that don't want it 669 | my $base = _menuBase($api->client, 'podcast', $id, $params); 670 | push @$items, @$base if @$base; 671 | 672 | push @$items, ( { 673 | # put that one as an "album" otherwise control icons won't appear 674 | type => 'text', 675 | name => $podcast->{title}, 676 | label => 'ALBUM', 677 | }, { 678 | type => 'text', 679 | name => $podcast->{link}, 680 | label => 'URL', 681 | parseURLs => 1 682 | }, { 683 | type => 'text', 684 | name => $podcast->{description}, 685 | label => 'COMMENT', 686 | parseURLs => 1 687 | } ); 688 | 689 | my $icon = Plugins::TIDAL::API->getImageUrl($podcast, 'usePlaceholder'); 690 | $cb->($icon, $podcast); 691 | 692 | }, $id ); 693 | } 694 | 695 | sub _menuEpisodeInfo { 696 | my ($api, $items, $cb, $params) = @_; 697 | 698 | my $cache = Slim::Utils::Cache->new; 699 | my $id = $params->{id}; 700 | 701 | # unlike tracks, we miss some information when drilling down on podcast episodes 702 | $api->episode( sub { 703 | my $episode = shift; 704 | 705 | # play/add/add_next options except for skins that don't want it 706 | my $base = _menuBase($api->client, 'episode', $id, $params); 707 | push @$items, @$base if @$base; 708 | 709 | push @$items, ( { 710 | # put that one as an "album" otherwise control icons won't appear 711 | type => 'text', 712 | name => $episode->{podcast}->{title}, 713 | label => 'ALBUM', 714 | }, { 715 | type => 'text', 716 | name => $episode->{title}, 717 | label => 'TITLE', 718 | }, { 719 | type => 'text', 720 | name => sprintf('%02s:%02s:%02s', int($episode->{duration} / 3600), int(($episode->{duration} % 3600)/ 60), $episode->{duration} % 60), 721 | label => 'LENGTH', 722 | }, { 723 | type => 'text', 724 | label => 'MODTIME', 725 | name => $episode->{date}, 726 | }, { 727 | type => 'text', 728 | name => $episode->{link}, 729 | label => 'URL', 730 | parseURLs => 1 731 | }, { 732 | type => 'text', 733 | name => $episode->{comment}, 734 | label => 'COMMENT', 735 | parseURLs => 1 736 | } ); 737 | 738 | my $icon = Plugins::TIDAL::API->getImageUrl($episode, 'usePlaceholder'); 739 | $cb->($icon, $episode); 740 | 741 | }, $id ); 742 | } 743 | =cut 744 | 745 | sub _completed { 746 | my ($client, $cb) = @_; 747 | $cb->({ 748 | items => [{ 749 | type => 'text', 750 | name => cstring($client, 'COMPLETE'), 751 | }], 752 | }); 753 | } 754 | 755 | 756 | 1; -------------------------------------------------------------------------------- /LastMix.pm: -------------------------------------------------------------------------------- 1 | package Plugins::TIDAL::LastMix; 2 | 3 | use strict; 4 | 5 | use base qw(Plugins::LastMix::Services::Base); 6 | 7 | use Slim::Utils::Log; 8 | 9 | my $log = logger('plugin.tidal'); 10 | 11 | sub isEnabled { 12 | my ($class, $client) = @_; 13 | 14 | return unless $client; 15 | 16 | return unless Slim::Utils::PluginManager->isEnabled('Plugins::TIDAL::Plugin'); 17 | 18 | require Plugins::TIDAL::API; 19 | return Plugins::TIDAL::API::->getSomeUserId() ? 'TIDAL' : undef; 20 | } 21 | 22 | sub lookup { 23 | my ($class, $client, $cb, $args) = @_; 24 | 25 | $class->client($client) if $client; 26 | $class->cb($cb) if $cb; 27 | $class->args($args) if $args; 28 | 29 | Plugins::TIDAL::Plugin::getAPIHandler($client)->search(sub { 30 | my $tracks = shift; 31 | 32 | if (!$tracks) { 33 | $class->cb->(); 34 | } 35 | 36 | my $candidates = []; 37 | my $searchArtist = $class->args->{artist}; 38 | my $ct = Plugins::TIDAL::API::getFormat(); 39 | 40 | for my $track ( @$tracks ) { 41 | next unless $track->{artist} && $track->{id} && $track->{title} && $track->{artist}->{name}; 42 | 43 | push @$candidates, { 44 | title => $track->{title}, 45 | artist => $track->{artist}->{name}, 46 | url => "tidal://$track->{id}.$ct", 47 | }; 48 | } 49 | 50 | my $track = $class->extractTrack($candidates); 51 | 52 | main::INFOLOG && $log->is_info && $track && $log->info("Found $track for: $args->{title} - $args->{artist}"); 53 | 54 | $class->cb->($track); 55 | }, { 56 | type => 'tracks', 57 | search => $args->{title}, 58 | limit => 20, 59 | }); 60 | } 61 | 62 | sub protocol { 'tidal' } 63 | 64 | 1; -------------------------------------------------------------------------------- /Plugin.pm: -------------------------------------------------------------------------------- 1 | package Plugins::TIDAL::Plugin; 2 | 3 | use strict; 4 | use Async::Util; 5 | 6 | use base qw(Slim::Plugin::OPMLBased); 7 | 8 | use Slim::Utils::Log; 9 | use Slim::Utils::Prefs; 10 | use Slim::Utils::Strings qw(cstring); 11 | 12 | use Plugins::TIDAL::API::Async; 13 | use Plugins::TIDAL::API::Auth; 14 | use Plugins::TIDAL::ProtocolHandler; 15 | 16 | use constant MODULE_MATCH_REGEX => qr/MIX_LIST|MIXED_TYPES_LIST|PLAYLIST_LIST|ALBUM_LIST|TRACK_LIST|HORIZONTAL_LIST/; 17 | 18 | my $log = Slim::Utils::Log->addLogCategory({ 19 | category => 'plugin.tidal', 20 | description => 'PLUGIN_TIDAL_NAME', 21 | defaultLevel => 'WARN', 22 | }); 23 | 24 | my $prefs = preferences('plugin.tidal'); 25 | 26 | sub initPlugin { 27 | my $class = shift; 28 | 29 | $prefs->init({ 30 | quality => 'HIGH', 31 | preferExplicit => 0, 32 | countryCode => '', 33 | }); 34 | 35 | # reset the API ref when a player changes user 36 | $prefs->setChange( sub { 37 | my ($pref, $userId, $client) = @_; 38 | $client->pluginData(api => 0); 39 | }, 'userId'); 40 | 41 | $prefs->setValidate({ 'validator' => sub { 42 | my $new = $_[1]; 43 | # countryCode must be empty or a 2-letter country code 44 | return !defined $new || $new eq '' || $new =~ /^[A-Z]{2}$/i; 45 | } }, 'countryCode'); 46 | 47 | Plugins::TIDAL::API::Auth->init(); 48 | 49 | if (main::WEBUI) { 50 | require Plugins::TIDAL::Settings; 51 | require Plugins::TIDAL::Settings::Auth; 52 | require Plugins::TIDAL::InfoMenu; 53 | Plugins::TIDAL::Settings->new(); 54 | Plugins::TIDAL::Settings::Auth->new(); 55 | Plugins::TIDAL::InfoMenu->init(); 56 | } 57 | 58 | Slim::Player::ProtocolHandlers->registerHandler('tidal', 'Plugins::TIDAL::ProtocolHandler'); 59 | # Hijack the old wimp:// URLs 60 | Slim::Player::ProtocolHandlers->registerHandler('wimp', 'Plugins::TIDAL::ProtocolHandler'); 61 | Slim::Music::Import->addImporter('Plugins::TIDAL::Importer', { use => 1 }); 62 | 63 | # Track Info item 64 | Slim::Menu::TrackInfo->registerInfoProvider( tidalTrackInfo => ( 65 | func => \&trackInfoMenu, 66 | ) ); 67 | 68 | Slim::Menu::ArtistInfo->registerInfoProvider( tidalArtistInfo => ( 69 | func => \&artistInfoMenu 70 | ) ); 71 | 72 | Slim::Menu::AlbumInfo->registerInfoProvider( tidalAlbumInfo => ( 73 | func => \&albumInfoMenu 74 | ) ); 75 | 76 | Slim::Menu::GlobalSearch->registerInfoProvider( tidalSearch => ( 77 | func => \&searchMenu 78 | ) ); 79 | 80 | $class->SUPER::initPlugin( 81 | feed => \&handleFeed, 82 | tag => 'tidal', 83 | menu => 'apps', 84 | is_app => 1, 85 | ); 86 | } 87 | 88 | sub postinitPlugin { 89 | my $class = shift; 90 | 91 | if ( Slim::Utils::PluginManager->isEnabled('Slim::Plugin::OnlineLibrary::Plugin') ) { 92 | Slim::Plugin::OnlineLibrary::Plugin->addLibraryIconProvider('tidal', '/plugins/TIDAL/html/emblem.png'); 93 | 94 | require Slim::Plugin::OnlineLibrary::BrowseArtist; 95 | Slim::Plugin::OnlineLibrary::BrowseArtist->registerBrowseArtistItem( TIDAL => sub { 96 | my ( $client ) = @_; 97 | 98 | return { 99 | name => cstring($client, 'BROWSE_ON_SERVICE', 'TIDAL'), 100 | type => 'link', 101 | icon => $class->_pluginDataFor('icon'), 102 | url => \&browseArtistMenu, 103 | }; 104 | } ); 105 | } 106 | 107 | if ( Slim::Utils::PluginManager->isEnabled('Plugins::LastMix::Plugin') ) { 108 | eval { 109 | require Plugins::LastMix::Services; 110 | }; 111 | 112 | if (!$@) { 113 | main::INFOLOG && $log->info("LastMix plugin is available - let's use it!"); 114 | require Plugins::TIDAL::LastMix; 115 | Plugins::LastMix::Services->registerHandler('Plugins::TIDAL::LastMix', 'lossless'); 116 | } 117 | } 118 | } 119 | 120 | sub onlineLibraryNeedsUpdate { 121 | my $class = shift; 122 | require Plugins::TIDAL::Importer; 123 | return Plugins::TIDAL::Importer->needsUpdate(@_); 124 | } 125 | 126 | sub getLibraryStats { 127 | require Plugins::TIDAL::Importer; 128 | my $totals = Plugins::TIDAL::Importer->getLibraryStats(); 129 | return wantarray ? ('PLUGIN_TIDAL_NAME', $totals) : $totals; 130 | } 131 | 132 | sub handleFeed { 133 | my ($client, $cb, $args) = @_; 134 | 135 | if ( !Plugins::TIDAL::API->getSomeUserId() ) { 136 | return $cb->({ 137 | items => [{ 138 | name => cstring($client, 'PLUGIN_TIDAL_REQUIRES_CREDENTIALS'), 139 | type => 'textarea', 140 | }] 141 | }); 142 | } 143 | 144 | my $items = [{ 145 | name => cstring($client, 'HOME'), 146 | image => 'plugins/TIDAL/html/home.png', 147 | type => 'link', 148 | url => \&getHome, 149 | },{ 150 | name => cstring($client, 'PLUGIN_TIDAL_FEATURES'), 151 | image => 'plugins/TIDAL/html/featured_MTL_svg_trophy.png', 152 | type => 'link', 153 | url => \&getFeatured, 154 | },{ 155 | name => cstring($client, 'PLUGIN_TIDAL_MY_MIX'), 156 | image => 'plugins/TIDAL/html/mix_MTL_svg_stream.png', 157 | type => 'playlist', 158 | url => \&getMyMixes, 159 | },{ 160 | name => cstring($client, 'PLAYLISTS'), 161 | image => 'html/images/playlists.png', 162 | type => 'link', 163 | url => \&getCollectionPlaylists, 164 | },{ 165 | name => cstring($client, 'ALBUMS'), 166 | image => 'html/images/albums.png', 167 | type => 'link', 168 | url => \&getFavorites, 169 | passthrough => [{ type => 'albums' }], 170 | },{ 171 | name => cstring($client, 'SONGS'), 172 | image => 'html/images/playall.png', 173 | type => 'link', 174 | url => \&getFavorites, 175 | passthrough => [{ type => 'tracks' }], 176 | },{ 177 | name => cstring($client, 'ARTISTS'), 178 | image => 'html/images/artists.png', 179 | type => 'link', 180 | url => \&getFavorites, 181 | passthrough => [{ type => 'artists' }], 182 | },{ 183 | name => cstring($client, 'SEARCH'), 184 | image => 'html/images/search.png', 185 | type => 'search', 186 | url => sub { 187 | my ($client, $cb, $params) = @_; 188 | my $menu = searchMenu($client, { 189 | search => lc($params->{search}) 190 | }); 191 | $cb->({ 192 | items => $menu->{items} 193 | }); 194 | }, 195 | },{ 196 | name => cstring($client, 'GENRES'), 197 | image => 'html/images/genres.png', 198 | type => 'link', 199 | url => \&getGenres, 200 | },{ 201 | name => cstring($client, 'PLUGIN_TIDAL_MOODS'), 202 | image => 'plugins/TIDAL/html/moods_MTL_icon_celebration.png', 203 | type => 'link', 204 | url => \&getMoods, 205 | } ]; 206 | 207 | if ($client && scalar keys %{$prefs->get('accounts') || {}} > 1) { 208 | push @$items, { 209 | name => cstring($client, 'PLUGIN_TIDAL_SELECT_ACCOUNT'), 210 | image => __PACKAGE__->_pluginDataFor('icon'), 211 | url => \&selectAccount, 212 | }; 213 | } 214 | 215 | $cb->({ items => $items }); 216 | } 217 | 218 | sub browseArtistMenu { 219 | my ($client, $cb, $params, $args) = @_; 220 | 221 | my $artistId = $params->{artist_id} || $args->{artist_id}; 222 | if ( defined($artistId) && $artistId =~ /^\d+$/ && (my $artistObj = Slim::Schema->resultset("Contributor")->find($artistId))) { 223 | my $renderer = sub { 224 | my $items = shift || { items => [] }; 225 | $items = $items->{items}; 226 | 227 | if ($items && ref $items eq 'ARRAY' && scalar @$items > 0) { 228 | $items = [ grep { 229 | Slim::Utils::Text::ignoreCase($_->{name} ) eq $artistObj->namesearch; 230 | } @$items ]; 231 | } 232 | 233 | if (scalar @$items == 1 && ref $items->[0]->{items}) { 234 | $items = $items->[0]->{items}; 235 | } 236 | 237 | $cb->( { 238 | items => $items 239 | } ); 240 | }; 241 | 242 | if (my ($extId) = grep /tidal:artist:(\d+)/, @{$artistObj->extIds}) { 243 | ($args->{artistId}) = $extId =~ /tidal:artist:(\d+)/; 244 | return getArtist($client, $renderer, $params, $args); 245 | } 246 | else { 247 | $args->{search} = $artistObj->name; 248 | $args->{type} = 'artists'; 249 | 250 | return search($client, $renderer, $params, $args); 251 | } 252 | } 253 | 254 | $cb->([{ 255 | type => 'text', 256 | title => cstring($client, 'EMPTY'), 257 | }]); 258 | } 259 | 260 | sub selectAccount { 261 | my ( $client, $cb ) = @_; 262 | 263 | my $userId = getAPIHandler($client)->userId; 264 | my $items = [ map { 265 | my $name = Plugins::TIDAL::API->getHumanReadableName($_); 266 | $name = '* ' . $name if $_->{userId} == $userId; 267 | 268 | { 269 | name => $name, 270 | url => sub { 271 | my ($client, $cb2, $params, $args) = @_; 272 | $prefs->client($client)->set('userId', $args->{id}); 273 | 274 | $cb2->({ items => [{ 275 | nextWindow => 'grandparent', 276 | }] }); 277 | }, 278 | passthrough => [{ 279 | id => $_->{userId} 280 | }], 281 | nextWindow => 'parent' 282 | } 283 | } sort values %{ $prefs->get('accounts') || {} } ]; 284 | 285 | $cb->({ items => $items }); 286 | } 287 | 288 | sub albumInfoMenu { 289 | my ($client, $url, $album, $remoteMeta) = @_; 290 | $remoteMeta ||= {}; 291 | 292 | my ($artist) = $album->artistsForRoles('ARTIST'); 293 | ($artist) = $album->artistsForRoles('ALBUMARTIST') unless $artist; 294 | 295 | return _objInfoMenu($client, 296 | $album->extid, 297 | ($artist && $artist->name) || $remoteMeta->{artist}, 298 | $album->title || $remoteMeta->{album}, 299 | ); 300 | } 301 | 302 | sub trackInfoMenu { 303 | my ( $client, $url, $track, $remoteMeta ) = @_; 304 | $remoteMeta ||= {}; 305 | 306 | my $isTidalTrack = $url =~ /^tidal:/; 307 | my $extid = $track->extid; 308 | $extid ||= $url if $isTidalTrack; 309 | 310 | my $artist = $track->remote ? $remoteMeta->{artist} : $track->artistName; 311 | my $album = $track->remote ? $remoteMeta->{album} : $track->albumname; 312 | my $title = $track->remote ? $remoteMeta->{title} : $track->title; 313 | 314 | my $search = cstring($client, 'SEARCH'); 315 | my $items = []; 316 | 317 | my $artists = ($track->remote && $isTidalTrack) ? $remoteMeta->{artists} : []; 318 | my $albumId = ($track->remote && $isTidalTrack) ? $remoteMeta->{album_id} : undef; 319 | my $trackId = Plugins::TIDAL::ProtocolHandler::getId($track->url); 320 | 321 | push @$items, { 322 | name => $album, 323 | line1 => $album, 324 | line2 => $artist, 325 | favorites_url => 'tidal://album:' . $albumId, 326 | type => 'playlist', 327 | url => \&getAlbum, 328 | image => Plugins::TIDAL::API->getImageUrl($remoteMeta, 'usePlaceholder'), 329 | passthrough => [{ id => $albumId }], 330 | } if $albumId; 331 | 332 | foreach my $_artist (@$artists) { 333 | push @$items, _renderArtist($client, $_artist); 334 | } 335 | 336 | push @$items, { 337 | name => cstring($client, 'PLUGIN_TIDAL_TRACK_MIX'), 338 | type => 'playlist', 339 | url => \&getTrackRadio, 340 | image => 'plugins/TIDAL/html/mix_MTL_svg_stream.png', 341 | passthrough => [{ id => $trackId }], 342 | } if $trackId; 343 | 344 | push @$items, { 345 | name => "$search " . cstring($client, 'ARTIST') . " '$artist'", 346 | type => 'link', 347 | url => \&search, 348 | image => 'html/images/artists.png', 349 | passthrough => [ { 350 | type => 'artists', 351 | query => $artist, 352 | } ], 353 | } if $artist; 354 | 355 | push @$items, { 356 | name => "$search " . cstring($client, 'ALBUM') . " '$album'", 357 | type => 'link', 358 | url => \&search, 359 | image => 'html/images/albums.png', 360 | passthrough => [ { 361 | type => 'albums', 362 | query => $album, 363 | } ], 364 | } if $album; 365 | 366 | push @$items, { 367 | name => "$search " . cstring($client, 'SONG') . " '$title'", 368 | type => 'link', 369 | url => \&search, 370 | image => 'html/images/playall.png', 371 | passthrough => [ { 372 | type => 'tracks', 373 | query => $title, 374 | } ], 375 | } if $title; 376 | 377 | # if we are playing a tidal track, then we can add it to favorites or playlists 378 | if ( $url =~ m|tidal://| ) { 379 | unshift @$items, ( { 380 | type => 'link', 381 | name => cstring($client, 'PLUGIN_TIDAL_ADD_TO_FAVORITES'), 382 | url => \&addPlayingToFavorites, 383 | passthrough => [ { url => $url } ], 384 | image => 'html/images/favorites.png' 385 | }, { 386 | type => 'link', 387 | name => cstring($client, 'PLUGIN_TIDAL_ADD_TO_PLAYLIST'), 388 | url => \&addPlayingToPlaylist, 389 | passthrough => [ { url => $url } ], 390 | image => 'html/images/playlists.png' 391 | } ); 392 | } 393 | 394 | return { 395 | type => 'outlink', 396 | items => $items, 397 | name => cstring($client, 'PLUGIN_TIDAL_ON_TIDAL'), 398 | }; 399 | } 400 | 401 | sub _completed { 402 | my ($client, $cb) = @_; 403 | $cb->({ 404 | items => [{ 405 | type => 'text', 406 | name => cstring($client, 'COMPLETE'), 407 | }], 408 | }); 409 | } 410 | 411 | sub addPlayingToFavorites { 412 | my ($client, $cb, $args, $params) = @_; 413 | 414 | my $id = Plugins::TIDAL::ProtocolHandler::getId($params->{url}); 415 | return _completed($client, $cb) unless $id; 416 | 417 | Plugins::TIDAL::Plugin::getAPIHandler($client)->updateFavorite( sub { 418 | _completed($client, $cb); 419 | }, 'add', 'track', $id ); 420 | } 421 | 422 | sub addPlayingToPlaylist { 423 | my ($client, $cb, $args, $params) = @_; 424 | 425 | my $id = Plugins::TIDAL::ProtocolHandler::getId($params->{url}); 426 | return _completed($client, $cb) unless $id; 427 | 428 | Plugins::TIDAL::InfoMenu::addToPlaylist($client, $cb, undef, { id => $id }), 429 | } 430 | 431 | sub artistInfoMenu { 432 | my ($client, $url, $artist, $remoteMeta) = @_; 433 | $remoteMeta ||= {}; 434 | 435 | return _objInfoMenu( $client, $artist->extid, $artist->name || $remoteMeta->{artist} ); 436 | } 437 | 438 | sub _objInfoMenu { 439 | my ( $client, $extid, $artist, $album, $track, $items ) = @_; 440 | 441 | # TODO - use $extid! 442 | 443 | $items ||= []; 444 | 445 | push @$items, { 446 | name => cstring($client, 'SEARCH'), 447 | url => \&searchEverything, 448 | passthrough => [{ 449 | query => join(' ', $artist, $album, $track), 450 | }] 451 | }; 452 | 453 | my $menu; 454 | if ( scalar @$items == 1) { 455 | $menu = $items->[0]; 456 | $menu->{name} = cstring($client, 'PLUGIN_TIDAL_ON_TIDAL'); 457 | } 458 | elsif (scalar @$items) { 459 | $menu = { 460 | name => cstring($client, 'PLUGIN_TIDAL_ON_TIDAL'), 461 | items => $items 462 | }; 463 | } 464 | 465 | return $menu if $menu; 466 | } 467 | 468 | sub searchMenu { 469 | my ( $client, $tags ) = @_; 470 | 471 | my $searchParam = { query => $tags->{search} }; 472 | 473 | return { 474 | name => cstring($client, 'PLUGIN_TIDAL_NAME'), 475 | items => [{ 476 | name => cstring($client, 'EVERYTHING'), 477 | url => \&searchEverything, 478 | passthrough => [ $searchParam ], 479 | },{ 480 | name => cstring($client, 'PLAYLISTS'), 481 | url => \&search, 482 | passthrough => [ { %$searchParam, type => 'playlists' } ], 483 | },{ 484 | name => cstring($client, 'ARTISTS'), 485 | url => \&search, 486 | passthrough => [ { %$searchParam, type => 'artists' } ], 487 | },{ 488 | name => cstring($client, 'ALBUMS'), 489 | url => \&search, 490 | passthrough => [ { %$searchParam, type => 'albums' } ], 491 | },{ 492 | name => cstring($client, 'SONGS'), 493 | url => \&search, 494 | passthrough => [ { %$searchParam, type => 'tracks' } ], 495 | }] 496 | }; 497 | } 498 | 499 | sub getCollectionPlaylists { 500 | my ( $client, $cb, $args, $params ) = @_; 501 | 502 | getAPIHandler($client)->getCollectionPlaylists(sub { 503 | my $items = shift; 504 | 505 | $items = [ map { _renderPlaylist($_) } @$items ] if $items; 506 | 507 | $cb->( { 508 | items => $items 509 | } ); 510 | }, $args->{quantity} != 1 ); 511 | } 512 | 513 | sub getFavorites { 514 | my ( $client, $cb, $args, $params ) = @_; 515 | 516 | getAPIHandler($client)->getFavorites(sub { 517 | my $items = shift; 518 | 519 | $items = [ map { _renderItem($client, $_, { addArtistToTitle => 1, sorted => 1 }) } @$items ] if $items; 520 | 521 | $cb->( { 522 | items => $items 523 | } ); 524 | }, $params->{type}, $args->{quantity} != 1 ); 525 | } 526 | 527 | sub getArtist { 528 | my ( $client, $cb, $args, $params ) = @_; 529 | 530 | my $artistId = $params->{artistId}; 531 | 532 | getAPIHandler($client)->getArtist(sub { 533 | my $item = _renderArtist($client, @_); 534 | $cb->( { 535 | items => [$item] 536 | } ); 537 | }, $artistId); 538 | } 539 | 540 | sub getSimilarArtists { 541 | my ( $client, $cb, $args, $params ) = @_; 542 | 543 | getAPIHandler($client)->similarArtists(sub { 544 | my $items = shift; 545 | 546 | $items = [ map { _renderItem($client, $_, { addArtistToTitle => 1 }) } @$items ] if $items; 547 | 548 | $cb->( { 549 | items => $items 550 | } ); 551 | }, $params->{id}); 552 | } 553 | 554 | sub getArtistAlbums { 555 | my ( $client, $cb, $args, $params ) = @_; 556 | 557 | getAPIHandler($client)->artistAlbums(sub { 558 | my $items = _renderAlbums(@_); 559 | $cb->( { 560 | items => $items 561 | } ); 562 | }, $params->{id}, $params->{type}); 563 | } 564 | 565 | sub getArtistTopTracks { 566 | my ( $client, $cb, $args, $params ) = @_; 567 | 568 | getAPIHandler($client)->artistTopTracks(sub { 569 | my $items = _renderTracks(@_); 570 | $cb->( { 571 | items => $items 572 | } ); 573 | }, $params->{id}); 574 | } 575 | 576 | sub getTrackRadio { 577 | my ( $client, $cb, $args, $params ) = @_; 578 | 579 | getAPIHandler($client)->trackRadio(sub { 580 | my $items = _renderTracks(@_); 581 | $cb->( { 582 | items => $items 583 | } ); 584 | }, $params->{id}); 585 | } 586 | 587 | sub getMyMixes { 588 | my ( $client, $cb ) = @_; 589 | 590 | getAPIHandler($client)->myMixes(sub { 591 | my $items = [ map { _renderMix($client, $_) } @{$_[0]} ]; 592 | $cb->( { 593 | items => $items 594 | } ); 595 | }); 596 | } 597 | 598 | sub getMix { 599 | my ( $client, $cb, $args, $params ) = @_; 600 | 601 | getAPIHandler($client)->mix(sub { 602 | my $items = _renderTracks(@_); 603 | $cb->( { 604 | items => $items 605 | } ); 606 | }, $params->{id}); 607 | } 608 | 609 | sub getAlbum { 610 | my ( $client, $cb, $args, $params ) = @_; 611 | 612 | getAPIHandler($client)->albumTracks(sub { 613 | my $items = _renderTracks(shift); 614 | $cb->( { 615 | items => $items 616 | } ); 617 | }, $params->{id}); 618 | } 619 | 620 | sub getGenres { 621 | my ( $client, $callback ) = @_; 622 | 623 | getAPIHandler($client)->genres(sub { 624 | my $items = [ map { _renderItem($client, $_, { handler => \&getGenreItems }) } @{$_[0]} ]; 625 | 626 | $callback->( { items => $items } ); 627 | }); 628 | } 629 | 630 | sub getGenreItems { 631 | my ( $client, $cb, $args, $params ) = @_; 632 | getAPIHandler($client)->genreByType(sub { 633 | my $items = [ map { _renderItem($client, $_, { addArtistToTitle => 1 } ) } @{$_[0]} ]; 634 | 635 | $cb->( { 636 | items => $items 637 | } ); 638 | }, $params->{path}, $params->{type} ); 639 | } 640 | 641 | sub getFeatured { 642 | my ( $client, $cb, $args, $params ) = @_; 643 | 644 | getAPIHandler($client)->featured(sub { 645 | my $items = [ map { _renderItem($client, $_, { handler => \&getFeaturedItem }) } @{$_[0]} ]; 646 | 647 | $cb->( { 648 | items => $items 649 | } ); 650 | }); 651 | } 652 | 653 | sub getHome { 654 | my ( $client, $cb, $args, $params ) = @_; 655 | 656 | getAPIHandler($client)->home(sub { 657 | my $modules = shift; 658 | 659 | my $items = [ map { 660 | { 661 | name => $_->{title}, 662 | type => 'link', 663 | url => $_->{type} eq 'HIGHLIGHT_MODULE' ? \&getHighlights : \&getModule, 664 | passthrough => [ { module => $_ } ], 665 | } 666 | } grep { 667 | my $knownType = ($_->{type} =~ MODULE_MATCH_REGEX || $_->{type} eq 'HIGHLIGHT_MODULE'); 668 | 669 | if (main::INFOLOG && $log->is_info && !$knownType) { 670 | $log->info('Unknown type: ' . $_->{type} . ' - ' . $_->{title}); 671 | main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($_)); 672 | } 673 | 674 | $knownType; 675 | } @{$modules || []} ]; 676 | 677 | $cb->( { 678 | items => $items 679 | } ); 680 | } ); 681 | } 682 | 683 | sub getHighlights { 684 | my ( $client, $cb, $args, $params ) = @_; 685 | 686 | my $module = $params->{module}; 687 | my $items = []; 688 | 689 | foreach my $entry (@{$module->{highlights}}) { 690 | next if $entry->{item}->{type} !~ /ALBUM|PLAYLIST|MIX|TRACK/; 691 | 692 | my $title = $entry->{title}; 693 | my $item = $entry->{item}->{item}; 694 | 695 | ($item) = @{Plugins::TIDAL::API->cacheTrackMetadata([ $item ])} if $entry->{item}->{type} eq 'TRACK'; 696 | $item = _renderItem($client, $item, { addArtistToTitle => 1 }); 697 | $item->{name} = "$title: $item->{name}" unless $entry->{item}->{type} eq 'MIX'; 698 | 699 | push @$items, $item; 700 | } 701 | 702 | $cb->({ 703 | image => 'plugins/TIDAL/html/personal.png', 704 | items => $items, 705 | }); 706 | } 707 | 708 | sub getModule { 709 | my ( $client, $cb, $args, $params ) = @_; 710 | 711 | my $module = $params->{module}; 712 | return $cb->() if $module->{type} !~ MODULE_MATCH_REGEX; 713 | 714 | my $items = $module->{pagedList}->{items} || $module->{items}; 715 | $items = Plugins::TIDAL::API->cacheTrackMetadata($items) if $module->{type} eq 'TRACK_LIST'; 716 | 717 | $items = [ map { 718 | my $item = $_; 719 | 720 | # sometimes the items are nested inside another "item" object... 721 | if ($item->{item} && ref $item->{item} && $item->{type} && scalar keys %$item == 2) { 722 | $item = $item->{item}; 723 | } 724 | 725 | _renderItem($client, $item, { addArtistToTitle => 1 }); 726 | } @$items ]; 727 | 728 | # don't ask for more if we have all items 729 | unshift @$items, { 730 | name => $module->{showMore}->{title}, 731 | type => 'link', 732 | image => __PACKAGE__->_pluginDataFor('icon'), 733 | url => \&getDataPage, 734 | passthrough => [ { 735 | page => $module->{pagedList}->{dataApiPath}, 736 | limit => $module->{pagedList}->{totalNumberOfItems}, 737 | type => $module->{type}, 738 | } ], 739 | } if $module->{showMore} && @$items < $module->{pagedList}->{totalNumberOfItems}; 740 | 741 | $cb->({ 742 | items => $items 743 | }); 744 | } 745 | 746 | sub getDataPage { 747 | my ( $client, $cb, $args, $params ) = @_; 748 | 749 | return $cb->() if $params->{type} !~ /MIX_LIST|PLAYLIST_LIST|ALBUM_LIST|TRACK_LIST/; 750 | 751 | getAPIHandler($client)->dataPage(sub { 752 | my $items = shift; 753 | 754 | $items = Plugins::TIDAL::API->cacheTrackMetadata($items) if $params->{type} eq 'TRACK_LIST'; 755 | $items = [ map { _renderItem($client, $_, { addArtistToTitle => 1 }) } @$items ]; 756 | 757 | $cb->({ 758 | items => $items 759 | }); 760 | }, $params->{page}, $params->{limit} ); 761 | } 762 | 763 | sub getFeaturedItem { 764 | my ( $client, $cb, $args, $params ) = @_; 765 | 766 | getAPIHandler($client)->featuredItem(sub { 767 | my $items = [ map { _renderItem($client, $_, { addArtistToTitle => 1 }) } @{$_[0]} ]; 768 | 769 | $cb->( { 770 | items => $items 771 | } ); 772 | },{ 773 | id => $params->{path}, 774 | type => $params->{type}, 775 | }); 776 | } 777 | 778 | sub getMoods { 779 | my ( $client, $callback, $args, $params ) = @_; 780 | getAPIHandler($client)->moods(sub { 781 | my $items = [ map { 782 | { 783 | name => $_->{name}, 784 | type => 'link', 785 | url => \&getMoodPlaylists, 786 | image => Plugins::TIDAL::API->getImageUrl($_, 'usePlaceholder', 'mood'), 787 | passthrough => [ { mood => $_->{path} } ], 788 | }; 789 | } @{$_[0]} ]; 790 | 791 | $callback->( { items => $items } ); 792 | } ); 793 | } 794 | 795 | sub getMoodPlaylists { 796 | my ( $client, $cb, $args, $params ) = @_; 797 | getAPIHandler($client)->moodPlaylists(sub { 798 | my $items = [ map { _renderPlaylist($_) } @{$_[0]->{items}} ]; 799 | 800 | $cb->( { 801 | items => $items 802 | } ); 803 | }, $params->{mood} ); 804 | } 805 | 806 | sub getPlaylist { 807 | my ( $client, $cb, $args, $params ) = @_; 808 | 809 | my $api = getAPIHandler($client); 810 | 811 | # we'll only set playlist id we own it so that we can remove track later 812 | my $renderArgs = { 813 | playlistId => $params->{uuid} 814 | } if $api->userId eq $params->{creatorId}; 815 | 816 | $api->playlist(sub { 817 | my $items = _renderTracks($_[0], $renderArgs); 818 | $cb->( { 819 | items => $items 820 | } ); 821 | }, $params->{uuid} ); 822 | } 823 | 824 | sub search { 825 | my ($client, $cb, $args, $params) = @_; 826 | 827 | $args->{search} ||= $params->{query} || $params->{search}; 828 | $args->{type} ||= $params->{type}; 829 | 830 | getAPIHandler($client)->search(sub { 831 | my $items = shift; 832 | $items = [ map { _renderItem($client, $_) } @$items ] if $items; 833 | 834 | $cb->( { 835 | items => $items || [] 836 | } ); 837 | }, $args); 838 | 839 | } 840 | 841 | sub searchEverything { 842 | my ($client, $cb, $args, $params) = @_; 843 | 844 | $args->{search} ||= $params->{query}; 845 | 846 | getAPIHandler($client)->search(sub { 847 | my $result = shift; 848 | my $items = []; 849 | 850 | if ($result->{topHit}) { 851 | $result->{topHit}->{value}->{type} = $result->{topHit}->{type}; 852 | my $item = _renderItem($client, $result->{topHit}->{value}); 853 | push @$items, $item if $item; 854 | } 855 | 856 | foreach my $key ("topHit", "playlists", "artists", "albums", "tracks") { 857 | next unless $result->{$key} && $result->{$key}->{totalNumberOfItems}; 858 | 859 | my $entries = $key ne 'tracks' ? 860 | $result->{$key}->{items} : 861 | Plugins::TIDAL::API->cacheTrackMetadata($result->{$key}->{items}); 862 | 863 | push @$items, { 864 | name => cstring($client, $key =~ s/tracks/songs/r), 865 | image => 'html/images/' . ($key ne 'tracks' ? $key : 'playall') . '.png', 866 | type => 'outline', 867 | items => [ map { _renderItem($client, $_) } @$entries ], 868 | } 869 | } 870 | 871 | $cb->( { 872 | items => $items || [] 873 | } ); 874 | }, $args); 875 | 876 | } 877 | 878 | sub _renderItem { 879 | my ($client, $item, $args) = @_; 880 | 881 | my $type = Plugins::TIDAL::API->typeOfItem($item); 882 | 883 | if ($type eq 'track') { 884 | return _renderTrack($item, $args->{addArtistToTitle}, $args->{playlistId}); 885 | } 886 | elsif ($type eq 'album') { 887 | return _renderAlbum($item, $args->{addArtistToTitle}, $args->{sorted}); 888 | } 889 | elsif ($type eq 'artist') { 890 | return _renderArtist($client, $item, $args); 891 | } 892 | elsif ($type eq 'playlist') { 893 | return _renderPlaylist($item); 894 | } 895 | elsif ($type eq 'category') { 896 | return _renderCategory($client, $item, $args->{handler}); 897 | } 898 | elsif ($type eq 'mix') { 899 | return _renderMix($client, $item); 900 | } 901 | } 902 | 903 | sub _renderPlaylists { 904 | my $results = shift; 905 | 906 | return [ map { 907 | _renderPlaylist($_) 908 | } @{$results}]; 909 | } 910 | 911 | sub _renderPlaylist { 912 | my $item = shift; 913 | 914 | return { 915 | name => $item->{title}, 916 | line1 => $item->{title}, 917 | line2 => join(', ', uniq(map { $_->{name} } @{$item->{promotedArtists} || []})), 918 | favorites_url => 'tidal://playlist:' . $item->{uuid}, 919 | # see note on album 920 | # play => 'tidal://playlist:' . $item->{uuid}, 921 | type => 'playlist', 922 | url => \&getPlaylist, 923 | image => Plugins::TIDAL::API->getImageUrl($item), 924 | passthrough => [ { uuid => $item->{uuid}, creatorId => $item->{creator}->{id} } ], 925 | itemActions => { 926 | info => { 927 | command => ['tidal_info', 'items'], 928 | fixedParams => { 929 | type => 'playlist', 930 | id => $item->{uuid}, 931 | }, 932 | }, 933 | play => _makeAction('play', 'playlist', $item->{uuid}), 934 | add => _makeAction('add', 'playlist', $item->{uuid}), 935 | insert => _makeAction('insert', 'playlist', $item->{uuid}), 936 | }, 937 | }; 938 | } 939 | 940 | sub _renderAlbums { 941 | my ($results, $addArtistToTitle) = @_; 942 | 943 | return [ map { 944 | _renderAlbum($_, $addArtistToTitle); 945 | } @{$results} ]; 946 | } 947 | 948 | sub _renderAlbum { 949 | my ($item, $addArtistToTitle, $sorted) = @_; 950 | 951 | # we could also join names 952 | my $artist = $item->{artist} || $item->{artists}->[0] || {}; 953 | $item->{title} .= ' [E]' if $item->{explicit}; 954 | my $title = $item->{title}; 955 | $title .= ' - ' . $artist->{name} if $addArtistToTitle; 956 | 957 | return { 958 | name => $title, 959 | line1 => $item->{title}, 960 | line2 => $artist->{name}, 961 | textkey => $sorted ? substr( uc($item->{title}), 0, 1 ) : undef, 962 | favorites_url => 'tidal://album:' . $item->{id}, 963 | favorites_title => $item->{title} . ' - ' . $artist->{name}, 964 | favorites_type => 'playlist', 965 | type => 'playlist', 966 | url => \&getAlbum, 967 | image => Plugins::TIDAL::API->getImageUrl($item, 'usePlaceholder'), 968 | passthrough => [{ id => $item->{id} }], 969 | # we need a 'play' for M(ore) to appear or set play, add and insert actions 970 | # play => 'tidal://album:' . $item->{id}, 971 | itemActions => { 972 | info => { 973 | command => ['tidal_info', 'items'], 974 | fixedParams => { 975 | type => 'album', 976 | id => $item->{id}, 977 | }, 978 | }, 979 | play => _makeAction('play', 'album', $item->{id}), 980 | add => _makeAction('add', 'album', $item->{id}), 981 | insert => _makeAction('insert', 'album', $item->{id}), 982 | }, 983 | }; 984 | } 985 | 986 | sub _renderTracks { 987 | my ($tracks, $args) = @_; 988 | $args ||= {}; 989 | 990 | my $index = 0; 991 | 992 | return [ map { 993 | # due to the need of an index when deleting a track from a playlist (...) 994 | # we insert it here for convenience, but we could search the trackId index 995 | # in the whole playlist which should be cached... 996 | _renderTrack($_, $args->{addArtistToTitle}, $args->{playlistId}, $index++); 997 | } @$tracks ]; 998 | } 999 | 1000 | sub _renderTrack { 1001 | my ($item, $addArtistToTitle, $playlistId, $index) = @_; 1002 | 1003 | my $title = $item->{title}; 1004 | $title .= ' - ' . $item->{artist}->{name} if $addArtistToTitle; 1005 | my $url = "tidal://$item->{id}." . Plugins::TIDAL::API::getFormat(); 1006 | 1007 | my $fixedParams = { 1008 | playlistId => $playlistId, 1009 | index => $index, 1010 | } if $playlistId; 1011 | 1012 | return { 1013 | name => $title, 1014 | type => 'audio', 1015 | favorites_title => $item->{title} . ' - ' . $item->{artist}->{name}, 1016 | line1 => $item->{title}, 1017 | line2 => $item->{artist}->{name}, 1018 | on_select => 'play', 1019 | url => $url, 1020 | play => $url, 1021 | playall => 1, 1022 | image => $item->{cover}, 1023 | itemActions => { 1024 | info => { 1025 | command => ['tidal_info', 'items'], 1026 | fixedParams => { 1027 | %{$fixedParams || {}}, 1028 | type => 'track', 1029 | id => $item->{id}, 1030 | }, 1031 | }, 1032 | }, 1033 | }; 1034 | } 1035 | 1036 | sub _renderArtists { 1037 | my ($client, $results) = @_; 1038 | 1039 | return [ map { 1040 | _renderArtist($client, $_); 1041 | } @{$results->{items}} ]; 1042 | } 1043 | 1044 | sub _renderArtist { 1045 | my ($client, $item, $args) = @_; 1046 | 1047 | $args ||= {}; 1048 | 1049 | my $items = [{ 1050 | name => cstring($client, 'PLUGIN_TIDAL_TOP_TRACKS'), 1051 | url => \&getArtistTopTracks, 1052 | passthrough => [{ id => $item->{id} }], 1053 | },{ 1054 | name => cstring($client, 'ALBUMS'), 1055 | url => \&getArtistAlbums, 1056 | passthrough => [{ id => $item->{id} }], 1057 | },{ 1058 | name => cstring($client, 'PLUGIN_TIDAL_EP_SINGLES'), 1059 | url => \&getArtistAlbums, 1060 | passthrough => [{ id => $item->{id}, type => 'EPSANDSINGLES' }], 1061 | },{ 1062 | name => cstring($client, 'COMPILATIONS'), 1063 | url => \&getArtistAlbums, 1064 | passthrough => [{ id => $item->{id}, type => 'COMPILATIONS' }], 1065 | }]; 1066 | 1067 | foreach (keys %{$item->{mixes} || {}}) { 1068 | $log->warn($_) unless /^(?:TRACK|ARTIST)_MIX/; 1069 | next unless /^(?:TRACK|ARTIST)_MIX/; 1070 | push @$items, { 1071 | name => cstring($client, "PLUGIN_TIDAL_$_"), 1072 | favorites_url => 'tidal://mix:' . $item->{mixes}->{$_}, 1073 | type => 'playlist', 1074 | url => \&getMix, 1075 | passthrough => [{ id => $item->{mixes}->{$_} }], 1076 | }; 1077 | } 1078 | 1079 | push @$items, { 1080 | name => cstring($client, "PLUGIN_TIDAL_SIMILAR_ARTISTS"), 1081 | url => \&getSimilarArtists, 1082 | passthrough => [{ id => $item->{id} }], 1083 | }; 1084 | 1085 | my $itemActions = { 1086 | info => { 1087 | command => ['tidal_info', 'items'], 1088 | fixedParams => { 1089 | type => 'artist', 1090 | id => $item->{id}, 1091 | }, 1092 | }, 1093 | }; 1094 | 1095 | return scalar @$items > 1 1096 | ? { 1097 | name => $item->{name}, 1098 | textkey => $args->{sorted} ? substr( uc($item->{name}), 0, 1 ) : undef, 1099 | type => 'outline', 1100 | items => $items, 1101 | itemActions => $itemActions, 1102 | image => Plugins::TIDAL::API->getImageUrl($item, 'usePlaceholder'), 1103 | } 1104 | : { 1105 | %{$items->[0]}, 1106 | name => $item->{name}, 1107 | itemActions => $itemActions, 1108 | image => Plugins::TIDAL::API->getImageUrl($item, 'usePlaceholder'), 1109 | }; 1110 | } 1111 | 1112 | sub _renderMix { 1113 | my ($client, $item) = @_; 1114 | 1115 | return { 1116 | name => $item->{title}, 1117 | line1 => $item->{title}, 1118 | line2 => join(', ', uniq(map { $_->{name} } @{$item->{artists}})), 1119 | favorites_url => 'tidal://mix:' . $item->{id}, 1120 | type => 'playlist', 1121 | url => \&getMix, 1122 | image => Plugins::TIDAL::API->getImageUrl($item, 'usePlaceholder'), 1123 | passthrough => [{ id => $item->{id} }], 1124 | }; 1125 | } 1126 | 1127 | sub _renderCategory { 1128 | my ($client, $item, $renderer) = @_; 1129 | 1130 | my $path = $item->{path}; 1131 | my $items = []; 1132 | 1133 | push @$items, { 1134 | name => cstring($client, 'PLAYLISTS'), 1135 | type => 'link', 1136 | url => $renderer, 1137 | passthrough => [ { path => $path, type => 'playlists' } ], 1138 | } if $item->{hasPlaylists}; 1139 | 1140 | push @$items, { 1141 | name => cstring($client, 'ARTISTS'), 1142 | type => 'link', 1143 | url => $renderer, 1144 | passthrough => [ { path => $path, type => 'artists' } ], 1145 | } if $item->{hasArtists}; 1146 | 1147 | push @$items, { 1148 | name => cstring($client, 'ALBUMS'), 1149 | type => 'link', 1150 | url => $renderer, 1151 | passthrough => [ { path => $path, type => 'albums' } ], 1152 | } if $item->{hasAlbums}; 1153 | 1154 | push @$items, { 1155 | name => cstring($client, 'SONGS'), 1156 | type => 'link', 1157 | url => $renderer, 1158 | passthrough => [ { path => $path, type => 'tracks' } ], 1159 | } if $item->{hasTracks}; 1160 | 1161 | return { 1162 | name => $item->{name}, 1163 | type => 'outline', 1164 | items => $items, 1165 | image => Plugins::TIDAL::API->getImageUrl($item, 'usePlaceholder', 'genre'), 1166 | passthrough => [ { path => $item->{path} } ], 1167 | }; 1168 | } 1169 | 1170 | sub _makeAction { 1171 | my ($action, $type, $id) = @_; 1172 | return { 1173 | command => ['tidal_browse', 'playlist', $action], 1174 | fixedParams => { 1175 | type => $type, 1176 | id => $id, 1177 | }, 1178 | }; 1179 | } 1180 | 1181 | sub getAPIHandler { 1182 | my ($client) = @_; 1183 | 1184 | my $api; 1185 | 1186 | if (ref $client) { 1187 | $api = $client->pluginData('api'); 1188 | 1189 | if ( !$api ) { 1190 | my $userdata = Plugins::TIDAL::API->getUserdata($prefs->client($client)->get('userId')); 1191 | 1192 | # if there's no account assigned to the player, just pick one 1193 | if ( !$userdata ) { 1194 | my $userId = Plugins::TIDAL::API->getSomeUserId(); 1195 | $prefs->client($client)->set('userId', $userId) if $userId; 1196 | } 1197 | 1198 | $api = $client->pluginData( api => Plugins::TIDAL::API::Async->new({ 1199 | client => $client 1200 | }) ); 1201 | } 1202 | } 1203 | else { 1204 | $api = Plugins::TIDAL::API::Async->new({ 1205 | userId => Plugins::TIDAL::API->getSomeUserId() 1206 | }); 1207 | } 1208 | 1209 | logBacktrace("Failed to get a TIDAL API instance: $client") unless $api; 1210 | 1211 | return $api; 1212 | } 1213 | 1214 | *uniq = List::Util->can('uniq') || sub { 1215 | my %seen; 1216 | return grep { !$seen{$_}++ } @_; 1217 | }; 1218 | 1219 | 1220 | 1; 1221 | -------------------------------------------------------------------------------- /ProtocolHandler.pm: -------------------------------------------------------------------------------- 1 | package Plugins::TIDAL::ProtocolHandler; 2 | 3 | use strict; 4 | 5 | use Async::Util; 6 | use JSON::XS::VersionOneAndTwo; 7 | use URI::Escape qw(uri_escape_utf8); 8 | use Scalar::Util qw(blessed); 9 | use MIME::Base64 qw(encode_base64 decode_base64); 10 | 11 | use Slim::Utils::Cache; 12 | use Slim::Utils::Log; 13 | use Slim::Utils::Misc; 14 | use Slim::Utils::Prefs; 15 | use Slim::Utils::Timers; 16 | 17 | use Plugins::TIDAL::Plugin; 18 | use Plugins::TIDAL::API; 19 | 20 | use base qw(Slim::Player::Protocols::HTTPS); 21 | 22 | my $prefs = preferences('plugin.tidal'); 23 | my $serverPrefs = preferences('server'); 24 | my $log = logger('plugin.tidal'); 25 | my $cache = Slim::Utils::Cache->new; 26 | 27 | # https://tidal.com/browse/track/95570766 28 | # https://tidal.com/browse/album/95570764 29 | # https://tidal.com/browse/playlist/5a36919b-251c-4fa7-802c-b659aef04216 30 | my $URL_REGEX = qr{^https://(?:\w+\.)?tidal.com/(?:browse/)?(track|playlist|album|artist|mix)/([a-z\d-]+)}i; 31 | my $URI_REGEX = qr{^(?:tidal|wimp)://(playlist|album|artist|mix|):?([0-9a-z-]+)}i; 32 | Slim::Player::ProtocolHandlers->registerURLHandler($URL_REGEX, __PACKAGE__); 33 | Slim::Player::ProtocolHandlers->registerURLHandler($URI_REGEX, __PACKAGE__); 34 | 35 | # many method do not need override like isRemote, shouldLoop ... 36 | sub canSkip { 1 } # where is this called? 37 | sub canSeek { 1 } 38 | 39 | sub getFormatForURL { 40 | my ($class, $url) = @_; 41 | return if $url =~ m{^(?:tidal|wimp)://.+:.+}; 42 | return Plugins::TIDAL::API::getFormat(); 43 | } 44 | 45 | sub formatOverride { 46 | my ($class, $song) = @_; 47 | my $format = $song->pluginData('format') || Plugins::TIDAL::API::getFormat; 48 | return $format =~ s/mp4/aac/r; 49 | } 50 | 51 | # some TIDAL streams are compressed in a way which causes stutter on ip3k based players 52 | sub forceTranscode { 53 | my ($self, $client, $format) = @_; 54 | return $format eq 'flc' && $client->model =~ /squeezebox|boom|transporter|receiver/; 55 | } 56 | 57 | sub trackGain { 58 | my ($class, $client, $url) = @_; 59 | 60 | return unless $client && blessed $client; 61 | return unless $serverPrefs->client($client)->get('replayGainMode'); 62 | 63 | my $trackId = getId($url); 64 | my $meta = $cache->get( 'tidal_meta_' . ($trackId || '') ); 65 | 66 | return unless ref $meta && defined $meta->{replay_gain} && defined $meta->{peak}; 67 | 68 | # TODO - try to get album gain information? 69 | 70 | my $gain = Slim::Player::ReplayGain::preventClipping($meta->{replay_gain}, $meta->{peak}); 71 | main::DEBUGLOG && $log->is_debug && $log->debug("Net replay gain: $gain"); 72 | 73 | return $gain; 74 | } 75 | 76 | # To support remote streaming (synced players), we need to subclass Protocols::HTTP 77 | sub new { 78 | my $class = shift; 79 | my $args = shift; 80 | 81 | my $client = $args->{client}; 82 | 83 | my $song = $args->{song}; 84 | my $streamUrl = $song->streamUrl() || return; 85 | 86 | main::DEBUGLOG && $log->debug( 'Remote streaming TIDAL track: ' . $streamUrl ); 87 | 88 | my $sock = $class->SUPER::new( { 89 | url => $streamUrl, 90 | song => $args->{song}, 91 | client => $client, 92 | } ) || return; 93 | 94 | return $sock; 95 | } 96 | 97 | # Avoid scanning 98 | sub scanUrl { 99 | my ( $class, $url, $args ) = @_; 100 | $args->{cb}->( $args->{song}->currentTrack() ); 101 | } 102 | 103 | # Source for AudioScrobbler 104 | sub audioScrobblerSource { 105 | my ( $class, $client, $url ) = @_; 106 | 107 | # P = Chosen by the user 108 | return 'P'; 109 | } 110 | 111 | sub explodePlaylist { 112 | my ( $class, $client, $url, $cb ) = @_; 113 | 114 | my ($type, $id) = $url =~ $URL_REGEX; 115 | 116 | if ( !($type && $id) ) { 117 | ($type, $id) = $url =~ $URI_REGEX; 118 | } 119 | 120 | if ($id) { 121 | return $cb->( [ $url ] ) if !$type; 122 | 123 | return $cb->( [ "tidal://$id." . Plugins::TIDAL::API::getFormat() ] ) if $type eq 'track'; 124 | 125 | my $method; 126 | my $params = { id => $id }; 127 | 128 | if ($type eq 'playlist') { 129 | $method = \&Plugins::TIDAL::Plugin::getPlaylist; 130 | $params = { uuid => $id }; 131 | } 132 | elsif ($type eq 'album') { 133 | $method = \&Plugins::TIDAL::Plugin::getAlbum; 134 | } 135 | elsif ($type eq 'artist') { 136 | $method = \&Plugins::TIDAL::Plugin::getArtistTopTracks; 137 | } 138 | elsif ($type eq 'mix') { 139 | $method = \&Plugins::TIDAL::Plugin::getMix; 140 | } 141 | 142 | $method->($client, $cb, {}, $params); 143 | main::INFOLOG && $log->is_info && $log->info("Getting $url: method: $method, id: $id"); 144 | } 145 | else { 146 | $cb->([]); 147 | } 148 | } 149 | 150 | sub _gotTrackError { 151 | my ( $error, $errorCb ) = @_; 152 | main::DEBUGLOG && $log->debug("Error during getTrackInfo: $error"); 153 | $errorCb->($error); 154 | } 155 | 156 | sub getNextTrack { 157 | my ( $class, $song, $successCb, $errorCb ) = @_; 158 | my $client = $song->master(); 159 | my $url = $song->track()->url; 160 | 161 | # Get track URL for the next track 162 | my $trackId = getId($url); 163 | 164 | if (!$trackId) { 165 | $log->error("can't get trackId"); 166 | return; 167 | } 168 | 169 | Async::Util::achain( 170 | steps => [ 171 | sub { 172 | my ($result, $acb) = @_; 173 | 174 | Plugins::TIDAL::Plugin::getAPIHandler($client)->getTrackUrl(sub { 175 | $acb->($_[0]) 176 | }, $trackId, { 177 | audioquality => $prefs->get('quality'), 178 | playbackmode => 'STREAM', 179 | assetpresentation => 'FULL', 180 | }); 181 | }, 182 | sub { 183 | my ($result, $acb) = @_; 184 | 185 | if ($result && $result->{manifestMimeType} !~ m|application/vnd.tidal.bt| && $prefs->get('quality') eq 'HI_RES') { 186 | $log->warn("failed to get streamable HiRes track ($url - $result->{manifestMimeType}), trying regular CD quality instead"); 187 | Plugins::TIDAL::Plugin::getAPIHandler($client)->getTrackUrl(sub { 188 | $acb->($_[0]) 189 | }, $trackId, { 190 | audioquality => 'LOSSLESS', 191 | playbackmode => 'STREAM', 192 | assetpresentation => 'FULL', 193 | }); 194 | 195 | return; 196 | } 197 | 198 | $acb->($result); 199 | }, 200 | ], 201 | cb => sub { 202 | my ($response, $error) = @_; 203 | 204 | $error = "failed to get track info" if !$response && !$error; 205 | 206 | return _gotTrackError($error, $errorCb) if $error; 207 | 208 | # no DASH or other for now 209 | if ($response->{manifestMimeType} !~ m|application/vnd.tidal.bt|) { 210 | return _gotTrackError("only plays streams $response->{manifestMimeType}", $errorCb); 211 | } 212 | 213 | my $manifest = eval { from_json(decode_base64($response->{manifest})) }; 214 | return _gotTrackError($@, $errorCb) if $@; 215 | 216 | my $streamUrl = $manifest->{urls}[0]; 217 | my ($format) = $manifest->{mimeType} =~ m|audio/(\w+)|; 218 | $format =~ s/flac/flc/; 219 | 220 | # TODO - store album gain information 221 | 222 | # this should not happen 223 | if ($format ne Plugins::TIDAL::API::getFormat) { 224 | $log->warn("did not get the expected format for $trackId ($format <> " . Plugins::TIDAL::API::getFormat() . ')'); 225 | $song->pluginData(format => $format); 226 | } 227 | 228 | # main::INFOLOG && $log->info("got $format track at $streamUrl"); 229 | $song->streamUrl($streamUrl); 230 | 231 | # now try to acquire the header for seeking and various details 232 | Slim::Utils::Scanner::Remote::parseRemoteHeader( 233 | $song->track, $streamUrl, $format, 234 | sub { 235 | # update what we got from parsing actual stream and update metadata 236 | $song->pluginData('bitrate', sprintf("%.0f" . Slim::Utils::Strings::string('KBPS'), $song->track->bitrate/1000)); 237 | $client->currentPlaylistUpdateTime( Time::HiRes::time() ); 238 | Slim::Control::Request::notifyFromArray( $client, [ 'newmetadata' ] ); 239 | $successCb->(); 240 | }, 241 | sub { 242 | my ($self, $error) = @_; 243 | $log->warn( "could not find $format header $error" ); 244 | $successCb->(); 245 | } 246 | ); 247 | } 248 | ); 249 | 250 | main::DEBUGLOG && $log->is_debug && $log->debug("Getting next track playback info for $url"); 251 | } 252 | 253 | my @pendingMeta = (); 254 | 255 | sub getMetadataFor { 256 | my ( $class, $client, $url ) = @_; 257 | return {} unless $url; 258 | 259 | my $trackId = getId($url); 260 | my $meta = $cache->get( 'tidal_meta_' . ($trackId || '') ); 261 | 262 | # if metadata is in cache, we just need to add bitrate 263 | if (ref $meta) { 264 | # TODO - remove if we decide to move to our own cache file which we can version 265 | $meta->{artist} = $meta->{artist}->{name} if ref $meta->{artist}; 266 | 267 | my $song = $client->playingSong(); 268 | if ($song && ($song->track->url eq $url || $song->currentTrack->url eq $url)) { 269 | $meta->{bitrate} = $song->pluginData('bitrate') || 'n/a'; 270 | } 271 | return $meta; 272 | } 273 | 274 | my $now = time(); 275 | 276 | # first cleanup old requests in case some got lost 277 | @pendingMeta = grep { $_->{time} + 60 > $now } @pendingMeta; 278 | 279 | # only proceed if our request is not pending and we have less than 10 in parallel 280 | if ( !(grep { $_->{id} == $trackId } @pendingMeta) && scalar(@pendingMeta) < 10 ) { 281 | 282 | push @pendingMeta, { 283 | id => $trackId, 284 | time => $now, 285 | }; 286 | 287 | main::DEBUGLOG && $log->is_debug && $log->debug("adding metadata query for $trackId"); 288 | 289 | Plugins::TIDAL::Plugin::getAPIHandler($client)->track(sub { 290 | my $meta = shift; 291 | @pendingMeta = grep { $_->{id} != $trackId } @pendingMeta; 292 | return unless $meta; 293 | 294 | main::DEBUGLOG && $log->is_debug && $log->debug("found metadata for $trackId", Data::Dump::dump($meta)); 295 | return if @pendingMeta; 296 | 297 | # Update the playlist time so the web will refresh, etc 298 | $client->currentPlaylistUpdateTime( Time::HiRes::time() ); 299 | Slim::Control::Request::notifyFromArray( $client, [ 'newmetadata' ] ); 300 | }, $trackId ); 301 | } 302 | 303 | my $icon = $class->getIcon(); 304 | 305 | return $meta || { 306 | bitrate => 'N/A', 307 | type => Plugins::TIDAL::API::getFormat(), 308 | icon => $icon, 309 | cover => $icon, 310 | }; 311 | } 312 | 313 | sub getIcon { 314 | my ( $class, $url ) = @_; 315 | return Plugins::TIDAL::Plugin->_pluginDataFor('icon'); 316 | } 317 | 318 | sub getId { 319 | my ($id) = $_[0] =~ m{(?:tidal|wimp)://(\d+)}; 320 | return $id; 321 | } 322 | 323 | 1; 324 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TIDAL Plugin for Squeezebox 2 | 3 | ## TODO 4 | 5 | * a lot 6 | * use [latest API](https://developer.tidal.com/documentation/api/api-reference) once it's stable and released 7 | 8 | ## Thanks! 9 | 10 | This implementation was inspired by the following projects: 11 | 12 | * https://github.com/tamland/python-tidal 13 | * https://github.com/Fokka-Engineering/libopenTIDAL 14 | * https://tidalapi.netlify.app -------------------------------------------------------------------------------- /Settings.pm: -------------------------------------------------------------------------------- 1 | package Plugins::TIDAL::Settings; 2 | 3 | use strict; 4 | use base qw(Slim::Web::Settings); 5 | 6 | use JSON::XS::VersionOneAndTwo; 7 | use HTTP::Status qw(RC_MOVED_TEMPORARILY); 8 | 9 | use Slim::Utils::Prefs; 10 | use Plugins::TIDAL::Settings::Auth; 11 | 12 | my $prefs = preferences('plugin.tidal'); 13 | my $log = Slim::Utils::Log::logger('plugin.tidal'); 14 | 15 | sub name { Slim::Web::HTTP::CSRF->protectName('PLUGIN_TIDAL_NAME') } 16 | 17 | sub page { Slim::Web::HTTP::CSRF->protectURI('plugins/TIDAL/settings.html') } 18 | 19 | sub prefs { return ($prefs, qw(quality countryCode)) } 20 | 21 | sub handler { 22 | my ($class, $client, $params, $callback, $httpClient, $response) = @_; 23 | 24 | if ($params->{addAccount}) { 25 | $response->code(RC_MOVED_TEMPORARILY); 26 | $response->header('Location' => 'auth.html'); 27 | return Slim::Web::HTTP::filltemplatefile($class->page, $params); 28 | } 29 | 30 | if ( my ($deleteAccount) = map { /delete_(.*)/; $1 } grep /^delete_/, keys %$params ) { 31 | my $accounts = $prefs->get('accounts') || {}; 32 | delete $accounts->{$deleteAccount}; 33 | $prefs->set('accounts', $accounts); 34 | } 35 | 36 | if ($params->{saveSettings}) { 37 | my $dontImportAccounts = $prefs->get('dontImportAccounts') || {}; 38 | my $explicitAlbumHandling = $prefs->get('explicitAlbumHandling') || {}; 39 | foreach my $prefName (keys %$params) { 40 | if ($prefName =~ /^pref_dontimport_(.*)/) { 41 | $dontImportAccounts->{$1} = $params->{$prefName}; 42 | } 43 | elsif ($prefName =~ /^pref_explicit_(.*)/) { 44 | $explicitAlbumHandling->{$1} = $params->{$prefName} || 0; 45 | } 46 | } 47 | $prefs->set('dontImportAccounts', $dontImportAccounts); 48 | $prefs->set('explicitAlbumHandling', $explicitAlbumHandling); 49 | } 50 | 51 | return $class->SUPER::handler($client, $params); 52 | } 53 | 54 | sub beforeRender { 55 | my ($class, $params) = @_; 56 | 57 | my $accounts = $prefs->get('accounts') || {}; 58 | 59 | $params->{credentials} = [ sort { 60 | $a->{name} cmp $b->{name} 61 | } map { 62 | { 63 | name => Plugins::TIDAL::API->getHumanReadableName($_), 64 | id => $_->{userId}, 65 | } 66 | } values %$accounts] if scalar keys %$accounts; 67 | 68 | $params->{dontImportAccounts} = $prefs->get('dontImportAccounts') || {}; 69 | $params->{explicitAlbumHandling} = $prefs->get('explicitAlbumHandling') || {}; 70 | } 71 | 72 | 1; -------------------------------------------------------------------------------- /Settings/Auth.pm: -------------------------------------------------------------------------------- 1 | package Plugins::TIDAL::Settings::Auth; 2 | 3 | use strict; 4 | use base qw(Slim::Web::Settings); 5 | 6 | use JSON::XS::VersionOneAndTwo; 7 | use HTTP::Status qw(RC_MOVED_TEMPORARILY); 8 | use Tie::Cache::LRU::Expires; 9 | 10 | use Slim::Utils::Cache; 11 | use Slim::Utils::Prefs; 12 | 13 | my $prefs = preferences('plugin.tidal'); 14 | my $log = Slim::Utils::Log::logger('plugin.tidal'); 15 | my $cache = Slim::Utils::Cache->new(); 16 | 17 | # automatically expire polling after x minutes 18 | tie my %deviceCodes, 'Tie::Cache::LRU::Expires', EXPIRES => 5 * 60, ENTRIES => 16; 19 | 20 | sub new { 21 | my $class = shift; 22 | 23 | Slim::Web::Pages->addPageFunction($class->page, $class); 24 | Slim::Web::Pages->addRawFunction("plugins/TIDAL/settings/hasCredentials", \&checkCredentials); 25 | } 26 | 27 | sub name { Slim::Web::HTTP::CSRF->protectName('PLUGIN_TIDAL_NAME') } 28 | 29 | sub page { Slim::Web::HTTP::CSRF->protectURI('plugins/TIDAL/auth.html') } 30 | 31 | sub handler { 32 | my ($class, $client, $params, $callback, $httpClient, $response) = @_; 33 | 34 | if ($params->{cancelAuth}) { 35 | Plugins::TIDAL::API::Auth->cancelDeviceAuth($params->{deviceCode}); 36 | 37 | $response->code(RC_MOVED_TEMPORARILY); 38 | $response->header('Location' => 'settings.html'); 39 | return Slim::Web::HTTP::filltemplatefile($class->page, $params); 40 | } 41 | 42 | Plugins::TIDAL::API::Auth->initDeviceFlow(sub { 43 | my $deviceAuthInfo = shift; 44 | 45 | my $deviceCode = $deviceAuthInfo->{deviceCode}; 46 | $deviceCodes{$deviceCode}++; 47 | 48 | Plugins::TIDAL::API::Auth->pollDeviceAuth($deviceAuthInfo, sub { 49 | my $accountInfo = shift || {}; 50 | 51 | if (!$accountInfo->{user} || !$accountInfo->{user_id}) { 52 | $log->error('Did not get any account information back'); 53 | } 54 | 55 | delete $deviceCodes{$deviceCode}; 56 | }); 57 | 58 | $params->{followAuthLink} = $deviceAuthInfo->{verificationUriComplete}; 59 | $params->{followAuthLink} = 'https://' . $params->{followAuthLink} unless $params->{followAuthLink} =~ /^https?:/; 60 | $params->{deviceCode} = $deviceCode; 61 | 62 | my $body = $class->SUPER::handler($client, $params); 63 | $callback->( $client, $params, $body, $httpClient, $response ); 64 | }); 65 | 66 | return; 67 | } 68 | 69 | # check whether we have credentials - called by the web page to decide if it can return 70 | sub checkCredentials { 71 | my ($httpClient, $response, $func) = @_; 72 | 73 | my $request = $response->request; 74 | 75 | my $query = $response->request->uri->query_form_hash || {}; 76 | my $deviceCode = $query->{deviceCode} || ''; 77 | 78 | my $result = { 79 | hasCredentials => $deviceCodes{$deviceCode} ? 0 : 1 80 | }; 81 | 82 | my $content = to_json($result); 83 | $response->header( 'Content-Length' => length($content) ); 84 | $response->code(200); 85 | $response->header('Connection' => 'close'); 86 | $response->content_type('application/json'); 87 | 88 | Slim::Web::HTTP::addHTTPResponse( $httpClient, $response, \$content ); 89 | } 90 | 91 | 92 | 1; -------------------------------------------------------------------------------- /install.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | PLUGIN_TIDAL_NAME 4 | PLUGIN_TIDAL_DESC 5 | philippe_44, Michael Herger 6 | 16da8158-263f-4347-8125-184372ea5610 7 | 1.6.0 8 | Plugins::TIDAL::Plugin 9 | Plugins::TIDAL::Importer 10 | plugins/TIDAL/settings.html 11 | plugins/TIDAL/html/tidal_MTL_svg_tidal.png 12 | true 13 | musicservices 14 | 15 | 8.3.0 16 | * 17 | 18 | 19 | -------------------------------------------------------------------------------- /repo/release.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | use strict; 3 | 4 | use XML::Simple; 5 | use File::Basename; 6 | use Digest::SHA; 7 | 8 | my $repofile = $ARGV[0]; 9 | my $version = $ARGV[1]; 10 | my $zipfile = $ARGV[2]; 11 | my $url = $ARGV[3]; 12 | 13 | my $repo = XMLin($repofile, ForceArray => 1, KeepRoot => 0, KeyAttr => 0, NoAttr => 0); 14 | $repo->{plugins}[0]->{plugin}[0]->{version} = $version; 15 | 16 | open (my $fh, "<", $zipfile) or die $!; 17 | binmode $fh; 18 | 19 | my $digest = Digest::SHA->new; 20 | $digest->addfile($fh); 21 | close $fh; 22 | 23 | $repo->{plugins}[0]->{plugin}[0]->{sha}[0] = $digest->hexdigest; 24 | print("version:$version sha:", $digest->hexdigest, "\n"); 25 | 26 | $url .= "/$zipfile"; 27 | $repo->{plugins}[0]->{plugin}[0]->{url}[0] = $url; 28 | 29 | XMLout($repo, RootName => 'extensions', NoSort => 1, XMLDecl => 1, KeyAttr => '', OutputFile => $repofile, NoAttr => 0); 30 | 31 | 32 | -------------------------------------------------------------------------------- /repo/repo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | musicservices 6 | https://raw.githubusercontent.com/michaelherger/lms-plugin-tidal/main/HTML/EN/plugins/TIDAL/html/tidal_MTL_svg_tidal.png 7 | https://github.com/michaelherger/lms-plugin-tidal/releases/download/1.6.0/TIDAL.zip 8 | https://github.com/michaelherger/lms-plugin-tidal 9 | 8903661dd3ea4799416f623caaa4e65c7ea5e86b 10 | Michael Herger, philippe_44 11 | TIDAL local 12 | TIDAL for local LMS use 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /strings.txt: -------------------------------------------------------------------------------- 1 | PLUGIN_TIDAL_NAME 2 | EN TIDAL 3 | 4 | PLUGIN_TIDAL_DESC 5 | DA TIDAL plugin for LMS, som ikke kræver MySqueezebox.com. 6 | DE TIDAL Plugin zur Integration mit LMS, ohne Verbindung zu MySqueezebox.com. 7 | EN TIDAL Plugin for LMS, does not depend on MySqueezebox.com. 8 | FR Plugin TIDAL pour LMS (sans lien avec MySqueezebox.com). 9 | HU A TIDAL beépülő modul az LMS-hez, nem függ a MySqueezebox.com webhelytől. 10 | 11 | PLUGIN_TIDAL_ADD_ACCOUNT 12 | CS Přidat účet 13 | DA Tilføj konto 14 | DE Konto hinzufügen 15 | EN Add Account 16 | FR Ajouter un compte 17 | HU Fiók hozzáadása 18 | IT Aggiungi Account 19 | NL Account toevoegen 20 | 21 | PLUGIN_TIDAL_AUTH 22 | CS Autorizace TIDAL 23 | DA Autoriser TIDAL 24 | DE TIDAL Anmeldung 25 | EN TIDAL Authorization 26 | FR Autorisation TIDAL 27 | HU TIDAL azonosítás 28 | NL TIDAL Autorisatie 29 | IT Autorizza TIDAL 30 | 31 | PLUGIN_TIDAL_IMPORT_LIBRARY 32 | CS Importovat 33 | DA Importer 34 | DE Importieren 35 | EN Import 36 | FR Importer 37 | HU Importőr 38 | IT Import 39 | NL Importeren 40 | 41 | PLUGIN_TIDAL_ACCOUNT 42 | CS Účet 43 | DA Konto 44 | DE Konto 45 | EN Account 46 | ES Cuenta 47 | FI Tili 48 | FR Compte 49 | HU Fiók 50 | IT Account 51 | NL Account 52 | NO Konto 53 | PL Konto 54 | RU Учетная запись 55 | SV Konto 56 | 57 | PLUGIN_TIDAL_FOLLOW_LINK 58 | DA Det efterfølgende link tager dig til TIDAL for at autorisere. Her skal du give Lyrion Music Server adgang til din TIDAL konto. Efter du er logget på, så kom tilbage hertil: 59 | DE Der folgende Link führt Sie zu TIDAL zur Authentifizierung. Bitte folgen Sie ihm, um dem Lyrion Music Server Zugriff auf Ihr TIDAL-Konto zu gewähren. Sobald Sie die Anmeldung abgeschlossen haben, kommen Sie hierher zurück: 60 | EN The following link takes you to TIDAL for authentication. Please follow it grant Lyrion Music Server access to your TIDAL account. Once you've completed sign-in come back here: 61 | FR Le lien suivant vous mènera à TIDAL pour authentification. Veuillez le suivre pour accorder à Lyrion Music Server l'accès à votre compte TIDAL. Une fois votre inscription terminée, revenez ici : 62 | HU A következő hivatkozás a TIDAL oldalra viszi a hitelesítést. Kérjük, kövesse a Lyrion Music Server hozzáférést a TIDAL-fiókjához. Miután befejezte a bejelentkezést, térjen vissza ide: 63 | IT Il seguente link reindirizza all'autenticazione di TIDAL. Per favore, garantisci l'accesso al tuo account TIDAL a Lyrion Music Server e torna in questa pagina una volta completato il login. 64 | 65 | PLUGIN_TIDAL_REQUIRES_CREDENTIALS 66 | DA Gå til Settings/advanced/TIDAL for at autorisere med TIDAL 67 | DE Bitte melden Sie sich in den Server-Einstellungen unter Erweitert/TIDAL an TIDAL an. 68 | EN Please go to Settings/Advanced/TIDAL to authenticate with TIDAL. 69 | FR Veuillez vous connecter à TIDAL en accédant aux réglages du plugin TIDAL (paramètres/avancé/TIDAL ou préférences/serveur/TIDAL). 70 | HU A TIDAL-lal történő hitelesítéshez lépjen a Beállítások/Speciális/TIDAL menüpontba. 71 | IT Per autenticarti con TIDAL, vai a Impostazioni/Avanzate/TIDAL 72 | 73 | PLUGIN_TIDAL_SELECT_ACCOUNT 74 | CS Zvolení účtu 75 | DA Vælg konto 76 | DE Konto auswählen 77 | EN Select Account 78 | ES Seleccionar cuenta 79 | FI Valitse tili 80 | FR Sélectionner le compte 81 | HU Válassz fiókot 82 | IT Seleziona account 83 | NL Account selecteren 84 | NO Velg konto 85 | PL Wybierz konto w usłudze 86 | RU Выберите учетную запись 87 | SV Välj konto 88 | 89 | PLUGIN_TIDAL_QUALITY 90 | DA Kvalitet 91 | DE Qualität 92 | EN Quality 93 | FR Qualité 94 | HU Minőség 95 | IT Qualità 96 | 97 | PLUGIN_TIDAL_QUALITY_DESC 98 | DA Streaming kvalitet 99 | DE Stream Qualität 100 | EN Streaming quality 101 | FR Qualité du streaming 102 | HU Streaming minőség 103 | IT Qualità dello streaming 104 | 105 | PLUGIN_TIDAL_COUNTRY_CODE 106 | DA Gennemtving landekode 107 | DE Erzwungener Ländercode 108 | EN Country code override 109 | FR Forcer le code pays 110 | 111 | PLUGIN_TIDAL_COUNTRY_CODE_DESC 112 | DA Brug dette for at gennemstinge en specifik landekode. Den kan sædvanligvis tages fra din TIDAL profil. En gang imellem kan det medføre et uønskede resultat. F.eks "CH" vil altid give en Tysk menu, selvom du har valgt Fransk som sprog. Men vi kan ikke gennem API bestemme hvilket sprog der bruges. 113 | DE Verwenden Sie diese Einstellung, um einen bestimmten Ländercode zu erzwingen. Dieser wird normalerweise von Ihrem TIDAL-Profil übernommen. Unter bestimmten Umständen kann dies jedoch zu verwirrenden Ergebnissen führen. Z.B. würde "CH" immer deutsche Menüs liefern, auch wenn Sie die Sprache Französisch verwenden. Aber wir können der API nicht sagen, welche Sprache sie verwenden soll. 114 | EN Use this preference to force a specific country code. This would usually be taken from your TIDAL profile. But under certain circumstances this can lead to confusing results. Eg. "CH" would always return German menus, even if you were using the French language. But we can't tell the API what language to use. 115 | FR Utiliser ce paramètre pour forcer un code pays spécifique. Le code pays est généralement déduit de votre profil TIDAL, ce qui peut ne pas produire le résultat attendu. Par exemple, un code "CH" implique toujours des menus en allemand, même si vous préférez la langue française. (Il n'est pas possible de dire à l’API quelle langue utiliser.) 116 | HU Ezzel a beállítással egy adott országkódot kényszeríthet ki. Ez általában az Ön TIDAL-profiljából származik. De bizonyos körülmények között ez zavaró eredményekhez vezethet. Például. A "CH" mindig a német menüket adja vissza, még akkor is, ha a francia nyelvet használja. De nem tudjuk megmondani az API-nak, hogy milyen nyelvet használjon. 117 | 118 | PLUGIN_TIDAL_MY_MIX 119 | DA Mit Miks 120 | DE Mein Mix 121 | EN My Mix 122 | FR My Mix 123 | HU Saját Mix 124 | IT I miei Mix 125 | 126 | PLUGIN_TIDAL_ARTIST_MIX 127 | DA Artist Miks 128 | DE Interpretenmix 129 | EN Artist Mix 130 | FR Radio artiste 131 | HU Előadó Mix 132 | IT Mix basato sull'artista 133 | 134 | PLUGIN_TIDAL_SIMILAR_ARTISTS 135 | DA Lignende artist 136 | DE Ähnliche Künstler 137 | EN Similar Artists 138 | FR Artistes similaires 139 | HU Hasonló előadók 140 | 141 | PLUGIN_TIDAL_TRACK_MIX 142 | CS Mix skladeb 143 | DA Miks baseret på tilfældige numre 144 | DE Titelmix 145 | EN Song Mix 146 | ES Mezcla de canciones 147 | FI Kappalevalikoima 148 | FR Morceaux aléatoires 149 | HU Dal Mix 150 | IT Mix basato sul brano 151 | NL Nummermix 152 | NO Sangmiks 153 | PL Składanka utworów 154 | RU Микс по песням 155 | SV Låtmix 156 | 157 | PLUGIN_TIDAL_MOODS 158 | DA Stemninger 159 | DE Stimmungen 160 | EN Moods 161 | FR Ambiances 162 | HU Hangulatok 163 | IT Moods 164 | 165 | PLUGIN_TIDAL_FEATURES 166 | DA Udvalgte 167 | EN Featured 168 | ES Destacados 169 | FR Sélection 170 | HU Kiemelt 171 | IT Consigliati 172 | NO Utvalgte 173 | SV Utvalt 174 | PL Polecane 175 | 176 | PLUGIN_TIDAL_ALBUMS_PROGRESS 177 | CS Alba TIDALu 178 | DA TIDAL Album 179 | DE TIDAL Alben 180 | EN TIDAL Albums 181 | FR Albums TIDAL 182 | HU TIDAL Albumok 183 | IT TIDAL Albums 184 | NL TIDAL Albums 185 | 186 | PLUGIN_TIDAL_PROGRESS_READ_ALBUMS 187 | CS Načítání alb (%s)... 188 | DA Henter Album (%s)... 189 | DE Lese Alben (%s)... 190 | EN Fetching Albums (%s)... 191 | FR Récupération des albums (%s)... 192 | HU Albumok lekérése (%s)... 193 | IT Recupero gli album (%s)... 194 | NL Albums ophalen (%s)... 195 | 196 | PLUGIN_TIDAL_ARTISTS_PROGRESS 197 | CS Interpeti TIDALu 198 | DA TIDAL Artister 199 | DE TIDAL Interpreten 200 | EN TIDAL Artists 201 | FR Artistes TIDAL 202 | HU TIDAL előadók 203 | IT TIDAL Artist 204 | NL TIDAL Artiesten 205 | 206 | PLUGIN_TIDAL_PROGRESS_READ_ARTISTS 207 | CS Načítání interpetů (%s)... 208 | DA Henter Artister (%s)... 209 | DE Lese Interpreten (%s)... 210 | EN Fetching Artists (%s)... 211 | FR Récupération des artistes (%s)... 212 | HU Előadók (%s) lekérése... 213 | IT Recupero gli artisti (%s)... 214 | NL Artiesten ophalen (%s)... 215 | 216 | PLUGIN_TIDAL_PLAYLISTS_PROGRESS 217 | CS Seznamy skladeb TIDALu 218 | DA TIDAL Afspilningslister 219 | DE TIDAL Wiedergabelisten 220 | EN TIDAL Playlists 221 | FR Listes de lecture TIDAL 222 | HU TIDAL lejátszási listák 223 | IT TIDAL Playlists 224 | NL TIDAL Afspeellijsten 225 | 226 | PLUGIN_TIDAL_PROGRESS_READ_PLAYLISTS 227 | CS Získávání seznamů skladeb (%s)... 228 | DA Henter Afspilningslister (%s)... 229 | DE Lese Wiedergabelisten (%s)... 230 | EN Fetching Playlists (%s)... 231 | FR Récupération des listes de lecture (%s)... 232 | HU Lejátszási listák (%s) lekérése... 233 | IT Recupero le playlists (%s)... 234 | NL Afspeellijsten ophalen (%s)... 235 | 236 | PLUGIN_TIDAL_TOP_TRACKS 237 | CS Nejlepší skladby 238 | DA Mest populære numre 239 | DE Top-Titel 240 | EN Top Tracks 241 | ES Pistas principales 242 | FI Suositut kappaleet 243 | FR Morceaux les plus écoutés 244 | HU Legjobb számok 245 | IT Brani più ascoltati 246 | NL Topnummers 247 | NO Mest populære spor 248 | PL Najczęściej słuchane 249 | RU Лучшие дорожки 250 | SV Toppspår 251 | 252 | PLUGIN_TIDAL_EP_SINGLES 253 | CS Singly a EP 254 | DA Singler & EP'er 255 | DE Singles & EPs 256 | EN Singles & EPs 257 | ES Singles y EPs 258 | FI Singlet ja EP-levyt 259 | FR Singles et EPs 260 | HU Kislemezek és EP-k 261 | IT Single/EP 262 | NL Singles & EP's 263 | NO Singler og EP-er 264 | PL Single i EP 265 | RU Синглы и EP 266 | SV Singlar och EP-skivor 267 | 268 | PLUGIN_TIDAL_ON_TIDAL 269 | CS Na TIDALu 270 | DA På TIDAL 271 | DE In TIDAL 272 | EN On TIDAL 273 | ES En TIDAL 274 | FI TIDAL:ssä 275 | FR Sur TIDAL 276 | HU A TIDAL-on 277 | IT Su TIDAL 278 | NL Op TIDAL 279 | NO På TIDAL 280 | PL W usłudze TIDAL 281 | RU На TIDAL 282 | SV Om TIDAL 283 | 284 | PLUGIN_TIDAL_ADD_TO_FAVORITES 285 | DA Tilføj til TIDAL favoritter 286 | DE Zu TIDAL Favoriten hinzufügen 287 | EN Add to TIDAL favorites 288 | FR Ajouter aux favoris TIDAL 289 | HU Hozzáadás a TIDAL kedvenceihez 290 | 291 | PLUGIN_TIDAL_REMOVE_FROM_FAVORITES 292 | DA Fjern fra TIDAL favoritter 293 | DE Aus TIDAL Favoriten entfernen 294 | EN Remove from TIDAL favorites 295 | FR Supprimer des favoris TIDAL 296 | HU Eltávolítás a TIDAL kedvencei közül 297 | 298 | PLUGIN_TIDAL_ADD_TO_PLAYLIST 299 | DA Tilføj til Tidal afspilningsliste 300 | DE Zu TIDAL Wiedergabeliste hinzufügen 301 | EN Add to a TIDAL playlist 302 | FR Ajouter à une liste de lecture TIDAL 303 | HU Hozzáadás a TIDAL lejátszási listához 304 | 305 | PLUGIN_TIDAL_REMOVE_FROM_PLAYLIST 306 | DA Fjern fra denne afspilningsliste 307 | DE Aus TIDAL Wiedergabeliste entfernen 308 | EN Remove from this playlist 309 | FR Supprimer de cette liste de lecture 310 | HU Eltávolítás erről a lejátszási listáról 311 | 312 | PLUGIN_TIDAL_EXPLICIT_ALBUMS 313 | DA Albumsfilter 314 | DE Albenfilter 315 | EN Album list filter 316 | FR Filtre d'albums 317 | HU Albumlista szűrő 318 | 319 | PLUGIN_TIDAL_HIDE_EXPLICIT 320 | DA Foretrækker ikke-eksplicit version 321 | DE Nicht-explizit bevorzugen 322 | EN Prefer non-explicit version 323 | FR Préférer les versions non explicites 324 | HU Ne explicit verzió módon 325 | 326 | PLUGIN_TIDAL_SHOW_EXPLICIT 327 | DA Foretrækker eksplicit version 328 | DE Explizite Version bevorzugen 329 | EN Prefer explicit version 330 | FR Préférer les versions explicites 331 | HU Explicit verzió előnyben részesítése 332 | 333 | PLUGIN_TIDAL_SHOW_ALL 334 | DA Vis begge versioner af albummet 335 | DE Beide Albumversionen anzeigen 336 | EN Show both 337 | FR Montrer les deux versions des albums. 338 | HU Mutassa mindkettőt 339 | -------------------------------------------------------------------------------- /tidal_icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml 361 | --------------------------------------------------------------------------------