drum 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,619 @@
1
+ require 'drum/model/album'
2
+ require 'drum/model/artist'
3
+ require 'drum/model/ref'
4
+ require 'drum/model/playlist'
5
+ require 'drum/model/track'
6
+ require 'drum/service/service'
7
+ require 'drum/utils/log'
8
+ require 'drum/utils/persist'
9
+ require 'drum/version'
10
+ require 'date'
11
+ require 'digest'
12
+ require 'jwt'
13
+ require 'json'
14
+ require 'launchy'
15
+ require 'rest-client'
16
+ require 'ruby-limiter'
17
+ require 'uri'
18
+ require 'webrick'
19
+
20
+ module Drum
21
+ # A service that uses the Apple Music API to query playlists.
22
+ class AppleMusicService < Service
23
+ include Log
24
+ extend Limiter::Mixin
25
+
26
+ BASE_URL = 'https://api.music.apple.com/v1'
27
+ PLAYLISTS_CHUNK_SIZE = 50
28
+ MAX_ALBUM_ARTWORK_WIDTH = 512
29
+ MAX_ALBUM_ARTWORK_HEIGHT = 512
30
+
31
+ MUSICKIT_P8_FILE_VAR = 'MUSICKIT_KEY_P8_FILE_PATH'
32
+ MUSICKIT_KEY_VAR = 'MUSICKIT_KEY_ID'
33
+ MUSICKIT_TEAM_ID_VAR = 'MUSICKIT_TEAM_ID'
34
+
35
+ # Rate-limiting for API methods
36
+
37
+ limit_method :api_library_playlists, rate: 60
38
+ limit_method :api_library_playlist_tracks, rate: 60
39
+
40
+ # Initializes the Apple Music service.
41
+ #
42
+ # @param [String] cache_dir The path to the cache directory (shared by all services)
43
+ def initialize(cache_dir)
44
+ @cache_dir = cache_dir / self.name
45
+ @cache_dir.mkdir unless @cache_dir.directory?
46
+
47
+ @auth_tokens = PersistentHash.new(@cache_dir / 'auth-tokens.yaml')
48
+ @authenticated = false
49
+ end
50
+
51
+ def name
52
+ 'applemusic'
53
+ end
54
+
55
+ # Authentication
56
+
57
+ def authenticate_app(p8_file, key_id, team_id)
58
+ existing = @auth_tokens[:app]
59
+
60
+ unless existing.nil? || existing[:expires_at].nil? || existing[:expires_at] < DateTime.now
61
+ log.info 'Skipping app authentication...'
62
+ return existing[:token]
63
+ end
64
+
65
+ expiration_in_days = 180 # may not be greater than 180
66
+ expiration_in_seconds = expiration_in_days * 86400
67
+
68
+ # Source: https://github.com/mkoehnke/musickit-token-encoder/blob/master/musickit-token-encoder
69
+ # Copyright (c) 2016 Mathias Koehnke (http://www.mathiaskoehnke.de)
70
+ #
71
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
72
+ # of this software and associated documentation files (the "Software"), to deal
73
+ # in the Software without restriction, including without limitation the rights
74
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
75
+ # copies of the Software, and to permit persons to whom the Software is
76
+ # furnished to do so, subject to the following conditions:
77
+ #
78
+ # The above copyright notice and this permission notice shall be included in
79
+ # all copies or substantial portions of the Software.
80
+ #
81
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
82
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
83
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
84
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
85
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
86
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
87
+ # THE SOFTWARE.
88
+
89
+ iat = Time.now.to_i
90
+ exp = (Time.now + expiration_in_seconds).to_i
91
+ pem_file = `openssl pkcs8 -nocrypt -in #{p8_file}`
92
+ private_key = OpenSSL::PKey::EC.new(pem_file)
93
+ payload = { iss: "#{team_id}", iat: iat, exp: exp }
94
+
95
+ token = JWT.encode(payload, private_key, "ES256", { alg: "ES256", kid: "#{key_id}" })
96
+ log.info "Generated MusicKit JWT token #{token}"
97
+
98
+ @auth_tokens[:app] = {
99
+ expires_at: DateTime.now + expiration_in_days,
100
+ token: token
101
+ }
102
+
103
+ token
104
+ end
105
+
106
+ def authenticate_user(token)
107
+ existing = @auth_tokens[:user]
108
+
109
+ unless existing.nil? || existing[:expires_at].nil? || existing[:expires_at] < DateTime.now
110
+ log.info 'Skipping user authentication...'
111
+ return existing[:token]
112
+ end
113
+
114
+ # Generate a new access refresh token,
115
+ # this might require user interaction. Since the
116
+ # user has to authenticate through the browser
117
+ # via Spotify's website, we use a small embedded
118
+ # HTTP server as a 'callback'.
119
+
120
+ port = 17997
121
+ server = WEBrick::HTTPServer.new({
122
+ Port: port,
123
+ StartCallback: Proc.new do
124
+ Launchy.open("http://localhost:#{port}/")
125
+ end
126
+ })
127
+ user_token = nil
128
+
129
+ server.mount_proc '/' do |req, res|
130
+ res.content_type = 'text/html'
131
+ res.body = [
132
+ '<!DOCTYPE html>',
133
+ '<html>',
134
+ ' <head>',
135
+ ' <script src="https://js-cdn.music.apple.com/musickit/v1/musickit.js"></script>',
136
+ ' <script>',
137
+ " document.addEventListener('musickitloaded', () => {",
138
+ ' MusicKit.configure({',
139
+ " developerToken: '#{token}',",
140
+ " app: { name: 'Drum', build: '#{VERSION}' }",
141
+ ' });',
142
+ ' });',
143
+ " window.addEventListener('load', () => {",
144
+ " document.getElementById('authbutton').addEventListener('click', () => {",
145
+ ' MusicKit.getInstance()',
146
+ ' .authorize()',
147
+ " .then(userToken => fetch('/callback', { method: 'POST', body: userToken }))",
148
+ " .then(response => { document.getElementById('status').innerText = 'Done!'; });",
149
+ ' });',
150
+ ' });',
151
+ ' </script>',
152
+ ' </head>',
153
+ ' <body>',
154
+ ' <div id="status"><button id="authbutton">Click me to authorize!</button></div>',
155
+ ' </body>',
156
+ '</html>'
157
+ ].join("\n")
158
+ end
159
+
160
+ server.mount_proc '/callback' do |req, res|
161
+ user_token = req.body
162
+ unless user_token.nil? || user_token.empty?
163
+ res.body = 'Successfully got user token!'
164
+ else
165
+ res.body = 'Did not get user token! :('
166
+ end
167
+ server.shutdown
168
+ end
169
+
170
+ trap 'INT' do server.shutdown end
171
+
172
+ log.info "Launching callback HTTP server on port #{port}, waiting for auth code..."
173
+ server.start
174
+
175
+ if user_token.nil?
176
+ raise "Did not get a MusicKit user token."
177
+ end
178
+ log.info "Generated MusicKit user token #{user_token}"
179
+
180
+ # Cache user token for half an hour (an arbitrary duration)
181
+ expiration_in_seconds = 1800
182
+
183
+ @auth_tokens[:user] = {
184
+ expires_at: DateTime.now + (expiration_in_seconds / 86400.0),
185
+ token: user_token
186
+ }
187
+
188
+ user_token
189
+ end
190
+
191
+ def authenticate
192
+ p8_file = ENV[MUSICKIT_P8_FILE_VAR]
193
+ key_id = ENV[MUSICKIT_KEY_VAR]
194
+ team_id = ENV[MUSICKIT_TEAM_ID_VAR]
195
+
196
+ if p8_file.nil? || key_id.nil? || team_id.nil?
197
+ raise "Please specify your MusicKit keys (#{MUSICKIT_P8_FILE_VAR}, #{MUSICKIT_KEY_VAR}, #{MUSICKIT_TEAM_ID_VAR}) in your env vars!"
198
+ end
199
+
200
+ token = self.authenticate_app(p8_file, key_id, team_id)
201
+ user_token = self.authenticate_user(token)
202
+
203
+ @token = token
204
+ @user_token = user_token
205
+ end
206
+
207
+ # API wrapper
208
+
209
+ def authorization_headers
210
+ {
211
+ 'Authorization': "Bearer #{@token}",
212
+ 'Music-User-Token': @user_token
213
+ }
214
+ end
215
+
216
+ def get_json(endpoint)
217
+ log.debug "-> GET #{endpoint}"
218
+ response = RestClient.get(
219
+ "#{BASE_URL}#{endpoint}",
220
+ self.authorization_headers
221
+ )
222
+ unless response.code >= 200 && response.code < 300
223
+ raise "Something went wrong while GETting #{endpoint}: #{response}"
224
+ end
225
+ JSON.parse(response.body)
226
+ end
227
+
228
+ def post_json(endpoint, json)
229
+ log.debug "-> POST #{endpoint} with #{json}"
230
+ response = RestClient.post(
231
+ "#{BASE_URL}#{endpoint}",
232
+ json.to_json,
233
+ self.authorization_headers.merge({
234
+ 'Content-Type': 'application/json'
235
+ })
236
+ )
237
+ unless response.code >= 200 && response.code < 300
238
+ raise "Something went wrong while POSTing to #{endpoint}: #{response}"
239
+ end
240
+ JSON.parse(response.body)
241
+ end
242
+
243
+ def api_library_playlists(offset: 0)
244
+ self.get_json("/me/library/playlists?limit=#{PLAYLISTS_CHUNK_SIZE}&offset=#{offset}")
245
+ end
246
+
247
+ def api_library_playlist_tracks(am_library_id, offset: 0)
248
+ self.get_json("/me/library/playlists/#{am_library_id}/tracks?limit=#{PLAYLISTS_CHUNK_SIZE}&offset=#{offset}")
249
+ end
250
+
251
+ def api_catalog_playlist(am_storefront, am_catalog_id)
252
+ self.get_json("/catalog/#{am_storefront}/playlists/#{am_catalog_id}")
253
+ end
254
+
255
+ def api_catalog_search(am_storefront, term, limit: 1, offset: 0, types: ['songs'])
256
+ encoded_term = URI.encode_www_form_component(term)
257
+ encoded_types = types.join(',')
258
+ self.get_json("/catalog/#{am_storefront}/search?term=#{encoded_term}&limit=#{limit}&offset=#{offset}&types=#{encoded_types}")
259
+ end
260
+
261
+ def api_create_library_playlist(name, description: nil, am_track_catalog_ids: [])
262
+ self.post_json("/me/library/playlists/", {
263
+ 'attributes' => {
264
+ 'name' => name,
265
+ 'description' => description
266
+ }.compact,
267
+ 'relationships' => {
268
+ 'tracks' => {
269
+ 'data' => am_track_catalog_ids.map do |am_id|
270
+ {
271
+ 'id' => am_id,
272
+ 'type' => 'songs'
273
+ }
274
+ end
275
+ }
276
+ # TODO: Support parents i.e. playlist folders?
277
+ }
278
+ })
279
+ end
280
+
281
+ def api_add_library_playlist_tracks(am_library_id, am_track_catalog_ids)
282
+ self.post_json("/me/library/playlists/#{am_library_id}/tracks", {
283
+ 'tracks' => {
284
+ 'data' => am_track_catalog_ids.map do |am_id|
285
+ {
286
+ 'id' => am_id,
287
+ 'type' => 'songs'
288
+ }
289
+ end
290
+ }
291
+ })
292
+ end
293
+
294
+ # Download helpers
295
+
296
+ def all_am_library_playlists(offset: 0, total: nil)
297
+ unless total != nil && offset >= total
298
+ response = self.api_library_playlists(offset: offset)
299
+ am_playlists = response['data']
300
+ unless am_playlists.empty?
301
+ return am_playlists + self.all_am_library_playlists(offset: offset + PLAYLISTS_CHUNK_SIZE, total: response.dig('meta', 'total'))
302
+ end
303
+ end
304
+ []
305
+ end
306
+
307
+ def all_am_library_playlist_tracks(am_playlist, offset: 0, total: nil)
308
+ unless total != nil && offset >= total
309
+ response = self.api_library_playlist_tracks(am_playlist['id'], offset: offset)
310
+ am_tracks = response['data']
311
+ unless am_tracks.empty?
312
+ return am_tracks + self.all_am_library_playlist_tracks(am_playlist, offset: offset + PLAYLISTS_CHUNK_SIZE, total: response.dig('meta', 'total'))
313
+ end
314
+ end
315
+ []
316
+ end
317
+
318
+ def from_am_id(am_id)
319
+ am_id.try { |i| Digest::SHA1.hexdigest(i) }
320
+ end
321
+
322
+ def from_am_album_artwork(am_artwork)
323
+ width = [am_artwork['width'], MAX_ALBUM_ARTWORK_WIDTH].compact.min
324
+ height = [am_artwork['height'], MAX_ALBUM_ARTWORK_HEIGHT].compact.min
325
+ am_artwork['url']
326
+ &.sub('{w}', width.to_s)
327
+ &.sub('{h}', height.to_s)
328
+ end
329
+
330
+ def from_am_artist_name(artist_name)
331
+ artist_names = artist_name.split(/\s*[,&]\s*/)
332
+ artist_names.map do |artist_name|
333
+ Artist.new(
334
+ id: self.from_am_id(artist_name),
335
+ name: artist_name
336
+ )
337
+ end
338
+ end
339
+
340
+ def from_am_track(am_track, new_playlist)
341
+ am_attributes = am_track['attributes']
342
+
343
+ # TODO: Generate the album/artist IDs from something other than the names
344
+
345
+ new_track = Track.new(
346
+ name: am_attributes['name'],
347
+ genres: am_attributes['genreNames'],
348
+ duration_ms: am_attributes['durationInMillis'],
349
+ isrc: am_attributes['isrc'],
350
+ released_at: am_attributes['releaseDate'].try { |d| DateTime.parse(d) }
351
+ )
352
+
353
+ album_name = am_attributes['albumName']
354
+ artist_name = am_attributes['artistName']
355
+ composer_name = am_attributes['composerName']
356
+ am_album_artwork = am_attributes['artwork'] || {}
357
+
358
+ new_artists = []
359
+ new_albums = []
360
+
361
+ unless album_name.nil?
362
+ new_album = Album.new(
363
+ id: self.from_am_id(album_name),
364
+ name: album_name,
365
+ applemusic: AlbumAppleMusic.new(
366
+ image_url: self.from_am_album_artwork(am_album_artwork)
367
+ )
368
+ )
369
+ new_track.album_id = new_album.id
370
+ new_albums << new_album
371
+ end
372
+
373
+ unless artist_name.nil?
374
+ new_artists = self.from_am_artist_name(artist_name)
375
+ new_track.artist_ids = new_artists.map { |a| a.id }
376
+ end
377
+
378
+ unless composer_name.nil?
379
+ new_composers = self.from_am_artist_name(composer_name)
380
+ new_track.composer_ids = new_composers.map { |c| c.id }
381
+ new_artists += new_composers
382
+ end
383
+
384
+ [new_track, new_artists, new_album]
385
+ end
386
+
387
+ def from_am_library_track(am_track, new_playlist)
388
+ am_attributes = am_track['attributes']
389
+ new_track, new_artists, new_album = self.from_am_track(am_track, new_playlist)
390
+
391
+ new_track.applemusic = TrackAppleMusic.new(
392
+ library_id: am_attributes.dig('playParams', 'id'),
393
+ catalog_id: am_attributes.dig('playParams', 'catalogId')
394
+ )
395
+
396
+ [new_track, new_artists, new_album]
397
+ end
398
+
399
+ def from_am_catalog_track(am_track, new_playlist)
400
+ am_attributes = am_track['attributes']
401
+ new_track, new_artists, new_album = self.from_am_track(am_track, new_playlist)
402
+
403
+ new_track.applemusic = TrackAppleMusic.new(
404
+ catalog_id: am_attributes.dig('playParams', 'id'),
405
+ preview_url: am_attributes.dig('previews', 0, 'url')
406
+ )
407
+
408
+ [new_track, new_artists, new_album]
409
+ end
410
+
411
+ def from_am_library_playlist(am_playlist)
412
+ am_attributes = am_playlist['attributes']
413
+ am_library_id = am_attributes.dig('playParams', 'id')
414
+ am_global_id = am_attributes.dig('playParams', 'globalId')
415
+ new_playlist = Playlist.new(
416
+ id: self.from_am_id(am_global_id || am_library_id),
417
+ name: am_attributes['name'] || '',
418
+ description: am_attributes.dig('description', 'standard'),
419
+ applemusic: PlaylistAppleMusic.new(
420
+ library_id: am_library_id,
421
+ global_id: am_global_id,
422
+ public: am_attributes['isPublic'],
423
+ editable: am_attributes['canEdit'],
424
+ image_url: am_attributes.dig('artwork', 'url')
425
+ )
426
+ )
427
+
428
+ # TODO: Author information
429
+
430
+ begin
431
+ am_tracks = self.all_am_library_playlist_tracks(am_playlist)
432
+ log.info "Got #{am_tracks.length} playlist track(s) for '#{new_playlist.name}'..."
433
+ am_tracks.each do |am_track|
434
+ new_track, new_artists, new_album = self.from_am_library_track(am_track, new_playlist)
435
+
436
+ new_playlist.store_track(new_track)
437
+ new_playlist.store_album(new_album)
438
+
439
+ new_artists.each do |new_artist|
440
+ new_playlist.store_artist(new_artist)
441
+ end
442
+ end
443
+ rescue RestClient::NotFound
444
+ # Swallow 404s, apparently sometimes there are no tracks associated with a list
445
+ nil
446
+ end
447
+
448
+ new_playlist
449
+ end
450
+
451
+ def from_am_catalog_playlist(am_playlist)
452
+ am_attributes = am_playlist['attributes']
453
+ am_global_id = am_attributes.dig('playParams', 'id')
454
+ new_playlist = Playlist.new(
455
+ id: self.from_am_id(am_global_id),
456
+ name: am_attributes['name'],
457
+ description: am_attributes.dig('description', 'standard')
458
+ )
459
+
460
+ author_name = am_attributes['curatorName']
461
+ new_author = User.new(
462
+ id: self.from_am_id(author_name),
463
+ display_name: author_name
464
+ )
465
+ new_playlist.author_id = new_author.id
466
+ new_playlist.store_user(new_author)
467
+
468
+ # TODO: Investigate whether this track list is complete,
469
+ # perhaps we need a mechanism similar to `all_am_library_playlist_tracks`.
470
+
471
+ am_tracks = am_playlist.dig('relationships', 'tracks', 'data') || []
472
+ am_tracks.each do |am_track|
473
+ new_track, new_artists, new_album = self.from_am_catalog_track(am_track, new_playlist)
474
+
475
+ new_playlist.store_track(new_track)
476
+ new_playlist.store_album(new_album)
477
+
478
+ new_artists.each do |new_artist|
479
+ new_playlist.store_artist(new_artist)
480
+ end
481
+ end
482
+
483
+ new_playlist
484
+ end
485
+
486
+ # Upload helpers
487
+
488
+ def to_am_catalog_track_id(track, playlist)
489
+ am_id = track.applemusic&.catalog_id
490
+ unless am_id.nil?
491
+ # We already have an associated catalog ID
492
+ am_id
493
+ else
494
+ # We need to search for the song
495
+ search_phrase = playlist.track_search_phrase(track)
496
+ am_storefront = 'de' # TODO: Make this configurable/dynamic
497
+ response = self.api_catalog_search(am_storefront, search_phrase, limit: 1, offset: 0, types: ['songs'])
498
+ response.dig('results', 'songs', 'data', 0).try do |am_track|
499
+ am_attributes = am_track['attributes']
500
+ log.info "Matched '#{track.name}' with '#{am_attributes['name']}' by '#{am_attributes['artistName']}' from Apple Music"
501
+ am_track['id']
502
+ end
503
+ end
504
+ end
505
+
506
+ def upload_playlist(playlist)
507
+ # TODO: Chunking?
508
+
509
+ self.api_create_library_playlist(
510
+ playlist.name,
511
+ description: playlist.description,
512
+ am_track_catalog_ids: playlist.tracks.filter_map { |t| self.to_am_catalog_track_id(t, playlist) }
513
+ )
514
+ end
515
+
516
+ # Ref parsing
517
+
518
+ def parse_resource_type(raw)
519
+ case raw
520
+ when 'playlist' then :playlist
521
+ when 'album' then :album
522
+ when 'artist' then :artist
523
+ else nil
524
+ end
525
+ end
526
+
527
+ def parse_applemusic_link(raw)
528
+ # Parses links like https://music.apple.com/us/playlist/some-name/pl.123456789
529
+
530
+ # TODO: Investigate whether such links can always be fetched through the catalog API
531
+ # TODO: Handle library links
532
+
533
+ uri = URI(raw)
534
+ unless ['http', 'https'].include?(uri&.scheme) && uri&.host == 'music.apple.com'
535
+ return nil
536
+ end
537
+
538
+ parsed_path = uri.path.split('/')
539
+ unless parsed_path.length == 5
540
+ return nil
541
+ end
542
+
543
+ am_storefront = parsed_path[1]
544
+ resource_type = self.parse_resource_type(parsed_path[2])
545
+ am_id = parsed_path[4]
546
+
547
+ Ref.new(self.name, resource_type, [am_storefront, am_id])
548
+ end
549
+
550
+ def parse_ref(raw_ref)
551
+ if raw_ref.is_token
552
+ location = case raw_ref.text
553
+ when "#{self.name}/tracks" then :tracks
554
+ when "#{self.name}/playlists" then :playlists
555
+ else return nil
556
+ end
557
+ Ref.new(self.name, :special, location)
558
+ else
559
+ self.parse_applemusic_link(raw_ref.text)
560
+ end
561
+ end
562
+
563
+ # Service
564
+
565
+ def download(ref)
566
+ self.authenticate
567
+
568
+ case ref.resource_type
569
+ when :special
570
+ case ref.resource_location
571
+ when :playlists
572
+ log.info 'Querying library playlists...'
573
+ am_playlists = self.all_am_library_playlists.filter { |p| !p.dig('attributes', 'name').nil? }
574
+
575
+ log.info 'Fetching playlists...'
576
+ Enumerator.new(am_playlists.length) do |enum|
577
+ am_playlists.each do |am_playlist|
578
+ new_playlist = self.from_am_library_playlist(am_playlist)
579
+ enum.yield new_playlist
580
+ end
581
+ end
582
+ else raise "Special resource location '#{ref.resource_location}' cannot be downloaded (yet)"
583
+ end
584
+ when :playlist
585
+ am_storefront, am_id = ref.resource_location
586
+
587
+ log.info 'Querying catalog playlist...'
588
+ response = self.api_catalog_playlist(am_storefront, am_id)
589
+ am_playlists = response['data']
590
+
591
+ log.info 'Fetching playlists...'
592
+ Enumerator.new(am_playlists.length) do |enum|
593
+ am_playlists.each do |am_playlist|
594
+ new_playlist = self.from_am_catalog_playlist(am_playlist)
595
+ enum.yield new_playlist
596
+ end
597
+ end
598
+ else raise "Resource type '#{ref.resource_type}' cannot be downloaded (yet)"
599
+ end
600
+ end
601
+
602
+ def upload(ref, playlists)
603
+ self.authenticate
604
+
605
+ # Note that pushes currently intentionally always create a new playlist
606
+ # TODO: Flag for overwriting (something like -f, --force?)
607
+ # (the flag should be declared in the CLI and perhaps added
608
+ # to Service.upload as a parameter)
609
+
610
+ unless ref.resource_type == :special && ref.resource_location == :playlists
611
+ raise 'Cannot upload to anything other than @applemusic/playlists yet!'
612
+ end
613
+
614
+ playlists.each do |playlist|
615
+ self.upload_playlist(playlist)
616
+ end
617
+ end
618
+ end
619
+ end
@@ -0,0 +1,89 @@
1
+ require 'drum/model/playlist'
2
+ require 'drum/model/ref'
3
+ require 'drum/service/service'
4
+ require 'drum/utils/log'
5
+ require 'pathname'
6
+ require 'uri'
7
+ require 'yaml'
8
+
9
+ module Drum
10
+ # A service that reads/writes playlists to/from YAML files.
11
+ class FileService < Service
12
+ include Log
13
+
14
+ def name
15
+ 'file'
16
+ end
17
+
18
+ def parse_ref(raw_ref)
19
+ if raw_ref.is_token
20
+ return nil
21
+ end
22
+
23
+ raw_path = if raw_ref.text.start_with?('file:')
24
+ URI(raw_ref.text).path
25
+ else
26
+ raw_ref.text
27
+ end
28
+
29
+ path = Pathname.new(raw_path)
30
+ Ref.new(self.name, :any, path)
31
+ end
32
+
33
+ def remove(playlist_ref)
34
+ path = playlist_ref.resource_location
35
+ if path.directory?
36
+ raise 'Removing directories is not supported!'
37
+ end
38
+ log.info "Removing #{path}..."
39
+ path.delete
40
+ end
41
+
42
+ def download(playlist_ref)
43
+ base_path = playlist_ref.resource_location
44
+
45
+ if base_path.directory?
46
+ Dir.glob("#{base_path}/**/*.{yaml,yml}").map do |p|
47
+ path = Pathname.new(p)
48
+ playlist = Playlist.deserialize(YAML.load(path.read))
49
+ playlist.path = path.relative_path_from(base_path).parent.each_filename.to_a
50
+ playlist
51
+ end
52
+ else
53
+ [Playlist.deserialize(YAML.load(base_path.read))]
54
+ end
55
+ end
56
+
57
+ def upload(playlist_ref, playlists)
58
+ base_path = playlist_ref.resource_location
59
+
60
+ playlists.each do |playlist|
61
+ path = base_path
62
+ dict = playlist.serialize
63
+
64
+ # Strip path from serialized playlist
65
+ dict.delete('path')
66
+
67
+ if !path.exist? || path.directory?
68
+ unless playlist.path.empty?
69
+ path = path / playlist.path.map { |n| Pathname.new(n) }.reduce(:/)
70
+ end
71
+
72
+ playlist_path = lambda do |length|
73
+ path / "#{playlist.name.kebabcase}-#{playlist.id[...length]}.yaml"
74
+ end
75
+
76
+ length = 6
77
+ while playlist_path[length].exist? && Playlist.deserialize(YAML.load(playlist_path[length].read)).id != playlist.id
78
+ length += 1
79
+ end
80
+
81
+ path = playlist_path[length]
82
+ end
83
+
84
+ path.parent.mkpath
85
+ path.write(dict.to_yaml)
86
+ end
87
+ end
88
+ end
89
+ end