class Muzik::Client
Constants
- INDEX_FIELDS_CLOUD
- INDEX_FIELDS_LOCAL
- TRASH_LIFE_SECONDS
Attributes
apple_music[RW]
cloud_directory[RW]
cloud_index_csv[RW]
cloud_index_file[RW]
github[RW]
google_drive[RW]
local_path[RW]
log_location[RW]
repo[RW]
trash_path[RW]
upload_path[RW]
Public Class Methods
new(**options)
click to toggle source
# File lib/muzik/client.rb, line 21 def initialize(**options) if options[:cloud_url] self.google_drive = GoogleDrive::Session.from_config(options[:google_drive_config_location]) self.cloud_directory = google_drive.folder_by_url(options[:cloud_url]) self.cloud_index_file = cloud_directory.files(q: ['name = ? and trashed = false', 'index.csv']).first self.cloud_index_file = nil if cloud_index_file&.trashed? end if options[:github_access_token] && options[:github_repo] self.github = Octokit::Client.new(access_token: options[:github_access_token]) self.repo = options[:github_repo] end self.apple_music = app(options[:apple_music]) if options[:apple_music] self.local_path = options[:local_path] self.upload_path = options[:upload_path] self.trash_path = options[:trash_path] self.log_location = options[:log_location] end
Public Instance Methods
refresh_cloud()
click to toggle source
# File lib/muzik/client.rb, line 64 def refresh_cloud print('Refreshing cloud library ~ ') return puts('Cloud index file not found.'.red) unless cloud_index_file download_index_file # Remove duplicates and build set of ids valid = {} songs = {} cloud_index_csv.each do |row| next unless row[:artist] && row[:title] && row[:id] songs[row[:artist]] ||= Set.new unless songs[row[:artist]].include?(row[:title]) songs[row[:artist]] << row[:title] valid[row[:id]] = row next end google_drive.file_by_id(row[:id])&.delete end # Remove rogue files and build new index rows = [] cloud_directory.subfolders(q: ['trashed = false']) do |directory| directory_empty = true directory.files(q: ['trashed = false']) do |file| unless file.full_file_extension == 'mp3' directory_empty = false next end if valid[file.id] directory_empty = false valid[file.id][:updated_at] = file.modified_time.to_time.to_i rows << valid[file.id] else file.delete end end directory.delete if directory_empty end rows.sort_by! { |row| [row[:artist], row[:title]] } csv = CSV.new('', **csv_options) csv << INDEX_FIELDS_CLOUD rows.each { |row| csv << row } io = csv.to_io io.rewind cloud_index_file.update_from_io(io) if github? io.rewind update_github_library(io.read) end puts('done'.green) rescue StandardError => error log_error(error) end
refresh_local()
click to toggle source
# File lib/muzik/client.rb, line 128 def refresh_local print('Refreshing local library ~ ') return puts('No local index file found.'.red) unless File.exists?(local_index_file_location) songs = {} local_index_csv = CSV.read(local_index_file_location, **csv_options) raise('Invalid headers on local index file.') unless local_index_csv.headers.sort == INDEX_FIELDS_LOCAL.sort # Remove duplicates and build set of file locations locations = Set.new songs = {} local_index_csv.each do |row| next unless row[:artist] && row[:title] && row[:location] songs[row[:artist]] ||= Set.new unless songs[row[:artist]].include?(row[:title]) songs[row[:artist]] << row[:title] locations << row[:location] next end move_file_to_trash(row[:location]) end download_index_file unless cloud_index_csv cloud_index = {} cloud_index_csv.each do |row| cloud_index[row[:artist]] ||= {} cloud_index[row[:artist]][row[:title]] = row[:id] end # Remove rogue files and build new index rows = [] Dir["#{local_path}/**/*.mp3"].each do |file_name| if locations.include?(file_name) File.open(file_name, 'rb') do |file| ID3Tag.read(file) do |tag| id = cloud_index.dig(tag.artist, tag.title) if id apple_music.add(file_name) if apple_music? rows << local_csv_row_for( artist: tag.artist, id: id, location: file_name, title: tag.title, updated_at: file.mtime.to_i ) else move_file_to_trash(file_name) end end end else move_file_to_trash(file_name) end end artist_index = INDEX_FIELDS_LOCAL.index(:artist) title_index = INDEX_FIELDS_LOCAL.index(:title) rows.sort_by! { |row| [row[artist_index], row[title_index]] } CSV.open(local_index_file_location, 'w', **csv_options) do |csv| csv << INDEX_FIELDS_LOCAL rows.each { |row| csv << row } end cleanup puts('done'.green) end
setup_cloud()
click to toggle source
# File lib/muzik/client.rb, line 43 def setup_cloud print('Setting up cloud ~ ') return puts('Cloud index file already exists.'.red) if cloud_index_file csv = CSV.new('', **csv_options) csv << INDEX_FIELDS_CLOUD csv.rewind cloud_directory.upload_from_io(csv.to_io, 'index.csv') puts('done'.green) end
setup_local()
click to toggle source
# File lib/muzik/client.rb, line 55 def setup_local print('Setting up local ~ ') return puts('Local index file already exists.'.red) if File.exists?(local_index_file_location) CSV.open(local_index_file_location, 'w', **csv_options) { |csv| csv << INDEX_FIELDS_LOCAL } puts('done'.green) end
sync()
click to toggle source
# File lib/muzik/client.rb, line 200 def sync puts('Synching local library with cloud ~ ') local_index = {} if File.exists?(local_index_file_location) local_index_csv = CSV.read(local_index_file_location, **csv_options) raise('Invalid headers on local index file.') unless local_index_csv.headers.sort == INDEX_FIELDS_LOCAL.sort local_index_csv.each { |row| local_index[row[:id]] = row.to_h.except(:id) } end download_index_file locations = Set.new rows = [] process_valid_row = proc do |file_name, row| locations << file_name rows << local_csv_row_for( location: file_name, **row.to_h.slice(:artist, :id, :title, :updated_at) ) apple_music.add(file_name) if apple_music? end count = 0 partial_failure = false cloud_index_csv.each do |row| local_data = local_index.delete(row[:id]) if local_data&.dig(:location) && local_data.except(:location) == row.to_h.except(:id) process_valid_row.call(local_data[:location], row) next end print("#{row[:artist]} - #{row[:title]}") file = google_drive.file_by_id(row[:id]) new_directory = "#{local_path}/#{google_drive.folder_by_id(file.parents.first).name}" FileUtils.mkdir_p(new_directory) unless File.directory?(new_directory) new_file_name = "#{new_directory}/#{file.name}" file.download_to_file(new_file_name) FileUtils.touch(new_file_name, mtime: row[:updated_at].to_i) process_valid_row.call(new_file_name, row) count += 1 puts(' ✓'.green) rescue StandardError => error puts(' ✘'.red) if row[:artist] && row[:title] log_error(error) partial_failure = true end artist_index = INDEX_FIELDS_LOCAL.index(:artist) title_index = INDEX_FIELDS_LOCAL.index(:title) rows.sort_by! { |row| [row[artist_index], row[title_index]] } CSV.open(local_index_file_location, 'w', **csv_options) do |csv| csv << INDEX_FIELDS_LOCAL rows.each { |row| csv << row } end if partial_failure refresh_local else Dir["#{local_path}/**/*.mp3"].each do |file_name| move_file_to_trash(file_name) unless locations.include?(file_name) end cleanup end puts("#{count} file#{count == 1 ? '' : 's'} downloaded.".green) rescue StandardError => error puts('Failed.'.red) log_error(error) refresh_local if cloud_index_csv end
upload()
click to toggle source
# File lib/muzik/client.rb, line 281 def upload puts('Uploading new music ~ ') count = 0 rows = [] uploaded_files = [] partial_failure = false begin directories = {} file_names = Dir["#{upload_path}/*.mp3"] new_songs = {} duplicates = [] artist = title = nil file_names.each do |file_name| artist = title = nil File.open(file_name, 'rb') do |file| ID3Tag.read(file) do |tag| artist = tag.artist title = tag.title end end new_songs[artist] ||= Set.new next duplicates << file_name if new_songs[artist].include?(title) print("#{artist} - #{title}") new_songs[artist] << title unless directories[artist] directories[artist] = cloud_directory.subfolders(q: ['name = ? and trashed = false', artist]).first || cloud_directory.create_subfolder(artist) end new_file_name = "#{title}.mp3".tr('/?#', '_') file = directories[artist].files(q: ['name = ? and trashed = false', new_file_name]).first if file && !file.trashed? file.update_from_file(file_name) file = google_drive.file_by_id(file.id) else file = directories[artist].upload_from_file(file_name, new_file_name) end rows << cloud_csv_row_for( artist: artist, id: file.id, title: title, updated_at: file.modified_time.to_time.to_i ) uploaded_files << file_name count += 1 puts(' ✓'.green) end rescue StandardError => error puts(' ✘'.red) if artist && title log_error(error) partial_failure = true end begin artist_index = INDEX_FIELDS_CLOUD.index(:artist) title_index = INDEX_FIELDS_CLOUD.index(:title) if cloud_index_file download_index_file cloud_index_csv.to_a[1..].each do |row| rows << row unless new_songs[row[artist_index]]&.include?(row[title_index]) end end rows.sort_by! { |row| [row[artist_index], row[title_index]] } csv = CSV.new('', **csv_options) csv << INDEX_FIELDS_CLOUD rows.each { |row| csv << row } io = csv.to_io io.rewind if cloud_index_file cloud_index_file.update_from_io(io) else cloud_directory.upload_from_io(io, 'index.csv') end rescue StandardError => error puts('Failed.'.red) puts log_error(error) refresh_cloud return end if count.positive? && github? io.rewind update_github_library(io.read) end begin uploaded_files.each { |file_name| move_file_to_trash(file_name) } rescue StandardError => error log_error(error) remove_failure = true end puts("#{count} file#{count == 1 ? '' : 's'} uploaded.".green) puts if partial_failure puts('Some files failed to upload.'.yellow) refresh_cloud end puts('Some files could not be removed.'.yellow) if remove_failure return if duplicates.empty? puts('Duplicate files ignored:'.yellow) duplicates.each { |duplicate| puts(duplicate) } end
Private Instance Methods
add_apple_music_track_to_playlists(file_name, *playlists)
click to toggle source
# File lib/muzik/client.rb, line 401 def add_apple_music_track_to_playlists(file_name, *playlists) track = apple_music.tracks[its.location.eq(MacTypes::Alias.path(file_name))].first playlists.each do |playlist| track.duplicate(to: apple_music.user_playlists[its.name.eq(playlist)].first) end end
apple_music?()
click to toggle source
# File lib/muzik/client.rb, line 408 def apple_music? !!apple_music end
cleanup()
click to toggle source
# File lib/muzik/client.rb, line 412 def cleanup Dir["#{local_path}/*/"].each { |directory| remove_empty_directory(directory) } remove_dead_apple_music_tracks if apple_music? take_out_trash end
cloud_csv_row_for(**fields)
click to toggle source
# File lib/muzik/client.rb, line 418 def cloud_csv_row_for(**fields) INDEX_FIELDS_CLOUD.each.with_object([]) { |header, array| array << fields[header] } end
csv_options()
click to toggle source
# File lib/muzik/client.rb, line 422 def csv_options { headers: true, header_converters: :symbol } end
download_index_file()
click to toggle source
# File lib/muzik/client.rb, line 426 def download_index_file return if cloud_index_csv raise('No cloud index file found.') unless cloud_index_file self.cloud_index_csv = CSV.parse(cloud_index_file.download_to_string, **csv_options) raise('Invalid headers on cloud index file.') unless cloud_index_csv.headers.sort == INDEX_FIELDS_CLOUD.sort end
github?()
click to toggle source
# File lib/muzik/client.rb, line 435 def github? !!github end
local_csv_row_for(**fields)
click to toggle source
# File lib/muzik/client.rb, line 439 def local_csv_row_for(**fields) INDEX_FIELDS_LOCAL.each.with_object([]) { |header, array| array << fields[header] } end
local_index_file_location()
click to toggle source
# File lib/muzik/client.rb, line 443 def local_index_file_location "#{local_path}/index.csv" end
log_error(error)
click to toggle source
# File lib/muzik/client.rb, line 447 def log_error(error) puts puts("An error occurred, go to #{log_location} for more details.".red) File.write(log_location, "#{error.class}: #{error.message}\n#{error.backtrace.join("\n")}") end
move_file_to_trash(file)
click to toggle source
# File lib/muzik/client.rb, line 453 def move_file_to_trash(file) return unless File.exists?(file) FileUtils.mv(file, "#{trash_path}/#{File.basename(file, '.mp3')} #{Time.now.to_i}.mp3") end
remove_dead_apple_music_tracks()
click to toggle source
Removes songs from Apple Music Library that are any of the following:
-
No longer associated with a file
-
Associated with a file outside of the designated local directory
-
Duplicates (version with highest played count is kept) [not entirely sure this is possible]
# File lib/muzik/client.rb, line 463 def remove_dead_apple_music_tracks return if apple_music.tracks.get.empty? keep = {} indexes_to_remove = Set.new played_counts = apple_music.tracks.played_count.get locations = apple_music.tracks.location.get locations.each.with_index do |location, index| if keep[location] if played_counts[index] > keep[location][:played_count] indexes_to_remove << keep[location][:index] keep[location][:index] = index else indexes_to_remove << index end next end if locations[index] == :missing_value || !locations[index].to_s.start_with?(local_path) indexes_to_remove << index else keep[location] = { index: index, played_count: played_counts[index] } end end apple_music.tracks.database_ID.get.values_at(*indexes_to_remove).each do |id| apple_music.tracks[its.database_ID.eq(id)].delete end end
remove_empty_directory(directory)
click to toggle source
# File lib/muzik/client.rb, line 494 def remove_empty_directory(directory) FileUtils.remove_dir(directory) if Dir["#{directory}/*.mp3"].empty? end
remove_song_from_apple_music(file_name)
click to toggle source
# File lib/muzik/client.rb, line 498 def remove_song_from_apple_music(file_name) apple_music.tracks[its.location.eq(MacTypes::Alias.path(file_name))].delete end
take_out_trash()
click to toggle source
# File lib/muzik/client.rb, line 502 def take_out_trash Dir["#{trash_path}/*.mp3"].each do |file_name| File.delete(file_name) if (Time.now - File.new(file_name).mtime) > TRASH_LIFE_SECONDS end end
update_apple_music_track(file_name, **attributes)
click to toggle source
# File lib/muzik/client.rb, line 508 def update_apple_music_track(file_name, **attributes) track = apple_music.tracks[its.location.eq(MacTypes::Alias.path(file_name))] attributes.each { |field, value| track.send(field).set(to: value) } end
update_github_library(contents)
click to toggle source
# File lib/muzik/client.rb, line 513 def update_github_library(contents) branch_ref = 'heads/master' latest_sha = github.ref(repo, branch_ref).object.sha base_tree = github.commit(repo, latest_sha).commit.tree.sha library_file_name = 'library.csv' version = Base64.decode64(github.contents(repo, path: 'version').content).to_i + 1 tree_data = { library_file_name => contents, version: version.to_s }.map do |path, data| blob = github.create_blob(repo, Base64.encode64(data), 'base64') { path: path, mode: '100644', type: 'blob', sha: blob } end new_tree = github.create_tree(repo, tree_data, base_tree: base_tree).sha new_sha = github.create_commit(repo, "v#{version}", new_tree, latest_sha).sha diff = github.compare(repo, latest_sha, new_sha) return unless diff.files.any? { |file| file.filename == library_file_name } github.update_ref(repo, branch_ref, new_sha) rescue StandardError => error log_error(error) puts('Failed to update github library. Run `muzik refresh cloud` to update it manually.'.red) end