diff --git a/API.md b/API.md index fc09f356..2e191ee4 100644 --- a/API.md +++ b/API.md @@ -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" } } ``` diff --git a/FAQ.md b/FAQ.md index 339bd87d..31a68061 100644 --- a/FAQ.md +++ b/FAQ.md @@ -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` | diff --git a/app/library/DataStore.py b/app/library/DataStore.py index 4d3460ea..0e82d8ff 100644 --- a/app/library/DataStore.py +++ b/app/library/DataStore.py @@ -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 diff --git a/app/library/config.py b/app/library/config.py index 7cf49008..5b995f2d 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -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", diff --git a/app/library/downloads/pool_manager.py b/app/library/downloads/pool_manager.py index 09336931..fcfd3c31 100644 --- a/app/library/downloads/pool_manager.py +++ b/app/library/downloads/pool_manager.py @@ -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, ) diff --git a/app/library/downloads/queue_manager.py b/app/library/downloads/queue_manager.py index 3bd26576..53fd3b22 100644 --- a/app/library/downloads/queue_manager.py +++ b/app/library/downloads/queue_manager.py @@ -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. diff --git a/app/routes/api/history.py b/app/routes/api/history.py index bb0bdd60..d2df5242 100644 --- a/app/routes/api/history.py +++ b/app/routes/api/history.py @@ -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, ) diff --git a/app/routes/api/system.py b/app/routes/api/system.py index 3580cad8..acd323f1 100644 --- a/app/routes/api/system.py +++ b/app/routes/api/system.py @@ -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, diff --git a/app/tests/test_download.py b/app/tests/test_download.py index a4a60c8d..a0614e70 100644 --- a/app/tests/test_download.py +++ b/app/tests/test_download.py @@ -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] = [] diff --git a/app/tests/test_history_routes.py b/app/tests/test_history_routes.py index 06dc00c8..31d2f543 100644 --- a/app/tests/test_history_routes.py +++ b/app/tests/test_history_routes.py @@ -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={}) diff --git a/app/tests/test_system_routes.py b/app/tests/test_system_routes.py index 680d158b..e21b8cf4 100644 --- a/app/tests/test_system_routes.py +++ b/app/tests/test_system_routes.py @@ -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() diff --git a/ui/app/components/Simple.vue b/ui/app/components/Simple.vue index 446366d5..a4f5f88d 100644 --- a/ui/app/components/Simple.vue +++ b/ui/app/components/Simple.vue @@ -193,7 +193,20 @@ Queue - {{ queueItems.length }} items +
+ {{ queueCountLabel }} + + + Show more + +
@@ -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(null); const playingNow = ref(false); const autoRefreshInterval = ref | null>(null); +const hadSocketDisconnect = ref(false); const videoOpen = computed({ 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(() => Object.values(stateStore.queue)); +const queueCountLabel = computed(() => { + if (stateStore.hasMore()) { + return `${stateStore.shown()} displayed / ${stateStore.count()} queued`; + } + + return `${stateStore.count()} queued`; +}); const historyEntries = computed(() => 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 => { } }; +const loadMoreQueue = async (): Promise => { + 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 }); }, ); diff --git a/ui/app/composables/useAppSocket.ts b/ui/app/composables/useAppSocket.ts index 36bd860e..429e9408 100644 --- a/ui/app/composables/useAppSocket.ts +++ b/ui/app/composables/useAppSocket.ts @@ -19,6 +19,20 @@ const getRuntimeConfig = () => useRuntimeConfig(); const getConfig = () => useYtpConfig(); const getQueueState = () => useQueueState(); const getToast = () => useNotification(); +let queueReloadTimer: ReturnType | 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(null); const isConnected = ref(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(); } } }); diff --git a/ui/app/composables/useQueueState.ts b/ui/app/composables/useQueueState.ts index 27f12736..e1b897bc 100644 --- a/ui/app/composables/useQueueState.ts +++ b/ui/app/composables/useQueueState.ts @@ -7,18 +7,88 @@ type KeyType = string; interface QueueState { queue: Record; + 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({ 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): 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): void => { +const addAll = (data: Record, 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 => { +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 => { + 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; - }; + } & 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 => { + 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 => { 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, diff --git a/ui/app/composables/useYtpConfig.ts b/ui/app/composables/useYtpConfig.ts index 8dc65b9c..803ca2a7 100644 --- a/ui/app/composables/useYtpConfig.ts +++ b/ui/app/composables/useYtpConfig.ts @@ -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({ 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; diff --git a/ui/app/pages/index.vue b/ui/app/pages/index.vue index cbf541bc..799254ac 100644 --- a/ui/app/pages/index.vue +++ b/ui/app/pages/index.vue @@ -38,7 +38,7 @@ variant="outline" size="sm" icon="i-lucide-pause" - @click="() => void pauseDownload()" + @click="() => pauseDownload()" > Pause @@ -49,7 +49,7 @@ variant="outline" size="sm" icon="i-lucide-play" - @click="() => void resumeDownload()" + @click="() => resumeDownload()" > Resume @@ -114,7 +114,7 @@ size="sm" icon="i-lucide-refresh-cw" :loading="isRefreshing" - @click="() => void refreshQueue()" + @click="() => refreshQueue()" > Refresh @@ -156,9 +156,20 @@ - Total: {{ stateStore.count() }} + {{ queueCountLabel }} + + + Show more + @@ -355,7 +366,7 @@ variant="outline" size="xs" icon="i-lucide-circle-play" - @click="() => void startItem(item)" + @click="() => startItem(item)" > Start @@ -366,7 +377,7 @@ variant="outline" size="xs" icon="i-lucide-pause" - @click="() => void pauseItem(item)" + @click="() => pauseItem(item)" > Pause @@ -618,7 +629,7 @@ variant="outline" icon="i-lucide-circle-play" class="w-full justify-center" - @click="() => void startItem(item)" + @click="() => startItem(item)" > Start @@ -629,7 +640,7 @@ variant="outline" icon="i-lucide-pause" class="w-full justify-center" - @click="() => void pauseItem(item)" + @click="() => pauseItem(item)" > Pause @@ -780,6 +791,7 @@ const masterSelectAll = ref(false); const embed_url = ref(''); const isRefreshing = ref(false); const autoRefreshInterval = ref | 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(() => { 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 => { } }; +const loadMoreQueue = async (): Promise => { + 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> 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> 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 => await stateStore.startItems([item._id]); diff --git a/ui/app/types/config.d.ts b/ui/app/types/config.d.ts index 09fce8a8..fec38156 100644 --- a/ui/app/types/config.d.ts +++ b/ui/app/types/config.d.ts @@ -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 */ diff --git a/ui/app/types/sockets.d.ts b/ui/app/types/sockets.d.ts index 00c6c715..14dc54e3 100644 --- a/ui/app/types/sockets.d.ts +++ b/ui/app/types/sockets.d.ts @@ -38,7 +38,11 @@ export type WSEP = { item_cancelled: EventPayload; item_deleted: EventPayload; 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 }>; diff --git a/ui/tests/composables/useQueueState.test.ts b/ui/tests/composables/useQueueState.test.ts new file mode 100644 index 00000000..ad10b4b7 --- /dev/null +++ b/ui/tests/composables/useQueueState.test.ts @@ -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); + }); +});