muzik 0.0.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0a14e57de8903f8bd1bddac2b87f04aae6faa7a43aeeafe3044a2f9b0f5d212e
4
+ data.tar.gz: 18a6c8cba0f2189e26e618539e4b9f6d788f05280b1193e0ee26ed45d32f549d
5
+ SHA512:
6
+ metadata.gz: 1b13e659865f94f834bbf08e6cd9487e40ebe74a5a086c5ac44f838b0b68c9d280428ff646fef17ba37fc1640bfa61184d8c0c9619b175a467b59100c606574c
7
+ data.tar.gz: 709c754b71ee16bae3c724fbc22123868d1ccfd2b719d9a0eb4c9e00ac16da5201e03a3ea95f3a92622bb427bd6e2bb1a501388b56ba998dce038d2bc4eb7f9b
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Thomas Russoniello
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # muzik
2
+
3
+ My personal music manager. Syncs my music across devices using Google Drive.
data/bin/muzik ADDED
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ if ARGV.empty?
4
+ puts('Available commands:')
5
+ [
6
+ 'sync [external PATH]',
7
+ 'upload',
8
+ 'tag',
9
+ 'download',
10
+ 'stage',
11
+ 'init',
12
+ 'config [FIELD=VALUE...]',
13
+ 'refresh [cloud|local|external PATH]',
14
+ 'setup [cloud|local|external PATH]'
15
+ ].each { |command| puts(" #{command}") }
16
+ exit(true)
17
+ end
18
+
19
+ if ARGV.first == 'init'
20
+ create_dir = lambda do |name|
21
+ Dir.mkdir(name) unless Dir.exist?(name)
22
+ end
23
+
24
+ directory = File.expand_path('~/muzik')
25
+ create_dir.call(directory)
26
+ create_dir.call("#{directory}/download")
27
+ create_dir.call("#{directory}/upload")
28
+ create_dir.call("#{directory}/trash")
29
+ File.write("#{directory}/config.json", '{}') unless File.exists?("#{directory}/config.json")
30
+ exit(true)
31
+ end
32
+
33
+ require 'colorize'
34
+
35
+ def boom(message)
36
+ STDERR.puts("#{'Muzik Error:'.red} #{message}")
37
+ exit(false)
38
+ end
39
+
40
+ location = File.expand_path('~/muzik/config.json')
41
+ boom('No config file found. Run `muzik init` to initialize it.') unless File.exists?(location)
42
+
43
+ require 'json'
44
+
45
+ begin
46
+ CONFIG = JSON.parse(File.read(location))
47
+ rescue StandardError
48
+ boom('Error parsing config file.')
49
+ end
50
+
51
+ def instantiate(cloud: true, external: nil)
52
+ relative 'muzik'
53
+
54
+ options = {
55
+ cloud_url: (CONFIG['cloud_url'] if cloud),
56
+ google_drive_config_location: CONFIG['google_drive_config_location'],
57
+ log_location: CONFIG['log_location'],
58
+ trash_path: CONFIG['trash_path']
59
+ }
60
+ if external
61
+ options[:local_path] = external
62
+ else
63
+ options.merge!(
64
+ apple_music: CONFIG['apple_music'],
65
+ github_access_token: CONFIG['github_access_token'],
66
+ github_repo: CONFIG['github_repo'],
67
+ local_path: CONFIG['local_path'],
68
+ upload_path: CONFIG['upload_path']
69
+ )
70
+ end
71
+
72
+ Muzik::Client.new(**options)
73
+ end
74
+
75
+ def get_external_path(value)
76
+ boom('No external path provided') unless value
77
+
78
+ path = File.expand_path(value.to_s)
79
+ boom("Invalid external path: #{value}") unless File.exists?(path)
80
+
81
+ path
82
+ end
83
+
84
+ begin
85
+ case ARGV.first
86
+ when 'sync'
87
+ case ARGV[1]
88
+ when nil
89
+ instantiate.sync
90
+ when 'external'
91
+ instantiate(external: get_external_path(ARGV[2])).sync
92
+ else
93
+ boom("Unknown sync option: #{ARGV[1]}")
94
+ end
95
+ when 'upload'
96
+ instantiate.upload
97
+ when 'tag'
98
+ system('open', CONFIG['download_path'], '-a', CONFIG['tagging_app'])
99
+ when 'download'
100
+ system(
101
+ 'youtube-dl',
102
+ '-x',
103
+ '--audio-format',
104
+ 'mp3',
105
+ '-o',
106
+ "#{CONFIG['download_path']}/%(title)s.%(ext)s",
107
+ CONFIG['new_music_playlist_url']
108
+ )
109
+ puts('done'.green)
110
+ when 'stage'
111
+ path = "#{CONFIG['download_path']}/*.mp3"
112
+ file_count = Dir[path].size
113
+ Dir[path].each do |file_name|
114
+ system('mv', file_name, "#{CONFIG['upload_path']}/#{File.basename(file_name)}")
115
+ end
116
+
117
+ puts("#{file_count} file#{file_count == 1 ? '' : 's'} staged for upload.".green)
118
+ when 'config'
119
+ begin
120
+ args = ARGV[1..].map { |arg| arg.split('=', 2) }.to_h
121
+ rescue ArgumentError
122
+ boom('Invalid arguments')
123
+ end
124
+
125
+ valid_args = %w[
126
+ apple_music
127
+ cloud_url
128
+ download_path
129
+ github_access_token
130
+ github_repo
131
+ google_drive_config_location
132
+ local_path
133
+ new_music_playlist_url
134
+ upload_path
135
+ tagging_app
136
+ trash_path
137
+ ]
138
+ args.each { |arg, _| boom("Unkown argument: #{arg}") unless valid_args.include?(arg) }
139
+ args = CONFIG.merge(args)
140
+
141
+ directory = File.expand_path('~/muzik')
142
+ args['upload_path'] ||= File.expand_path("#{directory}/upload")
143
+ args['download_path'] ||= File.expand_path("#{directory}/download")
144
+ args['trash_path'] ||= File.expand_path("#{directory}/trash")
145
+ args['log_location'] ||= File.expand_path("#{directory}/log")
146
+ args['google_drive_config_location'] ||= File.expand_path("#{directory}/google_drive.json")
147
+ args['tagging_app'] ||= 'Mp3Tag'
148
+
149
+ File.write("#{directory}/config.json", JSON.pretty_generate(args))
150
+ when 'refresh'
151
+ case ARGV[1]
152
+ when 'cloud'
153
+ instantiate.refresh_cloud
154
+ when 'local'
155
+ instantiate.refresh_local
156
+ when 'external'
157
+ instantiate(external: get_external_path(ARGV[2])).refresh_local
158
+ else
159
+ boom("Unknown refresh type: #{ARGV[1]}")
160
+ end
161
+ when 'setup'
162
+ case ARGV[1]
163
+ when 'cloud'
164
+ instantiate.setup_cloud
165
+ when 'local'
166
+ instantiate(cloud: false).setup_local
167
+ when 'external'
168
+ instantiate(cloud: false, external: get_external_path(ARGV[2])).setup_local
169
+ else
170
+ boom("Unknown setup type: #{ARGV[1]}")
171
+ end
172
+ when 'auth'
173
+ location = CONFIG['google_drive_config_location']
174
+ require 'google_drive'
175
+ begin
176
+ GoogleDrive::Session.from_config(location)
177
+ puts('Google Drive authentication is working properly.'.green)
178
+ rescue Signet::AuthorizationError
179
+ begin
180
+ google_drive_config = JSON.parse(File.read(location))
181
+ rescue StandardError
182
+ boom('Error parsing Google Drive config file.')
183
+ end
184
+
185
+ google_drive_config = google_drive_config.slice('client_id', 'client_secret')
186
+ File.write(location, JSON.pretty_generate(google_drive_config))
187
+ GoogleDrive::Session.from_config(location)
188
+ puts('Google Drive authentication updated successfully.'.green)
189
+ end
190
+ else
191
+ boom("Unknown command: #{ARGV.first}")
192
+ end
193
+ rescue Signet::AuthorizationError
194
+ boom('Google Drive authentication failed. Run `muzik auth` to set new refresh token.')
195
+ end
data/lib/muzik.rb ADDED
@@ -0,0 +1,11 @@
1
+ require 'csv'
2
+ require 'google_drive'
3
+ require 'active_support/core_ext/hash/except'
4
+ require 'fileutils'
5
+ require 'id3tag'
6
+ require 'colorize'
7
+ require 'rb-scpt'
8
+ require 'octokit'
9
+
10
+ require_relative 'muzik/version'
11
+ require_relative 'muzik/client'
@@ -0,0 +1,537 @@
1
+ module Muzik
2
+ class Client
3
+ include Appscript
4
+
5
+ INDEX_FIELDS_CLOUD = %i[artist title id updated_at].freeze
6
+ INDEX_FIELDS_LOCAL = %i[artist title location id updated_at].freeze
7
+ TRASH_LIFE_SECONDS = 7 * 24 * 60 * 60
8
+
9
+ attr_accessor :apple_music
10
+ attr_accessor :cloud_directory
11
+ attr_accessor :cloud_index_csv
12
+ attr_accessor :cloud_index_file
13
+ attr_accessor :github
14
+ attr_accessor :google_drive
15
+ attr_accessor :local_path
16
+ attr_accessor :log_location
17
+ attr_accessor :repo
18
+ attr_accessor :trash_path
19
+ attr_accessor :upload_path
20
+
21
+ def initialize(**options)
22
+ if options[:cloud_url]
23
+ self.google_drive = GoogleDrive::Session.from_config(options[:google_drive_config_location])
24
+ self.cloud_directory = google_drive.folder_by_url(options[:cloud_url])
25
+ self.cloud_index_file =
26
+ cloud_directory.files(q: ['name = ? and trashed = false', 'index.csv']).first
27
+ self.cloud_index_file = nil if cloud_index_file&.trashed?
28
+ end
29
+
30
+ if options[:github_access_token] && options[:github_repo]
31
+ self.github = Octokit::Client.new(access_token: options[:github_access_token])
32
+ self.repo = options[:github_repo]
33
+ end
34
+
35
+ self.apple_music = app(options[:apple_music]) if options[:apple_music]
36
+
37
+ self.local_path = options[:local_path]
38
+ self.upload_path = options[:upload_path]
39
+ self.trash_path = options[:trash_path]
40
+ self.log_location = options[:log_location]
41
+ end
42
+
43
+ def setup_cloud
44
+ print('Setting up cloud ~ ')
45
+ return puts('Cloud index file already exists.'.red) if cloud_index_file
46
+
47
+ csv = CSV.new('', **csv_options)
48
+ csv << INDEX_FIELDS_CLOUD
49
+ csv.rewind
50
+
51
+ cloud_directory.upload_from_io(csv.to_io, 'index.csv')
52
+ puts('done'.green)
53
+ end
54
+
55
+ def setup_local
56
+ print('Setting up local ~ ')
57
+ return puts('Local index file already exists.'.red) if
58
+ File.exists?(local_index_file_location)
59
+
60
+ CSV.open(local_index_file_location, 'w', **csv_options) { |csv| csv << INDEX_FIELDS_LOCAL }
61
+ puts('done'.green)
62
+ end
63
+
64
+ def refresh_cloud
65
+ print('Refreshing cloud library ~ ')
66
+ return puts('Cloud index file not found.'.red) unless cloud_index_file
67
+
68
+ download_index_file
69
+
70
+ # Remove duplicates and build set of ids
71
+ valid = {}
72
+ songs = {}
73
+ cloud_index_csv.each do |row|
74
+ next unless row[:artist] && row[:title] && row[:id]
75
+
76
+ songs[row[:artist]] ||= Set.new
77
+ unless songs[row[:artist]].include?(row[:title])
78
+ songs[row[:artist]] << row[:title]
79
+ valid[row[:id]] = row
80
+ next
81
+ end
82
+
83
+ google_drive.file_by_id(row[:id])&.delete
84
+ end
85
+
86
+ # Remove rogue files and build new index
87
+ rows = []
88
+ cloud_directory.subfolders(q: ['trashed = false']) do |directory|
89
+ directory_empty = true
90
+ directory.files(q: ['trashed = false']) do |file|
91
+ unless file.full_file_extension == 'mp3'
92
+ directory_empty = false
93
+ next
94
+ end
95
+
96
+ if valid[file.id]
97
+ directory_empty = false
98
+ valid[file.id][:updated_at] = file.modified_time.to_time.to_i
99
+ rows << valid[file.id]
100
+ else
101
+ file.delete
102
+ end
103
+ end
104
+
105
+ directory.delete if directory_empty
106
+ end
107
+
108
+ rows.sort_by! { |row| [row[:artist], row[:title]] }
109
+
110
+ csv = CSV.new('', **csv_options)
111
+ csv << INDEX_FIELDS_CLOUD
112
+ rows.each { |row| csv << row }
113
+ io = csv.to_io
114
+ io.rewind
115
+
116
+ cloud_index_file.update_from_io(io)
117
+
118
+ if github?
119
+ io.rewind
120
+ update_github_library(io.read)
121
+ end
122
+
123
+ puts('done'.green)
124
+ rescue StandardError => error
125
+ log_error(error)
126
+ end
127
+
128
+ def refresh_local
129
+ print('Refreshing local library ~ ')
130
+ return puts('No local index file found.'.red) unless File.exists?(local_index_file_location)
131
+
132
+ songs = {}
133
+ local_index_csv = CSV.read(local_index_file_location, **csv_options)
134
+ raise('Invalid headers on local index file.') unless
135
+ local_index_csv.headers.sort == INDEX_FIELDS_LOCAL.sort
136
+
137
+ # Remove duplicates and build set of file locations
138
+ locations = Set.new
139
+ songs = {}
140
+ local_index_csv.each do |row|
141
+ next unless row[:artist] && row[:title] && row[:location]
142
+
143
+ songs[row[:artist]] ||= Set.new
144
+ unless songs[row[:artist]].include?(row[:title])
145
+ songs[row[:artist]] << row[:title]
146
+ locations << row[:location]
147
+ next
148
+ end
149
+
150
+ move_file_to_trash(row[:location])
151
+ end
152
+
153
+ download_index_file unless cloud_index_csv
154
+
155
+ cloud_index = {}
156
+ cloud_index_csv.each do |row|
157
+ cloud_index[row[:artist]] ||= {}
158
+ cloud_index[row[:artist]][row[:title]] = row[:id]
159
+ end
160
+
161
+ # Remove rogue files and build new index
162
+ rows = []
163
+ Dir["#{local_path}/**/*.mp3"].each do |file_name|
164
+ if locations.include?(file_name)
165
+ File.open(file_name, 'rb') do |file|
166
+ ID3Tag.read(file) do |tag|
167
+ id = cloud_index.dig(tag.artist, tag.title)
168
+ if id
169
+ apple_music.add(file_name) if apple_music?
170
+ rows << local_csv_row_for(
171
+ artist: tag.artist,
172
+ id: id,
173
+ location: file_name,
174
+ title: tag.title,
175
+ updated_at: file.mtime.to_i
176
+ )
177
+ else
178
+ move_file_to_trash(file_name)
179
+ end
180
+ end
181
+ end
182
+ else
183
+ move_file_to_trash(file_name)
184
+ end
185
+ end
186
+
187
+ artist_index = INDEX_FIELDS_LOCAL.index(:artist)
188
+ title_index = INDEX_FIELDS_LOCAL.index(:title)
189
+ rows.sort_by! { |row| [row[artist_index], row[title_index]] }
190
+
191
+ CSV.open(local_index_file_location, 'w', **csv_options) do |csv|
192
+ csv << INDEX_FIELDS_LOCAL
193
+ rows.each { |row| csv << row }
194
+ end
195
+
196
+ cleanup
197
+ puts('done'.green)
198
+ end
199
+
200
+ def sync
201
+ puts('Synching local library with cloud ~ ')
202
+
203
+ local_index = {}
204
+ if File.exists?(local_index_file_location)
205
+ local_index_csv = CSV.read(local_index_file_location, **csv_options)
206
+ raise('Invalid headers on local index file.') unless
207
+ local_index_csv.headers.sort == INDEX_FIELDS_LOCAL.sort
208
+
209
+ local_index_csv.each { |row| local_index[row[:id]] = row.to_h.except(:id) }
210
+ end
211
+
212
+
213
+ download_index_file
214
+ locations = Set.new
215
+ rows = []
216
+ process_valid_row = proc do |file_name, row|
217
+ locations << file_name
218
+
219
+ rows << local_csv_row_for(
220
+ location: file_name,
221
+ **row.to_h.slice(:artist, :id, :title, :updated_at)
222
+ )
223
+
224
+ apple_music.add(file_name) if apple_music?
225
+ end
226
+
227
+ count = 0
228
+ partial_failure = false
229
+ cloud_index_csv.each do |row|
230
+ local_data = local_index.delete(row[:id])
231
+ if local_data&.dig(:location) && local_data.except(:location) == row.to_h.except(:id)
232
+ process_valid_row.call(local_data[:location], row)
233
+ next
234
+ end
235
+
236
+ print("#{row[:artist]} - #{row[:title]}")
237
+
238
+ file = google_drive.file_by_id(row[:id])
239
+ new_directory = "#{local_path}/#{google_drive.folder_by_id(file.parents.first).name}"
240
+ FileUtils.mkdir_p(new_directory) unless File.directory?(new_directory)
241
+ new_file_name = "#{new_directory}/#{file.name}"
242
+
243
+ file.download_to_file(new_file_name)
244
+ FileUtils.touch(new_file_name, mtime: row[:updated_at].to_i)
245
+ process_valid_row.call(new_file_name, row)
246
+ count += 1
247
+
248
+ puts(' ✓'.green)
249
+ rescue StandardError => error
250
+ puts(' ✘'.red) if row[:artist] && row[:title]
251
+ log_error(error)
252
+ partial_failure = true
253
+ end
254
+
255
+ artist_index = INDEX_FIELDS_LOCAL.index(:artist)
256
+ title_index = INDEX_FIELDS_LOCAL.index(:title)
257
+ rows.sort_by! { |row| [row[artist_index], row[title_index]] }
258
+
259
+ CSV.open(local_index_file_location, 'w', **csv_options) do |csv|
260
+ csv << INDEX_FIELDS_LOCAL
261
+ rows.each { |row| csv << row }
262
+ end
263
+
264
+ if partial_failure
265
+ refresh_local
266
+ else
267
+ Dir["#{local_path}/**/*.mp3"].each do |file_name|
268
+ move_file_to_trash(file_name) unless locations.include?(file_name)
269
+ end
270
+
271
+ cleanup
272
+ end
273
+
274
+ puts("#{count} file#{count == 1 ? '' : 's'} downloaded.".green)
275
+ rescue StandardError => error
276
+ puts('Failed.'.red)
277
+ log_error(error)
278
+ refresh_local if cloud_index_csv
279
+ end
280
+
281
+ def upload
282
+ puts('Uploading new music ~ ')
283
+
284
+ count = 0
285
+ rows = []
286
+ uploaded_files = []
287
+ partial_failure = false
288
+ begin
289
+ directories = {}
290
+ file_names = Dir["#{upload_path}/*.mp3"]
291
+ new_songs = {}
292
+ duplicates = []
293
+ artist = title = nil
294
+ file_names.each do |file_name|
295
+ artist = title = nil
296
+ File.open(file_name, 'rb') do |file|
297
+ ID3Tag.read(file) do |tag|
298
+ artist = tag.artist
299
+ title = tag.title
300
+ end
301
+ end
302
+
303
+ new_songs[artist] ||= Set.new
304
+ next duplicates << file_name if new_songs[artist].include?(title)
305
+
306
+ print("#{artist} - #{title}")
307
+ new_songs[artist] << title
308
+
309
+ unless directories[artist]
310
+ directories[artist] =
311
+ cloud_directory.subfolders(q: ['name = ? and trashed = false', artist]).first ||
312
+ cloud_directory.create_subfolder(artist)
313
+ end
314
+
315
+ new_file_name = "#{title}.mp3".tr('/?#', '_')
316
+ file = directories[artist].files(q: ['name = ? and trashed = false', new_file_name]).first
317
+ if file && !file.trashed?
318
+ file.update_from_file(file_name)
319
+ file = google_drive.file_by_id(file.id)
320
+ else
321
+ file = directories[artist].upload_from_file(file_name, new_file_name)
322
+ end
323
+
324
+ rows << cloud_csv_row_for(
325
+ artist: artist,
326
+ id: file.id,
327
+ title: title,
328
+ updated_at: file.modified_time.to_time.to_i
329
+ )
330
+ uploaded_files << file_name
331
+ count += 1
332
+ puts(' ✓'.green)
333
+ end
334
+ rescue StandardError => error
335
+ puts(' ✘'.red) if artist && title
336
+ log_error(error)
337
+ partial_failure = true
338
+ end
339
+
340
+ begin
341
+ artist_index = INDEX_FIELDS_CLOUD.index(:artist)
342
+ title_index = INDEX_FIELDS_CLOUD.index(:title)
343
+
344
+ if cloud_index_file
345
+ download_index_file
346
+ cloud_index_csv.to_a[1..].each do |row|
347
+ rows << row unless new_songs[row[artist_index]]&.include?(row[title_index])
348
+ end
349
+ end
350
+
351
+ rows.sort_by! { |row| [row[artist_index], row[title_index]] }
352
+
353
+ csv = CSV.new('', **csv_options)
354
+ csv << INDEX_FIELDS_CLOUD
355
+ rows.each { |row| csv << row }
356
+ io = csv.to_io
357
+ io.rewind
358
+
359
+ if cloud_index_file
360
+ cloud_index_file.update_from_io(io)
361
+ else
362
+ cloud_directory.upload_from_io(io, 'index.csv')
363
+ end
364
+ rescue StandardError => error
365
+ puts('Failed.'.red)
366
+ puts
367
+ log_error(error)
368
+ refresh_cloud
369
+ return
370
+ end
371
+
372
+ if count.positive? && github?
373
+ io.rewind
374
+ update_github_library(io.read)
375
+ end
376
+
377
+ begin
378
+ uploaded_files.each { |file_name| move_file_to_trash(file_name) }
379
+ rescue StandardError => error
380
+ log_error(error)
381
+ remove_failure = true
382
+ end
383
+
384
+ puts("#{count} file#{count == 1 ? '' : 's'} uploaded.".green)
385
+ puts
386
+
387
+ if partial_failure
388
+ puts('Some files failed to upload.'.yellow)
389
+ refresh_cloud
390
+ end
391
+
392
+ puts('Some files could not be removed.'.yellow) if remove_failure
393
+ return if duplicates.empty?
394
+
395
+ puts('Duplicate files ignored:'.yellow)
396
+ duplicates.each { |duplicate| puts(duplicate) }
397
+ end
398
+
399
+ private
400
+
401
+ def add_apple_music_track_to_playlists(file_name, *playlists)
402
+ track = apple_music.tracks[its.location.eq(MacTypes::Alias.path(file_name))].first
403
+ playlists.each do |playlist|
404
+ track.duplicate(to: apple_music.user_playlists[its.name.eq(playlist)].first)
405
+ end
406
+ end
407
+
408
+ def apple_music?
409
+ !!apple_music
410
+ end
411
+
412
+ def cleanup
413
+ Dir["#{local_path}/*/"].each { |directory| remove_empty_directory(directory) }
414
+ remove_dead_apple_music_tracks if apple_music?
415
+ take_out_trash
416
+ end
417
+
418
+ def cloud_csv_row_for(**fields)
419
+ INDEX_FIELDS_CLOUD.each.with_object([]) { |header, array| array << fields[header] }
420
+ end
421
+
422
+ def csv_options
423
+ { headers: true, header_converters: :symbol }
424
+ end
425
+
426
+ def download_index_file
427
+ return if cloud_index_csv
428
+ raise('No cloud index file found.') unless cloud_index_file
429
+
430
+ self.cloud_index_csv = CSV.parse(cloud_index_file.download_to_string, **csv_options)
431
+ raise('Invalid headers on cloud index file.') unless
432
+ cloud_index_csv.headers.sort == INDEX_FIELDS_CLOUD.sort
433
+ end
434
+
435
+ def github?
436
+ !!github
437
+ end
438
+
439
+ def local_csv_row_for(**fields)
440
+ INDEX_FIELDS_LOCAL.each.with_object([]) { |header, array| array << fields[header] }
441
+ end
442
+
443
+ def local_index_file_location
444
+ "#{local_path}/index.csv"
445
+ end
446
+
447
+ def log_error(error)
448
+ puts
449
+ puts("An error occurred, go to #{log_location} for more details.".red)
450
+ File.write(log_location, "#{error.class}: #{error.message}\n#{error.backtrace.join("\n")}")
451
+ end
452
+
453
+ def move_file_to_trash(file)
454
+ return unless File.exists?(file)
455
+
456
+ FileUtils.mv(file, "#{trash_path}/#{File.basename(file, '.mp3')} #{Time.now.to_i}.mp3")
457
+ end
458
+
459
+ # Removes songs from Apple Music Library that are any of the following:
460
+ # * No longer associated with a file
461
+ # * Associated with a file outside of the designated local directory
462
+ # * Duplicates (version with highest played count is kept) [not entirely sure this is possible]
463
+ def remove_dead_apple_music_tracks
464
+ return if apple_music.tracks.get.empty?
465
+
466
+ keep = {}
467
+ indexes_to_remove = Set.new
468
+ played_counts = apple_music.tracks.played_count.get
469
+ locations = apple_music.tracks.location.get
470
+ locations.each.with_index do |location, index|
471
+ if keep[location]
472
+ if played_counts[index] > keep[location][:played_count]
473
+ indexes_to_remove << keep[location][:index]
474
+ keep[location][:index] = index
475
+ else
476
+ indexes_to_remove << index
477
+ end
478
+
479
+ next
480
+ end
481
+
482
+ if locations[index] == :missing_value || !locations[index].to_s.start_with?(local_path)
483
+ indexes_to_remove << index
484
+ else
485
+ keep[location] = { index: index, played_count: played_counts[index] }
486
+ end
487
+ end
488
+
489
+ apple_music.tracks.database_ID.get.values_at(*indexes_to_remove).each do |id|
490
+ apple_music.tracks[its.database_ID.eq(id)].delete
491
+ end
492
+ end
493
+
494
+ def remove_empty_directory(directory)
495
+ FileUtils.remove_dir(directory) if Dir["#{directory}/*.mp3"].empty?
496
+ end
497
+
498
+ def remove_song_from_apple_music(file_name)
499
+ apple_music.tracks[its.location.eq(MacTypes::Alias.path(file_name))].delete
500
+ end
501
+
502
+ def take_out_trash
503
+ Dir["#{trash_path}/*.mp3"].each do |file_name|
504
+ File.delete(file_name) if (Time.now - File.new(file_name).mtime) > TRASH_LIFE_SECONDS
505
+ end
506
+ end
507
+
508
+ def update_apple_music_track(file_name, **attributes)
509
+ track = apple_music.tracks[its.location.eq(MacTypes::Alias.path(file_name))]
510
+ attributes.each { |field, value| track.send(field).set(to: value) }
511
+ end
512
+
513
+ def update_github_library(contents)
514
+ branch_ref = 'heads/master'
515
+
516
+ latest_sha = github.ref(repo, branch_ref).object.sha
517
+ base_tree = github.commit(repo, latest_sha).commit.tree.sha
518
+
519
+ library_file_name = 'library.csv'
520
+ version = Base64.decode64(github.contents(repo, path: 'version').content).to_i + 1
521
+ tree_data = { library_file_name => contents, version: version.to_s }.map do |path, data|
522
+ blob = github.create_blob(repo, Base64.encode64(data), 'base64')
523
+ { path: path, mode: '100644', type: 'blob', sha: blob }
524
+ end
525
+
526
+ new_tree = github.create_tree(repo, tree_data, base_tree: base_tree).sha
527
+ new_sha = github.create_commit(repo, "v#{version}", new_tree, latest_sha).sha
528
+ diff = github.compare(repo, latest_sha, new_sha)
529
+ return unless diff.files.any? { |file| file.filename == library_file_name }
530
+
531
+ github.update_ref(repo, branch_ref, new_sha)
532
+ rescue StandardError => error
533
+ log_error(error)
534
+ puts('Failed to update github library. Run `muzik refresh cloud` to update it manually.'.red)
535
+ end
536
+ end
537
+ end
@@ -0,0 +1,3 @@
1
+ module Muzik
2
+ VERSION = '0.0.0'.freeze
3
+ end
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: muzik
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Thomas Russoniello
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-07-25 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Muzik manages your personal library of music files by syncing it across
14
+ your devices using Google Drive.
15
+ email: tommy.russoniello@gmail.com
16
+ executables:
17
+ - muzik
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE.txt
22
+ - README.md
23
+ - bin/muzik
24
+ - lib/muzik.rb
25
+ - lib/muzik/client.rb
26
+ - lib/muzik/version.rb
27
+ homepage: https://github.com/tommy-russoniello/muzik
28
+ licenses:
29
+ - MIT
30
+ metadata:
31
+ source_code_uri: https://github.com/tommy-russoniello/muzik
32
+ post_install_message:
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 3.1.4
48
+ signing_key:
49
+ specification_version: 4
50
+ summary: Personal music manager
51
+ test_files: []