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