musare-dl.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. #!/usr/bin/python3.8
  2. import sys
  3. import getopt
  4. import json
  5. import pymongo
  6. from bson.objectid import ObjectId
  7. import youtube_dl
  8. import ffmpy
  9. import os
  10. import eyed3
  11. from urllib import request
  12. import unicodedata
  13. import re
  14. from PIL import Image
  15. import PIL
  16. from alive_progress import alive_bar
  17. class bcolors:
  18. HEADER = "\033[95m"
  19. OKBLUE = "\033[94m"
  20. OKCYAN = "\033[96m"
  21. OKGREEN = "\033[92m"
  22. WARNING = "\033[93m"
  23. FAIL = "\033[91m"
  24. ENDC = "\033[0m"
  25. BOLD = "\033[1m"
  26. UNDERLINE = "\033[4m"
  27. def keys_exists(element, *keys):
  28. if not isinstance(element, dict):
  29. raise AttributeError("keys_exists() expects dict as first argument.")
  30. if len(keys) == 0:
  31. raise AttributeError("keys_exists() expects at least two arguments, one given.")
  32. _element = element
  33. for key in keys:
  34. try:
  35. _element = _element[key]
  36. except:
  37. return False
  38. return True
  39. def usage(e=False):
  40. print(f"""{bcolors.HEADER}Usage{bcolors.ENDC}
  41. -h,--help | Help
  42. -p playlistId,--playlist-id=playlistId | Musare Playlist ID
  43. -P playlistFile,--playlist-file=playlistFile | Musare Playlist JSON file
  44. -o outputPath,--output=outputPath | Output directory
  45. -f outputFormat,--format=outputFormat | Format of download, audio or video
  46. -i downloadImages,--images=downloadImages | Whether to download images, true or false
  47. --max-songs=maxSongs | Maximum number of songs to download
  48. --mongo-host=mongoHost | MongoDB Host
  49. --mongo-port=mongoPort | MongoDB Port
  50. --mongo-username=mongoUsername | MongoDB Username
  51. --mongo-password=mongoPassword | MongoDB Password
  52. --mongo-database=mongoDatabase | MongoDB Database
  53. """)
  54. if e:
  55. sys.exit(2)
  56. def slugify(value):
  57. value = unicodedata.normalize("NFKD", str(value)).encode("ascii", "ignore").decode("ascii")
  58. value = re.sub(r"[^(?!+)\w\s-]", "", value)
  59. return re.sub(r"[\s]+", "_", value).strip("-_")
  60. mongoSettings = {}
  61. playlistId = None
  62. playlistFile = None
  63. outputDir = os.path.join(os.getcwd(), "")
  64. outputFormat = None
  65. configFile = f"{outputDir}config.json"
  66. downloadImages = True
  67. maxSongs = 0
  68. os.chdir(outputDir)
  69. try:
  70. opts, args = getopt.getopt(
  71. sys.argv[1:],
  72. "p:P:o:f:i:c:h",
  73. [
  74. "playlist-id=",
  75. "playlist-file=",
  76. "output=",
  77. "format=",
  78. "images=",
  79. "config=",
  80. "help",
  81. "max-songs=",
  82. "mongo-host=",
  83. "mongo-port=",
  84. "mongo-username=",
  85. "mongo-password=",
  86. "mongo-database="
  87. ]
  88. )
  89. except getopt.GetoptError:
  90. usage(True)
  91. try:
  92. for opt, arg in opts:
  93. if opt in ("-c", "--config"):
  94. configFile = arg
  95. if not os.path.exists(configFile):
  96. sys.exit(f"{bcolors.FAIL}Error: Config file does not exist{bcolors.ENDC}")
  97. if os.path.exists(configFile):
  98. if os.path.isdir(configFile):
  99. sys.exit(f"{bcolors.FAIL}Error: Config file is a directory{bcolors.ENDC}")
  100. with open(configFile, "r") as configJson:
  101. config = json.load(configJson)
  102. for var in ["playlistId", "playlistFile", "outputPath", "outputFile", "downloadImages", "maxSongs"]:
  103. if keys_exists(config, var):
  104. globals()[var] = config[var]
  105. for param in ["host", "port", "username", "password", "database"]:
  106. if not keys_exists(mongoSettings, param) and keys_exists(config, "mongo", param):
  107. mongoSettings[param] = config["mongo"][param]
  108. if not keys_exists(mongoSettings, "host"):
  109. mongoSettings["host"] = "localhost"
  110. if not keys_exists(mongoSettings, "port"):
  111. mongoSettings["port"] = 27017
  112. if not keys_exists(mongoSettings, "username") or not keys_exists(mongoSettings, "password") or not keys_exists(mongoSettings, "database"):
  113. raise ValueError
  114. except ValueError:
  115. print(f"{bcolors.FAIL}Error: Mongo username, password and database required{bcolors.ENDC}")
  116. usage(True)
  117. except:
  118. sys.exit(f"{bcolors.FAIL}Error loading config.json{bcolors.ENDC}")
  119. for opt, arg in opts:
  120. if opt in ("-h", "--help"):
  121. usage(True)
  122. elif opt in ("-p", "--playlist-id"):
  123. playlistId = arg
  124. elif opt in ("-P", "--playlist-file"):
  125. playlistFile = arg
  126. elif opt in ("-o", "--output"):
  127. outputDir = arg
  128. elif opt in ("-f", "--format"):
  129. outputFormat = arg
  130. elif opt in ("-i", "--images"):
  131. downloadImages = arg
  132. elif opt in ("-c", "--config"):
  133. pass
  134. elif opt == "--max-songs":
  135. if arg.isdigit() == False:
  136. sys.exit(f"{bcolors.FAIL}Error: Invalid max-songs, must be int{bcolors.ENDC}")
  137. maxSongs = arg
  138. elif opt == "--mongo-host":
  139. mongoSettings["host"] = arg
  140. elif opt == "--mongo-port":
  141. mongoSettings["port"] = arg
  142. elif opt == "--mongo-username":
  143. mongoSettings["username"] = arg
  144. elif opt == "--mongo-password":
  145. mongoSettings["password"] = arg
  146. elif opt == "--mongo-database":
  147. mongoSettings["database"] = arg
  148. else:
  149. usage(True)
  150. if not playlistId and not playlistFile:
  151. print(f"{bcolors.FAIL}Error: Playlist ID or Playlist File need to be specified{bcolors.ENDC}")
  152. usage(True)
  153. if playlistId and playlistFile:
  154. print(f"{bcolors.FAIL}Error: Playlist ID and Playlist File can not be used at the same time{bcolors.ENDC}")
  155. usage(True)
  156. if playlistId and len(playlistId) != 24:
  157. sys.exit(f"{bcolors.FAIL}Error: Invalid Musare Playlist ID{bcolors.ENDC}")
  158. if playlistFile:
  159. if not os.path.exists(playlistFile):
  160. sys.exit(f"{bcolors.FAIL}Error: Musare Playlist File does not exist{bcolors.ENDC}")
  161. if os.path.isdir(playlistFile):
  162. sys.exit(f"{bcolors.FAIL}Error: Musare Playlist File is a directory{bcolors.ENDC}")
  163. if os.path.exists(outputDir):
  164. if not os.path.isdir(outputDir):
  165. sys.exit(f"{bcolors.FAIL}Error: Output path is not a directory{bcolors.ENDC}")
  166. outputDir = os.path.join(outputDir, "")
  167. else:
  168. sys.exit(f"{bcolors.FAIL}Error: Output directory does not exist{bcolors.ENDC}")
  169. if outputFormat:
  170. outputFormat = outputFormat.lower()
  171. if outputFormat != "audio" and outputFormat != "video":
  172. sys.exit(f"{bcolors.FAIL}Error: Invalid format, audio or video only{bcolors.ENDC}")
  173. else:
  174. outputFormat = "audio"
  175. if str(downloadImages).lower() == "true":
  176. downloadImages = True
  177. elif str(downloadImages).lower() == "false":
  178. downloadImages = False
  179. else:
  180. sys.exit(f"{bcolors.FAIL}Error: Invalid images, must be True or False{bcolors.ENDC}")
  181. maxSongs = int(maxSongs)
  182. if playlistId and not playlistFile:
  183. try:
  184. mongo = pymongo.MongoClient(
  185. host=mongoSettings["host"],
  186. port=int(mongoSettings["port"]),
  187. username=mongoSettings["username"],
  188. password=mongoSettings["password"],
  189. authSource=mongoSettings["database"]
  190. )
  191. mydb = mongo[mongoSettings["database"]]
  192. songs = []
  193. for playlist in mydb["playlists"].find({ "_id": ObjectId(playlistId) }, { "songs._id" }):
  194. for song in playlist["songs"]:
  195. songs.append(song["_id"])
  196. songsCount = mydb["songs"].count_documents({ "_id": { "$in": songs } })
  197. songs = mydb["songs"].find({ "_id": { "$in": songs } })
  198. except:
  199. sys.exit(f"{bcolors.FAIL}Error: Could not load songs from Mongo{bcolors.ENDC}")
  200. elif not playlistId and playlistFile:
  201. try:
  202. with open(playlistFile, "r") as playlistJson:
  203. songs = json.load(playlistJson)
  204. songs = songs["playlist"]["songs"]
  205. songsCount = len(songs)
  206. except:
  207. sys.exit(f"{bcolors.FAIL}Error: Could not load songs from playlist JSON file{bcolors.ENDC}")
  208. if not os.access(outputDir, os.W_OK):
  209. sys.exit(f"{bcolors.FAIL}Error: Unable to write to output directory{bcolors.ENDC}")
  210. if os.getcwd() != outputDir:
  211. os.chdir(outputDir)
  212. if downloadImages and not os.path.exists("images"):
  213. os.makedirs("images")
  214. i = 0
  215. completeSongs = []
  216. failedSongs = []
  217. if maxSongs > 0 and songsCount > maxSongs:
  218. songsCount = maxSongs
  219. with alive_bar(songsCount, title=f"{bcolors.BOLD}{bcolors.OKCYAN}musare-dl{bcolors.ENDC}") as bar:
  220. for song in songs:
  221. i = i + 1
  222. if maxSongs != 0 and i > maxSongs:
  223. i = i - 1
  224. break
  225. try:
  226. bar.text(f"{bcolors.OKBLUE}Downloading ({song['_id']}) {','.join(song['artists'])} - {song['title']}..{bcolors.ENDC}")
  227. ydl_opts = {
  228. "outtmpl": f"{song['_id']}.tmp",
  229. "quiet": True,
  230. "no_warnings": True
  231. }
  232. if outputFormat == "audio":
  233. outputExtension = "mp3"
  234. ydl_opts["format"] = "bestaudio[ext=m4a]"
  235. elif outputFormat == "video":
  236. outputExtension = "mp4"
  237. ydl_opts["format"] = "best[height<=1080]/bestaudio"
  238. ffmpegOpts = ["-hide_banner", "-loglevel", "error"]
  239. if keys_exists(song, "skipDuration") and keys_exists(song, "duration"):
  240. ffmpegOpts.append("-ss")
  241. ffmpegOpts.append(str(song["skipDuration"]))
  242. ffmpegOpts.append("-t")
  243. ffmpegOpts.append(str(song["duration"]))
  244. with youtube_dl.YoutubeDL(ydl_opts) as ydl:
  245. ydl.download([f"https://www.youtube.com/watch?v={song['youtubeId']}"])
  246. bar.text(f"{bcolors.OKBLUE}Converting ({song['_id']}) {','.join(song['artists'])} - {song['title']}..{bcolors.ENDC}")
  247. ff = ffmpy.FFmpeg(
  248. inputs={ f"{song['_id']}.tmp": None },
  249. outputs={ f"{song['_id']}.{outputExtension}": ffmpegOpts }
  250. )
  251. ff.run()
  252. os.remove(f"{song['_id']}.tmp")
  253. if outputFormat == "audio":
  254. track = eyed3.load(f"{song['_id']}.{outputExtension}")
  255. track.tag.artist = ";".join(song["artists"])
  256. track.tag.title = str(song["title"])
  257. if keys_exists(song, "discogs", "album", "title"):
  258. track.tag.album = str(song["discogs"]["album"]["title"])
  259. fileName = slugify(f"{'+'.join(song['artists'])}-{song['title']}-{song['_id']}")
  260. bar.text(f"{bcolors.OKBLUE}Downloading Images for ({song['_id']}) {','.join(song['artists'])} - {song['title']}..{bcolors.ENDC}")
  261. if downloadImages:
  262. try:
  263. imgRequest = request.Request(
  264. song["thumbnail"],
  265. headers={
  266. "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36"
  267. }
  268. )
  269. img = Image.open(request.urlopen(imgRequest))
  270. img.save(f"images/{fileName}.jpg", "JPEG")
  271. if outputFormat == "audio":
  272. thumb = img.resize((32, 32), Image.Resampling.LANCZOS)
  273. thumb.save(f"images/{fileName}.thumb.jpg", "JPEG")
  274. imgData = open(f"images/{fileName}.jpg", "rb").read()
  275. thumbData = open(f"images/{fileName}.thumb.jpg", "rb").read()
  276. track.tag.images.set(1, thumbData, "image/jpeg", u"icon")
  277. track.tag.images.set(3, imgData, "image/jpeg", u"cover")
  278. except:
  279. print(f"{bcolors.FAIL}Error downloading album art for ({song['_id']}) {','.join(song['artists'])} - {song['title']}, skipping.{bcolors.ENDC}")
  280. if outputFormat == "audio":
  281. track.tag.save()
  282. os.rename(f"{song['_id']}.{outputExtension}", f"{fileName}.{outputExtension}")
  283. completeSongs.append(str(song["_id"]))
  284. print(f"{bcolors.OKGREEN}Downloaded ({song['_id']}) {','.join(song['artists'])} - {song['title']}{bcolors.ENDC}")
  285. except KeyboardInterrupt:
  286. print(f"{bcolors.FAIL}Cancelled downloads{bcolors.ENDC}")
  287. break
  288. except:
  289. failedSongs.append(str(song["_id"]))
  290. print(f"{bcolors.FAIL}Error downloading ({song['_id']}) {','.join(song['artists'])} - {song['title']}, skipping.{bcolors.ENDC}")
  291. bar()
  292. if len(failedSongs) > 0:
  293. print(f"\n{bcolors.FAIL}Failed Songs ({len(failedSongs)}): {', '.join(failedSongs)}{bcolors.ENDC}")