muzik 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
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: []