musicfix

music file renamer and tagger
git clone git@git.2f30.org/musicfix.git
Log | Files | Refs | README | LICENSE

musicfix (15421B)


      1 #!/usr/bin/env ruby
      2 # encoding: utf-8
      3 
      4 require 'rubygems'
      5 require 'fileutils'
      6 require 'open-uri'
      7 require 'stringex'
      8 require 'taglib'
      9 require 'yaml'
     10 
     11 # Headers
     12 Ver = '0.2.0'
     13 Homepage = 'http://git.2f30.org/musicfix/'
     14 Headers = {'User-Agent' => "musicfix/#{Ver} +#{Homepage}"}
     15 
     16 # Concatenate artist list using '&'
     17 # Convert "Sound, The (2)" to "The Sound"
     18 # Convert "Unknown (21), The" to "The Unknown"
     19 def mkartist al
     20     nl = al.collect {|a| a['name']}
     21     nl.each {|n| n.gsub! /\s\(\d+\)$/, ''}
     22     nl.each {|n| n.gsub! /(.*), The$/, 'The \1'}
     23     nl.each {|n| n.gsub! /\s\(\d+\)$/, ''}
     24     nl.join ' & '
     25 end
     26 
     27 # Convert "3" to ("", "03")
     28 # Convert "A" to ("A", "1")
     29 # Convert "A3" to ("A", "3")
     30 # Convert "2.3" to ("2", "03")
     31 # Convert "2.03" to ("2", "03")
     32 # Convert "CD2-3" to ("2", "03")
     33 def mkdiscnum pos
     34     np = pos.gsub /[-.]/, '#'
     35     d, n = np.split '#'
     36     if not n
     37         n = d
     38         d = ''
     39     else
     40         d = d.gsub /\D/, ''
     41     end
     42     parts = n.match /([A-Z])([0-9]*)/
     43     if parts then
     44         d = parts[1]
     45         n = parts[2] != "" && parts[2] || "1"
     46     else
     47         n = n.rjust(2, '0')
     48     end
     49     return d, n
     50 end
     51 
     52 # Add wordings for symbols
     53 syms = {"≠" => "not equals",
     54         "Χ" => "x",
     55         "★" => "blackstar",
     56         "•" => ""}
     57 Stringex::Localization.store_translations :en, :transliterations, syms
     58 
     59 # Convert to lowercase ASCII without punctuation
     60 # Convert "Jean-Michel Jarre" to "jean_michel_jarre"
     61 # Convert "Aaah...!" to "aaah"
     62 # Convert "Ruh / Spirit" to "ruh_spirit"
     63 def mkname n
     64     # These may be in track titles like "(7'' Version)"
     65     n = n.gsub '12"', '12 inch'
     66     n = n.gsub "12''", "12 inch"
     67     n = n.gsub "12'", "12 inch"
     68     n = n.gsub '10"', '10 inch'
     69     n = n.gsub "10''", "10 inch"
     70     n = n.gsub "10'", "10 inch"
     71     n = n.gsub '7"', '7 inch'
     72     n = n.gsub "7''", "7 inch"
     73     n = n.gsub "7'", "7 inch"
     74     n = n.gsub " & ", " and "
     75     n = n.gsub '.', ' '
     76     n = n.gsub '/', ' '
     77     # Transliterate
     78     n.to_url.gsub '-', '_'
     79 end
     80 
     81 # Get cover artwork
     82 def getimages rel
     83     return nil unless rel['images']
     84     imgs = []
     85     rel['images'].each do |img|
     86         imgs << img['uri']
     87     end
     88     imgs
     89 end
     90 
     91 # Formats we care about and their abbreviations
     92 # http://www.discogs.com/search/#more_facets_format_exact
     93 # Ignore "Vinyl" in favor of "LP"/"EP"/'7"'/'10"'/'12"' descriptions
     94 # Ignore "MP3"/"WAV"/"FLAC" etc descriptions in favor of "File"
     95 # Avoid too generic descriptions such as "Album"
     96 # Avoid too specific descriptions such as "Green", "Gatefold"
     97 @ft = {
     98     'CD' => 'CD',
     99     'CDr' => 'CDr',
    100     'LP' => 'LP',
    101     'EP' => 'EP',
    102     'Cassette' => 'Cass',
    103     '12"' => '12inch',
    104     '10"' => '10inch',
    105     '7"' => '7inch',
    106     'Mini-Album' => 'Mini',
    107     'Maxi-Single' => 'Maxi',
    108     'Picture Disc' => 'Pic',
    109     'Flexi-disc' => 'Flexi',
    110     'Promo' => 'Promo',
    111     'Reissue' => 'RE',
    112     'Remastered' => 'RM',
    113     'Remaster' => 'RM',
    114     'Repress' => 'RP',
    115     'Mispress' => 'MP',
    116     'Test Pressing' => 'TP',
    117     'Enhanced' => 'Enh',
    118     'Digipak ' => 'Dig',
    119     'Box Set' => 'Box',
    120     'Limited Edition' => 'Ltd',
    121     'Club Edition' => 'Club',
    122     'Compilation' => 'Comp',
    123     'Sampler' => 'Smplr',
    124     'Numbered' => 'Num',
    125     'Unofficial Release' => 'Unofficial',
    126     'Single Sided' => 'S/Sided',
    127     'File' => 'File',
    128 }
    129 
    130 # Make a sane format string also using format description
    131 def mkformat format
    132     f = []
    133     formats = []
    134     if format['name'] then
    135         formats << format['name']
    136     end
    137     if format['descriptions'] then
    138         formats += format['descriptions']
    139     end
    140     formats.each do |d|
    141         f << d if @ft.keys.include? d
    142     end
    143     f.join ' '
    144 end
    145 
    146 # Shorten certain common words that appear in format strings
    147 def mkshort n
    148     # Note that prefix substitution is broken
    149     ftre = /(#{@ft.keys.join('|')})/
    150     n.gsub(ftre, @ft)
    151 end
    152 
    153 # Return single item if array is full of duplicates
    154 def flatten_if_one ary
    155     if ary.uniq.length == 1 then
    156         ary.first
    157     else
    158         ary.uniq
    159     end
    160 end
    161 
    162 # Remove all tags and images for supported formats
    163 def rmtags fname
    164     TagLib::MPEG::File.open(fname) do |file|
    165         tag = file.id3v2_tag
    166         tag and tag.frame_list.each do |frame|
    167             tag.remove_frame frame
    168         end
    169         tag = file.id3v1_tag
    170         if tag then
    171             tag.artist = nil
    172             tag.album = nil
    173             tag.title = nil
    174             tag.track = 0
    175             tag.year = 0
    176             tag.genre = nil
    177             tag.comment = nil
    178         end
    179         file.save
    180     end
    181     TagLib::MP4::File.open(fname) do |file|
    182         tag = file.tag
    183         tag and tag.item_list_map.clear
    184         file.save
    185     end
    186     TagLib::Ogg::Vorbis::File.open(fname) do |file|
    187         tag = file.tag
    188         tag and tag.field_list_map.each do |field|
    189             tag.remove_field field[0]
    190         end
    191         file.save
    192     end
    193     TagLib::FLAC::File.open(fname) do |file|
    194         tag = file.xiph_comment
    195         tag and tag.field_list_map.each do |field|
    196             tag.remove_field field[0]
    197         end
    198         tag = file.id3v2_tag
    199         tag and tag.frame_list.each do |frame|
    200             tag.remove_frame frame
    201         end
    202         tag = file.id3v1_tag
    203         if tag then
    204             tag.artist = nil
    205             tag.album = nil
    206             tag.title = nil
    207             tag.track = 0
    208             tag.year = 0
    209             tag.genre = nil
    210             tag.comment = nil
    211         end
    212         file.remove_pictures
    213         file.save
    214     end
    215     TagLib::RIFF::AIFF::File.open(fname) do |file|
    216         tag = file.tag
    217         tag and tag.frame_list.each do |frame|
    218             tag.remove_frame frame
    219         end
    220         file.save
    221     end
    222     TagLib::RIFF::WAV::File.open(fname) do |file|
    223         tag = file.tag
    224         tag and tag.frame_list.each do |frame|
    225             tag.remove_frame frame
    226         end
    227         file.save
    228     end
    229 end
    230 
    231 # Parse command line
    232 usage = ''
    233 usage << "Usage: musicfix [fake] relid\n"
    234 usage << "       musicfix [fake] dump relid [relfile]\n"
    235 usage << "       musicfix [fake] load [relfile]\n"
    236 usage << "       musicfix [fake] tags [relfile]\n"
    237 fake = ARGV[0] == 'fake'
    238 ARGV.delete 'fake'
    239 cmd = ARGV[0] || (puts usage; exit)
    240 case cmd
    241 when 'load' then
    242     relfile = ARGV[1] || nil
    243 when 'dump' then
    244     relid = ARGV[1] || (puts usage; exit)
    245     relfile = ARGV[2] || 'release.yaml'
    246 when 'tags' then
    247     relfile = ARGV[1] || 'release.yaml'
    248 else
    249     relid = ARGV[0]
    250 end
    251 
    252 # Default configuration
    253 cfg = {}
    254 cfg['mdir'] = '~/music'
    255 cfg['track'] = '"#{mdir}/#{fba}-#{my}-#{fb}-#{fv}/#{fd}#{n}-#{fa}-#{ft}.#{x}"'
    256 cfg['image'] = '"#{mdir}/#{fba}-#{my}-#{fb}-#{fv}/#{zz}-#{fba}-#{fb}_cover#{i}.jpg"'
    257 cfg['rdata'] = '"#{mdir}/#{fba}-#{my}-#{fb}-#{fv}/#{zz}-#{fba}-#{fb}_release.yaml"'
    258 #cfg['after'] = '"mpc update #{fba}-#{my}-#{fb}-#{fv}"'
    259 cfg['nimg'] = 1
    260 
    261 # User configuration overrides
    262 cfgpath = File.expand_path('~/.musicfixrc')
    263 if File.exists? cfgpath
    264     new = YAML.load File.open(cfgpath, 'r')
    265     cfg.merge! new
    266 end
    267 
    268 # Authentication option
    269 urlopts = ''
    270 if cfg['token'] then
    271     urlopts = "?token=#{cfg['token']}"
    272 end
    273 
    274 # Expand music directory
    275 cfg['mdir'] = File.expand_path cfg['mdir']
    276 
    277 # Print configuration
    278 puts '# Configuration'
    279 puts cfg.to_yaml
    280 
    281 # Early file checks
    282 if cmd == 'dump' or cmd == 'tags' then
    283     if File.exists? relfile then
    284         STDERR.puts "Release file #{relfile} exists!"
    285         exit
    286     end
    287 end
    288 
    289 unless cmd == 'dump' then
    290     # Supported formats
    291     fmtre = /mp3|ogg|m4a|mpc|flac|wv|wav|aiff/i
    292     # Construct file list
    293     fl = Dir['*'].select {|f| File.extname(f).match fmtre}.sort
    294     if fl.empty? then
    295         STDERR.puts 'No music files found!'
    296         exit 1
    297     end
    298     # Output file list
    299     puts '# Files to process'
    300     puts fl.to_yaml
    301 end
    302 
    303 # Initialize release info
    304 if cmd == 'load' then
    305     # Load release data from file
    306     if relfile then
    307         # The user specified some file
    308         unless File.exists? relfile then
    309             STDERR.puts "Release file #{relfile} not found!"
    310             exit 1
    311         end
    312     else
    313         # Look for 'release.yaml' first
    314         if File.exists? 'release.yaml' then
    315             relfile = 'release.yaml'
    316         else
    317             # Look for any '.yaml' file
    318             relfl = Dir['*'].select {|f| File.extname(f).match /yaml/i}
    319             relfile = relfl.sort.first
    320         end
    321         unless relfile then
    322             STDERR.puts 'No release file found!'
    323             exit 1
    324         end
    325     end
    326     STDERR.puts "Loading release data from file..."
    327     rel = YAML.load File.open(relfile, 'r')
    328 elsif cmd == 'tags' then
    329     # Generate release file from audio file tags
    330     STDERR.puts "Generating release data from tags..."
    331     rel = {}
    332     rel['artist'] = []
    333     rel['album'] = []
    334     rel['year'] = []
    335     rel['masteryear'] = nil
    336     rel['genre'] = []
    337     rel['format'] = nil
    338     rel['comment'] = []
    339     rel['images'] = nil
    340     rel['tracklist'] = []
    341     # Populate tracklist
    342     fl.each do |fname|
    343         TagLib::FileRef.open(fname) do |f|
    344             trk = {}
    345             trk['pos'] = f.tag.track
    346             trk['artist'] = f.tag.artist
    347             trk['title'] = f.tag.title
    348             rel['tracklist'] << trk
    349             # Make lists and flatten afterwards
    350             rel['artist'] << f.tag.artist
    351             rel['album'] << f.tag.album
    352             rel['year'] << f.tag.year
    353             rel['genre'] << f.tag.genre
    354             rel['comment'] << f.tag.comment
    355         end
    356     end
    357     if rel['artist'].uniq.length == 1 then
    358         # Single-artist release
    359         rel['artist'] = rel['artist'].first
    360         rel['tracklist'].each do |trk|
    361             trk.delete 'artist'
    362         end
    363     else
    364         rel['artist'] = 'Various'
    365     end
    366     # These should be the same on all files
    367     rel['album'] = flatten_if_one rel['album']
    368     rel['year'] = flatten_if_one rel['year']
    369     rel['genre'] = flatten_if_one rel['genre']
    370     rel['comment'] = flatten_if_one rel['comment']
    371     # Assumptions
    372     rel['masteryear'] = rel['year']
    373     rel['format'] = 'CD'
    374 else
    375     # Get release data from Discogs
    376     STDERR.puts "Getting release data from Discogs..."
    377     r = YAML.load(open("https://api.discogs.com/releases/#{relid}#{urlopts}",
    378         Headers))
    379     mr = if r['master_id'] then
    380         YAML.load(open("https://api.discogs.com/masters/#{r['master_id']}#{urlopts}",
    381             Headers))
    382     end
    383     # Tracklist can contain dummy header tracks, strip them
    384     tl = r['tracklist'].select {|t| t['position'] != ''}
    385     # Gather release-wide data
    386     rel = {}
    387     rel['artist'] = mkartist r['artists']
    388     rel['album'] = r['title']
    389     rel['year'] = r['released']
    390     rel['masteryear'] = if mr then mr['year'] end || r['released']
    391     # Year can be full-date so keep only the year part
    392     rel['year'] = rel['year'].to_s.slice(0..3).to_i
    393     rel['masteryear'] = rel['masteryear'].to_s.slice(0..3).to_i
    394     rel['genre'] = if r['styles'] then r['styles'].first end ||
    395                    if r['genres'] then r['genres'].first end
    396     rel['format'] = mkformat r['formats'].first
    397     rel['comment'] = "Discogs: #{r['id']}"
    398     imgs = getimages(r)
    399     rel['images'] = if imgs then imgs.first(cfg['nimg']) end
    400     rel['tracklist'] = []
    401     # Populate tracklist
    402     tl.each do |s|
    403         trk = {}
    404         trk['pos'] = s['position']
    405         trk['artist'] = mkartist s['artists'] if s['artists']
    406         trk['title'] = s['title']
    407         rel['tracklist'] << trk
    408     end
    409 end
    410 
    411 # Output release info
    412 puts '# Release data'
    413 puts rel.to_yaml
    414 if cmd == 'dump' or cmd == 'tags' then
    415     STDERR.puts "Save rdata to #{relfile}"
    416     unless fake
    417         File.open(relfile, 'w') do |f|
    418             f.puts rel.to_yaml
    419         end
    420     end
    421     exit
    422 end
    423 
    424 # Variables for use in templates
    425 mdir = cfg['mdir']
    426 ba = rel['artist']
    427 b = rel['album']
    428 y = rel['year']
    429 my = rel['masteryear']
    430 g = rel['genre']
    431 v = rel['format']
    432 c = rel['comment']
    433 fba = mkname ba
    434 fb = mkname b
    435 fv = mkname (mkshort v)
    436 # Internal use only
    437 tl = rel['tracklist']
    438 
    439 # Sanity checks
    440 if tl.length != fl.length then
    441     puts "Found #{tl.length} tracks for #{fl.length} music files."
    442     if fl.length < tl.length then
    443         # Limit entries to number of files
    444         tl = tl.first fl.length
    445         print "Use only the first #{fl.length} entries? [y/N] "
    446     else
    447         # Limit files to available tracks
    448         fl = fl.first tl.length
    449         print "Use only the first #{fl.length} files? [y/N] "
    450     end
    451     res = STDIN.readline.strip
    452     exit unless res == 'y'
    453 end
    454 
    455 # First pass decides zero padding and file numbering
    456 zpad_disc = 0
    457 zpad_num = 0
    458 tl.each do |trk|
    459     disc, num = mkdiscnum trk['pos'].to_s
    460     if zpad_disc < disc.length then zpad_disc = disc.length end
    461     if zpad_num < num.length then zpad_num = num.length end
    462     trk['disc'] = disc
    463     trk['num'] = num
    464 end
    465 tl.each do |trk|
    466     trk['disc'] = trk['disc'].rjust(zpad_disc, '0')
    467     trk['num'] = trk['num'].rjust(zpad_num, '0')
    468 end
    469 
    470 # Loop over the music files and
    471 #   1. Copy them over with proper names
    472 #   2. Clear all tags and stored images
    473 #   3. Fix the tags on the new files
    474 tn = 0
    475 fl.each do |ofname|
    476     tn = tn.next
    477     trk = tl[tn - 1]
    478     # Use track artist for compilations, fallback to release
    479     a = trk['artist'] || rel['artist']
    480     t = trk['title']
    481     fa = mkname a
    482     ft = mkname t
    483     d = trk['disc']
    484     n = trk['num']
    485     fd = mkname d
    486     x = File.extname(ofname).delete('.').downcase
    487     nfname = eval cfg['track']
    488     # Add filename to track descriptor
    489     trk['file'] = nfname
    490     STDERR.puts "Copy track to #{nfname}"
    491     unless fake
    492         # Copy
    493         FileUtils.makedirs(File.dirname nfname)
    494         FileUtils.copy(ofname, nfname)
    495         # Clear
    496         rmtags nfname
    497         # Fix
    498         TagLib::FileRef.open(nfname) do |f|
    499             f.tag.artist = a
    500             f.tag.album = b
    501             f.tag.title = t
    502             f.tag.track = tn
    503             f.tag.year = y
    504             f.tag.genre = g
    505             f.tag.comment = c
    506             f.save
    507         end
    508     end
    509 end
    510 
    511 # Also save the first image of the artwork
    512 zz = '0' * (tl.first['disc'] + tl.first['num']).length
    513 if rel['images'] then
    514     relimgs = []
    515     rel['images'].each_with_index do |imgurl, idx|
    516         pad = rel['images'].length.to_s.length
    517         i = idx.to_s.rjust(pad, '0')
    518         if rel['images'].length == 1
    519             i = ''
    520         end
    521         # The variable i can be used in the image template
    522         imgname = eval cfg['image']
    523         STDERR.puts "Save image to #{imgname}"
    524         unless fake
    525             # Relative path or URL
    526             if File.exists? imgurl
    527                 img = open(imgurl).read
    528             else
    529                 img = open(imgurl, Headers).read
    530             end
    531             File.open(imgname, 'wb').write img
    532             # Update to local relative path now
    533             relimgs << (File.basename imgname)
    534         end
    535     end
    536     rel['images'] = relimgs
    537 end
    538 # Also save the release file for future use
    539 relfile = eval cfg['rdata']
    540 STDERR.puts "Save rdata to #{relfile}"
    541 unless fake
    542     # Sort tracklist in filename order
    543     rel['tracklist'].sort_by! {|s| s['file']}
    544     # Delete temporary data
    545     rel['tracklist'].each do |s|
    546         s.delete 'file'
    547         s.delete 'disc'
    548         s.delete 'num'
    549     end
    550     File.open(relfile, 'w') do |f|
    551         f.puts rel.to_yaml
    552     end
    553 end
    554 
    555 # Execute command if provided
    556 if cfg['after'] then
    557     run = eval cfg['after']
    558     STDERR.puts "Executing #{run}"
    559     unless fake
    560         puts `#{run}`
    561     end
    562 end
    563 
    564 # vim:set ts=4 sw=4 et: