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. 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**: **Response**:
```json ```json
{ {
"history_count": 0, // total number of completed items in history "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":{ "queue":{
"id": "abc123", "id": "abc123",
"url": "https://example.com/video", "url": "https://example.com/video",
@ -2629,52 +2635,19 @@ or an error:
--- ---
### GET /api/system/configuration ### GET /api/system/configuration
**Purpose**: Retrieve system configuration including app settings, presets, download fields, and queue status. **Purpose**: Retrieve system configuration.
**Response**: **Response**:
```json ```json
{ {
"app": { "app": {...},
"version": "...", "presets": [...],
"download_path": "/path/to/downloads", "dl_fields": [...],
"base_path": "/",
...
},
"presets": [
{
"id": 1,
"name": "default",
"description": "...",
...
}
],
"dl_fields": [
{
"id": 1,
"name": "Title",
"field": "title",
"kind": "text",
...
}
],
"paused": false, "paused": false,
"history_count": 150, "history_count": 150
"queue": [
{
"id": "abc123",
"url": "https://example.com/video",
"status": "pending",
...
}
]
} }
``` ```
**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 ### GET /api/system/folders
@ -3495,7 +3468,7 @@ Emitted when a download item is moved between queue and history.
"data": { "data": {
"id": "abc123", "id": "abc123",
"from": "queue", "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_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_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_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_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_TASKS_HANDLER_TIMER | The cron expression for the tasks handler timer | `15 */1 * * *` |
| YTP_TEMP_DISABLED | Disable temp files handling. | `false` | | YTP_TEMP_DISABLED | Disable temp files handling. | `false` |

View file

@ -166,6 +166,12 @@ class DataStore:
def items(self): def items(self):
return self._dict.items() 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: async def put(self, value: Download, no_notify: bool = False) -> Download:
_ = no_notify _ = no_notify
self._dict[value.info._id] = value self._dict[value.info._id] = value

View file

@ -227,6 +227,9 @@ class Config(metaclass=Singleton):
default_pagination: int = 50 default_pagination: int = 50
"""The default number of items per page for pagination.""" """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 task_handler_random_delay: float = 60.0
"""The maximum random delay in seconds before starting a task handler.""" """The maximum random delay in seconds before starting a task handler."""
@ -282,6 +285,7 @@ class Config(metaclass=Singleton):
"download_info_expires", "download_info_expires",
"auto_clear_history_days", "auto_clear_history_days",
"default_pagination", "default_pagination",
"queue_display_limit",
"extract_info_concurrency", "extract_info_concurrency",
"thumb_concurrency", "thumb_concurrency",
"flaresolverr_max_timeout", "flaresolverr_max_timeout",
@ -342,6 +346,7 @@ class Config(metaclass=Singleton):
"app_build_date", "app_build_date",
"app_branch", "app_branch",
"default_pagination", "default_pagination",
"queue_display_limit",
"check_for_updates", "check_for_updates",
"new_version", "new_version",
"yt_new_version", "yt_new_version",

View file

@ -240,7 +240,7 @@ class PoolManager:
await self.queue.done.put(entry) await self.queue.done.put(entry)
self._notify.emit( self._notify.emit(
Events.ITEM_MOVED, 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, title=nTitle,
message=nMessage, message=nMessage,
) )

View file

@ -320,7 +320,7 @@ class DownloadQueue(metaclass=Singleton):
await self.done.put(item) await self.done.put(item)
self._notify.emit( self._notify.emit(
Events.ITEM_MOVED, 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", title="Download Cancelled",
message=f"Download '{item.info.title}' has been cancelled.", message=f"Download '{item.info.title}' has been cancelled.",
) )
@ -725,6 +725,32 @@ class DownloadQueue(metaclass=Singleton):
return items 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]: async def get_item(self, **kwargs) -> tuple["StoreType", Download] | tuple[None, None]:
""" """
Get a specific item from the download queue or history. 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") @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 Get live queue data
Args: Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance. queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance. encoder (Encoder): The encoder instance.
config (Config): The configuration instance.
Returns: Returns:
Response: The response object with live queue items. 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( return web.json_response(
data={ data={"error": "limit must be a valid integer."}, status=web.HTTPBadRequest.status_code
"queue": (await queue.get("queue"))["queue"], )
"history_count": await queue.done.get_total_count(),
}, return web.json_response(
data={**queue.live_queue(limit), "history_count": await queue.done.get_total_count()},
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
dumps=encoder.encode, 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(), "dl_fields": await DLFields.get_instance().get_all_serialized(),
"paused": queue.is_paused(), "paused": queue.is_paused(),
"history_count": await queue.done.get_total_count(), "history_count": await queue.done.get_total_count(),
"queue": (await queue.get("queue"))["queue"],
}, },
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
dumps=encoder.encode, dumps=encoder.encode,

View file

@ -1299,6 +1299,19 @@ class TestStatusTracker:
class TestQueueManager: 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 @staticmethod
def _video_queue() -> Mock: def _video_queue() -> Mock:
async def put(item): async def put(item):
@ -1332,6 +1345,37 @@ class TestQueueManager:
auto_start=True, 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 @pytest.mark.asyncio
async def test_live_reextracts(self, monkeypatch: pytest.MonkeyPatch) -> None: async def test_live_reextracts(self, monkeypatch: pytest.MonkeyPatch) -> None:
seen: list[dict | 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.ItemDTO import ItemDTO
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.routes.api import history 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 from app.tests.helpers import temporary_test_dir
@ -84,6 +84,60 @@ async def test_items_delete_ids() -> None:
assert body == {"items": {}, "deleted": 2} 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 @pytest.mark.asyncio
async def test_item_rename_needs_name() -> None: async def test_item_rename_needs_name() -> None:
request = _FakeRequest(payload={}) request = _FakeRequest(payload={})

View file

@ -9,7 +9,7 @@ from app.library.config import Config
from app.library.cache import Cache from app.library.cache import Cache
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.UpdateChecker import UpdateChecker 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 @dataclass
@ -118,6 +118,38 @@ class TestCheckUpdatesEndpoint:
assert "update_available" in body, "Response should include update_available status" 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: class TestSystemLimitsEndpoint:
def setup_method(self): def setup_method(self):
Config._reset_singleton() Config._reset_singleton()

View file

@ -193,7 +193,20 @@
<span>Queue</span> <span>Queue</span>
</button> </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> </div>
<Transition name="section-collapse"> <Transition name="section-collapse">
@ -629,7 +642,7 @@
:isControls="true" :isControls="true"
:item="videoItem" :item="videoItem"
class="w-full" class="w-full"
@closeModel="() => void requestCloseVideo()" @closeModel="() => requestCloseVideo()"
@error="async (error: string) => await box.alert(error)" @error="async (error: string) => await box.alert(error)"
@playback-state-change="(playing: boolean) => (playingNow = playing)" @playback-state-change="(playing: boolean) => (playingNow = playing)"
/> />
@ -728,6 +741,7 @@ const embedUrl = ref('');
const videoItem = ref<StoreItem | null>(null); const videoItem = ref<StoreItem | null>(null);
const playingNow = ref(false); const playingNow = ref(false);
const autoRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null); const autoRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null);
const hadSocketDisconnect = ref(false);
const videoOpen = computed<boolean>({ const videoOpen = computed<boolean>({
get: () => Boolean(videoItem.value), get: () => Boolean(videoItem.value),
set: (value: boolean) => { set: (value: boolean) => {
@ -778,6 +792,13 @@ const urlInputUi = {
const historyPagination = computed(() => pagination.value); const historyPagination = computed(() => pagination.value);
const historyIsLoading = computed(() => isLoading.value); const historyIsLoading = computed(() => isLoading.value);
const queueItems = computed<StoreItem[]>(() => Object.values(stateStore.queue)); 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 historyEntries = computed<StoreItem[]>(() => historyItems.value);
const hasAnyItems = computed(() => queueItems.value.length > 0 || historyEntries.value.length > 0); const hasAnyItems = computed(() => queueItems.value.length > 0 || historyEntries.value.length > 0);
const showSections = computed(() => hasAnyItems.value || historyIsLoading.value); 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 => { const startAutoRefresh = (): void => {
if (autoRefreshInterval.value) { if (autoRefreshInterval.value) {
clearInterval(autoRefreshInterval.value); clearInterval(autoRefreshInterval.value);
@ -1343,7 +1381,10 @@ onMounted(async () => {
await normalizeSimpleRoute(); await normalizeSimpleRoute();
historyInitialized.value = true; historyInitialized.value = true;
socketStore.on('item_moved', handleHistoryItemMoved); 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) { if (!socketStore.isConnected && autoRefreshEnabled.value) {
startAutoRefresh(); startAutoRefresh();
@ -1374,7 +1415,7 @@ watch(
() => route.path, () => route.path,
() => { () => {
if (simpleMode.value) { if (simpleMode.value) {
void normalizeSimpleRoute(); normalizeSimpleRoute();
} }
}, },
); );
@ -1384,9 +1425,17 @@ watch(
(connected) => { (connected) => {
if (connected) { if (connected) {
stopAutoRefresh(); stopAutoRefresh();
if (hadSocketDisconnect.value) {
hadSocketDisconnect.value = false;
refreshQueue();
}
return; return;
} }
hadSocketDisconnect.value = true;
if (autoRefreshEnabled.value) { if (autoRefreshEnabled.value) {
startAutoRefresh(); startAutoRefresh();
} }
@ -1429,7 +1478,7 @@ watch(
return; return;
} }
void load(1, { order: 'DESC', perPage: configStore.app.default_pagination }); load(1, { order: 'DESC', perPage: configStore.app.default_pagination });
}, },
); );
</script> </script>

View file

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

View file

@ -7,18 +7,88 @@ type KeyType = string;
interface QueueState { interface QueueState {
queue: Record<KeyType, StoreItem>; 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>({ const state = reactive<QueueState>({
queue: {}, 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 => { const add = (key: KeyType, value: StoreItem): void => {
if (state.queue[key]) {
state.queue[key] = value; 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 => { const update = (key: KeyType, value: StoreItem): void => {
if (!state.queue[key]) {
if (isActive(value)) {
reveal(key, value);
}
return;
}
state.queue[key] = value; state.queue[key] = value;
syncLoaded();
}; };
const patch = (key: KeyType, fields: Partial<StoreItem>): void => { const patch = (key: KeyType, fields: Partial<StoreItem>): void => {
@ -34,6 +104,18 @@ const remove = (key: KeyType): void => {
const { [key]: _, ...rest } = state.queue; const { [key]: _, ...rest } = state.queue;
state.queue = rest; 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 => { const get = (key: KeyType, defaultValue: StoreItem | null = null): StoreItem | null => {
@ -46,30 +128,92 @@ const has = (key: KeyType): boolean => {
const clearAll = (): void => { const clearAll = (): void => {
state.queue = {}; 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.queue = data;
state.is_loaded = true;
applyMeta(meta);
}; };
const count = (): number => { 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 { 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 { const data = (await response.json()) as {
queue: Record<KeyType, StoreItem>; queue: Record<KeyType, StoreItem>;
}; } & QueueMeta;
state.queue = data.queue || {}; addAll(data.queue || {}, data);
} catch (error) { } catch (error) {
console.error('Failed to load queue:', error); console.error('Failed to load queue:', error);
throw 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 addDownload = async (data: item_request): Promise<void> => {
const socket = useAppSocket(); const socket = useAppSocket();
const toast = useNotification(); const toast = useNotification();
@ -235,12 +379,18 @@ const queueStateApi = proxyRefs({
update, update,
patch, patch,
remove, remove,
drop,
get, get,
has, has,
clearAll, clearAll,
addAll, addAll,
count, count,
isLoaded,
shown,
hasMore,
needsBackfill,
loadQueue, loadQueue,
loadMore,
addDownload, addDownload,
startItems, startItems,
pauseItems, pauseItems,

View file

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

View file

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

View file

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

View file

@ -38,7 +38,11 @@ export type WSEP = {
item_cancelled: EventPayload<StoreItem>; item_cancelled: EventPayload<StoreItem>;
item_deleted: EventPayload<StoreItem>; item_deleted: EventPayload<StoreItem>;
item_bulk_deleted: EventPayload<{ count: number; status?: string; ids?: string[] }>; 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 }>; item_status: EventPayload<{ status?: string; msg?: string; preset?: string }>;
paused: EventPayload<{ paused?: boolean }>; paused: EventPayload<{ paused?: boolean }>;
resumed: 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);
});
});