Add timestamp parsing and handling for downloads from URLs

This commit is contained in:
ph 2026-02-23 21:11:30 -07:00
parent e24890fd9b
commit 458bf12a9d

View file

@ -18,6 +18,7 @@ import yt_dlp.networking.impersonate
from yt_dlp.utils import STR_FORMAT_RE_TMPL, STR_FORMAT_TYPES 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
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
log = logging.getLogger('ytdl') log = logging.getLogger('ytdl')
@ -69,6 +70,60 @@ def _convert_generators_to_lists(obj):
else: else:
return obj return obj
def _parse_time_value(value):
"""Parse a timestamp value like '885', '14m45s', '1h2m3s' into seconds.
Returns None if the value cannot be parsed."""
if not value:
return None
# Try plain integer (seconds)
try:
seconds = int(value)
return seconds if seconds > 0 else None
except ValueError:
pass
# Try HMS format: 1h2m3s, 14m45s, 30s, 2h, etc.
match = re.match(r'^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$', value, re.IGNORECASE)
if match and any(match.groups()):
hours = int(match.group(1) or 0)
minutes = int(match.group(2) or 0)
seconds = int(match.group(3) or 0)
total = hours * 3600 + minutes * 60 + seconds
return total if total > 0 else None
return None
def parse_timestamp_from_url(url):
"""Extract a 't' query parameter from a URL and return (cleaned_url, start_seconds).
If no valid timestamp is found, returns (url, None) with the URL unchanged."""
try:
parsed = urlparse(url)
params = parse_qs(parsed.query)
except Exception:
return url, None
t_values = params.get('t')
if not t_values:
return url, None
start_seconds = _parse_time_value(t_values[0])
if start_seconds is None:
return url, None
# Rebuild the URL without the 't' parameter
filtered_params = {k: v for k, v in params.items() if k != 't'}
new_query = urlencode(filtered_params, doseq=True)
cleaned_url = urlunparse((
parsed.scheme, parsed.netloc, parsed.path,
parsed.params, new_query, parsed.fragment
))
log.info(f'Extracted start timestamp {start_seconds}s from URL, cleaned URL: {cleaned_url}')
return cleaned_url, start_seconds
class DownloadQueueNotifier: class DownloadQueueNotifier:
async def added(self, dl): async def added(self, dl):
raise NotImplementedError raise NotImplementedError
@ -86,7 +141,7 @@ class DownloadQueueNotifier:
raise NotImplementedError raise NotImplementedError
class DownloadInfo: class DownloadInfo:
def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit, split_by_chapters, chapter_template): def __init__(self, id, title, url, quality, format, folder, custom_name_prefix, error, entry, playlist_item_limit, split_by_chapters, chapter_template, start_timestamp=None):
self.id = id if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{id}' 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.title = title if len(custom_name_prefix) == 0 else f'{custom_name_prefix}.{title}'
self.url = url self.url = url
@ -104,6 +159,7 @@ class DownloadInfo:
self.playlist_item_limit = playlist_item_limit self.playlist_item_limit = playlist_item_limit
self.split_by_chapters = split_by_chapters self.split_by_chapters = split_by_chapters
self.chapter_template = chapter_template self.chapter_template = chapter_template
self.start_timestamp = start_timestamp
class Download: class Download:
manager = None manager = None
@ -196,6 +252,12 @@ class Download:
'force_keyframes': False 'force_keyframes': False
}) })
# Add timestamp-based download range if a start timestamp was extracted from the URL
if getattr(self.info, 'start_timestamp', None) is not None:
log.info(f"Applying download range: start at {self.info.start_timestamp}s")
ytdl_params['download_ranges'] = yt_dlp.utils.download_range_func(None, [(self.info.start_timestamp, None)])
ytdl_params['force_keyframes_at_cuts'] = True
ret = yt_dlp.YoutubeDL(params=ytdl_params).download([self.info.url]) ret = yt_dlp.YoutubeDL(params=ytdl_params).download([self.info.url])
self.status_queue.put({'status': 'finished' if ret == 0 else 'error'}) self.status_queue.put({'status': 'finished' if ret == 0 else 'error'})
log.info(f"Finished download for: {self.info.title}") log.info(f"Finished download for: {self.info.title}")
@ -526,7 +588,7 @@ class DownloadQueue:
self.pending.put(download) self.pending.put(download)
await self.notifier.added(dl) await self.notifier.added(dl)
async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already): async def __add_entry(self, entry, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already, start_timestamp=None):
if not entry: if not entry:
return {'status': 'error', 'msg': "Invalid/empty data was given."} return {'status': 'error', 'msg': "Invalid/empty data was given."}
@ -542,7 +604,7 @@ class DownloadQueue:
if etype.startswith('url'): if etype.startswith('url'):
log.debug('Processing as a url') log.debug('Processing as a url')
return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already) return await self.add(entry['url'], quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already, start_timestamp)
elif etype == 'playlist' or etype == 'channel': elif etype == 'playlist' or etype == 'channel':
log.debug(f'Processing as a {etype}') log.debug(f'Processing as a {etype}')
entries = entry['entries'] entries = entry['entries']
@ -562,7 +624,7 @@ class DownloadQueue:
for property in ("id", "title", "uploader", "uploader_id"): for property in ("id", "title", "uploader", "uploader_id"):
if property in entry: if property in entry:
etr[f"{etype}_{property}"] = entry[property] etr[f"{etype}_{property}"] = entry[property]
results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already)) results.append(await self.__add_entry(etr, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already, None))
if any(res['status'] == 'error' for res in results): if any(res['status'] == 'error' for res in results):
return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)} return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
return {'status': 'ok'} return {'status': 'ok'}
@ -570,13 +632,16 @@ class DownloadQueue:
log.debug('Processing as a video') log.debug('Processing as a video')
key = entry.get('webpage_url') or entry['url'] key = entry.get('webpage_url') or entry['url']
if not self.queue.exists(key): 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) 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, start_timestamp)
await self.__add_download(dl, auto_start) await self.__add_download(dl, auto_start)
return {'status': 'ok'} return {'status': 'ok'}
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'} return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
async def add(self, url, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start=True, split_by_chapters=False, chapter_template=None, already=None): async def add(self, url, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start=True, split_by_chapters=False, chapter_template=None, already=None, start_timestamp=None):
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=}') # Extract timestamp from URL on first call (when start_timestamp hasn't been set by a prior call)
if start_timestamp is None:
url, start_timestamp = parse_timestamp_from_url(url)
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=} {start_timestamp=}')
already = set() if already is None else already already = set() if already is None else already
if url in already: if url in already:
log.info('recursion detected, skipping') log.info('recursion detected, skipping')
@ -587,7 +652,7 @@ class DownloadQueue:
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url) entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url)
except yt_dlp.utils.YoutubeDLError as exc: except yt_dlp.utils.YoutubeDLError as exc:
return {'status': 'error', 'msg': str(exc)} return {'status': 'error', 'msg': str(exc)}
return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already) return await self.__add_entry(entry, quality, format, folder, custom_name_prefix, playlist_item_limit, auto_start, split_by_chapters, chapter_template, already, start_timestamp)
async def start_pending(self, ids): async def start_pending(self, ids):
for id in ids: for id in ids: