Add timestamp parsing and handling for downloads from URLs
This commit is contained in:
parent
e24890fd9b
commit
458bf12a9d
1 changed files with 73 additions and 8 deletions
81
app/ytdl.py
81
app/ytdl.py
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue