Advertisement
Guest User

Untitled

a guest
May 31st, 2016
216
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 13.60 KB | None | 0 0
  1. #!/usr/bin/python
  2.  
  3. """
  4. This script synchronizes a music collection recursivly one way from a losless format source directory to a lossy file format target directory.
  5. for Details see README.md
  6.  
  7. This software is licensed under the GNU GPLv3 without any warranty as is with ABSOLUTELY NO WARRANTY.
  8. """
  9.  
  10. import os
  11. import logging
  12. from optparse import OptionParser
  13. import multiprocessing
  14. import time
  15. import click
  16.  
  17.  
  18. parser = OptionParser(usage="usage: %prog [options] source_dir target_dir")
  19.  
  20. parser.add_option("-l", "--loglevel", dest="loglevel", default="INFO", help="Set's the log level (ie. the amount of output) possible values: DEBUG, INFO, WARNING, ERROR, CRITICAL")
  21. parser.add_option("-d", "--delete", action="store_true", dest="delete", default=False, help="Remove files in the target directory that don't have a corresponding file in the source directory.")
  22. parser.add_option("-f", "--format", dest="format", default="mp3", type="choice", choices=["ogg", "mp3"], help="Set the target format to mp3 or ogg (default is mp3)")
  23. parser.add_option("-s", "--followlinks", action="store_true", dest="followlinks", default=False, help="By default the script will not walk down into symbolic links that resolve to directories. Set followlinks to visit directories pointed to by symlinks, on systems that support them.")
  24. parser.add_option("-w", "--target_win", action="store_true", dest="win", help="Convert the filenames to Windows convention (for example if you copy to a FAT Partition)")
  25. parser.add_option("-r", "--resample", action="store_true", dest="resample", default=False, help="Resample to 44.1KHz when converting")
  26. parser.add_option("-m", "--multiprocess", action="store_true", dest="multiprocess", default=False, help="Use multiprocessing to convert the files (default is false)")
  27. parser.add_option("-p", "--nproc", dest="nproc", default=multiprocessing.cpu_count(), type="int", help="Set the number of processes used to convert the files, ignored if multiprocess option is not active (default is the number of detected CPUs)")
  28.  
  29. (options, args) = parser.parse_args()
  30.  
  31. if len(args) <> 2:
  32. parser.error("incorrect number of arguments")
  33. try:
  34. level = int(getattr(logging, options.loglevel))
  35. except (AttributeError, ValueError, TypeError):
  36. parser.error("incorrect loglevel value '%s'" % options.loglevel)
  37.  
  38. for path in args:
  39. if not os.path.exists(path):
  40. parser.error("path does not exist: %s" % path)
  41.  
  42. if args[0] == args[1]:
  43. parser.error("source and target path must not be the same")
  44.  
  45. if options.win:
  46. illegal_characters = '?[],\=+<>:;"*|^'
  47. else:
  48. illegal_characters = ''
  49.  
  50. target_format = ""
  51.  
  52. if options.format in ["mp3", "ogg"]:
  53. target_format = options.format
  54. else:
  55. parser.error("target format %s not supported" % options.format)
  56.  
  57. if(options.multiprocess and options.nproc < 1):
  58. parser.error("the number of processes must be superior to 0")
  59.  
  60. logging.basicConfig(level=level)
  61.  
  62. source_dir = args[0]
  63. target_dir = args[1]
  64.  
  65. flac_tags_synonyms = {
  66. "title": ["title"],
  67. "tracknumber": ["tracknumber"],
  68. "genre": ["genre"],
  69. "date": ["date"],
  70. "artist": ["artist"],
  71. "album": ["album"],
  72. "albumartist": ["albumartist"],
  73. "discnumber": ["discnumber"],
  74. "totaldiscs": ["totaldiscs", "disctotal"],
  75. "totaltracks": ["totaltracks", "tracktotal"],
  76. "composer": ["composer"]}
  77.  
  78. def sha1OfFile(filepath):
  79. import hashlib
  80. with open(filepath, 'rb') as f:
  81. return hashlib.sha1(f.read()).hexdigest()
  82.  
  83. def create_ID3V2_tag_values_from_flac(source):
  84. id3_tags_dict = dict.fromkeys(flac_tags_synonyms, "")
  85. id3_tags_dict['tracknumber'] = "0"
  86. id3_tags = os.popen("metaflac --export-tags-to=- %s" % shellquote(source)).read().split("\n")
  87. logging.debug("id3: %s" % id3_tags)
  88. for id3 in id3_tags:
  89. if id3:
  90. try:
  91. tag, value = id3.split("=", 1)
  92. except ValueError:
  93. logging.warning("id3 tag: '%s' ignored." % id3)
  94. else:
  95. try:
  96. reference_tag = [reference_key for (reference_key, reference_values)
  97. in flac_tags_synonyms.items() if tag.lower() in reference_values][0]
  98. if (reference_tag in ["composer", "artist"] and id3_tags_dict[reference_tag] != ""):
  99. #tag value is a list, mp3 id3 v2 separator is a /
  100. id3_tags_dict[reference_tag] = id3_tags_dict[reference_tag] + "/" + value
  101. else:
  102. id3_tags_dict[reference_tag] = value
  103. except IndexError:
  104. logging.info("unsupported id3 tag '%s' ignored" % id3)
  105. for key in id3_tags_dict.keys():
  106. id3_tags_dict[key] = shellquote(id3_tags_dict[key])
  107. return id3_tags_dict
  108.  
  109. def flac_to_mp3(source, target):
  110. cmd_dict = create_ID3V2_tag_values_from_flac(source)
  111. cmd_dict['flac_to_mp3_source_flac'] = shellquote(source)
  112. cmd_dict['flac_to_mp3_target_mp3'] = shellquote(target)
  113. cmd_dict['flac_to_mp3_enc_opts'] = "-b 256"
  114. cmd_dict['sha1_of_source'] = sha1OfFile(source)
  115. if options.resample:
  116. cmd_dict['flac_to_mp3_enc_opts'] += " --resample 44.1"
  117.  
  118. cmdstr = "flac -cd %(flac_to_mp3_source_flac)s | lame %(flac_to_mp3_enc_opts)s -h --add-id3v2 "\
  119. "--tt %(title)s " \
  120. "--tn %(tracknumber)s/%(totaltracks)s " \
  121. "--tg %(genre)s "\
  122. "--ty %(date)s "\
  123. "--tc %(sha1_of_source)s " \
  124. "--ta %(artist)s " \
  125. "--tl %(album)s " \
  126. "--tv TPE2=%(albumartist)s " \
  127. "--tv TPOS=%(discnumber)s/%(totaldiscs)s " \
  128. "--tv TCOM=%(composer)s " \
  129. "- %(flac_to_mp3_target_mp3)s" % cmd_dict
  130. logging.debug(cmdstr)
  131. os.system(cmdstr)
  132.  
  133.  
  134. def get_extension(filename):
  135. """Returns the file extension of given string"""
  136. return os.path.splitext(filename)[1].lower()
  137.  
  138. def is_newer(source, target):
  139. source_mtime = os.path.getmtime(source)
  140. target_mtime = os.path.getmtime(target)
  141. return (source_mtime >= target_mtime)
  142.  
  143. def is_valid_shasum(source, target):
  144. logging.debug("checking checksum of %s" % source)
  145. shasum = sha1OfFile(source)
  146. t = eyeD3.Tag()
  147. t.link(target)
  148. try:
  149. comment = t.getComment()
  150. except:
  151. logging.error("Could not read comment tag of %s", target)
  152. return False
  153.  
  154. return (shasum == comment)
  155.  
  156.  
  157. def x_to_ogg(source, target):
  158. oggencopts = "-q10" # 256 kbit/s
  159. if options.resample:
  160. oggencopts += " --resample 44100"
  161. # This automatically copies tags
  162. cmdstr = "oggenc %s -Q -o %s %s" % (oggencopts, shellquote(target), shellquote(source))
  163. logging.debug(cmdstr)
  164.  
  165. os.system(cmdstr)
  166.  
  167. def cp(source, target):
  168. os.system("cp %s %s" % (shellquote(source), shellquote(target)))
  169.  
  170. def wav_to_mp3(source, target):
  171. # TODO does it copy tags too?
  172. lame_opts = ""
  173. if options.resample:
  174. lame_opts += " --resample 44.1"
  175. os.system("lame %s -h %s %s" % (lame_opts, shellquote(source), shellquote(target)))
  176.  
  177.  
  178. convert_map = {".ogg": [".ogg", cp],
  179. ".mp3": [".mp3", cp],
  180. ".keep":[".keep", cp],
  181. ".skip":[".skip", cp],
  182. ".jpg": [".jpg", cp],
  183. ".jpeg":[".jpg", cp]}
  184.  
  185. # conditionally add conversion to the target format to the convert_map
  186. if target_format == "ogg":
  187. convert_map.update({".flac":[".ogg", x_to_ogg],
  188. ".wav": [".ogg", x_to_ogg]})
  189. elif target_format == 'mp3':
  190. convert_map.update({".flac":[".mp3", flac_to_mp3],
  191. ".wav": [".mp3", wav_to_mp3]})
  192.  
  193.  
  194. def shellquote(s):
  195. return "'" + s.replace("'", "'\\''") + "'"
  196.  
  197.  
  198. def clean(target_fname):
  199. source_fname = target_fname.replace(target_dir, source_dir)
  200.  
  201. if os.path.isdir(target_fname):
  202. if not os.path.isdir(source_fname):
  203. logging.info("removing target directory %s" % target_fname)
  204. os.system("rmdir %s" % shellquote(target_fname))
  205. return
  206.  
  207. extless_fname = os.path.splitext(source_fname)[0]
  208. for ext in convert_map.iterkeys():
  209.  
  210. test_fname = "%s%s" % (extless_fname, ext)
  211.  
  212. if os.path.isfile(test_fname):
  213. logging.debug("clean: source %s exists" % test_fname)
  214. return
  215.  
  216. # source file not found, get rid of target file
  217. logging.info("removing target file %s" % target_fname)
  218. os.unlink(target_fname)
  219.  
  220.  
  221.  
  222. def convert(source_fname):
  223. # target filename = source filename in target directory
  224. target_fname = source_fname.replace(source_dir, target_dir)
  225.  
  226. # determine type of conversion
  227. cmd = None
  228.  
  229. if os.path.isdir(source_fname):
  230. if not os.path.isdir(target_fname):
  231. # create target directory:w
  232. logging.debug("creating directory %s" % target_fname)
  233. os.system("mkdir %s" % shellquote(target_fname))
  234. return
  235.  
  236. elif os.path.isfile(source_fname) or os.path.islink(source_fname):
  237. try:
  238. # determine extension
  239. ext = get_extension(source_fname)
  240. conv = convert_map[ext]
  241. except KeyError:
  242. logging.warning("File extension '%s' not supported." % (ext))
  243. else:
  244. # replace the extension of the target filename with the one found in the convert_map
  245. target_fname = os.path.splitext(target_fname)[0] + "%s" % conv[0]
  246. # and use the corresponding conversion command from the convert_map
  247. cmd = conv[1]
  248. else:
  249. logging.error("File type not supported.")
  250.  
  251. # replace 'illegal characters' in the target filename with dashes
  252. for c in illegal_characters:
  253. target_fname = target_fname.replace(c, "-")
  254.  
  255. if cmd:
  256. logging.debug("cmd: %s, source: %s, target: %s" % (cmd, source_fname, target_fname))
  257.  
  258. if os.path.exists(target_fname):
  259.  
  260. if is_newer(source_fname, target_fname):
  261. # source is newer, create new target file
  262. logging.debug("Target '%s' exists, but Source is newer" % target_fname)
  263. cmd(source_fname, target_fname)
  264. else:
  265. # nothing to do
  266. logging.debug("Target '%s' already exists" % target_fname)
  267. else:
  268. # create target
  269. logging.debug("Target %s not found, converting source file" % target_fname)
  270. cmd(source_fname, target_fname)
  271. else:
  272. logging.debug("Source '%s' ignored." % source_fname)
  273.  
  274.  
  275. def do_single_process(file_list):
  276. with click.progressbar(file_list) as bar:
  277. for file in bar:
  278. convert(file)
  279.  
  280. def do_multi_process(file_list):
  281. pool = None
  282. try:
  283. logging.debug("Creating the process pool")
  284. pool = multiprocessing.Pool(number_of_processes())
  285. results = pool.map_async(convert, file_list)
  286. #Specify a timeout in order to receive control-c signal
  287. result = results.get(0x0FFFFF)
  288. except KeyboardInterrupt:
  289. logging.error("Control-c pressed, conversion terminated")
  290. finally:
  291. logging.debug("Ensuring the processes are stopped")
  292. if pool:
  293. pool.terminate()
  294. logging.debug("Processes stopped")
  295.  
  296. def number_of_processes():
  297. if(options.multiprocess):
  298. return options.nproc
  299. else:
  300. return 1
  301.  
  302.  
  303. def log_elapsed_time(start, end):
  304. elapsed_time = end - start
  305. nb_hours, remainder = divmod(elapsed_time, 3600)
  306. nb_mins, remainder = divmod(remainder, 60)
  307. nb_secs, remainder = divmod(remainder, 1)
  308. logging.info("Music collection synchronization performed in %02d:%02d:%02d" % (nb_hours, nb_mins, nb_secs))
  309.  
  310. if __name__ == '__main__':
  311. dir_list = []
  312. file_list = []
  313.  
  314. start = time.time()
  315.  
  316. for (path, dirs, files) in os.walk(source_dir, followlinks=options.followlinks):
  317. for dir_name in dirs:
  318. source = os.path.join(path, dir_name)
  319. dir_list.append(source)
  320. for file_name in files:
  321. source = os.path.join(path, file_name)
  322. file_list.append(source)
  323.  
  324. # The directories are handled first to make sure they all exist when the music files are generated
  325. logging.info("Starting directory synchronization")
  326. do_single_process(dir_list)
  327.  
  328. if(options.multiprocess and options.nproc > 1):
  329. logging.info("Starting file synchronization with %d processes" % number_of_processes())
  330. do_multi_process(file_list)
  331. else:
  332. logging.info("Starting file synchronization with 1 process")
  333. do_single_process(file_list)
  334.  
  335. if options.delete:
  336. # and now scan target dir for files
  337. for (path, dirs, files) in os.walk(target_dir):
  338. for file_name in files:
  339. target = os.path.join(path, file_name)
  340. clean(target)
  341.  
  342. # and for directories
  343. for (path, dirs, files) in os.walk(target_dir):
  344. for dir_name in dirs:
  345. target = os.path.join(path, dir_name)
  346. clean(target)
  347.  
  348. os.system("collectiongain -f %s" % shellquote(target_dir))
  349.  
  350. end = time.time()
  351.  
  352. log_elapsed_time(start, end)
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement