├── .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 |
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 |
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 |
361 |
--------------------------------------------------------------------------------