import os import re from string import Template from threading import Lock import youtube_dl from YtManagerApp.models import Video from YtManagerApp.services.scheduler.job import Job class DownloadVideoJob(Job): name = "DownloadVideoJob" __lock = Lock() def __init__(self, job_execution, video: Video, attempt: int = 1): super().__init__(job_execution) self._video = video self._attempt = attempt self._log_youtube_dl = self.log.getChild('youtube_dl') def get_description(self): ret = "Downloading video " + self._video.name if self._attempt > 1: ret += f" (attempt {self._attempt})" return ret def run(self): # Issue: if multiple videos are downloaded at the same time, a race condition appears in the mkdirs() call that # youtube-dl makes, which causes it to fail with the error 'Cannot create folder - file already exists'. # For now, allow a single download instance. self.__lock.acquire() try: user = self._video.subscription.user max_attempts = user.preferences['max_download_attempts'] youtube_dl_params, output_path = self.__build_youtube_dl_params(self._video) with youtube_dl.YoutubeDL(youtube_dl_params) as yt: ret = yt.download(["https://www.youtube.com/watch?v=" + self._video.video_id]) self.log.info('Download finished with code %d', ret) if ret == 0: self._video.downloaded_path = output_path self._video.save() self.log.info('Video %d [%s %s] downloaded successfully!', self._video.id, self._video.video_id, self._video.name) elif self._attempt <= max_attempts: self.log.warning('Re-enqueueing video (attempt %d/%d)', self._attempt, max_attempts) DownloadVideoJob.schedule(self._video, self._attempt + 1) else: self.log.error('Multiple attempts to download video %d [%s %s] failed!', self._video.id, self._video.video_id, self._video.name) self._video.downloaded_path = '' self._video.save() finally: self.__lock.release() def __build_youtube_dl_params(self, video: Video): sub = video.subscription user = sub.user # resolve path download_path = user.preferences['download_path'] template_dict = self.__build_template_dict(video) output_pattern = Template(user.preferences['download_file_pattern']).safe_substitute(template_dict) output_path = os.path.join(download_path, output_pattern) output_path = os.path.normpath(output_path) youtube_dl_params = { 'logger': self._log_youtube_dl, 'format': user.preferences['download_format'], 'outtmpl': output_path, 'writethumbnail': True, 'writedescription': True, 'writesubtitles': user.preferences['download_subtitles'], 'writeautomaticsub': user.preferences['download_autogenerated_subtitles'], 'allsubtitles': user.preferences['download_subtitles_all'], 'merge_output_format': 'mp4', 'postprocessors': [ { 'key': 'FFmpegMetadata' }, ] } sub_langs = user.preferences['download_subtitles_langs'].split(',') sub_langs = [i.strip() for i in sub_langs] if len(sub_langs) > 0: youtube_dl_params['subtitleslangs'] = sub_langs sub_format = user.preferences['download_subtitles_format'] if len(sub_format) > 0: youtube_dl_params['subtitlesformat'] = sub_format return youtube_dl_params, output_path def __build_template_dict(self, video: Video): return { 'channel': video.subscription.channel_name, 'channel_id': video.subscription.channel_id, 'playlist': video.subscription.name, 'playlist_id': video.subscription.playlist_id, 'playlist_index': "{:03d}".format(1 + video.playlist_index), 'title': video.name, 'id': video.video_id, } def __get_valid_path(self, path): """ Normalizes string, converts to lowercase, removes non-alpha characters, and converts spaces to hyphens. """ import unicodedata value = unicodedata.normalize('NFKD', path).encode('ascii', 'ignore').decode('ascii') value = re.sub('[:"*]', '', value).strip() value = re.sub('[?<>|]', '#', value) return value @staticmethod def schedule(video: Video, attempt: int = 1): """ Schedules to download video immediately :param video: :param attempt: :return: """ from YtManagerApp.management.services import Services Services.scheduler.add_job(DownloadVideoJob, args=[video, attempt])