diff --git a/app/dl_formats.py b/app/dl_formats.py index 283f53c..e229366 100644 --- a/app/dl_formats.py +++ b/app/dl_formats.py @@ -21,91 +21,90 @@ def _normalize_subtitle_language(language: str) -> str: return language or "en" -def get_format(format: str, quality: str, video_codec: str = "auto") -> str: +def get_format(download_type: str, codec: str, format: str, quality: str) -> str: """ - Returns format for download + Returns yt-dlp format selector. Args: - format (str): format selected - quality (str): quality selected - video_codec (str): video codec filter (auto, h264, h265, av1, vp9) + download_type (str): selected content type (video, audio, captions, thumbnail) + codec (str): selected video codec (auto, h264, h265, av1, vp9) + format (str): selected output format/profile for type + quality (str): selected quality Raises: - Exception: unknown quality, unknown format + Exception: unknown type/format Returns: - dl_format: Formatted download string + str: yt-dlp format selector """ - format = format or "any" + download_type = (download_type or "video").strip().lower() + format = (format or "any").strip().lower() + codec = (codec or "auto").strip().lower() + quality = (quality or "best").strip().lower() if format.startswith("custom:"): return format[7:] - if format == "thumbnail": - # Quality is irrelevant in this case since we skip the download + if download_type == "thumbnail": return "bestaudio/best" - if format == "captions": - # Quality is irrelevant in this case since we skip the download + if download_type == "captions": return "bestaudio/best" - if format in AUDIO_FORMATS: - # Audio quality needs to be set post-download, set in opts + if download_type == "audio": + if format not in AUDIO_FORMATS: + raise Exception(f"Unknown audio format {format}") return f"bestaudio[ext={format}]/bestaudio/best" - if format in ("mp4", "any"): - if quality == "audio": - return "bestaudio/best" - # video {res} {vfmt} + audio {afmt} {res} {vfmt} - vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format == "mp4" else ("", "") - vres = f"[height<={quality}]" if quality not in ("best", "best_ios", "worst") else "" + if download_type == "video": + if format not in ("any", "mp4", "ios"): + raise Exception(f"Unknown video format {format}") + vfmt, afmt = ("[ext=mp4]", "[ext=m4a]") if format in ("mp4", "ios") else ("", "") + vres = f"[height<={quality}]" if quality not in ("best", "worst") else "" vcombo = vres + vfmt - codec_filter = CODEC_FILTER_MAP.get(video_codec, "") + codec_filter = CODEC_FILTER_MAP.get(codec, "") - if quality == "best_ios": - # iOS has strict requirements for video files, requiring h264 or h265 - # video codec and aac audio codec in MP4 container. This format string - # attempts to get the fully compatible formats first, then the h264/h265 - # video codec with any M4A audio codec (because audio is faster to - # convert if needed), and falls back to getting the best available MP4 - # file. + if format == "ios": return f"bestvideo[vcodec~='^((he|a)vc|h26[45])']{vres}+bestaudio[acodec=aac]/bestvideo[vcodec~='^((he|a)vc|h26[45])']{vres}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}" if codec_filter: - # Try codec-filtered first, fall back to unfiltered return f"bestvideo{codec_filter}{vcombo}+bestaudio{afmt}/bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}" return f"bestvideo{vcombo}+bestaudio{afmt}/best{vcombo}" - raise Exception(f"Unkown format {format}") + raise Exception(f"Unknown download_type {download_type}") def get_opts( + download_type: str, + codec: str, format: str, quality: str, ytdl_opts: dict, - subtitle_format: str = "srt", subtitle_language: str = "en", subtitle_mode: str = "prefer_manual", - video_codec: str = "auto", ) -> dict: """ - Returns extra download options - Mostly postprocessing options + Returns extra yt-dlp options/postprocessors. Args: - format (str): format selected - quality (str): quality of format selected (needed for some formats) + download_type (str): selected content type + codec (str): selected codec (unused currently, kept for API consistency) + format (str): selected format/profile + quality (str): selected quality ytdl_opts (dict): current options selected Returns: - ytdl_opts: Extra options + dict: extended options """ + del codec # kept for parity with get_format signature + download_type = (download_type or "video").strip().lower() + format = (format or "any").strip().lower() opts = copy.deepcopy(ytdl_opts) postprocessors = [] - if format in AUDIO_FORMATS: + if download_type == "audio": postprocessors.append( { "key": "FFmpegExtractAudio", @@ -114,7 +113,6 @@ def get_opts( } ) - # Audio formats without thumbnail if format not in ("wav") and "writethumbnail" not in opts: opts["writethumbnail"] = True postprocessors.append( @@ -127,19 +125,18 @@ def get_opts( postprocessors.append({"key": "FFmpegMetadata"}) postprocessors.append({"key": "EmbedThumbnail"}) - if format == "thumbnail": + if download_type == "thumbnail": opts["skip_download"] = True opts["writethumbnail"] = True postprocessors.append( {"key": "FFmpegThumbnailsConvertor", "format": "jpg", "when": "before_dl"} ) - if format == "captions": + if download_type == "captions": mode = _normalize_caption_mode(subtitle_mode) language = _normalize_subtitle_language(subtitle_language) opts["skip_download"] = True - requested_subtitle_format = (subtitle_format or "srt").lower() - # txt is a derived, non-timed format produced from SRT after download. + requested_subtitle_format = (format or "srt").lower() if requested_subtitle_format == "txt": requested_subtitle_format = "srt" opts["subtitlesformat"] = requested_subtitle_format diff --git a/app/main.py b/app/main.py index b2d9f14..6e3cbbf 100644 --- a/app/main.py +++ b/app/main.py @@ -194,6 +194,68 @@ routes = web.RouteTableDef() VALID_SUBTITLE_FORMATS = {'srt', 'txt', 'vtt', 'ttml', 'sbv', 'scc', 'dfxp'} VALID_SUBTITLE_MODES = {'auto_only', 'manual_only', 'prefer_manual', 'prefer_auto'} SUBTITLE_LANGUAGE_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9-]{0,34}$') +VALID_DOWNLOAD_TYPES = {'video', 'audio', 'captions', 'thumbnail'} +VALID_VIDEO_CODECS = {'auto', 'h264', 'h265', 'av1', 'vp9'} +VALID_VIDEO_FORMATS = {'any', 'mp4', 'ios'} +VALID_AUDIO_FORMATS = {'m4a', 'mp3', 'opus', 'wav', 'flac'} +VALID_THUMBNAIL_FORMATS = {'jpg'} + + +def _migrate_legacy_request(post: dict) -> dict: + """ + BACKWARD COMPATIBILITY: Translate old API request schema into the new one. + + Old API: + format (any/mp4/m4a/mp3/opus/wav/flac/thumbnail/captions) + quality + video_codec + subtitle_format (only when format=captions) + + New API: + download_type (video/audio/captions/thumbnail) + codec + format + quality + """ + if "download_type" in post: + return post + + old_format = str(post.get("format") or "any").strip().lower() + old_quality = str(post.get("quality") or "best").strip().lower() + old_video_codec = str(post.get("video_codec") or "auto").strip().lower() + + if old_format in VALID_AUDIO_FORMATS: + post["download_type"] = "audio" + post["codec"] = "auto" + post["format"] = old_format + elif old_format == "thumbnail": + post["download_type"] = "thumbnail" + post["codec"] = "auto" + post["format"] = "jpg" + post["quality"] = "best" + elif old_format == "captions": + post["download_type"] = "captions" + post["codec"] = "auto" + post["format"] = str(post.get("subtitle_format") or "srt").strip().lower() + post["quality"] = "best" + else: + # old_format is usually any/mp4 (legacy video path) + post["download_type"] = "video" + post["codec"] = old_video_codec + if old_quality == "best_ios": + post["format"] = "ios" + post["quality"] = "best" + elif old_quality == "audio": + # Legacy "audio only" under video format maps to m4a audio. + post["download_type"] = "audio" + post["codec"] = "auto" + post["format"] = "m4a" + post["quality"] = "best" + else: + post["format"] = old_format + post["quality"] = old_quality + + return post class Notifier(DownloadQueueNotifier): async def added(self, dl): @@ -272,23 +334,24 @@ if config.YTDL_OPTIONS_FILE: async def add(request): log.info("Received request to add download") post = await request.json() + post = _migrate_legacy_request(post) log.info(f"Request data: {post}") url = post.get('url') - quality = post.get('quality') - if not url or not quality: - log.error("Bad request: missing 'url' or 'quality'") - raise web.HTTPBadRequest() + download_type = post.get('download_type') + codec = post.get('codec') format = post.get('format') + quality = post.get('quality') + if not url or not quality or not download_type: + log.error("Bad request: missing 'url', 'download_type', or 'quality'") + raise web.HTTPBadRequest() folder = post.get('folder') custom_name_prefix = post.get('custom_name_prefix') playlist_item_limit = post.get('playlist_item_limit') auto_start = post.get('auto_start') split_by_chapters = post.get('split_by_chapters') chapter_template = post.get('chapter_template') - subtitle_format = post.get('subtitle_format') subtitle_language = post.get('subtitle_language') subtitle_mode = post.get('subtitle_mode') - video_codec = post.get('video_codec') if custom_name_prefix is None: custom_name_prefix = '' @@ -302,46 +365,72 @@ async def add(request): split_by_chapters = False if chapter_template is None: chapter_template = config.OUTPUT_TEMPLATE_CHAPTER - if subtitle_format is None: - subtitle_format = 'srt' if subtitle_language is None: subtitle_language = 'en' if subtitle_mode is None: subtitle_mode = 'prefer_manual' - subtitle_format = str(subtitle_format).strip().lower() + download_type = str(download_type).strip().lower() + codec = str(codec or 'auto').strip().lower() + format = str(format or '').strip().lower() + quality = str(quality).strip().lower() subtitle_language = str(subtitle_language).strip() subtitle_mode = str(subtitle_mode).strip() + if chapter_template and ('..' in chapter_template or chapter_template.startswith('/') or chapter_template.startswith('\\')): raise web.HTTPBadRequest(reason='chapter_template must not contain ".." or start with a path separator') - if subtitle_format not in VALID_SUBTITLE_FORMATS: - raise web.HTTPBadRequest(reason=f'subtitle_format must be one of {sorted(VALID_SUBTITLE_FORMATS)}') if not SUBTITLE_LANGUAGE_RE.fullmatch(subtitle_language): raise web.HTTPBadRequest(reason='subtitle_language must match pattern [A-Za-z0-9-] and be at most 35 characters') if subtitle_mode not in VALID_SUBTITLE_MODES: raise web.HTTPBadRequest(reason=f'subtitle_mode must be one of {sorted(VALID_SUBTITLE_MODES)}') - if video_codec is None: - video_codec = 'auto' - video_codec = str(video_codec).strip().lower() - if video_codec not in ('auto', 'h264', 'h265', 'av1', 'vp9'): - raise web.HTTPBadRequest(reason="video_codec must be one of ['auto', 'h264', 'h265', 'av1', 'vp9']") + if download_type not in VALID_DOWNLOAD_TYPES: + raise web.HTTPBadRequest(reason=f'download_type must be one of {sorted(VALID_DOWNLOAD_TYPES)}') + if codec not in VALID_VIDEO_CODECS: + raise web.HTTPBadRequest(reason=f'codec must be one of {sorted(VALID_VIDEO_CODECS)}') + + if download_type == 'video': + if format not in VALID_VIDEO_FORMATS: + raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_VIDEO_FORMATS)} for video') + if quality not in {'best', 'worst', '2160', '1440', '1080', '720', '480', '360', '240'}: + raise web.HTTPBadRequest(reason="quality must be one of ['best', '2160', '1440', '1080', '720', '480', '360', '240', 'worst'] for video") + elif download_type == 'audio': + if format not in VALID_AUDIO_FORMATS: + raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_AUDIO_FORMATS)} for audio') + allowed_audio_qualities = {'best'} + if format == 'mp3': + allowed_audio_qualities |= {'320', '192', '128'} + elif format == 'm4a': + allowed_audio_qualities |= {'192', '128'} + if quality not in allowed_audio_qualities: + raise web.HTTPBadRequest(reason=f'quality must be one of {sorted(allowed_audio_qualities)} for format {format}') + codec = 'auto' + elif download_type == 'captions': + if format not in VALID_SUBTITLE_FORMATS: + raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_SUBTITLE_FORMATS)} for captions') + quality = 'best' + codec = 'auto' + elif download_type == 'thumbnail': + if format not in VALID_THUMBNAIL_FORMATS: + raise web.HTTPBadRequest(reason=f'format must be one of {sorted(VALID_THUMBNAIL_FORMATS)} for thumbnail') + quality = 'best' + codec = 'auto' playlist_item_limit = int(playlist_item_limit) status = await dqueue.add( url, - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, - subtitle_format, subtitle_language, subtitle_mode, - video_codec=video_codec, ) return web.Response(text=serializer.encode(status)) diff --git a/app/ytdl.py b/app/ytdl.py index 370c458..e738fe9 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -151,6 +151,8 @@ class DownloadInfo: title, url, quality, + download_type, + codec, format, folder, custom_name_prefix, @@ -159,15 +161,15 @@ class DownloadInfo: playlist_item_limit, split_by_chapters, chapter_template, - subtitle_format="srt", subtitle_language="en", subtitle_mode="prefer_manual", - video_codec="auto", ): self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}' self.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}' self.url = url self.quality = quality + self.download_type = download_type + self.codec = codec self.format = format self.folder = folder self.custom_name_prefix = custom_name_prefix @@ -181,11 +183,48 @@ class DownloadInfo: self.playlist_item_limit = playlist_item_limit self.split_by_chapters = split_by_chapters self.chapter_template = chapter_template - self.subtitle_format = subtitle_format self.subtitle_language = subtitle_language self.subtitle_mode = subtitle_mode self.subtitle_files = [] - self.video_codec = video_codec + + def __setstate__(self, state): + """BACKWARD COMPATIBILITY: migrate old DownloadInfo from persistent queue files.""" + self.__dict__.update(state) + if 'download_type' not in state: + old_format = state.get('format', 'any') + old_video_codec = state.get('video_codec', 'auto') + old_quality = state.get('quality', 'best') + old_subtitle_format = state.get('subtitle_format', 'srt') + + if old_format in AUDIO_FORMATS: + self.download_type = 'audio' + self.codec = 'auto' + elif old_format == 'thumbnail': + self.download_type = 'thumbnail' + self.codec = 'auto' + self.format = 'jpg' + elif old_format == 'captions': + self.download_type = 'captions' + self.codec = 'auto' + self.format = old_subtitle_format + else: + self.download_type = 'video' + self.codec = old_video_codec + if old_quality == 'best_ios': + self.format = 'ios' + self.quality = 'best' + elif old_quality == 'audio': + self.download_type = 'audio' + self.codec = 'auto' + self.format = 'm4a' + self.quality = 'best' + self.__dict__.pop('video_codec', None) + self.__dict__.pop('subtitle_format', None) + + if not getattr(self, "codec", None): + self.codec = "auto" + if not hasattr(self, "subtitle_files"): + self.subtitle_files = [] class Download: manager = None @@ -196,15 +235,20 @@ class Download: self.output_template = output_template self.output_template_chapter = output_template_chapter self.info = info - self.format = get_format(format, quality, video_codec=getattr(info, 'video_codec', 'auto')) + self.format = get_format( + getattr(info, 'download_type', 'video'), + getattr(info, 'codec', 'auto'), + format, + quality, + ) self.ytdl_opts = get_opts( + getattr(info, 'download_type', 'video'), + getattr(info, 'codec', 'auto'), format, quality, ytdl_opts, - subtitle_format=getattr(info, 'subtitle_format', 'srt'), subtitle_language=getattr(info, 'subtitle_language', 'en'), subtitle_mode=getattr(info, 'subtitle_mode', 'prefer_manual'), - video_codec=getattr(info, 'video_codec', 'auto'), ) if "impersonate" in self.ytdl_opts: self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"]) @@ -244,7 +288,7 @@ class Download: # For captions-only downloads, yt-dlp may still report a media-like # filepath in MoveFiles. Capture subtitle outputs explicitly so the # UI can link to real caption files. - if self.info.format == 'captions': + if getattr(self.info, 'download_type', '') == 'captions': requested_subtitles = d.get('info_dict', {}).get('requested_subtitles', {}) or {} for subtitle in requested_subtitles.values(): if isinstance(subtitle, dict) and subtitle.get('filepath'): @@ -352,14 +396,14 @@ class Download: rel_name = os.path.relpath(fileName, self.download_dir) # For captions mode, ignore media-like placeholders and let subtitle_file # statuses define the final file shown in the UI. - if self.info.format == 'captions': - requested_subtitle_format = str(getattr(self.info, 'subtitle_format', '')).lower() + if getattr(self.info, 'download_type', '') == 'captions': + requested_subtitle_format = str(getattr(self.info, 'format', '')).lower() allowed_caption_exts = ('.txt',) if requested_subtitle_format == 'txt' else ('.vtt', '.srt', '.sbv', '.scc', '.ttml', '.dfxp') if not rel_name.lower().endswith(allowed_caption_exts): continue self.info.filename = rel_name self.info.size = os.path.getsize(fileName) if os.path.exists(fileName) else None - if self.info.format == 'thumbnail': + if getattr(self.info, 'download_type', '') == 'thumbnail': self.info.filename = re.sub(r'\.webm$', '.jpg', self.info.filename) # Handle chapter files @@ -384,7 +428,7 @@ class Download: subtitle_output_file = subtitle_file # txt mode is derived from SRT by stripping cue metadata. - if self.info.format == 'captions' and str(getattr(self.info, 'subtitle_format', '')).lower() == 'txt': + if getattr(self.info, 'download_type', '') == 'captions' and str(getattr(self.info, 'format', '')).lower() == 'txt': converted_txt = _convert_srt_to_txt_file(subtitle_file) if converted_txt: subtitle_output_file = converted_txt @@ -400,9 +444,9 @@ class Download: if not existing: self.info.subtitle_files.append({'filename': rel_path, 'size': file_size}) # Prefer first subtitle file as the primary result link in captions mode. - if self.info.format == 'captions' and ( + if getattr(self.info, 'download_type', '') == 'captions' and ( not getattr(self.info, 'filename', None) or - str(getattr(self.info, 'subtitle_format', '')).lower() == 'txt' + str(getattr(self.info, 'format', '')).lower() == 'txt' ): self.info.filename = rel_path self.info.size = file_size @@ -434,7 +478,7 @@ class PersistentQueue: def load(self): for k, v in self.saved_items(): - self.dict[k] = Download(None, None, None, None, None, None, {}, v) + self.dict[k] = Download(None, None, None, None, getattr(v, 'quality', 'best'), getattr(v, 'format', 'any'), {}, v) def exists(self, key): return key in self.dict @@ -625,8 +669,8 @@ class DownloadQueue: **({'impersonate': yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.config.YTDL_OPTIONS['impersonate'])} if 'impersonate' in self.config.YTDL_OPTIONS else {}), }).extract_info(url, download=False) - def __calc_download_path(self, quality, format, folder): - base_directory = self.config.DOWNLOAD_DIR if (quality != 'audio' and format not in AUDIO_FORMATS) else self.config.AUDIO_DOWNLOAD_DIR + def __calc_download_path(self, download_type, folder): + base_directory = self.config.AUDIO_DOWNLOAD_DIR if download_type == 'audio' else self.config.DOWNLOAD_DIR if folder: if not self.config.CUSTOM_DIRS: return None, {'status': 'error', 'msg': 'A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.'} @@ -643,7 +687,7 @@ class DownloadQueue: return dldirectory, None async def __add_download(self, dl, auto_start): - dldirectory, error_message = self.__calc_download_path(dl.quality, dl.format, dl.folder) + dldirectory, error_message = self.__calc_download_path(dl.download_type, dl.folder) if error_message is not None: return error_message output = self.config.OUTPUT_TEMPLATE if len(dl.custom_name_prefix) == 0 else f'{dl.custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}' @@ -677,20 +721,20 @@ class DownloadQueue: async def __add_entry( self, entry, - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, - subtitle_format, subtitle_language, subtitle_mode, already, _add_gen=None, - video_codec="auto", ): if not entry: return {'status': 'error', 'msg': "Invalid/empty data was given."} @@ -709,20 +753,20 @@ class DownloadQueue: log.debug('Processing as a url') return await self.add( entry['url'], - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, - subtitle_format, subtitle_language, subtitle_mode, already, _add_gen, - video_codec, ) elif etype == 'playlist' or etype == 'channel': log.debug(f'Processing as a {etype}') @@ -749,20 +793,20 @@ class DownloadQueue: results.append( await self.__add_entry( etr, - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, - subtitle_format, subtitle_language, subtitle_mode, already, _add_gen, - video_codec, ) ) if any(res['status'] == 'error' for res in results): @@ -776,22 +820,22 @@ class DownloadQueue: return {'status': 'ok'} if not self.queue.exists(key): dl = DownloadInfo( - entry['id'], - entry.get('title') or entry['id'], - key, - quality, - format, - folder, - custom_name_prefix, - error, - entry, - playlist_item_limit, - split_by_chapters, - chapter_template, - subtitle_format, - subtitle_language, - subtitle_mode, - video_codec, + id=entry['id'], + title=entry.get('title') or entry['id'], + url=key, + quality=quality, + download_type=download_type, + codec=codec, + format=format, + folder=folder, + custom_name_prefix=custom_name_prefix, + error=error, + entry=entry, + playlist_item_limit=playlist_item_limit, + split_by_chapters=split_by_chapters, + chapter_template=chapter_template, + subtitle_language=subtitle_language, + subtitle_mode=subtitle_mode, ) await self.__add_download(dl, auto_start) return {'status': 'ok'} @@ -800,26 +844,25 @@ class DownloadQueue: async def add( self, url, - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start=True, split_by_chapters=False, chapter_template=None, - subtitle_format="srt", subtitle_language="en", subtitle_mode="prefer_manual", already=None, _add_gen=None, - video_codec="auto", ): log.info( - f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} ' + f'adding {url}: {download_type=} {codec=} {format=} {quality=} {already=} {folder=} {custom_name_prefix=} ' f'{playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} ' - f'{subtitle_format=} {subtitle_language=} {subtitle_mode=} ' - f'{video_codec=}' + f'{subtitle_language=} {subtitle_mode=}' ) if already is None: _add_gen = self._add_generation @@ -836,20 +879,20 @@ class DownloadQueue: return {'status': 'error', 'msg': str(exc)} return await self.__add_entry( entry, - quality, + download_type, + codec, format, + quality, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, - subtitle_format, subtitle_language, subtitle_mode, already, _add_gen, - video_codec, ) async def start_pending(self, ids): @@ -889,7 +932,7 @@ class DownloadQueue: if self.config.DELETE_FILE_ON_TRASHCAN: dl = self.done.get(id) try: - dldirectory, _ = self.__calc_download_path(dl.info.quality, dl.info.format, dl.info.folder) + dldirectory, _ = self.__calc_download_path(dl.info.download_type, dl.info.folder) os.remove(os.path.join(dldirectory, dl.info.filename)) except Exception as e: log.warn(f'deleting file for download {id} failed with error message {e!r}') diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 56a0b44..9c3b8d7 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -120,39 +120,205 @@