mirror of
https://github.com/alexta69/metube.git
synced 2026-06-13 16:40:05 +00:00
+149
@@ -14,6 +14,7 @@ import logging
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
from watchfiles import DefaultFilter, Change, awatch
|
||||
|
||||
from ytdl import DownloadQueueNotifier, DownloadQueue, Download
|
||||
@@ -260,6 +261,115 @@ def _parse_ytdl_options_overrides(value, *, enabled: bool) -> dict:
|
||||
return value
|
||||
|
||||
|
||||
_YOUTUBE_T_COMPACT_RE = re.compile(
|
||||
r'^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)(?:s)?)?$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_youtube_t_compact(value: str) -> float | None:
|
||||
"""Parse YouTube-style ``t`` values: ``885``, ``885s``, ``14m45s``, ``1h2m3s``."""
|
||||
v = value.strip()
|
||||
if not v:
|
||||
return None
|
||||
if re.fullmatch(r'-?\d+(\.\d+)?', v):
|
||||
sec = float(v)
|
||||
return sec if sec >= 0 else None
|
||||
m = _YOUTUBE_T_COMPACT_RE.match(v)
|
||||
if m and any(m.groups()):
|
||||
hours = int(m.group(1) or 0)
|
||||
minutes = int(m.group(2) or 0)
|
||||
seconds = int(m.group(3) or 0)
|
||||
total = hours * 3600 + minutes * 60 + seconds
|
||||
return float(total) if total >= 0 else None
|
||||
return None
|
||||
|
||||
|
||||
def _parse_clock_timestamp(s: str) -> float:
|
||||
"""Parse ``MM:SS``, ``H:MM:SS``, or single segment as seconds (with optional decimals)."""
|
||||
part = s.strip()
|
||||
if not part:
|
||||
raise ValueError('empty timestamp')
|
||||
segments = part.split(':')
|
||||
if len(segments) > 3:
|
||||
raise ValueError('too many segments')
|
||||
try:
|
||||
nums = [float(x) for x in segments]
|
||||
except ValueError as exc:
|
||||
raise ValueError('invalid number') from exc
|
||||
if any(x < 0 for x in nums):
|
||||
raise ValueError('negative segment')
|
||||
if len(segments) == 1:
|
||||
return nums[0]
|
||||
if len(segments) == 2:
|
||||
return nums[0] * 60 + nums[1]
|
||||
return nums[0] * 3600 + nums[1] * 60 + nums[2]
|
||||
|
||||
|
||||
def _parse_clip_timestamp_value(value) -> float:
|
||||
"""Coerce a clip boundary from JSON to seconds (non-negative)."""
|
||||
if isinstance(value, bool):
|
||||
raise web.HTTPBadRequest(reason='clip timestamp must be a number or string')
|
||||
if isinstance(value, (int, float)):
|
||||
if value < 0:
|
||||
raise web.HTTPBadRequest(reason='clip timestamp must be non-negative')
|
||||
return float(value)
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
raise web.HTTPBadRequest(reason='clip timestamp cannot be empty')
|
||||
if ':' in s:
|
||||
try:
|
||||
return _parse_clock_timestamp(s)
|
||||
except ValueError as exc:
|
||||
raise web.HTTPBadRequest(reason='invalid clip timestamp format') from exc
|
||||
compact = _parse_youtube_t_compact(s)
|
||||
if compact is not None:
|
||||
return compact
|
||||
raise web.HTTPBadRequest(reason='invalid clip timestamp format')
|
||||
|
||||
|
||||
def _optional_clip_field(raw) -> float | None:
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, str) and not raw.strip():
|
||||
return None
|
||||
return _parse_clip_timestamp_value(raw)
|
||||
|
||||
|
||||
def _clip_field_provided_in_post(raw) -> bool:
|
||||
if raw is None:
|
||||
return False
|
||||
if isinstance(raw, str) and not raw.strip():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _extract_t_query_from_url(url: str) -> tuple[str, float | None]:
|
||||
"""If ``t=`` is present and parseable, return URL without ``t`` and start seconds."""
|
||||
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 = _parse_youtube_t_compact(t_values[0])
|
||||
if start is None:
|
||||
return url, None
|
||||
filtered = {k: v for k, v in params.items() if k != 't'}
|
||||
new_query = urlencode(filtered, doseq=True)
|
||||
cleaned = urlunparse((
|
||||
parsed.scheme,
|
||||
parsed.netloc,
|
||||
parsed.path,
|
||||
parsed.params,
|
||||
new_query,
|
||||
parsed.fragment,
|
||||
))
|
||||
return cleaned, float(start)
|
||||
|
||||
|
||||
def _parse_ytdl_options_presets(post: dict) -> list[str]:
|
||||
"""Normalize preset names from add/subscribe body; supports list or legacy singular string."""
|
||||
raw = post.get('ytdl_options_presets')
|
||||
@@ -542,6 +652,39 @@ def parse_download_options(post: dict) -> dict:
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise web.HTTPBadRequest(reason='playlist_item_limit must be an integer') from exc
|
||||
|
||||
clip_start_raw = post.get('clip_start')
|
||||
clip_end_raw = post.get('clip_end')
|
||||
clip_start: float | None
|
||||
clip_end: float | None
|
||||
if download_type in ('captions', 'thumbnail'):
|
||||
if _clip_field_provided_in_post(clip_start_raw) or _clip_field_provided_in_post(clip_end_raw):
|
||||
raise web.HTTPBadRequest(
|
||||
reason='clip_start and clip_end are only supported for video and audio downloads',
|
||||
)
|
||||
clip_start = None
|
||||
clip_end = None
|
||||
else:
|
||||
cleaned_url, url_t = _extract_t_query_from_url(url)
|
||||
if url_t is not None:
|
||||
url = cleaned_url
|
||||
explicit_start = _optional_clip_field(clip_start_raw)
|
||||
explicit_end = _optional_clip_field(clip_end_raw)
|
||||
explicit_start_provided = _clip_field_provided_in_post(clip_start_raw)
|
||||
explicit_end_provided = _clip_field_provided_in_post(clip_end_raw)
|
||||
if explicit_start_provided:
|
||||
clip_start = explicit_start
|
||||
elif explicit_end_provided:
|
||||
clip_start = 0.0
|
||||
elif url_t is not None:
|
||||
clip_start = url_t
|
||||
else:
|
||||
clip_start = None
|
||||
clip_end = explicit_end
|
||||
if clip_end is not None and clip_start is None:
|
||||
clip_start = 0.0
|
||||
if clip_start is not None and clip_end is not None and clip_end <= clip_start:
|
||||
raise web.HTTPBadRequest(reason='clip_end must be greater than clip_start')
|
||||
|
||||
return {
|
||||
'url': url,
|
||||
'download_type': download_type,
|
||||
@@ -558,6 +701,8 @@ def parse_download_options(post: dict) -> dict:
|
||||
'subtitle_mode': subtitle_mode,
|
||||
'ytdl_options_presets': ytdl_options_presets,
|
||||
'ytdl_options_overrides': ytdl_options_overrides,
|
||||
'clip_start': clip_start,
|
||||
'clip_end': clip_end,
|
||||
}
|
||||
|
||||
|
||||
@@ -594,6 +739,8 @@ async def add(request):
|
||||
o['subtitle_mode'],
|
||||
o['ytdl_options_presets'],
|
||||
o['ytdl_options_overrides'],
|
||||
o['clip_start'],
|
||||
o['clip_end'],
|
||||
)
|
||||
return web.Response(text=serializer.encode(status))
|
||||
|
||||
@@ -627,6 +774,8 @@ async def subscribe(request):
|
||||
raise web.HTTPBadRequest(reason='check_interval_minutes must be an integer') from exc
|
||||
if cic < 1:
|
||||
raise web.HTTPBadRequest(reason='check_interval_minutes must be at least 1')
|
||||
if o.get('clip_start') is not None or o.get('clip_end') is not None:
|
||||
raise web.HTTPBadRequest(reason='clip options are not supported for subscriptions')
|
||||
|
||||
result = await submgr.add_subscription(
|
||||
o['url'],
|
||||
|
||||
@@ -279,3 +279,30 @@ async def test_add_legacy_format_migrated(mock_dqueue):
|
||||
call = mock_dqueue.add.await_args
|
||||
assert call is not None
|
||||
assert call.args[1] == "audio"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_passes_clip_bounds_to_queue(mock_dqueue):
|
||||
req = _json_request(
|
||||
_valid_video_add_body(clip_start="2:26", clip_end="3:24"),
|
||||
)
|
||||
resp = await main.add(req)
|
||||
assert resp.status == 200
|
||||
call = mock_dqueue.add.await_args
|
||||
assert call is not None
|
||||
assert call.args[15] == pytest.approx(146.0)
|
||||
assert call.args[16] == pytest.approx(204.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_rejects_clip_options(mock_dqueue, monkeypatch):
|
||||
monkeypatch.setattr(main.submgr, "add_subscription", AsyncMock())
|
||||
req = _json_request(
|
||||
{
|
||||
**_valid_video_add_body(clip_start="10"),
|
||||
"check_interval_minutes": 60,
|
||||
}
|
||||
)
|
||||
with pytest.raises(web.HTTPBadRequest):
|
||||
await main.subscribe(req)
|
||||
main.submgr.add_subscription.assert_not_awaited()
|
||||
|
||||
@@ -351,3 +351,38 @@ async def test_extract_info_metube_extract_keys_win_over_preset(dq_env):
|
||||
assert result["status"] == "ok"
|
||||
assert captured_params[0]["extract_flat"] is True
|
||||
assert captured_params[0]["noplaylist"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_sets_clip_bounds_on_download_info(dq_env):
|
||||
notifier = AsyncMock()
|
||||
|
||||
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
|
||||
return {
|
||||
"_type": "video",
|
||||
"id": "vid1",
|
||||
"title": "Test Video",
|
||||
"url": url,
|
||||
"webpage_url": url,
|
||||
}
|
||||
|
||||
dq = DownloadQueue(dq_env, notifier)
|
||||
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
||||
result = await dq.add(
|
||||
"https://example.com/clip",
|
||||
"video",
|
||||
"auto",
|
||||
"any",
|
||||
"best",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
auto_start=False,
|
||||
clip_start=10.0,
|
||||
clip_end=99.5,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
download = dq.pending.get("https://example.com/clip")
|
||||
assert download.info.clip_start == 10.0
|
||||
assert download.info.clip_end == 99.5
|
||||
|
||||
@@ -205,6 +205,80 @@ class ParseDownloadOptionsTests(unittest.TestCase):
|
||||
finally:
|
||||
main.config.YTDL_OPTIONS_PRESETS = previous
|
||||
|
||||
def test_clip_start_end_seconds_and_clock(self):
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
"clip_start": "2:26",
|
||||
"clip_end": "3:24",
|
||||
})
|
||||
self.assertEqual(parsed["clip_start"], 146.0)
|
||||
self.assertEqual(parsed["clip_end"], 204.0)
|
||||
|
||||
def test_clip_url_t_param_strips_query_and_sets_start(self):
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1&t=855s",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
})
|
||||
self.assertEqual(parsed["url"], "https://example.com/watch?v=1")
|
||||
self.assertEqual(parsed["clip_start"], 855.0)
|
||||
self.assertIsNone(parsed["clip_end"])
|
||||
|
||||
def test_clip_explicit_start_wins_over_url_t(self):
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1&t=100",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
"clip_start": "50",
|
||||
})
|
||||
self.assertEqual(parsed["url"], "https://example.com/watch?v=1")
|
||||
self.assertEqual(parsed["clip_start"], 50.0)
|
||||
self.assertIsNone(parsed["clip_end"])
|
||||
|
||||
def test_clip_end_only_sets_start_zero_and_strips_url_t(self):
|
||||
parsed = main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1&t=999",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
"clip_end": "60",
|
||||
})
|
||||
self.assertEqual(parsed["url"], "https://example.com/watch?v=1")
|
||||
self.assertEqual(parsed["clip_start"], 0.0)
|
||||
self.assertEqual(parsed["clip_end"], 60.0)
|
||||
|
||||
def test_clip_rejects_end_before_start(self):
|
||||
with self.assertRaises(main.web.HTTPBadRequest):
|
||||
main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1",
|
||||
"download_type": "video",
|
||||
"codec": "auto",
|
||||
"format": "any",
|
||||
"quality": "best",
|
||||
"clip_start": "100",
|
||||
"clip_end": "50",
|
||||
})
|
||||
|
||||
def test_clip_rejected_for_captions(self):
|
||||
with self.assertRaises(main.web.HTTPBadRequest):
|
||||
main.parse_download_options({
|
||||
"url": "https://example.com/watch?v=1",
|
||||
"download_type": "captions",
|
||||
"codec": "auto",
|
||||
"format": "srt",
|
||||
"quality": "best",
|
||||
"clip_start": "1",
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+37
-1
@@ -192,6 +192,8 @@ class DownloadInfo:
|
||||
subtitle_mode="prefer_manual",
|
||||
ytdl_options_presets=None,
|
||||
ytdl_options_overrides=None,
|
||||
clip_start=None,
|
||||
clip_end=None,
|
||||
):
|
||||
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}'
|
||||
@@ -216,6 +218,8 @@ class DownloadInfo:
|
||||
self.subtitle_mode = subtitle_mode
|
||||
self.ytdl_options_presets = list(ytdl_options_presets or [])
|
||||
self.ytdl_options_overrides = dict(ytdl_options_overrides or {})
|
||||
self.clip_start = clip_start
|
||||
self.clip_end = clip_end
|
||||
self.subtitle_files = []
|
||||
|
||||
def __setstate__(self, state):
|
||||
@@ -284,6 +288,10 @@ class DownloadInfo:
|
||||
self.subtitle_files = []
|
||||
if not hasattr(self, "chapter_files"):
|
||||
self.chapter_files = []
|
||||
if not hasattr(self, "clip_start"):
|
||||
self.clip_start = None
|
||||
if not hasattr(self, "clip_end"):
|
||||
self.clip_end = None
|
||||
|
||||
|
||||
_PERSISTED_DOWNLOAD_FIELDS = (
|
||||
@@ -303,6 +311,8 @@ _PERSISTED_DOWNLOAD_FIELDS = (
|
||||
"subtitle_mode",
|
||||
"ytdl_options_presets",
|
||||
"ytdl_options_overrides",
|
||||
"clip_start",
|
||||
"clip_end",
|
||||
"status",
|
||||
"timestamp",
|
||||
"error",
|
||||
@@ -473,6 +483,16 @@ class Download:
|
||||
'force_keyframes': False
|
||||
})
|
||||
|
||||
clip_start = getattr(self.info, 'clip_start', None)
|
||||
clip_end = getattr(self.info, 'clip_end', None)
|
||||
if clip_start is not None or clip_end is not None:
|
||||
start = float(clip_start) if clip_start is not None else 0.0
|
||||
end = float(clip_end) if clip_end is not None else float('inf')
|
||||
ytdl_params['download_ranges'] = yt_dlp.utils.download_range_func(
|
||||
None,
|
||||
[(start, end)],
|
||||
)
|
||||
|
||||
ret = yt_dlp.YoutubeDL(params=ytdl_params).download([self.info.url])
|
||||
self.status_queue.put({'status': 'finished' if ret == 0 else 'error'})
|
||||
log.info(f"Finished download for: {self.info.title}")
|
||||
@@ -890,6 +910,8 @@ class DownloadQueue:
|
||||
subtitle_mode,
|
||||
ytdl_options_presets,
|
||||
ytdl_options_overrides,
|
||||
clip_start,
|
||||
clip_end,
|
||||
already,
|
||||
_add_gen=None,
|
||||
):
|
||||
@@ -924,6 +946,8 @@ class DownloadQueue:
|
||||
subtitle_mode,
|
||||
ytdl_options_presets,
|
||||
ytdl_options_overrides,
|
||||
clip_start,
|
||||
clip_end,
|
||||
already,
|
||||
_add_gen,
|
||||
)
|
||||
@@ -975,6 +999,8 @@ class DownloadQueue:
|
||||
subtitle_mode,
|
||||
ytdl_options_presets,
|
||||
ytdl_options_overrides,
|
||||
clip_start,
|
||||
clip_end,
|
||||
already,
|
||||
_add_gen,
|
||||
)
|
||||
@@ -1008,6 +1034,8 @@ class DownloadQueue:
|
||||
subtitle_mode=subtitle_mode,
|
||||
ytdl_options_presets=ytdl_options_presets,
|
||||
ytdl_options_overrides=ytdl_options_overrides,
|
||||
clip_start=clip_start,
|
||||
clip_end=clip_end,
|
||||
)
|
||||
await self.__add_download(dl, auto_start)
|
||||
return {'status': 'ok'}
|
||||
@@ -1030,6 +1058,8 @@ class DownloadQueue:
|
||||
subtitle_mode="prefer_manual",
|
||||
ytdl_options_presets=None,
|
||||
ytdl_options_overrides=None,
|
||||
clip_start=None,
|
||||
clip_end=None,
|
||||
already=None,
|
||||
_add_gen=None,
|
||||
):
|
||||
@@ -1038,7 +1068,7 @@ class DownloadQueue:
|
||||
log.info(
|
||||
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_language=} {subtitle_mode=} {ytdl_options_presets=}'
|
||||
f'{subtitle_language=} {subtitle_mode=} {ytdl_options_presets=} {clip_start=} {clip_end=}'
|
||||
)
|
||||
if already is None:
|
||||
_add_gen = self._add_generation
|
||||
@@ -1072,6 +1102,8 @@ class DownloadQueue:
|
||||
subtitle_mode,
|
||||
ytdl_options_presets,
|
||||
ytdl_options_overrides,
|
||||
clip_start,
|
||||
clip_end,
|
||||
already,
|
||||
_add_gen,
|
||||
)
|
||||
@@ -1093,6 +1125,8 @@ class DownloadQueue:
|
||||
subtitle_mode="prefer_manual",
|
||||
ytdl_options_presets=None,
|
||||
ytdl_options_overrides=None,
|
||||
clip_start=None,
|
||||
clip_end=None,
|
||||
):
|
||||
if ytdl_options_presets is None:
|
||||
ytdl_options_presets = []
|
||||
@@ -1114,6 +1148,8 @@ class DownloadQueue:
|
||||
subtitle_mode,
|
||||
ytdl_options_presets,
|
||||
ytdl_options_overrides,
|
||||
clip_start,
|
||||
clip_end,
|
||||
already,
|
||||
None,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user