2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
c6b28477f9 Fix: Respect CUSTOM_DIRS setting to prevent folder creation
When CUSTOM_DIRS is false, do not use OUTPUT_TEMPLATE_PLAYLIST or OUTPUT_TEMPLATE_CHANNEL which contain folder paths. This prevents automatic creation of folders for playlist/channel downloads when users have explicitly disabled custom directories.

Co-authored-by: alexta69 <7450369+alexta69@users.noreply.github.com>
2026-02-08 06:08:17 +00:00
copilot-swe-agent[bot]
49fba670af Initial plan 2026-02-08 06:06:01 +00:00

View File

@@ -11,53 +11,13 @@ import re
import types import types
import dbm import dbm
import subprocess import subprocess
from typing import Any
from functools import lru_cache
import yt_dlp.networking.impersonate import yt_dlp.networking.impersonate
from yt_dlp.utils import STR_FORMAT_RE_TMPL, STR_FORMAT_TYPES
from dl_formats import get_format, get_opts, AUDIO_FORMATS from dl_formats import get_format, get_opts, AUDIO_FORMATS
from datetime import datetime from datetime import datetime
log = logging.getLogger('ytdl') log = logging.getLogger('ytdl')
@lru_cache(maxsize=None)
def _compile_outtmpl_pattern(field: str) -> re.Pattern:
"""Compile a regex pattern to match a specific field in an output template, including optional format specifiers."""
conversion_types = f"[{re.escape(STR_FORMAT_TYPES)}]"
return re.compile(STR_FORMAT_RE_TMPL.format(re.escape(field), conversion_types))
def _outtmpl_substitute_field(template: str, field: str, value: Any) -> str:
"""Substitute a single field in an output template, applying any format specifiers to the value."""
pattern = _compile_outtmpl_pattern(field)
def replacement(match: re.Match) -> str:
if match.group("has_key") is None:
return match.group(0)
prefix = match.group("prefix") or ""
format_spec = match.group("format")
if not format_spec:
return f"{prefix}{value}"
conversion_type = format_spec[-1]
try:
if conversion_type in "diouxX":
coerced_value = int(value)
elif conversion_type in "eEfFgG":
coerced_value = float(value)
else:
coerced_value = value
return f"{prefix}{('%' + format_spec) % coerced_value}"
except (ValueError, TypeError):
return f"{prefix}{value}"
return pattern.sub(replacement, template)
def _convert_generators_to_lists(obj): def _convert_generators_to_lists(obj):
"""Recursively convert generators to lists in a dictionary to make it pickleable.""" """Recursively convert generators to lists in a dictionary to make it pickleable."""
if isinstance(obj, types.GeneratorType): if isinstance(obj, types.GeneratorType):
@@ -144,7 +104,10 @@ class Download:
def put_status_postprocessor(d): def put_status_postprocessor(d):
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished': if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
filename = d['info_dict']['filepath'] if '__finaldir' in d['info_dict']:
filename = os.path.join(d['info_dict']['__finaldir'], os.path.basename(d['info_dict']['filepath']))
else:
filename = d['info_dict']['filepath']
self.status_queue.put({'status': 'finished', 'filename': filename}) self.status_queue.put({'status': 'finished', 'filename': filename})
# Capture all chapter files when SplitChapters finishes # Capture all chapter files when SplitChapters finishes
@@ -200,14 +163,8 @@ class Download:
self.notifier = notifier self.notifier = notifier
self.info.status = 'preparing' self.info.status = 'preparing'
await self.notifier.updated(self.info) await self.notifier.updated(self.info)
self.status_task = asyncio.create_task(self.update_status()) asyncio.create_task(self.update_status())
await self.loop.run_in_executor(None, self.proc.join) return await self.loop.run_in_executor(None, self.proc.join)
# Signal update_status to stop and wait for it to finish
# so that all status updates (including MoveFiles with correct
# file size) are processed before _post_download_cleanup runs.
if self.status_queue is not None:
self.status_queue.put(None)
await self.status_task
def cancel(self): def cancel(self):
log.info(f"Cancelling download: {self.info.title}") log.info(f"Cancelling download: {self.info.title}")
@@ -224,6 +181,8 @@ class Download:
log.info(f"Closing download process for: {self.info.title}") log.info(f"Closing download process for: {self.info.title}")
if self.started(): if self.started():
self.proc.close() self.proc.close()
if self.status_queue is not None:
self.status_queue.put(None)
def running(self): def running(self):
try: try:
@@ -467,7 +426,7 @@ class DownloadQueue:
base_directory = self.config.DOWNLOAD_DIR if (quality != 'audio' and format not in AUDIO_FORMATS) else self.config.AUDIO_DOWNLOAD_DIR base_directory = self.config.DOWNLOAD_DIR if (quality != 'audio' and format not in AUDIO_FORMATS) else self.config.AUDIO_DOWNLOAD_DIR
if folder: if folder:
if not self.config.CUSTOM_DIRS: 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.'} return None, {'status': 'error', 'msg': f'A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.'}
dldirectory = os.path.realpath(os.path.join(base_directory, folder)) dldirectory = os.path.realpath(os.path.join(base_directory, folder))
real_base_directory = os.path.realpath(base_directory) real_base_directory = os.path.realpath(base_directory)
if not dldirectory.startswith(real_base_directory): if not dldirectory.startswith(real_base_directory):
@@ -487,18 +446,20 @@ class DownloadQueue:
output = self.config.OUTPUT_TEMPLATE if len(dl.custom_name_prefix) == 0 else f'{dl.custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}' output = self.config.OUTPUT_TEMPLATE if len(dl.custom_name_prefix) == 0 else f'{dl.custom_name_prefix}.{self.config.OUTPUT_TEMPLATE}'
output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER output_chapter = self.config.OUTPUT_TEMPLATE_CHAPTER
entry = getattr(dl, 'entry', None) entry = getattr(dl, 'entry', None)
if entry is not None and entry.get('playlist_index') is not None: # Only use playlist/channel templates if CUSTOM_DIRS is enabled
if len(self.config.OUTPUT_TEMPLATE_PLAYLIST): if self.config.CUSTOM_DIRS:
output = self.config.OUTPUT_TEMPLATE_PLAYLIST if entry is not None and 'playlist' in entry and entry['playlist'] is not None:
for property, value in entry.items(): if len(self.config.OUTPUT_TEMPLATE_PLAYLIST):
if property.startswith("playlist"): output = self.config.OUTPUT_TEMPLATE_PLAYLIST
output = _outtmpl_substitute_field(output, property, value) for property, value in entry.items():
if entry is not None and entry.get('channel_index') is not None: if property.startswith("playlist"):
if len(self.config.OUTPUT_TEMPLATE_CHANNEL): output = output.replace(f"%({property})s", str(value))
output = self.config.OUTPUT_TEMPLATE_CHANNEL if entry is not None and 'channel' in entry and entry['channel'] is not None:
for property, value in entry.items(): if len(self.config.OUTPUT_TEMPLATE_CHANNEL):
if property.startswith("channel"): output = self.config.OUTPUT_TEMPLATE_CHANNEL
output = _outtmpl_substitute_field(output, property, value) for property, value in entry.items():
if property.startswith("channel"):
output = output.replace(f"%({property})s", str(value))
ytdl_options = dict(self.config.YTDL_OPTIONS) ytdl_options = dict(self.config.YTDL_OPTIONS)
playlist_item_limit = getattr(dl, 'playlist_item_limit', 0) playlist_item_limit = getattr(dl, 'playlist_item_limit', 0)
if playlist_item_limit > 0: if playlist_item_limit > 0: