Compare commits
5 Commits
2026.02.08
...
2026.02.14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bf7fb51f4 | ||
|
|
8ae06c65d0 | ||
|
|
97378d8704 | ||
|
|
de7e1418b5 | ||
|
|
f47e5db284 |
73
app/ytdl.py
73
app/ytdl.py
@@ -11,13 +11,53 @@ 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):
|
||||||
@@ -104,10 +144,21 @@ 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':
|
||||||
|
filepath = d['info_dict']['filepath']
|
||||||
if '__finaldir' in d['info_dict']:
|
if '__finaldir' in d['info_dict']:
|
||||||
filename = os.path.join(d['info_dict']['__finaldir'], os.path.basename(d['info_dict']['filepath']))
|
finaldir = d['info_dict']['__finaldir']
|
||||||
|
# Compute relative path from temp dir to preserve
|
||||||
|
# subdirectory structure from the output template.
|
||||||
|
try:
|
||||||
|
rel_path = os.path.relpath(filepath, self.temp_dir)
|
||||||
|
except ValueError:
|
||||||
|
rel_path = os.path.basename(filepath)
|
||||||
|
if rel_path.startswith('..'):
|
||||||
|
# filepath is not under temp_dir, fall back to basename
|
||||||
|
rel_path = os.path.basename(filepath)
|
||||||
|
filename = os.path.join(finaldir, rel_path)
|
||||||
else:
|
else:
|
||||||
filename = d['info_dict']['filepath']
|
filename = 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
|
||||||
@@ -163,8 +214,14 @@ 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)
|
||||||
asyncio.create_task(self.update_status())
|
self.status_task = asyncio.create_task(self.update_status())
|
||||||
return await self.loop.run_in_executor(None, self.proc.join)
|
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}")
|
||||||
@@ -181,8 +238,6 @@ 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:
|
||||||
@@ -426,7 +481,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': f'A folder for the download was specified but CUSTOM_DIRS is not true in the configuration.'}
|
return None, {'status': 'error', 'msg': '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):
|
||||||
@@ -451,13 +506,13 @@ class DownloadQueue:
|
|||||||
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
|
output = self.config.OUTPUT_TEMPLATE_PLAYLIST
|
||||||
for property, value in entry.items():
|
for property, value in entry.items():
|
||||||
if property.startswith("playlist"):
|
if property.startswith("playlist"):
|
||||||
output = output.replace(f"%({property})s", str(value))
|
output = _outtmpl_substitute_field(output, property, value)
|
||||||
if entry is not None and entry.get('channel_index') is not None:
|
if entry is not None and entry.get('channel_index') is not None:
|
||||||
if len(self.config.OUTPUT_TEMPLATE_CHANNEL):
|
if len(self.config.OUTPUT_TEMPLATE_CHANNEL):
|
||||||
output = self.config.OUTPUT_TEMPLATE_CHANNEL
|
output = self.config.OUTPUT_TEMPLATE_CHANNEL
|
||||||
for property, value in entry.items():
|
for property, value in entry.items():
|
||||||
if property.startswith("channel"):
|
if property.startswith("channel"):
|
||||||
output = output.replace(f"%({property})s", str(value))
|
output = _outtmpl_substitute_field(output, property, 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:
|
||||||
|
|||||||
Reference in New Issue
Block a user