mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 21:45:04 +00:00
Compare commits
3 Commits
2026.09.15
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 300bf79b52 | |||
| ec01524527 | |||
| a6d81d4513 |
+13
-1
@@ -2,7 +2,19 @@
|
|||||||
# has lagged behind and resolved to a Node patch older than the Angular CLI's
|
# has lagged behind and resolved to a Node patch older than the Angular CLI's
|
||||||
# minimum supported version, breaking the build. node:22-alpine currently
|
# minimum supported version, breaking the build. node:22-alpine currently
|
||||||
# satisfies @angular/cli's >=22.22.3 requirement.
|
# satisfies @angular/cli's >=22.22.3 requirement.
|
||||||
FROM node:22-alpine AS builder
|
#
|
||||||
|
# Pinned further, to a digest: Docker Hub rebuilt node:22-alpine on 2026-09-17
|
||||||
|
# with the same Node (22.23.2) but refreshed Alpine layers, and that rebuild
|
||||||
|
# dies under QEMU while cross-building the arm64 leg — `qemu: uncaught target
|
||||||
|
# signal 4 (Illegal instruction)`, exit 132, during `pnpm install`. Three
|
||||||
|
# consecutive release builds failed identically on it while every other input
|
||||||
|
# (runner image, binfmt digest, pnpm version, lockfile) was unchanged.
|
||||||
|
#
|
||||||
|
# This digest is the last image known to cross-build cleanly (built 2026-07-29).
|
||||||
|
# It ships nothing: this stage is thrown away and only ui/dist is copied out.
|
||||||
|
# Unpin once the arm64 leg builds natively instead of under emulation, or once
|
||||||
|
# a later node:22-alpine is confirmed to survive QEMU.
|
||||||
|
FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS builder
|
||||||
|
|
||||||
WORKDIR /metube
|
WORKDIR /metube
|
||||||
COPY ui ./
|
COPY ui ./
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import copy
|
import copy
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -311,6 +312,47 @@ async def test_start_pending_moves_to_queue(dq_env):
|
|||||||
with patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
|
||||||
await dq.start_pending([url])
|
await dq.start_pending([url])
|
||||||
assert not dq.pending.exists(url)
|
assert not dq.pending.exists(url)
|
||||||
|
# It is in the queue now and starts on its own, so it must not keep
|
||||||
|
# advertising the Start button the UI draws for 'pending' (#1081), and the
|
||||||
|
# client has to be told before the concurrency slot frees up.
|
||||||
|
assert dq.queue.get(url).info.status == "queued"
|
||||||
|
assert notifier.updated.await_args[0][0].status == "queued"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_queued_download_is_not_offered_as_startable(dq_env):
|
||||||
|
"""A download waiting on a MAX_CONCURRENT_DOWNLOADS slot used to sit at
|
||||||
|
'pending', which is the status the UI draws a Start button for — but
|
||||||
|
start_pending has nothing to do for an item already in the queue, so the
|
||||||
|
button silently did nothing and still reported success (#1081).
|
||||||
|
"""
|
||||||
|
notifier = AsyncMock()
|
||||||
|
dq_env.MAX_CONCURRENT_DOWNLOADS = "1"
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
released = asyncio.Event()
|
||||||
|
|
||||||
|
def fake_extract(self, url, *_args, **_kwargs):
|
||||||
|
return {"_type": "video", "id": url[-1], "title": f"Video {url[-1]}",
|
||||||
|
"url": url, "webpage_url": url}
|
||||||
|
|
||||||
|
async def blocking_start(self, notifier_, executor=None):
|
||||||
|
await released.wait()
|
||||||
|
|
||||||
|
first, second = "https://example.com/v1", "https://example.com/v2"
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract), \
|
||||||
|
patch("ytdl.Download.start", blocking_start), \
|
||||||
|
patch("ytdl.Download.close", lambda self: None):
|
||||||
|
for url in (first, second):
|
||||||
|
await dq.add(url, "video", "auto", "any", "best", "", "", 0, auto_start=True)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# The first holds the only slot; the second is waiting behind it.
|
||||||
|
assert dq.queue.get(second).info.status == "queued"
|
||||||
|
assert not dq.pending.exists(second)
|
||||||
|
|
||||||
|
released.set()
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1200,7 +1242,7 @@ async def test_probe_scheduled_starts_when_live(dq_env):
|
|||||||
|
|
||||||
assert url not in dq._scheduled_probe_at
|
assert url not in dq._scheduled_probe_at
|
||||||
assert download.info.live_status == "is_live"
|
assert download.info.live_status == "is_live"
|
||||||
assert download.info.status == "pending"
|
assert download.info.status == "queued"
|
||||||
start_mock.assert_called_once_with(download)
|
start_mock.assert_called_once_with(download)
|
||||||
|
|
||||||
|
|
||||||
@@ -1345,7 +1387,7 @@ async def test_probe_recovers_after_transient_then_starts(dq_env):
|
|||||||
|
|
||||||
assert url not in dq._scheduled_probe_at
|
assert url not in dq._scheduled_probe_at
|
||||||
assert url not in dq._scheduled_probe_failures
|
assert url not in dq._scheduled_probe_failures
|
||||||
assert download.info.status == "pending"
|
assert download.info.status == "queued"
|
||||||
# Placeholder error/msg cleared now that a real download is starting.
|
# Placeholder error/msg cleared now that a real download is starting.
|
||||||
assert download.info.error is None
|
assert download.info.error is None
|
||||||
assert download.info.msg is None
|
assert download.info.msg is None
|
||||||
|
|||||||
+15
-2
@@ -495,6 +495,12 @@ class DownloadInfo:
|
|||||||
self.folder = folder
|
self.folder = folder
|
||||||
self.custom_name_prefix = custom_name_prefix
|
self.custom_name_prefix = custom_name_prefix
|
||||||
self.msg = self.percent = self.speed = self.eta = None
|
self.msg = self.percent = self.speed = self.eta = None
|
||||||
|
# 'pending' means "waiting for the user to press Start" — an item added
|
||||||
|
# with auto_start=False, sitting in self.pending. A download that is in
|
||||||
|
# self.queue waiting for a MAX_CONCURRENT_DOWNLOADS slot is 'queued'
|
||||||
|
# instead: it starts on its own and there is nothing to press. Keeping
|
||||||
|
# both under one name left the UI showing a Start button that silently
|
||||||
|
# did nothing (#1081).
|
||||||
self.status = "pending"
|
self.status = "pending"
|
||||||
self.size = None
|
self.size = None
|
||||||
self.timestamp = time.time_ns()
|
self.timestamp = time.time_ns()
|
||||||
@@ -1484,7 +1490,7 @@ class DownloadQueue:
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._unregister_scheduled(url)
|
self._unregister_scheduled(url)
|
||||||
info.status = 'pending'
|
info.status = 'queued'
|
||||||
# Clear the "scheduled to start at ..." placeholder now that the stream
|
# Clear the "scheduled to start at ..." placeholder now that the stream
|
||||||
# is live and a real download is about to begin.
|
# is live and a real download is about to begin.
|
||||||
info.error = None
|
info.error = None
|
||||||
@@ -1499,7 +1505,7 @@ class DownloadQueue:
|
|||||||
|
|
||||||
def _force_start_scheduled(self, download: Download) -> None:
|
def _force_start_scheduled(self, download: Download) -> None:
|
||||||
self._unregister_scheduled(download.info.url)
|
self._unregister_scheduled(download.info.url)
|
||||||
download.info.status = 'pending'
|
download.info.status = 'queued'
|
||||||
download.info.error = None
|
download.info.error = None
|
||||||
download.info.msg = None
|
download.info.msg = None
|
||||||
bg_tasks.create_task(self.__start_download(download), name="start_download")
|
bg_tasks.create_task(self.__start_download(download), name="start_download")
|
||||||
@@ -1649,6 +1655,7 @@ class DownloadQueue:
|
|||||||
if is_upcoming:
|
if is_upcoming:
|
||||||
await self._schedule_upcoming_download(download)
|
await self._schedule_upcoming_download(download)
|
||||||
else:
|
else:
|
||||||
|
download.info.status = 'queued'
|
||||||
await self.queue.put(download)
|
await self.queue.put(download)
|
||||||
bg_tasks.create_task(self.__start_download(download), name="start_download")
|
bg_tasks.create_task(self.__start_download(download), name="start_download")
|
||||||
else:
|
else:
|
||||||
@@ -2159,7 +2166,13 @@ class DownloadQueue:
|
|||||||
if getattr(dl.info, 'live_status', None) == 'is_upcoming':
|
if getattr(dl.info, 'live_status', None) == 'is_upcoming':
|
||||||
await self._schedule_upcoming_download(dl)
|
await self._schedule_upcoming_download(dl)
|
||||||
else:
|
else:
|
||||||
|
dl.info.status = 'queued'
|
||||||
await self.queue.put(dl)
|
await self.queue.put(dl)
|
||||||
|
# Tell the client it moved out of 'pending' now, not when a
|
||||||
|
# slot frees up: with MAX_CONCURRENT_DOWNLOADS saturated the
|
||||||
|
# wait is unbounded, and until this lands the row still
|
||||||
|
# offers the Start button it has already outgrown.
|
||||||
|
await self.notifier.updated(dl.info)
|
||||||
bg_tasks.create_task(self.__start_download(dl), name="start_download")
|
bg_tasks.create_task(self.__start_download(dl), name="start_download")
|
||||||
continue
|
continue
|
||||||
if self.queue.exists(id):
|
if self.queue.exists(id):
|
||||||
|
|||||||
+5
-2
@@ -728,6 +728,9 @@
|
|||||||
@if (download.value.live_status === 'is_live' && download.value.status !== 'scheduled') {
|
@if (download.value.live_status === 'is_live' && download.value.status !== 'scheduled') {
|
||||||
<span class="badge bg-danger">LIVE</span>
|
<span class="badge bg-danger">LIVE</span>
|
||||||
}
|
}
|
||||||
|
@if (download.value.status === 'queued') {
|
||||||
|
<span class="badge bg-secondary">Queued</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (download.value.status === 'scheduled') {
|
@if (download.value.status === 'scheduled') {
|
||||||
<span class="badge bg-warning text-dark">
|
<span class="badge bg-warning text-dark">
|
||||||
@@ -751,7 +754,7 @@
|
|||||||
<td>{{ download.value.speed | speed }}</td>
|
<td>{{ download.value.speed | speed }}</td>
|
||||||
<td>{{ download.value.eta | eta }}</td>
|
<td>{{ download.value.eta | eta }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex">
|
<div class="d-flex justify-content-end">
|
||||||
@if (download.value.status === 'pending' || download.value.status === 'scheduled') {
|
@if (download.value.status === 'pending' || download.value.status === 'scheduled') {
|
||||||
<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>
|
<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>
|
||||||
}
|
}
|
||||||
@@ -878,7 +881,7 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex">
|
<div class="d-flex justify-content-end">
|
||||||
@if (entry[1].status === 'error') {
|
@if (entry[1].status === 'error') {
|
||||||
<button type="button" class="btn btn-link" [attr.aria-label]="'Retry download for ' + entry[1].title" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
|
<button type="button" class="btn btn-link" [attr.aria-label]="'Retry download for ' + entry[1].title" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -544,4 +544,57 @@ describe('App', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Issue #1081: a download waiting for a concurrency slot ('queued') starts on
|
||||||
|
// its own, so it must not offer the Start button that a 'pending' row — one
|
||||||
|
// added with auto-start off — legitimately has.
|
||||||
|
describe('queued rows do not offer a dead Start button (#1081)', () => {
|
||||||
|
const queueEntry = (status: string): Download => ({
|
||||||
|
id: 'vid1',
|
||||||
|
title: 'Test',
|
||||||
|
url: 'https://example.com/v',
|
||||||
|
download_type: 'video',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'any',
|
||||||
|
folder: '',
|
||||||
|
custom_name_prefix: '',
|
||||||
|
playlist_item_limit: 0,
|
||||||
|
status,
|
||||||
|
msg: '',
|
||||||
|
percent: 0,
|
||||||
|
speed: 0,
|
||||||
|
eta: 0,
|
||||||
|
filename: '',
|
||||||
|
checked: false,
|
||||||
|
} as Download);
|
||||||
|
|
||||||
|
const render = (status: string) => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
downloads.queue.set('https://example.com/v', queueEntry(status));
|
||||||
|
downloads.queueChanged.next();
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('hides Start for a queued row but keeps it for a pending one', () => {
|
||||||
|
const queued = render('queued');
|
||||||
|
expect(
|
||||||
|
(queued.nativeElement as HTMLElement).querySelector('[aria-label="Start download for Test"]')
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
downloads.queue.clear();
|
||||||
|
|
||||||
|
const pending = render('pending');
|
||||||
|
expect(
|
||||||
|
(pending.nativeElement as HTMLElement).querySelector('[aria-label="Start download for Test"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says why the row is idle and counts it as queued', () => {
|
||||||
|
const fixture = render('queued');
|
||||||
|
expect((fixture.nativeElement as HTMLElement).textContent).toContain('Queued');
|
||||||
|
expect(fixture.componentInstance.queuedDownloads).toBe(1);
|
||||||
|
expect(fixture.componentInstance.activeDownloads).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1728,7 +1728,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
|
|||||||
speed += download.speed || 0;
|
speed += download.speed || 0;
|
||||||
} else if (download.status === 'preparing' || download.status === 'postprocessing') {
|
} else if (download.status === 'preparing' || download.status === 'postprocessing') {
|
||||||
active++;
|
active++;
|
||||||
} else if (download.status === 'pending' || download.status === 'scheduled') {
|
} else if (download.status === 'queued' || download.status === 'pending' || download.status === 'scheduled') {
|
||||||
queued++;
|
queued++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user