Compare commits

..

3 Commits

Author SHA1 Message Date
Alex Shnitman 300bf79b52 build: pin the frontend builder image to the last QEMU-safe digest
Docker Hub rebuilt node:22-alpine on 2026-09-17 with the same Node
(22.23.2) but refreshed Alpine layers. 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. Every other
input was unchanged and verified by digest — runner image 20260907.300.1,
tonistiigi/binfmt sha256:400a4873, pnpm pinned at 11.5.2, lockfile
untouched — and quality-checks, which runs natively, passed every time.
The tag was the only floating input:

  last green build 09-15  node:22-alpine@sha256:c610fcdf  built 2026-07-29
  failing         09-20   node:22-alpine@sha256:b6f26b36  built 2026-09-17

Pinned to the older of the two. It ships nothing: the builder stage is
discarded and only ui/dist is copied out, so no Alpine bytes reach the
runtime image and holding an older base costs no exposure.

This is a stopgap for the emulated build, not a fix for it. The durable
answer is building the arm64 leg on a native runner, which removes the
whole failure class.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:28:10 +03:00
Alex Shnitman ec01524527 fix: align the row action icons across both download tables
The actions cell packs its icons left, but several are conditional: Start
in Downloading, and Retry / Download file / Share in Completed. A table
column takes one width — the widest row's — so every shorter row left its
slack on the right and slid its icons out of column.

Measured in the browser: with a mixed Completed list, the error row put
Delete at x=769 and the finished row put it at x=815. Delete is the
destructive control, and it moved depending on whether the row above
happened to have errored.

Right-aligning the group pins the always-present icons — Open source and
Delete — to a fixed column on every row, and lets the optional ones
extend leftward instead. It costs no layout: the column was already sized
to the widest row, so this only moves the slack from the right to the
left of the group.

The chapter-file sub-rows are deliberately left packing left. Their lone
download icon then sits under the parent row's own download icon, which
is the alignment that reads correctly there; right-aligning it would park
it under Delete instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 10:01:37 +03:00
Alex Shnitman a6d81d4513 fix: stop offering a dead Start button on queued downloads (closes #1081)
A download waiting for a MAX_CONCURRENT_DOWNLOADS slot sat at status
'pending' — the same status as an item added with auto-start off, which
is waiting for the user to press Start. The Downloading table draws its
Start button for exactly that status, so every queued row offered one.

Pressing it did nothing. start_pending() looks the id up in self.pending
first, and a queued download is not there; the fallback branch only acts
on 'scheduled' items, so the call fell through and still returned
{'status': 'ok'} — the UI reported success for a no-op.

One status name was covering two different states. A download in
self.queue waiting on the semaphore is now 'queued', leaving 'pending'
to mean only "waiting for you to press Start". The template condition is
unchanged and now excludes these rows by construction, and a 'Queued'
badge says why the row is idle instead of leaving a bare empty bar.

start_pending() notifies on promotion as well: with the slots saturated
the wait before Download.start() reports 'preparing' is unbounded, and
until something lands the client keeps showing the button it outgrew.

Persisted state needs no migration — __import_queue re-adds saved items
through __add_download, which sets the new status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 09:29:49 +03:00
6 changed files with 131 additions and 8 deletions
+13 -1
View File
@@ -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 ./
+44 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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>
} }
+53
View File
@@ -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
View File
@@ -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++;
} }
}); });