black
This commit is contained in:
parent
e3461d1cd2
commit
b123654514
8 changed files with 105 additions and 83 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -127,3 +127,6 @@ dmypy.json
|
||||||
|
|
||||||
# Pyre type checker
|
# Pyre type checker
|
||||||
.pyre/
|
.pyre/
|
||||||
|
|
||||||
|
*.DS_Store
|
||||||
|
.idea/
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
black
|
||||||
dicttoxml==1.7.4
|
dicttoxml==1.7.4
|
||||||
mergedeep==1.3.4
|
mergedeep==1.3.4
|
||||||
music-tag==0.4.3
|
music-tag==0.4.3
|
||||||
|
|
|
||||||
3
setup.py
3
setup.py
|
|
@ -1,5 +1,6 @@
|
||||||
from setuptools import setup, find_packages
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='ytdl_subscribe',
|
name="ytdl_subscribe",
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
)
|
)
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionSource(object):
|
class SubscriptionSource(object):
|
||||||
YOUTUBE = 'youtube'
|
YOUTUBE = "youtube"
|
||||||
SOUNDCLOUD = 'soundcloud'
|
SOUNDCLOUD = "soundcloud"
|
||||||
|
|
||||||
|
|
||||||
class YAMLSection(object):
|
class YAMLSection(object):
|
||||||
CONFIG_KEY = 'config'
|
CONFIG_KEY = "config"
|
||||||
PRESET_KEY = 'presets'
|
PRESET_KEY = "presets"
|
||||||
SUBSCRIPTIONS_KEY = 'subscriptions'
|
SUBSCRIPTIONS_KEY = "subscriptions"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
|
|
||||||
from ytdl_subscribe.parse import parse_subscriptions
|
from ytdl_subscribe.parse import parse_subscriptions
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# execute only if run as a script
|
# execute only if run as a script
|
||||||
subscriptions = parse_subscriptions('subscriptions.yaml')
|
subscriptions = parse_subscriptions("subscriptions.yaml")
|
||||||
for s in subscriptions:
|
for s in subscriptions:
|
||||||
s.extract_info()
|
s.extract_info()
|
||||||
print('hey')
|
print("hey")
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,17 @@ from ytdl_subscribe.subscriptions import Subscription
|
||||||
from ytdl_subscribe.enums import YAMLSection
|
from ytdl_subscribe.enums import YAMLSection
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _set_config_variables(config):
|
def _set_config_variables(config):
|
||||||
Subscription.WORKING_DIRECTORY = config.get('working_directory', '')
|
Subscription.WORKING_DIRECTORY = config.get("working_directory", "")
|
||||||
Subscription.USERNAME = config.get('ytdl_username')
|
Subscription.USERNAME = config.get("ytdl_username")
|
||||||
if Subscription.USERNAME:
|
if Subscription.USERNAME:
|
||||||
print(f"Enter password for '{Subscription.USERNAME}': ")
|
print(f"Enter password for '{Subscription.USERNAME}': ")
|
||||||
time.sleep(0.2)
|
time.sleep(0.2)
|
||||||
Subscription.PASSWORD = getpass()
|
Subscription.PASSWORD = getpass()
|
||||||
|
|
||||||
|
|
||||||
def parse_subscriptions(subscription_yaml_path):
|
def parse_subscriptions(subscription_yaml_path):
|
||||||
with open(subscription_yaml_path, 'r') as f:
|
with open(subscription_yaml_path, "r") as f:
|
||||||
yaml_dict = yaml.safe_load(f)
|
yaml_dict = yaml.safe_load(f)
|
||||||
|
|
||||||
config = yaml_dict[YAMLSection.CONFIG_KEY]
|
config = yaml_dict[YAMLSection.CONFIG_KEY]
|
||||||
|
|
@ -30,10 +30,9 @@ def parse_subscriptions(subscription_yaml_path):
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
for name, subscription in yaml_dict[YAMLSection.SUBSCRIPTIONS_KEY].items():
|
for name, subscription in yaml_dict[YAMLSection.SUBSCRIPTIONS_KEY].items():
|
||||||
preset = {}
|
preset = {}
|
||||||
if subscription.get('preset') in presets:
|
if subscription.get("preset") in presets:
|
||||||
preset = presets[subscription['preset']]
|
preset = presets[subscription["preset"]]
|
||||||
subscription = mergedeep.merge({}, preset, subscription)
|
subscription = mergedeep.merge({}, preset, subscription)
|
||||||
subscriptions.append(Subscription.from_dict(name, subscription))
|
subscriptions.append(Subscription.from_dict(name, subscription))
|
||||||
|
|
||||||
|
|
||||||
return subscriptions
|
return subscriptions
|
||||||
|
|
@ -10,6 +10,7 @@ from ytdl_subscribe import SubscriptionSource
|
||||||
|
|
||||||
import youtube_dl as ytdl
|
import youtube_dl as ytdl
|
||||||
|
|
||||||
|
|
||||||
def _f(value, entry):
|
def _f(value, entry):
|
||||||
return value.format(**entry)
|
return value.format(**entry)
|
||||||
|
|
||||||
|
|
@ -24,7 +25,7 @@ def _ffile(file_value, path, entry, makedirs=False):
|
||||||
|
|
||||||
|
|
||||||
class Subscription(object):
|
class Subscription(object):
|
||||||
WORKING_DIRECTORY = ''
|
WORKING_DIRECTORY = ""
|
||||||
|
|
||||||
source = None
|
source = None
|
||||||
|
|
||||||
|
|
@ -56,73 +57,84 @@ class Subscription(object):
|
||||||
self.WORKING_DIRECTORY += f"{'/' if self.WORKING_DIRECTORY else ''}{self.name}"
|
self.WORKING_DIRECTORY += f"{'/' if self.WORKING_DIRECTORY else ''}{self.name}"
|
||||||
|
|
||||||
# Always set outtmpl to the id and extension. Will be renamed using the subscription's output_path value
|
# Always set outtmpl to the id and extension. Will be renamed using the subscription's output_path value
|
||||||
self.ytdl_opts['outtmpl'] = self.WORKING_DIRECTORY + '/%(id)s.%(ext)s'
|
self.ytdl_opts["outtmpl"] = self.WORKING_DIRECTORY + "/%(id)s.%(ext)s"
|
||||||
self.ytdl_opts['writethumbnail'] = True
|
self.ytdl_opts["writethumbnail"] = True
|
||||||
|
|
||||||
def parse_entry(self, entry):
|
def parse_entry(self, entry):
|
||||||
# Add overrides to the entry
|
# Add overrides to the entry
|
||||||
entry = dict(entry, **self.overrides)
|
entry = dict(entry, **self.overrides)
|
||||||
|
|
||||||
# Add the file path to the entry, assert it exists
|
# Add the file path to the entry, assert it exists
|
||||||
entry['file_path'] = f"{self.WORKING_DIRECTORY}/{entry['id']}.{entry['ext']}"
|
entry["file_path"] = f"{self.WORKING_DIRECTORY}/{entry['id']}.{entry['ext']}"
|
||||||
|
|
||||||
# Add sanitized values
|
# Add sanitized values
|
||||||
entry['sanitized_artist'] = sanitize(entry['artist'])
|
entry["sanitized_artist"] = sanitize(entry["artist"])
|
||||||
entry['sanitized_title'] = sanitize(entry['title'])
|
entry["sanitized_title"] = sanitize(entry["title"])
|
||||||
|
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
def _post_process_tagging(self, entry):
|
def _post_process_tagging(self, entry):
|
||||||
t = music_tag.load_file(entry['file_path'])
|
t = music_tag.load_file(entry["file_path"])
|
||||||
for tag, tag_formatter in self.post_process['tagging'].items():
|
for tag, tag_formatter in self.post_process["tagging"].items():
|
||||||
t[tag] = _f(tag_formatter, entry)
|
t[tag] = _f(tag_formatter, entry)
|
||||||
t.save()
|
t.save()
|
||||||
|
|
||||||
def _post_process_nfo(self, entry):
|
def _post_process_nfo(self, entry):
|
||||||
nfo = {}
|
nfo = {}
|
||||||
for tag, tag_formatter in self.post_process['nfo'].items():
|
for tag, tag_formatter in self.post_process["nfo"].items():
|
||||||
nfo[tag] = _f(tag_formatter, entry)
|
nfo[tag] = _f(tag_formatter, entry)
|
||||||
|
|
||||||
xml = dicttoxml.dicttoxml(
|
xml = dicttoxml.dicttoxml(
|
||||||
obj=nfo,
|
obj=nfo,
|
||||||
root='nfo_root' in self.post_process,
|
root="nfo_root" in self.post_process,
|
||||||
custom_root=self.post_process.get('nfo_root'),
|
custom_root=self.post_process.get("nfo_root"),
|
||||||
attr_type=False,
|
attr_type=False,
|
||||||
)
|
)
|
||||||
nfo_file_path = _ffile(self.post_process['nfo_name'], self.output_path, entry, makedirs=True)
|
nfo_file_path = _ffile(
|
||||||
with open(nfo_file_path, 'wb') as f:
|
self.post_process["nfo_name"], self.output_path, entry, makedirs=True
|
||||||
|
)
|
||||||
|
with open(nfo_file_path, "wb") as f:
|
||||||
f.write(xml)
|
f.write(xml)
|
||||||
|
|
||||||
def extract_info(self):
|
def extract_info(self):
|
||||||
"""
|
"""
|
||||||
Extracts only the info of the source, does not download it
|
Extracts only the info of the source, does not download it
|
||||||
"""
|
"""
|
||||||
raise NotImplemented('Each source needs to implement how it extracts info')
|
raise NotImplemented("Each source needs to implement how it extracts info")
|
||||||
|
|
||||||
def post_process_entry(self, entry):
|
def post_process_entry(self, entry):
|
||||||
if 'tagging' in self.post_process:
|
if "tagging" in self.post_process:
|
||||||
self._post_process_tagging(entry)
|
self._post_process_tagging(entry)
|
||||||
|
|
||||||
# Move the file after all direct file modifications are complete
|
# Move the file after all direct file modifications are complete
|
||||||
output_file_path = _ffile(self.post_process['file_name'], self.output_path, entry, makedirs=True)
|
output_file_path = _ffile(
|
||||||
copyfile(entry['file_path'], output_file_path)
|
self.post_process["file_name"], self.output_path, entry, makedirs=True
|
||||||
|
)
|
||||||
|
copyfile(entry["file_path"], output_file_path)
|
||||||
|
|
||||||
# Download the thumbnail if its present
|
# Download the thumbnail if its present
|
||||||
if 'thumbnail_name' in self.post_process:
|
if "thumbnail_name" in self.post_process:
|
||||||
thumbnail_dest_path = _ffile(self.post_process['thumbnail_name'], self.output_path, entry, makedirs=True)
|
thumbnail_dest_path = _ffile(
|
||||||
|
self.post_process["thumbnail_name"],
|
||||||
|
self.output_path,
|
||||||
|
entry,
|
||||||
|
makedirs=True,
|
||||||
|
)
|
||||||
if not os.path.isfile(thumbnail_dest_path):
|
if not os.path.isfile(thumbnail_dest_path):
|
||||||
thumbnail_file_path = f"{self.WORKING_DIRECTORY}/{entry['id']}.{entry['thumbnail_ext']}"
|
thumbnail_file_path = (
|
||||||
|
f"{self.WORKING_DIRECTORY}/{entry['id']}.{entry['thumbnail_ext']}"
|
||||||
|
)
|
||||||
if os.path.isfile(thumbnail_file_path):
|
if os.path.isfile(thumbnail_file_path):
|
||||||
copyfile(thumbnail_file_path, thumbnail_dest_path)
|
copyfile(thumbnail_file_path, thumbnail_dest_path)
|
||||||
|
|
||||||
if 'convert_thumbnail' in self.post_process:
|
if "convert_thumbnail" in self.post_process:
|
||||||
# TODO: Clean with yaml definitions
|
# TODO: Clean with yaml definitions
|
||||||
if self.post_process['convert_thumbnail'] == 'jpg':
|
if self.post_process["convert_thumbnail"] == "jpg":
|
||||||
self.post_process['convert_thumbnail'] = 'jpeg'
|
self.post_process["convert_thumbnail"] = "jpeg"
|
||||||
im = Image.open(thumbnail_dest_path).convert("RGB")
|
im = Image.open(thumbnail_dest_path).convert("RGB")
|
||||||
im.save(thumbnail_dest_path, self.post_process['convert_thumbnail'])
|
im.save(thumbnail_dest_path, self.post_process["convert_thumbnail"])
|
||||||
|
|
||||||
if 'nfo' in self.post_process:
|
if "nfo" in self.post_process:
|
||||||
self._post_process_nfo(entry)
|
self._post_process_nfo(entry)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -133,15 +145,15 @@ class Subscription(object):
|
||||||
elif SubscriptionSource.YOUTUBE in d:
|
elif SubscriptionSource.YOUTUBE in d:
|
||||||
dclass = YoutubeSubscription
|
dclass = YoutubeSubscription
|
||||||
else:
|
else:
|
||||||
raise ValueError('dne')
|
raise ValueError("dne")
|
||||||
|
|
||||||
return dclass(
|
return dclass(
|
||||||
name=name,
|
name=name,
|
||||||
options=d[dclass.source],
|
options=d[dclass.source],
|
||||||
ytdl_opts=d['ytdl_opts'],
|
ytdl_opts=d["ytdl_opts"],
|
||||||
post_process=d['post_process'],
|
post_process=d["post_process"],
|
||||||
overrides=d['overrides'],
|
overrides=d["overrides"],
|
||||||
output_path=d['output_path'],
|
output_path=d["output_path"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -149,33 +161,33 @@ class SoundcloudSubscription(Subscription):
|
||||||
source = SubscriptionSource.SOUNDCLOUD
|
source = SubscriptionSource.SOUNDCLOUD
|
||||||
|
|
||||||
def is_entry_skippable(self, entry):
|
def is_entry_skippable(self, entry):
|
||||||
return self.options['skip_premiere_tracks'] and '/preview/' in entry['url']
|
return self.options["skip_premiere_tracks"] and "/preview/" in entry["url"]
|
||||||
|
|
||||||
def parse_entry(self, entry):
|
def parse_entry(self, entry):
|
||||||
entry = super(SoundcloudSubscription, self).parse_entry(entry)
|
entry = super(SoundcloudSubscription, self).parse_entry(entry)
|
||||||
entry['upload_year'] = entry['upload_date'][:4]
|
entry["upload_year"] = entry["upload_date"][:4]
|
||||||
|
|
||||||
# Add thumbnail ext value
|
# Add thumbnail ext value
|
||||||
entry['thumbnail_ext'] = entry['thumbnail'].split('.')[-1]
|
entry["thumbnail_ext"] = entry["thumbnail"].split(".")[-1]
|
||||||
|
|
||||||
# If the entry does not have album fields, set them to be the track fields
|
# If the entry does not have album fields, set them to be the track fields
|
||||||
if 'album' not in entry:
|
if "album" not in entry:
|
||||||
entry['album'] = entry['title']
|
entry["album"] = entry["title"]
|
||||||
entry['sanitized_album'] = entry['sanitized_title']
|
entry["sanitized_album"] = entry["sanitized_title"]
|
||||||
entry['album_year'] = entry['upload_year']
|
entry["album_year"] = entry["upload_year"]
|
||||||
entry['tracknumber'] = 1
|
entry["tracknumber"] = 1
|
||||||
entry['tracknumberpadded'] = f'{1:02d}'
|
entry["tracknumberpadded"] = f"{1:02d}"
|
||||||
|
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
def parse_album_entry(self, album_entry):
|
def parse_album_entry(self, album_entry):
|
||||||
album_year = min([int(e['upload_date'][:4]) for e in album_entry['entries']])
|
album_year = min([int(e["upload_date"][:4]) for e in album_entry["entries"]])
|
||||||
for track_number, e in enumerate(album_entry['entries'], start=1):
|
for track_number, e in enumerate(album_entry["entries"], start=1):
|
||||||
e['album'] = album_entry['title']
|
e["album"] = album_entry["title"]
|
||||||
e['sanitized_album'] = sanitize(album_entry['title'])
|
e["sanitized_album"] = sanitize(album_entry["title"])
|
||||||
e['album_year'] = album_year
|
e["album_year"] = album_year
|
||||||
e['tracknumber'] = track_number
|
e["tracknumber"] = track_number
|
||||||
e['tracknumberpadded'] = f'{track_number:02d}'
|
e["tracknumberpadded"] = f"{track_number:02d}"
|
||||||
return album_entry
|
return album_entry
|
||||||
|
|
||||||
def extract_info(self):
|
def extract_info(self):
|
||||||
|
|
@ -185,30 +197,39 @@ class SoundcloudSubscription(Subscription):
|
||||||
base_url = f"https://soundcloud.com/{self.options['username']}"
|
base_url = f"https://soundcloud.com/{self.options['username']}"
|
||||||
tracks = []
|
tracks = []
|
||||||
|
|
||||||
if self.options.get('download_strategy') == 'albums_then_tracks':
|
if self.options.get("download_strategy") == "albums_then_tracks":
|
||||||
track_ytdl_opts = {
|
track_ytdl_opts = {
|
||||||
'download_archive': self.WORKING_DIRECTORY + '/ytdl-download-archive.txt',
|
"download_archive": self.WORKING_DIRECTORY
|
||||||
'forcejson': True,
|
+ "/ytdl-download-archive.txt",
|
||||||
|
"forcejson": True,
|
||||||
}
|
}
|
||||||
# Get the album tracks first, but do not download. Unfortunately we cannot use download_archive for
|
# Get the album tracks first, but do not download. Unfortunately we cannot use download_archive for
|
||||||
# this be
|
# this be
|
||||||
with ytdl.YoutubeDL(self.ytdl_opts) as ytd:
|
with ytdl.YoutubeDL(self.ytdl_opts) as ytd:
|
||||||
info = ytd.extract_info(base_url + '/albums', download=False)
|
info = ytd.extract_info(base_url + "/albums", download=False)
|
||||||
|
|
||||||
album_entries = [self.parse_album_entry(a) for a in info['entries']]
|
album_entries = [self.parse_album_entry(a) for a in info["entries"]]
|
||||||
for album_entry in album_entries:
|
for album_entry in album_entries:
|
||||||
tracks += [self.parse_entry(e) for e in album_entry['entries'] if not self.is_entry_skippable(e)]
|
tracks += [
|
||||||
|
self.parse_entry(e)
|
||||||
|
for e in album_entry["entries"]
|
||||||
|
if not self.is_entry_skippable(e)
|
||||||
|
]
|
||||||
|
|
||||||
with ytdl.YoutubeDL(dict(self.ytdl_opts, **track_ytdl_opts)) as ytd:
|
with ytdl.YoutubeDL(dict(self.ytdl_opts, **track_ytdl_opts)) as ytd:
|
||||||
# Get the rest of the tracks that are part of the album tracks
|
# Get the rest of the tracks that are part of the album tracks
|
||||||
info = ytd.extract_info(base_url + '/tracks')
|
info = ytd.extract_info(base_url + "/tracks")
|
||||||
album_track_ids = [t['id'] for t in tracks]
|
album_track_ids = [t["id"] for t in tracks]
|
||||||
tracks += [self.parse_entry(e) for e in info['entries'] if e['id'] not in album_track_ids and not self.is_entry_skippable(e)]
|
tracks += [
|
||||||
|
self.parse_entry(e)
|
||||||
|
for e in info["entries"]
|
||||||
|
if e["id"] not in album_track_ids and not self.is_entry_skippable(e)
|
||||||
|
]
|
||||||
|
|
||||||
for e in tracks:
|
for e in tracks:
|
||||||
self.post_process_entry(e)
|
self.post_process_entry(e)
|
||||||
else:
|
else:
|
||||||
raise ValueError('Invalid download_strategy field for Soundcloud')
|
raise ValueError("Invalid download_strategy field for Soundcloud")
|
||||||
|
|
||||||
|
|
||||||
class YoutubeSubscription(Subscription):
|
class YoutubeSubscription(Subscription):
|
||||||
|
|
@ -216,12 +237,12 @@ class YoutubeSubscription(Subscription):
|
||||||
|
|
||||||
def parse_entry(self, entry):
|
def parse_entry(self, entry):
|
||||||
entry = super(YoutubeSubscription, self).parse_entry(entry)
|
entry = super(YoutubeSubscription, self).parse_entry(entry)
|
||||||
entry['upload_year'] = entry['upload_date'][:4]
|
entry["upload_year"] = entry["upload_date"][:4]
|
||||||
|
|
||||||
entry['thumbnail_ext'] = 'webp'
|
entry["thumbnail_ext"] = "webp"
|
||||||
|
|
||||||
# Try to get the track, fall back on title
|
# Try to get the track, fall back on title
|
||||||
entry['sanitized_track'] = sanitize(entry.get('track', entry['title']))
|
entry["sanitized_track"] = sanitize(entry.get("track", entry["title"]))
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
def extract_info(self):
|
def extract_info(self):
|
||||||
|
|
@ -232,6 +253,6 @@ class YoutubeSubscription(Subscription):
|
||||||
with ytdl.YoutubeDL(self.ytdl_opts) as ytd:
|
with ytdl.YoutubeDL(self.ytdl_opts) as ytd:
|
||||||
info = ytd.extract_info(url)
|
info = ytd.extract_info(url)
|
||||||
|
|
||||||
entries = [self.parse_entry(e) for e in info['entries']]
|
entries = [self.parse_entry(e) for e in info["entries"]]
|
||||||
for e in entries:
|
for e in entries:
|
||||||
self.post_process_entry(e)
|
self.post_process_entry(e)
|
||||||
Loading…
Reference in a new issue