implement time-clipped downloads (closes #969, replaces #907)

This commit is contained in:
Alex Shnitman
2026-04-26 23:07:50 +03:00
parent 91ee8312bf
commit 4f83174d05
11 changed files with 426 additions and 4 deletions
+149
View File
@@ -14,6 +14,7 @@ import logging
import json import json
import pathlib import pathlib
import re import re
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
from watchfiles import DefaultFilter, Change, awatch from watchfiles import DefaultFilter, Change, awatch
from ytdl import DownloadQueueNotifier, DownloadQueue, Download from ytdl import DownloadQueueNotifier, DownloadQueue, Download
@@ -260,6 +261,115 @@ def _parse_ytdl_options_overrides(value, *, enabled: bool) -> dict:
return value 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]: def _parse_ytdl_options_presets(post: dict) -> list[str]:
"""Normalize preset names from add/subscribe body; supports list or legacy singular string.""" """Normalize preset names from add/subscribe body; supports list or legacy singular string."""
raw = post.get('ytdl_options_presets') raw = post.get('ytdl_options_presets')
@@ -542,6 +652,39 @@ def parse_download_options(post: dict) -> dict:
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise web.HTTPBadRequest(reason='playlist_item_limit must be an integer') from 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 { return {
'url': url, 'url': url,
'download_type': download_type, 'download_type': download_type,
@@ -558,6 +701,8 @@ def parse_download_options(post: dict) -> dict:
'subtitle_mode': subtitle_mode, 'subtitle_mode': subtitle_mode,
'ytdl_options_presets': ytdl_options_presets, 'ytdl_options_presets': ytdl_options_presets,
'ytdl_options_overrides': ytdl_options_overrides, '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['subtitle_mode'],
o['ytdl_options_presets'], o['ytdl_options_presets'],
o['ytdl_options_overrides'], o['ytdl_options_overrides'],
o['clip_start'],
o['clip_end'],
) )
return web.Response(text=serializer.encode(status)) 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 raise web.HTTPBadRequest(reason='check_interval_minutes must be an integer') from exc
if cic < 1: if cic < 1:
raise web.HTTPBadRequest(reason='check_interval_minutes must be at least 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( result = await submgr.add_subscription(
o['url'], o['url'],
+27
View File
@@ -279,3 +279,30 @@ async def test_add_legacy_format_migrated(mock_dqueue):
call = mock_dqueue.add.await_args call = mock_dqueue.add.await_args
assert call is not None assert call is not None
assert call.args[1] == "audio" 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()
+35
View File
@@ -351,3 +351,38 @@ async def test_extract_info_metube_extract_keys_win_over_preset(dq_env):
assert result["status"] == "ok" assert result["status"] == "ok"
assert captured_params[0]["extract_flat"] is True assert captured_params[0]["extract_flat"] is True
assert captured_params[0]["noplaylist"] 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
+74
View File
@@ -205,6 +205,80 @@ class ParseDownloadOptionsTests(unittest.TestCase):
finally: finally:
main.config.YTDL_OPTIONS_PRESETS = previous 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+37 -1
View File
@@ -192,6 +192,8 @@ class DownloadInfo:
subtitle_mode="prefer_manual", subtitle_mode="prefer_manual",
ytdl_options_presets=None, ytdl_options_presets=None,
ytdl_options_overrides=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.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}'
@@ -216,6 +218,8 @@ class DownloadInfo:
self.subtitle_mode = subtitle_mode self.subtitle_mode = subtitle_mode
self.ytdl_options_presets = list(ytdl_options_presets or []) self.ytdl_options_presets = list(ytdl_options_presets or [])
self.ytdl_options_overrides = dict(ytdl_options_overrides or {}) self.ytdl_options_overrides = dict(ytdl_options_overrides or {})
self.clip_start = clip_start
self.clip_end = clip_end
self.subtitle_files = [] self.subtitle_files = []
def __setstate__(self, state): def __setstate__(self, state):
@@ -284,6 +288,10 @@ class DownloadInfo:
self.subtitle_files = [] self.subtitle_files = []
if not hasattr(self, "chapter_files"): if not hasattr(self, "chapter_files"):
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 = ( _PERSISTED_DOWNLOAD_FIELDS = (
@@ -303,6 +311,8 @@ _PERSISTED_DOWNLOAD_FIELDS = (
"subtitle_mode", "subtitle_mode",
"ytdl_options_presets", "ytdl_options_presets",
"ytdl_options_overrides", "ytdl_options_overrides",
"clip_start",
"clip_end",
"status", "status",
"timestamp", "timestamp",
"error", "error",
@@ -473,6 +483,16 @@ class Download:
'force_keyframes': False '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]) 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}")
@@ -890,6 +910,8 @@ class DownloadQueue:
subtitle_mode, subtitle_mode,
ytdl_options_presets, ytdl_options_presets,
ytdl_options_overrides, ytdl_options_overrides,
clip_start,
clip_end,
already, already,
_add_gen=None, _add_gen=None,
): ):
@@ -924,6 +946,8 @@ class DownloadQueue:
subtitle_mode, subtitle_mode,
ytdl_options_presets, ytdl_options_presets,
ytdl_options_overrides, ytdl_options_overrides,
clip_start,
clip_end,
already, already,
_add_gen, _add_gen,
) )
@@ -975,6 +999,8 @@ class DownloadQueue:
subtitle_mode, subtitle_mode,
ytdl_options_presets, ytdl_options_presets,
ytdl_options_overrides, ytdl_options_overrides,
clip_start,
clip_end,
already, already,
_add_gen, _add_gen,
) )
@@ -1008,6 +1034,8 @@ class DownloadQueue:
subtitle_mode=subtitle_mode, subtitle_mode=subtitle_mode,
ytdl_options_presets=ytdl_options_presets, ytdl_options_presets=ytdl_options_presets,
ytdl_options_overrides=ytdl_options_overrides, ytdl_options_overrides=ytdl_options_overrides,
clip_start=clip_start,
clip_end=clip_end,
) )
await self.__add_download(dl, auto_start) await self.__add_download(dl, auto_start)
return {'status': 'ok'} return {'status': 'ok'}
@@ -1030,6 +1058,8 @@ class DownloadQueue:
subtitle_mode="prefer_manual", subtitle_mode="prefer_manual",
ytdl_options_presets=None, ytdl_options_presets=None,
ytdl_options_overrides=None, ytdl_options_overrides=None,
clip_start=None,
clip_end=None,
already=None, already=None,
_add_gen=None, _add_gen=None,
): ):
@@ -1038,7 +1068,7 @@ class DownloadQueue:
log.info( log.info(
f'adding {url}: {download_type=} {codec=} {format=} {quality=} {already=} {folder=} {custom_name_prefix=} ' 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'{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: if already is None:
_add_gen = self._add_generation _add_gen = self._add_generation
@@ -1072,6 +1102,8 @@ class DownloadQueue:
subtitle_mode, subtitle_mode,
ytdl_options_presets, ytdl_options_presets,
ytdl_options_overrides, ytdl_options_overrides,
clip_start,
clip_end,
already, already,
_add_gen, _add_gen,
) )
@@ -1093,6 +1125,8 @@ class DownloadQueue:
subtitle_mode="prefer_manual", subtitle_mode="prefer_manual",
ytdl_options_presets=None, ytdl_options_presets=None,
ytdl_options_overrides=None, ytdl_options_overrides=None,
clip_start=None,
clip_end=None,
): ):
if ytdl_options_presets is None: if ytdl_options_presets is None:
ytdl_options_presets = [] ytdl_options_presets = []
@@ -1114,6 +1148,8 @@ class DownloadQueue:
subtitle_mode, subtitle_mode,
ytdl_options_presets, ytdl_options_presets,
ytdl_options_overrides, ytdl_options_overrides,
clip_start,
clip_end,
already, already,
None, None,
) )
+28
View File
@@ -428,6 +428,34 @@
} }
</div> </div>
</div> </div>
@if (downloadType === 'video' || downloadType === 'audio') {
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Clip start</span>
<input type="text"
class="form-control"
name="clipStart"
[(ngModel)]="clipStart"
(change)="clipStartChanged()"
placeholder="e.g. 2:26"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Optional start time (seconds, M:SS, or H:MM:SS). Blank = from start or YouTube &t= in URL.">
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Clip end</span>
<input type="text"
class="form-control"
name="clipEnd"
[(ngModel)]="clipEnd"
(change)="clipEndChanged()"
placeholder="e.g. 3:24"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Optional end time. Blank = until end of media.">
</div>
</div>
}
</div> </div>
<!-- Behavior --> <!-- Behavior -->
+24
View File
@@ -194,6 +194,30 @@ describe('App', () => {
expect(payload.titleRegex).toBe('EPISODE'); expect(payload.titleRegex).toBe('EPISODE');
}); });
it('omits clip fields from subscribe payload', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
app.addUrl = 'https://example.com/channel';
app.clipStart = '1:00';
app.clipEnd = '2:00';
app.addSubscription();
expect(subs.subscribeCalls.length).toBe(1);
const payload = subs.subscribeCalls[0] as Record<string, unknown>;
expect('clipStart' in payload).toBe(false);
expect('clipEnd' in payload).toBe(false);
});
it('buildAddPayload includes clip times', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
app.clipStart = '0:10';
app.clipEnd = '1:20';
const payload = app['buildAddPayload']();
expect(payload.clipStart).toBe('0:10');
expect(payload.clipEnd).toBe('1:20');
});
it('blocks subscribe with invalid title regex', () => { it('blocks subscribe with invalid title regex', () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined); const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
+21 -1
View File
@@ -81,6 +81,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
playlistItemLimit!: number; playlistItemLimit!: number;
splitByChapters: boolean; splitByChapters: boolean;
chapterTemplate: string; chapterTemplate: string;
clipStart = '';
clipEnd = '';
subtitleLanguage: string; subtitleLanguage: string;
subtitleMode: string; subtitleMode: string;
ytdlOptionsPresets: string[] = []; ytdlOptionsPresets: string[] = [];
@@ -242,6 +244,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true'; this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
// Will be set from backend configuration, use empty string as placeholder // Will be set from backend configuration, use empty string as placeholder
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || ''; this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
this.clipStart = this.cookieService.get('metube_clip_start') || '';
this.clipEnd = this.cookieService.get('metube_clip_end') || '';
this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en'; this.subtitleLanguage = this.cookieService.get('metube_subtitle_language') || 'en';
this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual'; this.subtitleMode = this.cookieService.get('metube_subtitle_mode') || 'prefer_manual';
this.ytdlOptionsPresets = this.loadYtdlOptionsPresetsFromCookie(); this.ytdlOptionsPresets = this.loadYtdlOptionsPresetsFromCookie();
@@ -579,10 +583,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) { if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
return; return;
} }
// Subscriptions do not support clip ranges (backend rejects clip fields).
const { clipStart: _clipStart, clipEnd: _clipEnd, ...subscribeBase } = payload;
void _clipStart;
void _clipEnd;
this.subscribeInProgress = true; this.subscribeInProgress = true;
this.subscriptionsSvc this.subscriptionsSvc
.subscribe({ .subscribe({
...payload, ...subscribeBase,
checkIntervalMinutes: this.checkIntervalMinutes, checkIntervalMinutes: this.checkIntervalMinutes,
titleRegex: tr, titleRegex: tr,
}) })
@@ -807,6 +815,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: this.settingsCookieExpiryDays }); this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: this.settingsCookieExpiryDays });
} }
clipStartChanged() {
this.cookieService.set('metube_clip_start', this.clipStart, { expires: this.settingsCookieExpiryDays });
}
clipEndChanged() {
this.cookieService.set('metube_clip_end', this.clipEnd, { expires: this.settingsCookieExpiryDays });
}
subtitleLanguageChanged() { subtitleLanguageChanged() {
this.cookieService.set('metube_subtitle_language', this.subtitleLanguage, { expires: this.settingsCookieExpiryDays }); this.cookieService.set('metube_subtitle_language', this.subtitleLanguage, { expires: this.settingsCookieExpiryDays });
this.saveSelection(this.downloadType); this.saveSelection(this.downloadType);
@@ -1033,6 +1049,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
ytdlOptionsOverrides: allowYtdlOptionsOverrides ytdlOptionsOverrides: allowYtdlOptionsOverrides
? (overrides.ytdlOptionsOverrides ?? this.ytdlOptionsOverrides) ? (overrides.ytdlOptionsOverrides ?? this.ytdlOptionsOverrides)
: '', : '',
clipStart: overrides.clipStart ?? this.clipStart,
clipEnd: overrides.clipEnd ?? this.clipEnd,
}; };
} }
@@ -1106,6 +1124,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
? [...download.ytdl_options_presets] ? [...download.ytdl_options_presets]
: [], : [],
ytdlOptionsOverrides: download.ytdl_options_overrides ? JSON.stringify(download.ytdl_options_overrides) : '', ytdlOptionsOverrides: download.ytdl_options_overrides ? JSON.stringify(download.ytdl_options_overrides) : '',
clipStart: download.clip_start != null ? String(download.clip_start) : '',
clipEnd: download.clip_end != null ? String(download.clip_end) : '',
}); });
this.downloads.delById('done', [key]).subscribe(); this.downloads.delById('done', [key]).subscribe();
} }
+2
View File
@@ -16,6 +16,8 @@ export interface Download {
subtitle_mode?: string; subtitle_mode?: string;
ytdl_options_presets?: string[]; ytdl_options_presets?: string[];
ytdl_options_overrides?: Record<string, unknown>; ytdl_options_overrides?: Record<string, unknown>;
clip_start?: number;
clip_end?: number;
status: string; status: string;
msg: string; msg: string;
percent: number; percent: number;
@@ -41,6 +41,8 @@ function basePayload(): AddDownloadPayload {
subtitleMode: 'prefer_manual', subtitleMode: 'prefer_manual',
ytdlOptionsPresets: [], ytdlOptionsPresets: [],
ytdlOptionsOverrides: '', ytdlOptionsOverrides: '',
clipStart: '',
clipEnd: '',
}; };
} }
@@ -88,6 +90,24 @@ describe('DownloadsService', () => {
req.flush({ status: 'ok' }); req.flush({ status: 'ok' });
}); });
it('add() sends clip_start and clip_end when set', () => {
service
.add({
...basePayload(),
clipStart: '1:00',
clipEnd: '2:00',
})
.subscribe();
const req = httpMock.expectOne('add');
expect(req.request.body).toEqual(
expect.objectContaining({
clip_start: '1:00',
clip_end: '2:00',
}),
);
req.flush({ status: 'ok' });
});
it('getPresets() fetches configured preset names', () => { it('getPresets() fetches configured preset names', () => {
service.getPresets().subscribe((result) => { service.getPresets().subscribe((result) => {
expect(result).toEqual({ presets: ['Preset A'] }); expect(result).toEqual({ presets: ['Preset A'] });
+9 -2
View File
@@ -22,6 +22,8 @@ export interface AddDownloadPayload {
subtitleMode: string; subtitleMode: string;
ytdlOptionsPresets: string[]; ytdlOptionsPresets: string[];
ytdlOptionsOverrides: string; ytdlOptionsOverrides: string;
clipStart?: string;
clipEnd?: string;
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -129,7 +131,7 @@ export class DownloadsService {
} }
public add(payload: AddDownloadPayload) { public add(payload: AddDownloadPayload) {
return this.http.post<Status>('add', { const body: Record<string, unknown> = {
url: payload.url, url: payload.url,
download_type: payload.downloadType, download_type: payload.downloadType,
codec: payload.codec, codec: payload.codec,
@@ -145,7 +147,12 @@ export class DownloadsService {
subtitle_mode: payload.subtitleMode, subtitle_mode: payload.subtitleMode,
ytdl_options_presets: payload.ytdlOptionsPresets, ytdl_options_presets: payload.ytdlOptionsPresets,
ytdl_options_overrides: payload.ytdlOptionsOverrides, ytdl_options_overrides: payload.ytdlOptionsOverrides,
}).pipe( };
const cs = payload.clipStart?.trim();
const ce = payload.clipEnd?.trim();
if (cs) body['clip_start'] = cs;
if (ce) body['clip_end'] = ce;
return this.http.post<Status>('add', body).pipe(
catchError(this.handleHTTPError) catchError(this.handleHTTPError)
); );
} }