Last active
February 6, 2016 19:04
-
-
Save irisfofs/713bf7d119666f716973 to your computer and use it in GitHub Desktop.
Script to run through a media library (in iTunes XML format) and combine duplicates (add play counts together).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"mappings": { | |
"file://localhost/X:\\music\\CLOVER STUDIO - 大神 オリジナル・サウンドトラック [Disc 1]": "file://localhost/X:\\music\\Okami OST [Disc 1]", | |
"file://localhost/X:\\music\\CLOVER - Okami Original Soundtrack [Disc 2]": "file://localhost/X:\\music\\Okami OST [Disc 2]", | |
"file://localhost/X:\\music\\CLOVER STUDIO - Okami Original Soundtrack Disc 3": "file://localhost/X:\\music\\Okami OST [Disc 3]", | |
"file://localhost/X:\\music\\CLOVER STUDIO - Okami Original Soundtrack [Disc 4]": "file://localhost/X:\\music\\Okami OST [Disc 4]", | |
"file://localhost/X:\\music\\Masami Ueda [上田雅美] - Okami Original Soundtrack [大神]": "file://localhost/X:\\music\\Okami OST [Disc 5]" | |
} | |
} | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require "json" | |
require "nokogiri" | |
require "uri" | |
def parse_library(filename) | |
library = File.open(filename, "r") { |f| Nokogiri::XML(f) } | |
puts "Parsed" | |
library | |
end | |
IMPORTANT_KEYS = ["Name","Artist","Album","Size"] | |
PLAY_COUNT = "Play Count" | |
RATING = "Rating" | |
LOCATION = "Location" | |
TITLE = "Name" | |
def key_node(dict_node, key) | |
dict_node.at_xpath("key[.='#{key}']") | |
end | |
def dict_get(dict_node, key) | |
node = key_node(dict_node, key) | |
if node.nil? | |
nil | |
else | |
node.next_sibling.content | |
end | |
end | |
def dict_set(dict_node, key, value) | |
node = key_node(dict_node, key) | |
if node | |
node.next_sibling.content = value | |
else | |
# Doesn't exist, we have to add it ourselves | |
type = if value.is_a? Numeric | |
"integer" | |
else | |
"string" | |
end | |
value_node = Nokogiri::XML::Node.new(type, dict_node.document) | |
value_node.content = value | |
dict_node.children[-1].add_next_sibling("<key>#{key}</key>") | |
dict_node.children[-1].add_next_sibling(value_node) | |
end | |
end | |
# Pointless now | |
def verified_duplicate?(song1, song2) | |
IMPORTANT_KEYS.all? { |key| | |
dict_get(song1, key) == dict_get(song2, key) | |
} | |
end | |
def song_hash(song) | |
hash = 17; | |
IMPORTANT_KEYS.each { |key| | |
hash = hash * 23 + dict_get(song, key).hash | |
} | |
hash | |
end | |
def remove_duplicates(library) | |
puts "Songs to check: #{library.css("plist > dict > dict > dict").length}" | |
duplicate_count = 0 | |
track_map = Hash.new | |
library.css("plist > dict > dict > dict").each { |song| | |
key = song_hash(song) | |
if track_map.has_key? key | |
# Do stuff for duplicate track | |
first_song = track_map[key] | |
# Add play counts | |
first_pc = dict_get(first_song, PLAY_COUNT).to_i | |
second_pc = dict_get(song, PLAY_COUNT).to_i | |
dict_set(first_song, PLAY_COUNT, second_pc + first_pc) | |
# Take rating from either if it exists | |
first_rating = dict_get(first_song, RATING) | |
second_rating = dict_get(song, RATING) | |
if first_rating && !second_rating | |
rating = first_rating | |
elsif !first_rating && second_rating | |
rating = second_rating | |
elsif first_rating && second_rating | |
# If ratings disagree then output error + song title and skip it | |
if first_rating != second_rating | |
puts "Ratings disagree for #{dict_get(song, TITLE)}: #{first_rating} vs #{second_rating}" | |
next | |
else | |
rating = first_rating | |
end | |
else | |
rating = nil | |
end | |
# Set the rating | |
if rating | |
dict_set(first_song, RATING, rating) | |
end | |
# Change location to be X:\ | |
location = dict_get(first_song, LOCATION) | |
location.sub!("E:", "X:") | |
dict_set(first_song, LOCATION, location) | |
# Delete this duplicate | |
# Remove the key | |
song.previous_element.remove | |
# Delete the dict entry | |
song.remove | |
duplicate_count += 1 | |
else | |
track_map[key] = song | |
end | |
} | |
# Reorder all of the keys so there are no gaps | |
library.css("plist > dict > dict > key").each_with_index { |node, i| | |
node.content = i unless node.blank? | |
} | |
puts | |
puts "Songs remaining: #{library.css("plist > dict > dict > dict").length}" | |
puts "Duplicates removed: #{duplicate_count}" | |
File.open(ARGV[1], "w") { |outfile| | |
library.write_to(outfile, :encoding => "UTF-8") | |
} | |
end | |
# https://stackoverflow.com/questions/14127343/why-dir-glob-in-ruby-doesnt-see-files-in-folders-named-with-square-brackets | |
def escape_glob(s) | |
s.gsub(/[\\\{\}\[\]\*\?]/) { |x| "\\"+x } | |
end | |
def rename_folder(library, mapping) | |
puts "Renaming folders: #{mapping.inspect}" | |
puts "Songs to check: #{library.css("plist > dict > dict > dict").length}" | |
from_songs = {} | |
mapping.keys.each { |k| from_songs[k] = [] } | |
# Put all songs in the library into from_songs | |
library.css("plist > dict > dict > dict").each do |song| | |
path = URI.unescape(dict_get(song, LOCATION)) | |
dir = File.dirname(path) | |
from_songs[dir] << song if from_songs.has_key? dir | |
end | |
# Sort the songs by name (by track number) | |
from_songs.each_value do |v| | |
v.sort_by! { |song| File.basename(URI.unescape(dict_get(song, LOCATION))) } | |
end | |
puts "\nFiles in Winamp library directories:" | |
from_songs.each_pair { |k, v| puts "#{k} => #{v.length}"} | |
to_songs = {} | |
mapping.map do |from_dir, dir| | |
fixdir = escape_glob(dir.sub("file://localhost/", "").gsub("\\", "/")) | |
to_songs[from_dir] = Dir["#{fixdir}/*"] | |
end | |
puts "\nFiles in target directories:" | |
to_songs.each_pair { |k, v| puts "#{k} => #{v.length}"} | |
from_songs.each_key do |key_dir| | |
next if from_songs[key_dir].length == to_songs[key_dir].length | |
fail "Song counts didn't match for #{key_dir}, aborting." | |
end | |
puts "\n Updating locations:" | |
from_songs.each_key do |key_dir| | |
from_songs[key_dir].zip(to_songs[key_dir]).each do |song, target_location| | |
final_location = URI.escape("file://localhost/#{target_location}") | |
puts "#{dict_get(song, TITLE)} => #{final_location}" | |
dict_set(song, LOCATION, final_location) | |
end | |
end | |
File.open(ARGV[1], "w") { |outfile| | |
library.write_to(outfile, :encoding => "UTF-8") | |
} | |
end | |
def main(args) | |
# remove_duplicates | |
puts args.inspect | |
library = parse_library(args[0]) | |
mappings = JSON.parse(File.read("mappings.json", external_encoding: "UTF-8"))["mappings"] | |
rename_folder(library, mappings) | |
end | |
main(ARGV) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment