Compare commits

...

4 Commits

Author SHA1 Message Date
Alex Shnitman abb9492d21 upgrade dependencies 2026-04-21 16:20:39 +03:00
Alex Shnitman 23de9824f0 cr fixes 2026-04-21 16:13:58 +03:00
rdiaz738 0ea934c08f Updated import and fixed race condition 2026-04-20 17:24:16 -07:00
Alex Shnitman e9f979b349 fix yt-dlp options overrides (closes #958) 2026-04-18 08:46:29 +03:00
7 changed files with 513 additions and 305 deletions
+4
View File
@@ -104,6 +104,8 @@ MeTube lets you customize how [yt-dlp](https://github.com/yt-dlp/yt-dlp) behaves
When a download starts, these layers are combined in order. If the same option appears in more than one layer, the more specific one wins: per-download overrides beat presets, and presets beat global options. When a download starts, these layers are combined in order. If the same option appears in more than one layer, the more specific one wins: per-download overrides beat presets, and presets beat global options.
In JSON presets and overrides, setting an option to **`null`** clears that option for that download (for example, `"download_archive": null` overrides a global archive path so the archive is not used). This follows yt-dlps usual meaning of `None` for that option.
### Option format ### Option format
yt-dlp options in MeTube are expressed as JSON objects. The keys are yt-dlp API option names, which roughly correspond to command-line flags with dashes replaced by underscores. For example, the command-line flag `--write-subs` becomes `"writesubtitles": true` in JSON. yt-dlp options in MeTube are expressed as JSON objects. The keys are yt-dlp API option names, which roughly correspond to command-line flags with dashes replaced by underscores. For example, the command-line flag `--write-subs` becomes `"writesubtitles": true` in JSON.
@@ -204,6 +206,8 @@ When a download starts, the final set of yt-dlp options is built in this order:
2. Apply each selected **preset** in order (later presets overwrite earlier ones for conflicting keys). 2. Apply each selected **preset** in order (later presets overwrite earlier ones for conflicting keys).
3. Apply any **per-download overrides** on top (overwrite everything else for conflicting keys). 3. Apply any **per-download overrides** on top (overwrite everything else for conflicting keys).
MeTube always forces its own flat-extract behaviour during the initial metadata fetch (`extract_flat`, `noplaylist`, etc.); presets cannot override those keys for that phase.
**Example:** Suppose your global options set `"writesubtitles": false`, but you select a preset that sets `"writesubtitles": true`. Subtitles will be written for that download because the preset overrides the global setting. If you additionally enter `{"writesubtitles": false}` in the per-download overrides field, that value wins and subtitles will not be written. **Example:** Suppose your global options set `"writesubtitles": false`, but you select a preset that sets `"writesubtitles": true`. Subtitles will be written for that download because the preset overrides the global setting. If you additionally enter `{"writesubtitles": false}` in the per-download overrides field, that value wins and subtitles will not be written.
### Configuration cookbooks ### Configuration cookbooks
+136 -4
View File
@@ -56,7 +56,7 @@ def test_get_returns_tuple_of_lists(dq_env):
async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env): async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
notifier = AsyncMock() notifier = AsyncMock()
def fake_extract(self, url): def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return { return {
"_type": "video", "_type": "video",
"id": "vid1", "id": "vid1",
@@ -86,7 +86,7 @@ async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
async def test_cancel_removes_from_pending(dq_env): async def test_cancel_removes_from_pending(dq_env):
notifier = AsyncMock() notifier = AsyncMock()
def fake_extract(self, url): def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return { return {
"_type": "video", "_type": "video",
"id": "vid1", "id": "vid1",
@@ -114,11 +114,54 @@ async def test_cancel_removes_from_pending(dq_env):
notifier.canceled.assert_awaited() notifier.canceled.assert_awaited()
@pytest.mark.asyncio
async def test_cancel_before_start_marks_download_canceled(dq_env):
"""Regression test for the race condition where cancel() arrives after the
download has been placed in the queue and ``__start_download`` has been
scheduled via ``asyncio.create_task`` but has not yet executed. Without the
fix, the pending task would run ``download.start()`` despite the user
cancelling, because its ``download.canceled`` guard was never flipped."""
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)
url = "https://example.com/race"
start_mock = AsyncMock()
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
await dq.add(
url,
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=True,
)
assert dq.queue.exists(url)
download = dq.queue.get(url)
assert download.canceled is False
await dq.cancel([url])
assert not dq.queue.exists(url)
assert download.canceled is True
notifier.canceled.assert_awaited_with(url)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_start_pending_moves_to_queue(dq_env): async def test_start_pending_moves_to_queue(dq_env):
notifier = AsyncMock() notifier = AsyncMock()
def fake_extract(self, url): def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return { return {
"_type": "video", "_type": "video",
"id": "vid1", "id": "vid1",
@@ -187,7 +230,7 @@ async def test_add_merges_global_preset_and_override_options(dq_env):
"Preset B": {"writesubtitles": False, "ratelimit": 1000}, "Preset B": {"writesubtitles": False, "ratelimit": 1000},
} }
def fake_extract(self, url): def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return { return {
"_type": "video", "_type": "video",
"id": "vid2", "id": "vid2",
@@ -219,3 +262,92 @@ async def test_add_merges_global_preset_and_override_options(dq_env):
assert queued.ytdl_opts["ratelimit"] == 1000 assert queued.ytdl_opts["ratelimit"] == 1000
assert queued.ytdl_opts["proxy"] == "http://override" assert queued.ytdl_opts["proxy"] == "http://override"
assert queued.ytdl_opts["embed_thumbnail"] is True assert queued.ytdl_opts["embed_thumbnail"] is True
@pytest.mark.asyncio
async def test_extract_info_preset_null_download_archive_overrides_global(dq_env):
"""Preset download_archive:null must apply during extract_info (global archive otherwise wins first)."""
dq_env.YTDL_OPTIONS = {"download_archive": "/tmp/archive.txt"}
dq_env.YTDL_OPTIONS_PRESETS = {"NoArchive": {"download_archive": None}}
captured_params: list = []
class FakeYoutubeDL:
def __init__(self, params=None):
captured_params.append(params)
def extract_info(self, url, download=False):
return {
"_type": "video",
"id": "vid-archive",
"title": "Archive Test",
"url": url,
"webpage_url": url,
}
notifier = AsyncMock()
dq = DownloadQueue(dq_env, notifier)
with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL):
result = await dq.add(
"https://example.com/archive-test",
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=False,
ytdl_options_presets=["NoArchive"],
)
assert result["status"] == "ok"
assert len(captured_params) == 1
extract_params = captured_params[0]
assert extract_params.get("download_archive") is None
assert extract_params["extract_flat"] is True
assert extract_params["noplaylist"] is True
@pytest.mark.asyncio
async def test_extract_info_metube_extract_keys_win_over_preset(dq_env):
"""MeTube's flat-extract settings must not be overridden by presets."""
dq_env.YTDL_OPTIONS = {}
dq_env.YTDL_OPTIONS_PRESETS = {
"TryOverride": {"extract_flat": False, "noplaylist": False},
}
captured_params: list = []
class FakeYoutubeDL:
def __init__(self, params=None):
captured_params.append(params)
def extract_info(self, url, download=False):
return {
"_type": "video",
"id": "vid-flat",
"title": "Flat Test",
"url": url,
"webpage_url": url,
}
notifier = AsyncMock()
dq = DownloadQueue(dq_env, notifier)
with patch("ytdl.yt_dlp.YoutubeDL", FakeYoutubeDL):
result = await dq.add(
"https://example.com/flat-test",
"video",
"auto",
"any",
"best",
"",
"",
0,
auto_start=False,
ytdl_options_presets=["TryOverride"],
)
assert result["status"] == "ok"
assert captured_params[0]["extract_flat"] is True
assert captured_params[0]["noplaylist"] is True
+30 -12
View File
@@ -9,6 +9,7 @@ from collections import OrderedDict
import time import time
import asyncio import asyncio
import multiprocessing import multiprocessing
from functools import partial
import logging import logging
import re import re
import types import types
@@ -796,9 +797,19 @@ class DownloadQueue:
log.debug(f'Auto-clearing completed download: {url}') log.debug(f'Auto-clearing completed download: {url}')
await self.clear([url]) await self.clear([url])
def __extract_info(self, url): def _build_ytdl_options(self, ytdl_options_presets=None, ytdl_options_overrides=None):
"""Merge global options, presets (in order), and per-download overrides."""
opts = dict(self.config.YTDL_OPTIONS)
for preset_name in ytdl_options_presets or []:
opts.update(self.config.YTDL_OPTIONS_PRESETS.get(preset_name, {}))
opts.update(ytdl_options_overrides or {})
return opts
def __extract_info(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG) debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
return yt_dlp.YoutubeDL(params={ user_opts = self._build_ytdl_options(ytdl_options_presets, ytdl_options_overrides)
params = {
**user_opts,
'quiet': not debug_logging, 'quiet': not debug_logging,
'verbose': debug_logging, 'verbose': debug_logging,
'no_color': True, 'no_color': True,
@@ -806,9 +817,11 @@ class DownloadQueue:
'ignore_no_formats_error': True, 'ignore_no_formats_error': True,
'noplaylist': True, 'noplaylist': True,
'paths': {"home": self.config.DOWNLOAD_DIR, "temp": self.config.TEMP_DIR}, 'paths': {"home": self.config.DOWNLOAD_DIR, "temp": self.config.TEMP_DIR},
**self.config.YTDL_OPTIONS, }
**({'impersonate': yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.config.YTDL_OPTIONS['impersonate'])} if 'impersonate' in self.config.YTDL_OPTIONS else {}), imp = user_opts.get('impersonate')
}).extract_info(url, download=False) if imp is not None:
params['impersonate'] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(imp)
return yt_dlp.YoutubeDL(params=params).extract_info(url, download=False)
def __calc_download_path(self, download_type, folder): def __calc_download_path(self, download_type, folder):
base_directory = self.config.AUDIO_DOWNLOAD_DIR if download_type == 'audio' else self.config.DOWNLOAD_DIR base_directory = self.config.AUDIO_DOWNLOAD_DIR if download_type == 'audio' else self.config.DOWNLOAD_DIR
@@ -844,10 +857,10 @@ class DownloadQueue:
output = self.config.OUTPUT_TEMPLATE_CHANNEL output = self.config.OUTPUT_TEMPLATE_CHANNEL
sanitized = {k: _sanitize_path_component(v) for k, v in entry.items()} sanitized = {k: _sanitize_path_component(v) for k, v in entry.items()}
output = _resolve_outtmpl_fields(output, sanitized, ('channel',)) output = _resolve_outtmpl_fields(output, sanitized, ('channel',))
ytdl_options = dict(self.config.YTDL_OPTIONS) ytdl_options = self._build_ytdl_options(
for preset_name in getattr(dl, 'ytdl_options_presets', None) or []: getattr(dl, 'ytdl_options_presets', None),
ytdl_options.update(self.config.YTDL_OPTIONS_PRESETS.get(preset_name, {})) getattr(dl, 'ytdl_options_overrides', {}) or {},
ytdl_options.update(getattr(dl, 'ytdl_options_overrides', {}) or {}) )
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:
log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries') log.info(f'playlist limit is set. Processing only first {playlist_item_limit} entries')
@@ -1037,7 +1050,10 @@ class DownloadQueue:
else: else:
already.add(url) already.add(url)
try: try:
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url) entry = await asyncio.get_running_loop().run_in_executor(
None,
partial(self.__extract_info, url, ytdl_options_presets, ytdl_options_overrides),
)
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( return await self.__add_entry(
@@ -1124,9 +1140,11 @@ class DownloadQueue:
if not self.queue.exists(id): if not self.queue.exists(id):
log.warning(f'requested cancel for non-existent download {id}') log.warning(f'requested cancel for non-existent download {id}')
continue continue
if self.queue.get(id).started(): dl = self.queue.get(id)
self.queue.get(id).cancel() if dl.started():
dl.cancel()
else: else:
dl.canceled = True
self.queue.delete(id) self.queue.delete(id)
await self.notifier.canceled(id) await self.notifier.canceled(id)
return {'status': 'ok'} return {'status': 'ok'}
+1 -1
View File
@@ -58,6 +58,6 @@
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "8.47.0", "typescript-eslint": "8.47.0",
"vitest": "^4.1.4" "vitest": "^4.1.5"
} }
} }
+266 -249
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -608,6 +608,19 @@
@if (batchImportStatus) { @if (batchImportStatus) {
<small>{{ batchImportStatus }}</small> <small>{{ batchImportStatus }}</small>
} }
@if (batchImportTotal > 0) {
<div class="progress mt-2" style="height: 20px;">
<div class="progress-bar"
[class.bg-danger]="batchImportFailures > 0"
role="progressbar"
[style.width.%]="(batchImportCount / batchImportTotal) * 100"
[attr.aria-valuenow]="batchImportCount"
[attr.aria-valuemin]="0"
[attr.aria-valuemax]="batchImportTotal">
{{ batchImportCount }} / {{ batchImportTotal }}
</div>
</div>
}
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
+63 -39
View File
@@ -1,7 +1,7 @@
import { AsyncPipe, DatePipe, KeyValuePipe, NgTemplateOutlet } from '@angular/common'; import { AsyncPipe, DatePipe, KeyValuePipe, NgTemplateOutlet } from '@angular/common';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, viewChild, inject, OnDestroy, OnInit } from '@angular/core'; import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, viewChild, inject, OnDestroy, OnInit } from '@angular/core';
import { Observable, Subscription, map, distinctUntilChanged, finalize } from 'rxjs'; import { Observable, Subject, Subscription, from, map, distinctUntilChanged, finalize, mergeMap, takeUntil, tap } from 'rxjs';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
@@ -104,8 +104,15 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
batchImportModalOpen = false; batchImportModalOpen = false;
batchImportText = ''; batchImportText = '';
batchImportStatus = ''; batchImportStatus = '';
batchImportCount = 0;
batchImportTotal = 0;
batchImportFailures = 0;
importInProgress = false; importInProgress = false;
cancelImportFlag = false; private batchImportCancel$ = new Subject<void>();
// Maximum number of /add requests to have in-flight at once during a batch
// import. Keeps the server from being hit with hundreds of simultaneous
// yt-dlp metadata extractions when a user pastes a huge URL list.
private static readonly BATCH_IMPORT_CONCURRENCY = 4;
ytDlpOptionsUpdateTime: string | null = null; ytDlpOptionsUpdateTime: string | null = null;
ytDlpVersion: string | null = null; ytDlpVersion: string | null = null;
metubeVersion: string | null = null; metubeVersion: string | null = null;
@@ -1173,8 +1180,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.batchImportModalOpen = true; this.batchImportModalOpen = true;
this.batchImportText = ''; this.batchImportText = '';
this.batchImportStatus = ''; this.batchImportStatus = '';
this.batchImportCount = 0;
this.batchImportTotal = 0;
this.batchImportFailures = 0;
this.importInProgress = false; this.importInProgress = false;
this.cancelImportFlag = false;
setTimeout(() => { setTimeout(() => {
const textarea = document.getElementById('batch-import-textarea'); const textarea = document.getElementById('batch-import-textarea');
if (textarea instanceof HTMLTextAreaElement) { if (textarea instanceof HTMLTextAreaElement) {
@@ -1200,48 +1209,63 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
return; return;
} }
this.importInProgress = true; this.importInProgress = true;
this.cancelImportFlag = false; this.batchImportCount = 0;
this.batchImportStatus = `Starting to import ${urls.length} URLs...`; this.batchImportFailures = 0;
let index = 0; this.batchImportTotal = urls.length;
const delayBetween = 1000; this.updateBatchImportStatus();
const processNext = () => {
if (this.cancelImportFlag) { from(urls).pipe(
this.batchImportStatus = `Import cancelled after ${index} of ${urls.length} URLs.`; mergeMap(
this.importInProgress = false; url => this.downloads.add(this.buildAddPayload({ url })).pipe(
return; // downloads.add() already catches HTTP errors and emits a single
} // Status value, so `tap` (not `finalize`) is the right place to
if (index >= urls.length) { // count. This avoids incrementing the counter when an in-flight
this.batchImportStatus = `Finished importing ${urls.length} URLs.`; // request is aborted by cancellation.
this.importInProgress = false; tap((status: Status) => {
return;
}
const url = urls[index];
this.batchImportStatus = `Importing URL ${index + 1} of ${urls.length}: ${url}`;
// Pass current selection options to backend
this.downloads.add(this.buildAddPayload({ url }))
.subscribe({
next: (status: Status) => {
if (status.status === 'error') { if (status.status === 'error') {
alert(`Error adding URL ${url}: ${status.msg}`); this.batchImportFailures++;
console.error(`Error adding URL ${url}: ${status.msg}`);
} }
index++; this.batchImportCount++;
setTimeout(processNext, delayBetween); this.updateBatchImportStatus();
}, this.cdr.markForCheck();
error: (err) => { }),
console.error(`Error importing URL ${url}:`, err); ),
index++; App.BATCH_IMPORT_CONCURRENCY,
setTimeout(processNext, delayBetween); ),
} takeUntil(this.batchImportCancel$),
}); takeUntilDestroyed(this.destroyRef),
}; finalize(() => {
processNext(); this.importInProgress = false;
this.updateBatchImportStatus(true);
this.cdr.markForCheck();
}),
).subscribe();
} }
// Cancel the batch import process private updateBatchImportStatus(done = false): void {
const parts: string[] = [];
if (done) {
const processed = this.batchImportCount;
if (processed < this.batchImportTotal) {
parts.push(`Import cancelled after ${processed} of ${this.batchImportTotal} URLs.`);
} else {
parts.push(`Finished importing ${this.batchImportTotal} URLs.`);
}
} else {
parts.push(`Importing ${this.batchImportCount} of ${this.batchImportTotal} URLs...`);
}
if (this.batchImportFailures > 0) {
parts.push(`${this.batchImportFailures} failed.`);
}
this.batchImportStatus = parts.join(' ');
}
// Cancel the batch import process: aborts in-flight and pending requests
// immediately via the cancellation Subject wired into the pipeline.
cancelBatchImport(): void { cancelBatchImport(): void {
if (this.importInProgress) { if (this.importInProgress) {
this.cancelImportFlag = true; this.batchImportCancel$.next();
this.batchImportStatus += ' Cancelling...';
} }
} }