feat: paginate queue

This commit is contained in:
arabcoders 2026-06-12 20:18:58 +03:00
parent db8d8663eb
commit 18f54a28c6
19 changed files with 636 additions and 92 deletions

51
API.md
View file

@ -700,10 +700,16 @@ GET /api/history?type=queue&status=pending&order=ASC
This endpoint returns the current state of active downloads from memory.
**Query Parameters**:
- `limit` (optional): Override the configured queue display limit for this request. `0` means unlimited.
**Response**:
```json
{
"history_count": 0, // total number of completed items in history
"queue_count": 250, // total number of queued items
"queue_loaded": 100, // number of queued items included in this response
"queue_limit": 100, // effective display limit, 0 means unlimited
"queue":{
"id": "abc123",
"url": "https://example.com/video",
@ -2629,52 +2635,19 @@ or an error:
---
### GET /api/system/configuration
**Purpose**: Retrieve system configuration including app settings, presets, download fields, and queue status.
**Purpose**: Retrieve system configuration.
**Response**:
```json
{
"app": {
"version": "...",
"download_path": "/path/to/downloads",
"base_path": "/",
...
},
"presets": [
{
"id": 1,
"name": "default",
"description": "...",
...
}
],
"dl_fields": [
{
"id": 1,
"name": "Title",
"field": "title",
"kind": "text",
...
}
],
"app": {...},
"presets": [...],
"dl_fields": [...],
"paused": false,
"history_count": 150,
"queue": [
{
"id": "abc123",
"url": "https://example.com/video",
"status": "pending",
...
}
]
"history_count": 150
}
```
**Notes**:
- This endpoint combines multiple data sources into a single response for efficient initialization
- The `queue` array contains active download items
- Folder listing is available via the separate `/api/system/folders` endpoint
---
### GET /api/system/folders
@ -3495,7 +3468,7 @@ Emitted when a download item is moved between queue and history.
"data": {
"id": "abc123",
"from": "queue",
"to": "done"
"to": "history"
}
}
```

1
FAQ.md
View file

@ -44,6 +44,7 @@ or the `environment:` section in `compose.yaml` file.
| YTP_FLARESOLVERR_CACHE_TTL | The cache TTL (in seconds) for FlareSolverr solutions | `600` |
| YTP_BASE_PATH | Set this if you are serving YTPTube from sub-folder | `/` |
| YTP_PREVENT_LIVE_PREMIERE | Prevents the initial youtube premiere stream from being downloaded | `false` |
| YTP_QUEUE_DISPLAY_LIMIT | Max queued downloads returned to the UI. `0` = unlimited | `100` |
| YTP_LIVE_PREMIERE_BUFFER | buffer time in minutes to add to video duration | `5` |
| YTP_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` |

View file

@ -166,6 +166,12 @@ class DataStore:
def items(self):
return self._dict.items()
def __contains__(self, key: str) -> bool:
return key in self._dict
def __len__(self) -> int:
return len(self._dict)
async def put(self, value: Download, no_notify: bool = False) -> Download:
_ = no_notify
self._dict[value.info._id] = value

View file

@ -227,6 +227,9 @@ class Config(metaclass=Singleton):
default_pagination: int = 50
"""The default number of items per page for pagination."""
queue_display_limit: int = 100
"""Maximum number of queued downloads returned to the UI. 0 means unlimited."""
task_handler_random_delay: float = 60.0
"""The maximum random delay in seconds before starting a task handler."""
@ -282,6 +285,7 @@ class Config(metaclass=Singleton):
"download_info_expires",
"auto_clear_history_days",
"default_pagination",
"queue_display_limit",
"extract_info_concurrency",
"thumb_concurrency",
"flaresolverr_max_timeout",
@ -342,6 +346,7 @@ class Config(metaclass=Singleton):
"app_build_date",
"app_branch",
"default_pagination",
"queue_display_limit",
"check_for_updates",
"new_version",
"yt_new_version",

View file

@ -240,7 +240,7 @@ class PoolManager:
await self.queue.done.put(entry)
self._notify.emit(
Events.ITEM_MOVED,
data={"to": "history", "preset": entry.info.preset, "item": entry.info},
data={"from": "queue", "to": "history", "preset": entry.info.preset, "item": entry.info},
title=nTitle,
message=nMessage,
)

View file

@ -320,7 +320,7 @@ class DownloadQueue(metaclass=Singleton):
await self.done.put(item)
self._notify.emit(
Events.ITEM_MOVED,
data={"to": "history", "preset": item.info.preset, "item": item.info},
data={"from": "queue", "to": "history", "preset": item.info.preset, "item": item.info},
title="Download Cancelled",
message=f"Download '{item.info.title}' has been cancelled.",
)
@ -725,6 +725,32 @@ class DownloadQueue(metaclass=Singleton):
return items
def live_queue(self, limit: int = 0) -> dict[str, int | dict[str, ItemDTO]]:
"""Return a display-limited live queue snapshot with total metadata."""
limit = max(0, int(limit or 0))
items: dict[str, ItemDTO] = {}
active = self.pool.get_active_downloads()
for key, download in active.items():
if key in self.queue:
items[key] = download.info
for key, download in self.queue.items():
if limit > 0 and len(items) >= limit:
break
if key in items:
continue
items[key] = active[key].info if key in active else download.info
return {
"queue": items,
"queue_count": len(self.queue),
"queue_loaded": len(items),
"queue_limit": limit,
}
async def get_item(self, **kwargs) -> tuple["StoreType", Download] | tuple[None, None]:
"""
Get a specific item from the download queue or history.

View file

@ -123,23 +123,29 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder, c
@route("GET", "api/history/live", "items_live")
async def items_live(queue: DownloadQueue, encoder: Encoder) -> Response:
async def items_live(request: Request, queue: DownloadQueue, encoder: Encoder, config: Config) -> Response:
"""
Get live queue data
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance.
config (Config): The configuration instance.
Returns:
Response: The response object with live queue items.
"""
try:
limit = int(request.query.get("limit", config.queue_display_limit))
except ValueError:
return web.json_response(
data={"error": "limit must be a valid integer."}, status=web.HTTPBadRequest.status_code
)
return web.json_response(
data={
"queue": (await queue.get("queue"))["queue"],
"history_count": await queue.done.get_total_count(),
},
data={**queue.live_queue(limit), "history_count": await queue.done.get_total_count()},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)

View file

@ -45,7 +45,6 @@ async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder)
"dl_fields": await DLFields.get_instance().get_all_serialized(),
"paused": queue.is_paused(),
"history_count": await queue.done.get_total_count(),
"queue": (await queue.get("queue"))["queue"],
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,

View file

@ -1299,6 +1299,19 @@ class TestStatusTracker:
class TestQueueManager:
class LiveStore:
def __init__(self, items: dict[str, Download]) -> None:
self._items = items
def items(self):
return self._items.items()
def __contains__(self, key: str) -> bool:
return key in self._items
def __len__(self) -> int:
return len(self._items)
@staticmethod
def _video_queue() -> Mock:
async def put(item):
@ -1332,6 +1345,37 @@ class TestQueueManager:
auto_start=True,
)
def test_live_queue_caps_visible_items(self) -> None:
queue_manager = object.__new__(DownloadQueue)
items = {f"id{i}": Mock(info=make_item(id=f"id{i}", title=f"Video {i}")) for i in range(5)}
queue_manager.queue = self.LiveStore(items)
queue_manager.pool = Mock()
queue_manager.pool.get_active_downloads.return_value = {}
snapshot = DownloadQueue.live_queue(queue_manager, limit=2)
assert list(snapshot["queue"].keys()) == ["id0", "id1"]
assert snapshot["queue_count"] == 5
assert snapshot["queue_loaded"] == 2
assert snapshot["queue_limit"] == 2
def test_live_queue_keeps_active(self) -> None:
queue_manager = object.__new__(DownloadQueue)
items = {f"id{i}": Mock(info=make_item(id=f"id{i}", title=f"Video {i}")) for i in range(5)}
queue_manager.queue = self.LiveStore(items)
queue_manager.pool = Mock()
queue_manager.pool.get_active_downloads.return_value = {
"id3": Mock(info=make_item(id="id3", title="Active 3")),
"id4": Mock(info=make_item(id="id4", title="Active 4")),
}
snapshot = DownloadQueue.live_queue(queue_manager, limit=1)
assert list(snapshot["queue"].keys()) == ["id3", "id4"]
assert snapshot["queue_count"] == 5
assert snapshot["queue_loaded"] == 2
assert snapshot["queue_limit"] == 1
@pytest.mark.asyncio
async def test_live_reextracts(self, monkeypatch: pytest.MonkeyPatch) -> None:
seen: list[dict | None] = []

View file

@ -12,7 +12,7 @@ from app.library.cache import Cache
from app.library.ItemDTO import ItemDTO
from app.library.encoder import Encoder
from app.routes.api import history
from app.routes.api.history import item_rename, item_thumbnail, items_delete
from app.routes.api.history import item_rename, item_thumbnail, items_delete, items_live
from app.tests.helpers import temporary_test_dir
@ -84,6 +84,60 @@ async def test_items_delete_ids() -> None:
assert body == {"items": {}, "deleted": 2}
@pytest.mark.asyncio
async def test_items_live_metadata() -> None:
request = _FakeRequest()
request.query = {}
queue = Mock()
queue.live_queue = Mock(
return_value={"queue": {"a": {"title": "A"}}, "queue_count": 3, "queue_loaded": 1, "queue_limit": 1}
)
queue.done.get_total_count = AsyncMock(return_value=7)
encoder = Encoder()
config = SimpleNamespace(queue_display_limit=1)
response = await items_live(request, queue, encoder, config)
assert response.status == 200
queue.live_queue.assert_called_once_with(1)
body = json.loads(response.body.decode("utf-8"))
assert body["queue_count"] == 3
assert body["queue_loaded"] == 1
assert body["queue_limit"] == 1
assert body["history_count"] == 7
@pytest.mark.asyncio
async def test_items_live_limit_query() -> None:
request = _FakeRequest()
request.query = {"limit": "25"}
queue = Mock()
queue.live_queue = Mock(return_value={"queue": {}, "queue_count": 0, "queue_loaded": 0, "queue_limit": 25})
queue.done.get_total_count = AsyncMock(return_value=0)
encoder = Encoder()
config = SimpleNamespace(queue_display_limit=1)
response = await items_live(request, queue, encoder, config)
assert response.status == 200
queue.live_queue.assert_called_once_with(25)
@pytest.mark.asyncio
async def test_items_live_bad_limit() -> None:
request = _FakeRequest()
request.query = {"limit": "many"}
queue = Mock()
queue.live_queue = Mock()
encoder = Encoder()
config = SimpleNamespace(queue_display_limit=1)
response = await items_live(request, queue, encoder, config)
assert response.status == 400
queue.live_queue.assert_not_called()
@pytest.mark.asyncio
async def test_item_rename_needs_name() -> None:
request = _FakeRequest(payload={})

View file

@ -9,7 +9,7 @@ from app.library.config import Config
from app.library.cache import Cache
from app.library.encoder import Encoder
from app.library.UpdateChecker import UpdateChecker
from app.routes.api.system import check_updates, system_diagnostics, system_folders, system_limits
from app.routes.api.system import check_updates, system_config, system_diagnostics, system_folders, system_limits
@dataclass
@ -118,6 +118,38 @@ class TestCheckUpdatesEndpoint:
assert "update_available" in body, "Response should include update_available status"
class TestSystemConfigEndpoint:
def setup_method(self):
Config._reset_singleton()
@pytest.mark.asyncio
async def test_excludes_queue(self) -> None:
config = Config.get_instance()
encoder = Encoder()
queue = MagicMock()
queue.is_paused.return_value = False
queue.done.get_total_count = AsyncMock(return_value=0)
with (
patch("app.features.dl_fields.service.DLFields.get_instance") as mock_dlfields,
patch("app.features.presets.service.Presets.get_instance") as mock_presets,
):
dl_fields = MagicMock()
dl_fields.get_all_serialized = AsyncMock(return_value=[])
mock_dlfields.return_value = dl_fields
presets = MagicMock()
presets.get_all.return_value = []
mock_presets.return_value = presets
response = await system_config(queue, config, encoder)
assert response.status == 200
body = json.loads(response.body.decode("utf-8"))
assert "queue" not in body
queue.get.assert_not_called()
class TestSystemLimitsEndpoint:
def setup_method(self):
Config._reset_singleton()

View file

@ -193,7 +193,20 @@
<span>Queue</span>
</button>
<UBadge color="info" variant="soft" size="sm">{{ queueItems.length }} items</UBadge>
<div class="flex flex-wrap items-center gap-2">
<UBadge color="info" variant="soft" size="sm">{{ queueCountLabel }}</UBadge>
<UButton
v-if="stateStore.hasMore()"
color="neutral"
variant="outline"
size="xs"
:loading="isRefreshing"
@click="() => loadMoreQueue()"
>
Show more
</UButton>
</div>
</div>
<Transition name="section-collapse">
@ -629,7 +642,7 @@
:isControls="true"
:item="videoItem"
class="w-full"
@closeModel="() => void requestCloseVideo()"
@closeModel="() => requestCloseVideo()"
@error="async (error: string) => await box.alert(error)"
@playback-state-change="(playing: boolean) => (playingNow = playing)"
/>
@ -728,6 +741,7 @@ const embedUrl = ref('');
const videoItem = ref<StoreItem | null>(null);
const playingNow = ref(false);
const autoRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null);
const hadSocketDisconnect = ref(false);
const videoOpen = computed<boolean>({
get: () => Boolean(videoItem.value),
set: (value: boolean) => {
@ -778,6 +792,13 @@ const urlInputUi = {
const historyPagination = computed(() => pagination.value);
const historyIsLoading = computed(() => isLoading.value);
const queueItems = computed<StoreItem[]>(() => Object.values(stateStore.queue));
const queueCountLabel = computed(() => {
if (stateStore.hasMore()) {
return `${stateStore.shown()} displayed / ${stateStore.count()} queued`;
}
return `${stateStore.count()} queued`;
});
const historyEntries = computed<StoreItem[]>(() => historyItems.value);
const hasAnyItems = computed(() => queueItems.value.length > 0 || historyEntries.value.length > 0);
const showSections = computed(() => hasAnyItems.value || historyIsLoading.value);
@ -863,6 +884,23 @@ const refreshQueue = async (): Promise<void> => {
}
};
const loadMoreQueue = async (): Promise<void> => {
if (isRefreshing.value) {
return;
}
isRefreshing.value = true;
try {
await stateStore.loadMore();
} catch (error) {
console.error('Failed to load more queue items:', error);
toast.error('Failed to load more queue items');
} finally {
isRefreshing.value = false;
}
};
const startAutoRefresh = (): void => {
if (autoRefreshInterval.value) {
clearInterval(autoRefreshInterval.value);
@ -1343,7 +1381,10 @@ onMounted(async () => {
await normalizeSimpleRoute();
historyInitialized.value = true;
socketStore.on('item_moved', handleHistoryItemMoved);
await load(1, { order: 'DESC', perPage: configStore.app.default_pagination });
await Promise.allSettled([
refreshQueue(),
load(1, { order: 'DESC', perPage: configStore.app.default_pagination }),
]);
if (!socketStore.isConnected && autoRefreshEnabled.value) {
startAutoRefresh();
@ -1374,7 +1415,7 @@ watch(
() => route.path,
() => {
if (simpleMode.value) {
void normalizeSimpleRoute();
normalizeSimpleRoute();
}
},
);
@ -1384,9 +1425,17 @@ watch(
(connected) => {
if (connected) {
stopAutoRefresh();
if (hadSocketDisconnect.value) {
hadSocketDisconnect.value = false;
refreshQueue();
}
return;
}
hadSocketDisconnect.value = true;
if (autoRefreshEnabled.value) {
startAutoRefresh();
}
@ -1429,7 +1478,7 @@ watch(
return;
}
void load(1, { order: 'DESC', perPage: configStore.app.default_pagination });
load(1, { order: 'DESC', perPage: configStore.app.default_pagination });
},
);
</script>

View file

@ -19,6 +19,20 @@ const getRuntimeConfig = () => useRuntimeConfig();
const getConfig = () => useYtpConfig();
const getQueueState = () => useQueueState();
const getToast = () => useNotification();
let queueReloadTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleQueueBackfill = (): void => {
const queueState = getQueueState();
if (!queueState.needsBackfill() || queueReloadTimer) {
return;
}
queueReloadTimer = setTimeout(() => {
queueReloadTimer = null;
queueState.loadQueue(queueState.limit || undefined);
}, 500);
};
const socket = ref<WebSocket | null>(null);
const isConnected = ref<boolean>(false);
@ -278,8 +292,11 @@ on('item_added', (data: WSEP['item_added']) => {
const queueState = getQueueState();
const toast = getToast();
queueState.add(data.data._id, data.data);
toast.success(`Item queued: ${ag(queueState.get(data.data._id, {} as StoreItem), 'title')}`);
if (queueState.isLoaded()) {
queueState.add(data.data._id, data.data);
}
toast.success(`Item queued: ${ag(data.data, 'title')}`);
});
on(
@ -314,33 +331,39 @@ on('item_cancelled', (data: WSEP['item_cancelled']) => {
const toast = getToast();
const id = data.data._id;
if (!queueState.isLoaded()) {
return;
}
if (true !== queueState.has(id)) {
return;
}
toast.warning(`Download cancelled: ${ag(queueState.get(id, {} as StoreItem), 'title')}`);
if (true === queueState.has(id)) {
queueState.remove(id);
}
});
on('item_deleted', (data: WSEP['item_deleted']) => {
const queueState = getQueueState();
const id = data.data._id;
if (!queueState.isLoaded()) {
return;
}
if (true === queueState.has(id)) {
queueState.remove(id);
scheduleQueueBackfill();
}
});
on('item_updated', (data: WSEP['item_updated']) => {
const queueState = getQueueState();
const id = data.data._id;
if (true === queueState.has(id)) {
queueState.update(id, data.data);
if (!queueState.isLoaded()) {
return;
}
queueState.update(data.data._id, data.data);
});
on('item_progress', (data: WSEP['item_progress']) => {
@ -357,13 +380,24 @@ on('item_moved', (data: WSEP['item_moved']) => {
const to = data.data.to;
const id = data.data.item._id;
if (!queueState.isLoaded()) {
return;
}
if ('queue' === to) {
queueState.add(id, data.data.item);
}
if ('history' === to) {
if ('queue' === data.data.from) {
queueState.drop(id);
scheduleQueueBackfill();
return;
}
if (true === queueState.has(id)) {
queueState.remove(id);
scheduleQueueBackfill();
}
}
});

View file

@ -7,18 +7,88 @@ type KeyType = string;
interface QueueState {
queue: Record<KeyType, StoreItem>;
total: number;
loaded: number;
limit: number;
is_loaded: boolean;
is_loading: boolean;
}
type QueueMeta = {
queue_count?: number;
queue_loaded?: number;
queue_limit?: number;
};
const state = reactive<QueueState>({
queue: {},
total: 0,
loaded: 0,
limit: 0,
is_loaded: false,
is_loading: false,
});
const visibleCount = (): number => Object.keys(state.queue).length;
const syncLoaded = (): void => {
state.loaded = visibleCount();
if (state.total < state.loaded) {
state.total = state.loaded;
}
};
const canAddVisible = (): boolean => state.limit < 1 || visibleCount() < state.limit;
const isActive = (item: StoreItem): boolean => {
return ['started', 'preparing', 'downloading', 'postprocessing'].includes(item.status || '');
};
const applyMeta = (meta: QueueMeta = {}): void => {
state.loaded = Number(meta.queue_loaded ?? visibleCount());
state.total = Number(meta.queue_count ?? state.loaded);
state.limit = Number(meta.queue_limit ?? 0);
syncLoaded();
};
const add = (key: KeyType, value: StoreItem): void => {
if (state.queue[key]) {
state.queue[key] = value;
syncLoaded();
return;
}
state.total += 1;
if (!canAddVisible()) {
return;
}
state.queue[key] = value;
syncLoaded();
};
const reveal = (key: KeyType, value: StoreItem): void => {
if (state.queue[key]) {
state.queue[key] = value;
syncLoaded();
return;
}
state.queue[key] = value;
syncLoaded();
};
const update = (key: KeyType, value: StoreItem): void => {
if (!state.queue[key]) {
if (isActive(value)) {
reveal(key, value);
}
return;
}
state.queue[key] = value;
syncLoaded();
};
const patch = (key: KeyType, fields: Partial<StoreItem>): void => {
@ -34,6 +104,18 @@ const remove = (key: KeyType): void => {
const { [key]: _, ...rest } = state.queue;
state.queue = rest;
state.total = Math.max(0, state.total - 1);
syncLoaded();
};
const drop = (key: KeyType): void => {
if (state.queue[key]) {
const { [key]: _, ...rest } = state.queue;
state.queue = rest;
}
state.total = Math.max(0, state.total - 1);
syncLoaded();
};
const get = (key: KeyType, defaultValue: StoreItem | null = null): StoreItem | null => {
@ -46,30 +128,92 @@ const has = (key: KeyType): boolean => {
const clearAll = (): void => {
state.queue = {};
state.total = 0;
state.loaded = 0;
state.limit = 0;
state.is_loaded = false;
state.is_loading = false;
};
const addAll = (data: Record<KeyType, StoreItem>): void => {
const addAll = (data: Record<KeyType, StoreItem>, meta: QueueMeta = {}): void => {
state.queue = data;
state.is_loaded = true;
applyMeta(meta);
};
const count = (): number => {
return Object.keys(state.queue).length;
return state.total;
};
const loadQueue = async (): Promise<void> => {
const isLoaded = (): boolean => {
return state.is_loaded;
};
const shown = (): number => {
return visibleCount();
};
const hasMore = (): boolean => {
if (!state.is_loaded) {
return false;
}
return state.total > visibleCount();
};
const needsBackfill = (): boolean => {
if (!state.is_loaded) {
return false;
}
if (state.limit < 1) {
return false;
}
return visibleCount() < Math.min(state.limit, state.total);
};
const loadQueue = async (limit?: number): Promise<void> => {
if (state.is_loading) {
return;
}
state.is_loading = true;
try {
const response = await request('/api/history/live');
const params = new URLSearchParams();
if (typeof limit === 'number') {
params.set('limit', String(limit));
}
const query = params.toString();
const response = await request(`/api/history/live${query ? `?${query}` : ''}`);
if (!response.ok) {
throw new Error('Failed to load queue');
}
const data = (await response.json()) as {
queue: Record<KeyType, StoreItem>;
};
} & QueueMeta;
state.queue = data.queue || {};
addAll(data.queue || {}, data);
} catch (error) {
console.error('Failed to load queue:', error);
throw error;
} finally {
state.is_loading = false;
}
};
const loadMore = async (): Promise<void> => {
if (!hasMore()) {
return;
}
const step = state.limit > 0 ? state.limit : Math.max(visibleCount(), 100);
await loadQueue(Math.max(visibleCount() + 1, state.limit + step));
};
const addDownload = async (data: item_request): Promise<void> => {
const socket = useAppSocket();
const toast = useNotification();
@ -235,12 +379,18 @@ const queueStateApi = proxyRefs({
update,
patch,
remove,
drop,
get,
has,
clearAll,
addAll,
count,
isLoaded,
shown,
hasMore,
needsBackfill,
loadQueue,
loadMore,
addDownload,
startItems,
pauseItems,

View file

@ -4,7 +4,6 @@ import type { ConfigState } from '~/types/config';
import type { DLField } from '~/types/dl_fields';
import type { Preset } from '~/types/presets';
import type { ConfigFeature, ConfigUpdateAction } from '~/types/sockets';
import { useQueueState } from '~/composables/useQueueState';
import { request } from '~/utils';
let last_reload = 0;
@ -33,6 +32,7 @@ const state = reactive<ConfigState>({
app_env: 'production',
simple_mode: false,
default_pagination: 50,
queue_display_limit: 100,
log_level: '',
runtime_log_level: '',
check_for_updates: true,
@ -79,17 +79,11 @@ const loadConfig = async (force: boolean = false) => {
return;
}
const data = await resp.json();
const queueState = useQueueState();
if ('number' === typeof data.history_count) {
delete data.history_count;
}
if (data.queue) {
queueState.addAll(data.queue);
delete data.queue;
}
setAll(data);
state.is_loaded = true;
last_reload = now;

View file

@ -38,7 +38,7 @@
variant="outline"
size="sm"
icon="i-lucide-pause"
@click="() => void pauseDownload()"
@click="() => pauseDownload()"
>
<span>Pause</span>
</UButton>
@ -49,7 +49,7 @@
variant="outline"
size="sm"
icon="i-lucide-play"
@click="() => void resumeDownload()"
@click="() => resumeDownload()"
>
<span>Resume</span>
</UButton>
@ -114,7 +114,7 @@
size="sm"
icon="i-lucide-refresh-cw"
:loading="isRefreshing"
@click="() => void refreshQueue()"
@click="() => refreshQueue()"
>
Refresh
</UButton>
@ -156,9 +156,20 @@
<UBadge color="neutral" variant="soft" size="sm">
<span class="inline-flex items-center gap-1.5">
<UIcon name="i-lucide-list-ordered" class="size-3.5" />
<span>Total: {{ stateStore.count() }}</span>
<span>{{ queueCountLabel }}</span>
</span>
</UBadge>
<UButton
v-if="stateStore.hasMore()"
color="neutral"
variant="outline"
size="sm"
:loading="isRefreshing"
@click="() => loadMoreQueue()"
>
Show more
</UButton>
</div>
</div>
@ -355,7 +366,7 @@
variant="outline"
size="xs"
icon="i-lucide-circle-play"
@click="() => void startItem(item)"
@click="() => startItem(item)"
>
Start
</UButton>
@ -366,7 +377,7 @@
variant="outline"
size="xs"
icon="i-lucide-pause"
@click="() => void pauseItem(item)"
@click="() => pauseItem(item)"
>
Pause
</UButton>
@ -618,7 +629,7 @@
variant="outline"
icon="i-lucide-circle-play"
class="w-full justify-center"
@click="() => void startItem(item)"
@click="() => startItem(item)"
>
Start
</UButton>
@ -629,7 +640,7 @@
variant="outline"
icon="i-lucide-pause"
class="w-full justify-center"
@click="() => void pauseItem(item)"
@click="() => pauseItem(item)"
>
Pause
</UButton>
@ -780,6 +791,7 @@ const masterSelectAll = ref(false);
const embed_url = ref('');
const isRefreshing = ref(false);
const autoRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null);
const hadSocketDisconnect = ref(false);
const hasQueueContent = computed(() => stateStore.count() > 0 || query.value.trim().length > 0);
const contentStyle = computed<'grid' | 'list'>(() =>
@ -798,6 +810,14 @@ const displayedItems = computed<StoreItem[]>(() => {
return items.filter((item) => deepIncludes(item, normalizedQuery, new WeakSet()));
});
const queueCountLabel = computed(() => {
if (stateStore.hasMore()) {
return `Queued: ${stateStore.shown()} displayed / ${stateStore.count()} total`;
}
return `Queued: ${stateStore.count()}`;
});
const hasItems = computed(() => displayedItems.value.length > 0);
const hasSelected = computed(() => selectedElms.value.length > 0);
const displayedItemIds = computed(() => displayedItems.value.map((item) => item._id));
@ -827,6 +847,22 @@ const refreshQueue = async (): Promise<void> => {
}
};
const loadMoreQueue = async (): Promise<void> => {
if (isRefreshing.value) {
return;
}
isRefreshing.value = true;
try {
await stateStore.loadMore();
} catch {
toast.error('Failed to load more queue items');
} finally {
isRefreshing.value = false;
}
};
const startAutoRefresh = (): void => {
if (autoRefreshInterval.value) {
clearInterval(autoRefreshInterval.value);
@ -867,6 +903,8 @@ onMounted(async () => {
pendingDownloadFormItem.value = {};
}
await refreshQueue();
if (!socket.isConnected && autoRefreshEnabled.value) {
startAutoRefresh();
}
@ -885,9 +923,17 @@ watch(
(connected) => {
if (connected) {
stopAutoRefresh();
if (hadSocketDisconnect.value) {
hadSocketDisconnect.value = false;
void refreshQueue();
}
return;
}
hadSocketDisconnect.value = true;
if (autoRefreshEnabled.value) {
startAutoRefresh();
}
@ -1041,7 +1087,7 @@ const bulkActionGroups = computed(() => {
label: 'Start',
icon: 'i-lucide-circle-play',
disabled: !hasSelected.value,
onSelect: () => void startItems(),
onSelect: () => startItems(),
});
}
@ -1050,7 +1096,7 @@ const bulkActionGroups = computed(() => {
label: 'Pause',
icon: 'i-lucide-pause',
disabled: !hasSelected.value,
onSelect: () => void pauseSelected(),
onSelect: () => pauseSelected(),
});
}
@ -1058,7 +1104,7 @@ const bulkActionGroups = computed(() => {
label: selectedLiveOnly ? 'Stop' : 'Cancel',
icon: 'i-lucide-circle-off',
disabled: !hasSelected.value,
onSelect: () => void cancelSelected(),
onSelect: () => cancelSelected(),
});
return groups;
@ -1081,14 +1127,14 @@ const itemActionGroups = (item: StoreItem): Array<Array<Record<string, unknown>>
primaryActions.push({
label: item.is_live ? 'Stop Stream' : 'Cancel Download',
icon: 'i-lucide-circle-off',
onSelect: () => void confirmCancel(item),
onSelect: () => confirmCancel(item),
});
if (canStartItem(item)) {
primaryActions.push({
label: 'Start Download',
icon: 'i-lucide-circle-play',
onSelect: () => void startItem(item),
onSelect: () => startItem(item),
});
}
@ -1096,7 +1142,7 @@ const itemActionGroups = (item: StoreItem): Array<Array<Record<string, unknown>>
primaryActions.push({
label: 'Pause Download',
icon: 'i-lucide-pause',
onSelect: () => void pauseItem(item),
onSelect: () => pauseItem(item),
});
}
@ -1351,7 +1397,7 @@ const cancelItems = (item: string | string[]): void => {
return;
}
void stateStore.cancelItems(items);
stateStore.cancelItems(items);
};
const startItem = async (item: StoreItem): Promise<void> => await stateStore.startItems([item._id]);

View file

@ -43,6 +43,8 @@ type AppConfig = {
app_env: 'production' | 'development';
/** Default number of items per page for pagination */
default_pagination: number;
/** Maximum number of queued downloads loaded into the UI. 0 means unlimited. */
queue_display_limit: number;
/** Configured default log level */
log_level: 'debug' | 'info' | 'warning' | 'error' | '';
/** Active runtime log level */

View file

@ -38,7 +38,11 @@ export type WSEP = {
item_cancelled: EventPayload<StoreItem>;
item_deleted: EventPayload<StoreItem>;
item_bulk_deleted: EventPayload<{ count: number; status?: string; ids?: string[] }>;
item_moved: EventPayload<{ to: 'queue' | 'history'; item: StoreItem }>;
item_moved: EventPayload<{
from?: 'queue' | 'history';
to: 'queue' | 'history';
item: StoreItem;
}>;
item_status: EventPayload<{ status?: string; msg?: string; preset?: string }>;
paused: EventPayload<{ paused?: boolean }>;
resumed: EventPayload<{ paused?: boolean }>;

View file

@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as utils from '~/utils/index';
import { useQueueState } from '~/composables/useQueueState';
import type { StoreItem } from '~/types/store';
const createMockResponse = (jsonData: unknown): Response => {
return {
ok: true,
status: 200,
headers: new Headers({ 'Content-Type': 'application/json' }),
redirected: false,
statusText: 'OK',
type: 'basic',
url: '',
body: null,
bodyUsed: false,
clone() {
return this;
},
async json() {
return jsonData;
},
text: async () => JSON.stringify(jsonData),
arrayBuffer: async () => new ArrayBuffer(0),
blob: async () => new Blob(),
formData: async () => new FormData(),
} as Response;
};
const makeItem = (id: string, status: StoreItem['status'] = null): StoreItem => {
return {
_id: id,
id,
title: `Video ${id}`,
description: '',
url: `https://example.com/${id}`,
preset: 'default',
folder: '',
download_dir: '/downloads',
temp_dir: '/tmp',
status,
error: null,
cookies: '',
template: '',
template_chapter: '',
timestamp: 0,
is_live: false,
datetime: '',
live_in: null,
file_size: null,
cli: '',
auto_start: true,
options: {},
sidecar: {},
extras: {},
};
};
describe('useQueueState', () => {
beforeEach(() => {
useQueueState().clearAll();
});
it('load_metadata', async () => {
const requestSpy = spyOn(utils, 'request');
requestSpy.mockResolvedValueOnce(
createMockResponse({
queue: { a: makeItem('a') },
queue_count: 3,
queue_loaded: 1,
queue_limit: 1,
}),
);
const queue = useQueueState();
await queue.loadQueue();
expect(queue.count()).toBe(3);
expect(queue.shown()).toBe(1);
expect(queue.hasMore()).toBe(true);
expect(queue.limit).toBe(1);
expect(queue.get('a')?.title).toBe('Video a');
requestSpy.mockRestore();
});
it('add_respects_limit', () => {
const queue = useQueueState();
queue.addAll({ a: makeItem('a') }, { queue_count: 1, queue_loaded: 1, queue_limit: 1 });
queue.add('b', makeItem('b'));
expect(queue.count()).toBe(2);
expect(queue.shown()).toBe(1);
expect(queue.get('b')).toBeNull();
});
it('update_reveals_active', () => {
const queue = useQueueState();
queue.addAll({ a: makeItem('a') }, { queue_count: 2, queue_loaded: 1, queue_limit: 1 });
queue.update('b', makeItem('b', 'downloading'));
expect(queue.count()).toBe(2);
expect(queue.shown()).toBe(2);
expect(queue.get('b')?.status).toBe('downloading');
});
it('drop_hidden_decrements_total', () => {
const queue = useQueueState();
queue.addAll({ a: makeItem('a') }, { queue_count: 2, queue_loaded: 1, queue_limit: 1 });
queue.drop('b');
expect(queue.count()).toBe(1);
expect(queue.shown()).toBe(1);
});
});