feat: paginate queue
This commit is contained in:
parent
db8d8663eb
commit
18f54a28c6
19 changed files with 636 additions and 92 deletions
51
API.md
51
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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
1
FAQ.md
1
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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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={})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
2
ui/app/types/config.d.ts
vendored
2
ui/app/types/config.d.ts
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
6
ui/app/types/sockets.d.ts
vendored
6
ui/app/types/sockets.d.ts
vendored
|
|
@ -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 }>;
|
||||
|
|
|
|||
119
ui/tests/composables/useQueueState.test.ts
Normal file
119
ui/tests/composables/useQueueState.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue