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: