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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +3 -0
- data/bin/muzik +195 -0
- data/lib/muzik.rb +11 -0
- data/lib/muzik/client.rb +537 -0
- data/lib/muzik/version.rb +3 -0
- metadata +51 -0
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
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'
|
data/lib/muzik/client.rb
ADDED
@@ -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
|
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: []
|