Skip to content

Instantly share code, notes, and snippets.

@irisfofs
Last active February 6, 2016 19:04
Show Gist options
  • Save irisfofs/713bf7d119666f716973 to your computer and use it in GitHub Desktop.
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).
{
"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]"
}
}
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