refactor: make the UI work without websocket

This commit is contained in:
arabcoders 2026-01-22 17:01:03 +03:00
parent 501455029c
commit c3eb7091af
17 changed files with 763 additions and 182 deletions

132
API.md
View file

@ -26,6 +26,10 @@ This document describes the available endpoints and their usage. All endpoints r
- [POST /api/history/{id}](#post-apihistoryid) - [POST /api/history/{id}](#post-apihistoryid)
- [GET /api/history/{id}](#get-apihistoryid) - [GET /api/history/{id}](#get-apihistoryid)
- [GET /api/history](#get-apihistory) - [GET /api/history](#get-apihistory)
- [GET /api/history/live](#get-apihistorylive)
- [POST /api/history/start](#post-apihistorystart)
- [POST /api/history/pause](#post-apihistorypause)
- [POST /api/history/cancel](#post-apihistorycancel)
- [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive) - [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive)
- [POST /api/history/{id}/archive](#post-apihistoryidarchive) - [POST /api/history/{id}/archive](#post-apihistoryidarchive)
- [GET /api/archiver](#get-apiarchiver) - [GET /api/archiver](#get-apiarchiver)
@ -99,7 +103,6 @@ This document describes the available endpoints and their usage. All endpoints r
- [Connection Events](#connection-events) - [Connection Events](#connection-events)
- [`connect` (Server → Client)](#connect-server--client) - [`connect` (Server → Client)](#connect-server--client)
- [`disconnect` (Server → Client)](#disconnect-server--client) - [`disconnect` (Server → Client)](#disconnect-server--client)
- [`configuration` (Server → Client)](#configuration-server--client)
- [`config_update` (Server → Client)](#config_update-server--client) - [`config_update` (Server → Client)](#config_update-server--client)
- [`connected` (Server → Client)](#connected-server--client) - [`connected` (Server → Client)](#connected-server--client)
- [`active_queue` (Server → Client)](#active_queue-server--client) - [`active_queue` (Server → Client)](#active_queue-server--client)
@ -599,6 +602,124 @@ GET /api/history?type=queue&status=pending&order=ASC
--- ---
### GET /api/history/live
**Purpose**: Get live queue data with real-time download progress.
This endpoint returns the current state of active downloads from memory.
**Response**:
```json
{
"history_count": 0, // total number of completed items in history
"queue":{
"id": "abc123",
"url": "https://example.com/video",
"title": "Video Title",
"status": "downloading",
"progress": 45.6,
"speed": "2.5 MiB/s",
"eta": "00:05:23",
...
},
...
}
```
---
### POST /api/history/start
**Purpose**: Start one or more downloads in the queue.
**Body**:
```json
{
"ids": ["<id1>", "<id2>", ...]
}
```
**Response**:
```json
{
"<id1>": "started",
"<id2>": "started",
...
}
```
**Error Responses**:
- `400 Bad Request` if `ids` is missing or not an array:
```json
{ "error": "ids is required and must be an array." }
```
**Notes**:
- Items must exist in the queue
- Sets `auto_start: true` for the specified items
---
### POST /api/history/pause
**Purpose**: Pause one or more downloads in the queue.
**Body**:
```json
{
"ids": ["<id1>", "<id2>", ...]
}
```
**Response**:
```json
{
"<id1>": "paused",
"<id2>": "paused",
...
}
```
**Error Responses**:
- `400 Bad Request` if `ids` is missing or not an array:
```json
{ "error": "ids is required and must be an array." }
```
**Notes**:
- Items must exist in the queue
- Sets `auto_start: false` for the specified items
---
### POST /api/history/cancel
**Purpose**: Cancel one or more downloads and move them to history.
**Body**:
```json
{
"ids": ["<id1>", "<id2>", ...]
}
```
**Response**:
```json
{
"<id1>": "ok",
"<id2>": "ok",
...
}
```
**Error Responses**:
- `400 Bad Request` if `ids` is missing or not an array:
```json
{ "error": "ids is required and must be an array." }
```
**Notes**:
- Items must exist in the queue
- Stops active downloads if they are currently running
---
### DELETE /api/history/{id}/archive ### DELETE /api/history/{id}/archive
**Purpose**: Remove an item from archive file, allowing it to be re-downloaded. **Purpose**: Remove an item from archive file, allowing it to be re-downloaded.
@ -2122,15 +2243,6 @@ Fired when WebSocket connection is closed. No data payload.
socket.on('disconnect', (reason: string) => console.log('WebSocket disconnected:', reason)); socket.on('disconnect', (reason: string) => console.log('WebSocket disconnected:', reason));
``` ```
##### `configuration` (Server → Client)
Sends the current application configuration.
**Data Fields**:
- `config`: Global configuration object
- `presets`: Available download presets
- `dl_fields`: Available download fields
- `paused`: Queue pause status (boolean)
##### `config_update` (Server → Client) ##### `config_update` (Server → Client)
Emitted when configuration-backed resources change (presets, dl fields, conditions, notifications). Emitted when configuration-backed resources change (presets, dl fields, conditions, notifications).

View file

@ -116,6 +116,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:
"""
Get live queue data
Args:
queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object with live queue items.
"""
return web.json_response(
data={
"queue": (await queue.get("queue"))["queue"],
"history_count": await queue.done.get_total_count(),
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("DELETE", "api/history/", "items_delete") @route("DELETE", "api/history/", "items_delete")
async def items_delete(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response: async def items_delete(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
""" """
@ -390,6 +413,84 @@ async def items_add(request: Request, queue: DownloadQueue, encoder: Encoder) ->
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode) return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", "api/history/start", "items_start")
async def items_start(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
"""
Start one or more queued downloads.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object.
"""
data = await request.json()
if not (ids := data.get("ids", [])):
return web.json_response(data={"error": "ids array is required."}, status=web.HTTPBadRequest.status_code)
if not isinstance(ids, list):
return web.json_response(data={"error": "ids must be an array."}, status=web.HTTPBadRequest.status_code)
status: dict[str, str] = await queue.start_items(ids)
return web.json_response(data=status, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", "api/history/pause", "items_pause")
async def items_pause(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
"""
Pause one or more queued downloads.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object.
"""
data = await request.json()
if not (ids := data.get("ids", [])):
return web.json_response(data={"error": "ids array is required."}, status=web.HTTPBadRequest.status_code)
if not isinstance(ids, list):
return web.json_response(data={"error": "ids must be an array."}, status=web.HTTPBadRequest.status_code)
status: dict[str, str] = await queue.pause_items(ids)
return web.json_response(data=status, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", "api/history/cancel", "items_cancel")
async def items_cancel(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
"""
Cancel one or more queued downloads.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object.
"""
data = await request.json()
if not (ids := data.get("ids", [])):
return web.json_response(data={"error": "ids array is required."}, status=web.HTTPBadRequest.status_code)
if not isinstance(ids, list):
return web.json_response(data={"error": "ids must be an array."}, status=web.HTTPBadRequest.status_code)
status: dict[str, str] = await queue.cancel(ids)
return web.json_response(data=status, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", r"api/history/{id}/archive", "history.item.archive.add") @route("POST", r"api/history/{id}/archive", "history.item.archive.add")
async def item_archive_add(request: Request, queue: DownloadQueue, notify: EventBus) -> Response: async def item_archive_add(request: Request, queue: DownloadQueue, notify: EventBus) -> Response:
""" """

View file

@ -4,18 +4,22 @@ import logging
import os import os
import shlex import shlex
import time import time
from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from aiohttp import web from aiohttp import web
from aiohttp.web import Request, Response from aiohttp.web import Request, Response
from aiohttp.web_runner import GracefulExit from aiohttp.web_runner import GracefulExit
from app.features.dl_fields.service import DLFields
from app.library.config import Config from app.library.config import Config
from app.library.downloads import DownloadQueue from app.library.downloads import DownloadQueue
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.Presets import Presets
from app.library.router import route from app.library.router import route
from app.library.UpdateChecker import UpdateChecker from app.library.UpdateChecker import UpdateChecker
from app.library.Utils import list_folders
if TYPE_CHECKING: if TYPE_CHECKING:
from asyncio import Task from asyncio import Task
@ -25,6 +29,39 @@ if TYPE_CHECKING:
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger(__name__)
@route("GET", "api/system/configuration", "system.configuration")
async def system_config(queue: DownloadQueue, config: Config, encoder: Encoder) -> Response:
"""
Pause non-active downloads.
Args:
queue (DownloadQueue): The download queue instance.
config (Config): The config instance.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object.
"""
return web.json_response(
data={
"app": config.frontend(),
"presets": Presets.get_instance().get_all(),
"dl_fields": await DLFields.get_instance().get_all_serialized(),
"paused": queue.is_paused(),
"folders": list_folders(
path=Path(config.download_path),
base=Path(config.download_path),
depth_limit=config.download_path_depth - 1,
),
"history_count": await queue.done.get_total_count(),
"queue": (await queue.get("queue"))["queue"],
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("POST", "api/system/pause", "system.pause") @route("POST", "api/system/pause", "system.pause")
async def downloads_pause(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response: async def downloads_pause(queue: DownloadQueue, encoder: Encoder, notify: EventBus) -> Response:
""" """
@ -189,9 +226,6 @@ async def check_updates(config: Config, encoder: Encoder, update_checker: Update
) )
LOG: logging.Logger = logging.getLogger(__name__)
@route("POST", "api/system/terminal", "system.terminal") @route("POST", "api/system/terminal", "system.terminal")
async def stream_terminal(request: Request, config: Config, encoder: Encoder) -> Response | web.StreamResponse: async def stream_terminal(request: Request, config: Config, encoder: Encoder) -> Response | web.StreamResponse:
if not config.console_enabled: if not config.console_enabled:

View file

@ -1,71 +1,15 @@
import logging import logging
from pathlib import Path
from app.features.dl_fields.service import DLFields
from app.library.config import Config
from app.library.downloads import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.HttpSocket import WebSocketHub
from app.library.Presets import Presets
from app.library.router import RouteType, route from app.library.router import RouteType, route
from app.library.Utils import list_folders
LOG: logging.Logger = logging.getLogger(__name__) LOG: logging.Logger = logging.getLogger(__name__)
class _Data:
subscribers: dict[str, list[str]] = {}
@route(RouteType.SOCKET, "connect", "socket_connect") @route(RouteType.SOCKET, "connect", "socket_connect")
async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: str): async def connect(sid: str):
notify.emit( pass
Events.CONFIGURATION,
data={
"config": config.frontend(),
"presets": Presets.get_instance().get_all(),
"dl_fields": await DLFields.get_instance().get_all_serialized(),
"paused": queue.is_paused(),
},
title="Client connected",
message=f"Client '{sid}' connected.",
to=sid,
)
notify.emit(
Events.CONNECTED,
data={
"folders": list_folders(
path=Path(config.download_path),
base=Path(config.download_path),
depth_limit=config.download_path_depth - 1,
),
"history_count": await queue.done.get_total_count(),
"queue": (await queue.get("queue"))["queue"],
},
title="Sending initial download data",
message=f"Sending initial download data to client '{sid}'.",
to=sid,
)
notify.emit(
Events.ACTIVE_QUEUE,
data={"queue": (await queue.get("queue"))["queue"]},
title="Sending initial active queue data",
message=f"Sending active queue data to client '{sid}'.",
to=sid,
)
@route(RouteType.SOCKET, "disconnect", "socket_disconnect") @route(RouteType.SOCKET, "disconnect", "socket_disconnect")
async def disconnect(sio: WebSocketHub, sid: str, data: str | None = None): # noqa: ARG001 async def disconnect(sid: str):
""" pass
Handle client disconnection.
Args:
sio (WebSocketHub): The WebSocket hub instance.
sid (str): The session ID of the client.
data (str): The reason for disconnection.
"""
LOG.debug(f"Client '{sid}' disconnected. {data}")

View file

@ -163,7 +163,7 @@
<div class="field is-grouped is-grouped-centered"> <div class="field is-grouped is-grouped-centered">
<div class="control" v-if="item.status != 'finished' || !item.filename"> <div class="control" v-if="item.status != 'finished' || !item.filename">
<button class="button is-warning is-fullwidth is-small" v-tooltip="'Retry download'" <button class="button is-warning is-fullwidth is-small" v-tooltip="'Retry download'"
@click="() => retryItem(item, true)"> @click="async () => await retryItem(item, true)">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span> <span class="icon"><i class="fa-solid fa-rotate-right" /></span>
</button> </button>
</div> </div>
@ -207,7 +207,7 @@
</NuxtLink> </NuxtLink>
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="retryItem(item, true)"> <NuxtLink class="dropdown-item" @click="async () => await retryItem(item, true)">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span> <span class="icon"><i class="fa-solid fa-rotate-right" /></span>
<span>Add to download form</span> <span>Add to download form</span>
</NuxtLink> </NuxtLink>
@ -335,7 +335,7 @@
</div> </div>
<div class="columns is-mobile is-multiline"> <div class="columns is-mobile is-multiline">
<div class="column is-half-mobile" v-if="item.status != 'finished' || !item.filename"> <div class="column is-half-mobile" v-if="item.status != 'finished' || !item.filename">
<a class="button is-warning is-fullwidth" @click="() => retryItem(item, false)"> <a class="button is-warning is-fullwidth" @click="async () => retryItem(item, false)">
<span class="icon-text is-block"> <span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span> <span class="icon"><i class="fa-solid fa-rotate-right" /></span>
<span>Retry</span> <span>Retry</span>
@ -391,7 +391,7 @@
</NuxtLink> </NuxtLink>
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="retryItem(item, true)"> <NuxtLink class="dropdown-item" @click="async () => await retryItem(item, true)">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span> <span class="icon"><i class="fa-solid fa-rotate-right" /></span>
<span>Add to download form</span> <span>Add to download form</span>
</NuxtLink> </NuxtLink>
@ -452,8 +452,7 @@
<li><code>source_name:task_name</code> - items added by the specified task.</li> <li><code>source_name:task_name</code> - items added by the specified task.</li>
</ul> </ul>
</Message> </Message>
<Message v-else class="is-primary" title="No items" icon="fas fa-exclamation-triangle" <Message v-else class="is-primary" title="No items" icon="fas fa-exclamation-triangle" :new-style="true">
:new-style="true">
<p>Download history is empty.</p> <p>Download history is empty.</p>
</Message> </Message>
</div> </div>
@ -584,7 +583,7 @@ watch(showCompleted, async isShown => {
}) })
onMounted(async () => { onMounted(async () => {
if (showCompleted.value && !paginationInfo.value.isLoaded && socket.isConnected) { if (showCompleted.value) {
try { try {
await stateStore.loadPaginated('history', 1, config.app.default_pagination, 'DESC', true) await stateStore.loadPaginated('history', 1, config.app.default_pagination, 'DESC', true)
} catch (error) { } catch (error) {
@ -701,10 +700,7 @@ const deleteSelectedItems = async () => {
return return
} }
await stateStore.deleteItems('history', { await stateStore.removeItems('history', [...selectedElms.value], config.app.remove_files)
ids: [...selectedElms.value],
removeFile: config.app.remove_files
})
selectedElms.value = [] selectedElms.value = []
} }
@ -803,7 +799,7 @@ const retryIncomplete = async () => {
if ('finished' === item.status) { if ('finished' === item.status) {
continue continue
} }
retryItem(item) await retryItem(item)
} }
} }
@ -831,7 +827,7 @@ const archiveItem = async (item: StoreItem, opts = {}) => {
if (!(opts as any)?.remove_history) { if (!(opts as any)?.remove_history) {
return return
} }
socket.emit('item_delete', { id: item._id, remove_file: false }) await stateStore.removeItems('history', [item._id], false)
} }
const removeItem = async (item: StoreItem) => { const removeItem = async (item: StoreItem) => {
@ -853,7 +849,7 @@ const removeItem = async (item: StoreItem) => {
} }
} }
const retryItem = (item: StoreItem, re_add: boolean = false, remove_file: boolean = false) => { const retryItem = async (item: StoreItem, re_add: boolean = false, remove_file: boolean = false) => {
const item_req: Partial<StoreItem> = { const item_req: Partial<StoreItem> = {
url: item.url, url: item.url,
preset: item.preset, preset: item.preset,
@ -865,7 +861,7 @@ const retryItem = (item: StoreItem, re_add: boolean = false, remove_file: boolea
auto_start: item.auto_start, auto_start: item.auto_start,
} }
socket.emit('item_delete', { id: item._id, remove_file: remove_file }) await stateStore.removeItems('history', [item._id], remove_file)
if (selectedElms.value.includes(item._id || '')) { if (selectedElms.value.includes(item._id || '')) {
selectedElms.value = selectedElms.value.filter(i => i !== item._id) selectedElms.value = selectedElms.value.filter(i => i !== item._id)
@ -876,7 +872,7 @@ const retryItem = (item: StoreItem, re_add: boolean = false, remove_file: boolea
emitter('add_new', item_req) emitter('add_new', item_req)
return return
} }
socket.emit('add_url', item_req) await stateStore.addDownload(item_req)
} }
const pImg = (e: Event) => { const pImg = (e: Event) => {
@ -990,12 +986,12 @@ const removeFromArchive = async (item: StoreItem, opts?: { re_add?: boolean, rem
} }
if (opts?.re_add) { if (opts?.re_add) {
retryItem(item, true, file_delete) await retryItem(item, true, file_delete)
return return
} }
if (opts?.remove_history) { if (opts?.remove_history) {
socket.emit('item_delete', { id: item._id, remove_file: file_delete }) await stateStore.removeItems('history', [item._id], file_delete)
} }
} }

View file

@ -1,4 +1,20 @@
<template> <template>
<div class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end" v-if="!socket.isConnected">
<div class="column is-narrow">
<div class="field is-grouped">
<p class="control">
<button type="button" class="button is-info" @click="refreshQueue" :class="{ 'is-loading': isRefreshing }"
v-tooltip.bottom="'Refresh queue data'">
<span class="icon">
<i class="fa-solid fa-refresh" />
</span>
<span>Refresh</span>
</button>
</p>
</div>
</div>
</div>
<div class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end" <div class="columns is-multiline is-mobile has-text-centered is-justify-content-flex-end"
v-if="filteredItems.length > 0"> v-if="filteredItems.length > 0">
<div class="column is-narrow"> <div class="column is-narrow">
@ -369,9 +385,71 @@ const show_popover = useStorage<boolean>('show_popover', true)
const selectedElms = ref<string[]>([]) const selectedElms = ref<string[]>([])
const masterSelectAll = ref(false) const masterSelectAll = ref(false)
const embed_url = ref('') const embed_url = ref('')
const isRefreshing = ref(false)
const autoRefreshInterval = ref<NodeJS.Timeout | null>(null)
const autoRefreshEnabled = useStorage<boolean>('queue_auto_refresh', true)
const autoRefreshDelay = useStorage<number>('queue_auto_refresh_delay', 10000)
const showThumbnails = computed(() => !!props.thumbnails && !hideThumbnail.value) const showThumbnails = computed(() => !!props.thumbnails && !hideThumbnail.value)
const refreshQueue = async () => {
isRefreshing.value = true
try {
await stateStore.loadQueue()
} catch {
toast.error('Failed to refresh queue')
} finally {
isRefreshing.value = false
}
}
const startAutoRefresh = () => {
if (autoRefreshInterval.value) {
clearInterval(autoRefreshInterval.value)
}
if (!autoRefreshEnabled.value || socket.isConnected) {
return
}
autoRefreshInterval.value = setInterval(async () => {
if (!socket.isConnected && autoRefreshEnabled.value) {
await refreshQueue()
}
}, autoRefreshDelay.value)
}
const stopAutoRefresh = () => {
if (autoRefreshInterval.value) {
clearInterval(autoRefreshInterval.value)
autoRefreshInterval.value = null
}
}
watch(() => socket.isConnected, (connected) => {
if (connected) {
stopAutoRefresh()
} else if (autoRefreshEnabled.value) {
startAutoRefresh()
}
})
watch(autoRefreshEnabled, (enabled) => {
if (enabled && !socket.isConnected) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
})
onMounted(() => {
if (!socket.isConnected && autoRefreshEnabled.value) {
startAutoRefresh()
}
})
onBeforeUnmount(() => stopAutoRefresh())
watch(masterSelectAll, (value) => { watch(masterSelectAll, (value) => {
if (value) { if (value) {
selectedElms.value = Object.values(stateStore.queue).map((element: StoreItem) => element._id) selectedElms.value = Object.values(stateStore.queue).map((element: StoreItem) => element._id)
@ -570,11 +648,11 @@ const cancelItems = (item: string | string[]) => {
if (0 > items.length) { if (0 > items.length) {
return return
} }
items.forEach(id => socket.emit('item_cancel', id)) stateStore.cancelItems(items)
} }
const startItem = (item: StoreItem) => socket.emit('item_start', item._id) const startItem = async (item: StoreItem) => await stateStore.startItems([item._id])
const pauseItem = (item: StoreItem) => socket.emit('item_pause', item._id) const pauseItem = async (item: StoreItem) => await stateStore.pauseItems([item._id])
const startItems = async () => { const startItems = async () => {
if (1 > selectedElms.value.length) { if (1 > selectedElms.value.length) {
@ -595,7 +673,7 @@ const startItems = async () => {
if (true !== (await box.confirm(`Start '${filtered.length}' selected items?`))) { if (true !== (await box.confirm(`Start '${filtered.length}' selected items?`))) {
return false return false
} }
filtered.forEach(id => socket.emit('item_start', id)) await stateStore.startItems(filtered)
} }
const pauseSelected = async () => { const pauseSelected = async () => {
@ -617,7 +695,7 @@ const pauseSelected = async () => {
if (true !== (await box.confirm(`Pause '${filtered.length}' selected items?`))) { if (true !== (await box.confirm(`Pause '${filtered.length}' selected items?`))) {
return false return false
} }
filtered.forEach(id => socket.emit('item_pause', id)) await stateStore.pauseItems(filtered)
} }
const pImg = (e: Event) => { const pImg = (e: Event) => {

View file

@ -155,6 +155,47 @@
</div> </div>
</div> </div>
<div class="box">
<p class="title is-5 mb-4">
<span class="icon-text">
<span class="icon"><i class="fas fa-download" /></span>
<span>Queue</span>
</span>
</p>
<div class="field">
<label class="label">Auto-refresh queue when disconnected</label>
<div class="control">
<input id="queue_auto_refresh" type="checkbox" class="switch is-success" v-model="queue_auto_refresh">
<label for="queue_auto_refresh" class="is-unselectable">
{{ queue_auto_refresh ? 'Enabled' : 'Disabled' }}
</label>
</div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
Automatically refresh queue data when WebSocket connection is unavailable.
</p>
</div>
<div class="field" v-if="queue_auto_refresh">
<label class="label">Auto-refresh interval (seconds)</label>
<div class="field has-addons">
<div class="control">
<a class="button is-static">
<code>{{ queue_auto_refresh_delay / 1000 }}s</code>
</a>
</div>
<div class="control is-expanded">
<input class="input" type="range" v-model.number="queue_auto_refresh_delay" min="5000" max="60000" step="5000">
</div>
</div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
How often to refresh the queue (5-60 seconds). Lower values increase server load.
</p>
</div>
</div>
<div class="box"> <div class="box">
<p class="title is-5 mb-4"> <p class="title is-5 mb-4">
<span class="icon-text"> <span class="icon-text">
@ -261,6 +302,8 @@ const show_popover = useStorage<boolean>('show_popover', true)
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1') const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',') const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',')
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false) const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
const queue_auto_refresh = useStorage<boolean>('queue_auto_refresh', true)
const queue_auto_refresh_delay = useStorage<number>('queue_auto_refresh_delay', 10000)
const isSecureContext = ref<boolean>(false) const isSecureContext = ref<boolean>(false)
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {

View file

@ -7,6 +7,11 @@
<template v-if="!isMobile">{{ greetingMessage }}</template> <template v-if="!isMobile">{{ greetingMessage }}</template>
<template v-else>What would you like to download?</template> <template v-else>What would you like to download?</template>
<span class="is-pulled-right"> <span class="is-pulled-right">
<span class="icon has-text-info is-pointer mr-2" v-if="!socketStore.isConnected" v-tooltip="'Reload queue'"
@click="async () => await refreshQueue()">
<i class="fas fa-sync-alt" :class="{ 'fa-spin': isRefreshing }" />
</span>
<span class="icon is-pointer" :class="connectionStatusColor" @click="$emit('show_settings')" <span class="icon is-pointer" :class="connectionStatusColor" @click="$emit('show_settings')"
v-tooltip="'WebUI Settings'"> v-tooltip="'WebUI Settings'">
<i class="fas fa-cogs" /></span> <i class="fas fa-cogs" /></span>
@ -77,6 +82,7 @@
<Transition name="queue-fade"> <Transition name="queue-fade">
<section v-if="hasAnyItems" key="queue" class="queue-section"> <section v-if="hasAnyItems" key="queue" class="queue-section">
<TransitionGroup name="queue-card" tag="div" class="columns is-multiline queue-card-columns"> <TransitionGroup name="queue-card" tag="div" class="columns is-multiline queue-card-columns">
<div v-for="entry in displayItems" :key="entry.item._id" class="column is-12-mobile is-6-tablet"> <div v-for="entry in displayItems" :key="entry.item._id" class="column is-12-mobile is-6-tablet">
<article class="queue-card card" :class="{ 'is-history': 'history' === entry.source }"> <article class="queue-card card" :class="{ 'is-history': 'history' === entry.source }">
@ -166,7 +172,7 @@
<span>Download</span> <span>Download</span>
</a> </a>
<button v-if="entry.item.status != 'finished' || !entry.item.filename" class="button is-info is-light" <button v-if="entry.item.status != 'finished' || !entry.item.filename" class="button is-info is-light"
type="button" @click="requeueItem(entry.item)"> type="button" @click="async () => await requeueItem(entry.item)">
<span class="icon"><i class="fas fa-rotate-right" /></span> <span class="icon"><i class="fas fa-rotate-right" /></span>
<span>Requeue</span> <span>Requeue</span>
</button> </button>
@ -252,6 +258,23 @@ const formUrl = ref<string>('')
const formPreset = ref<{ preset: string }>({ preset: app.value.default_preset || '' }) const formPreset = ref<{ preset: string }>({ preset: app.value.default_preset || '' })
const addInProgress = ref<boolean>(false) const addInProgress = ref<boolean>(false)
const showExtras = ref<boolean>(false) const showExtras = ref<boolean>(false)
const isRefreshing = ref<boolean>(false)
const refreshQueue = async (): Promise<void> => {
if (isRefreshing.value) {
return
}
isRefreshing.value = true
try {
await stateStore.loadQueue()
} catch (error) {
console.error('Failed to refresh queue:', error)
toast.error('Failed to refresh queue')
} finally {
isRefreshing.value = false
}
}
const paginationInfo = computed(() => stateStore.getPagination()) const paginationInfo = computed(() => stateStore.getPagination())
const queueItems = computed<StoreItem[]>(() => Object.values(queue.value ?? {}).slice().sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0))) const queueItems = computed<StoreItem[]>(() => Object.values(queue.value ?? {}).slice().sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)))
@ -575,16 +598,16 @@ const getProgressWidth = (item: StoreItem): string => {
const canStart = (item: StoreItem): boolean => !item.status && false === item.auto_start const canStart = (item: StoreItem): boolean => !item.status && false === item.auto_start
const canPause = (item: StoreItem): boolean => !item.status && true === item.auto_start const canPause = (item: StoreItem): boolean => !item.status && true === item.auto_start
const startQueueItem = (item: StoreItem): void => { const startQueueItem = async (item: StoreItem): Promise<void> => {
socketStore.emit('item_start', item._id) await stateStore.startItems([item._id])
} }
const pauseQueueItem = (item: StoreItem): void => { const pauseQueueItem = async (item: StoreItem): Promise<void> => {
socketStore.emit('item_pause', item._id) await stateStore.pauseItems([item._id])
} }
const cancelDownload = (item: StoreItem): void => { const cancelDownload = async (item: StoreItem): Promise<void> => {
socketStore.emit('item_cancel', item._id) await stateStore.cancelItems([item._id])
} }
const getDownloadLink = (item: StoreItem): string => { const getDownloadLink = (item: StoreItem): string => {
@ -602,7 +625,7 @@ const getDownloadName = (item: StoreItem): string => {
return segments[segments.length - 1] || 'download' return segments[segments.length - 1] || 'download'
} }
const requeueItem = (item: StoreItem): void => { const requeueItem = async (item: StoreItem): Promise<void> => {
if (!item.url) { if (!item.url) {
toast.error('Unable to requeue item; missing URL.') toast.error('Unable to requeue item; missing URL.')
return return
@ -622,12 +645,12 @@ const requeueItem = (item: StoreItem): void => {
payload.extras = JSON.parse(JSON.stringify(item.extras)) payload.extras = JSON.parse(JSON.stringify(item.extras))
} }
socketStore.emit('item_delete', { id: item._id, remove_file: false }) await stateStore.removeItems('history', [item._id], false)
socketStore.emit('add_url', payload) await stateStore.addDownload(payload)
} }
const deleteHistoryItem = (item: StoreItem): void => { const deleteHistoryItem = async (item: StoreItem): Promise<void> => {
socketStore.emit('item_delete', { id: item._id, remove_file: app.value.remove_files }) await stateStore.removeItems('history', [item._id], app.value.remove_files)
toast.info('Removed from history queue.') toast.info('Removed from history queue.')
} }
@ -668,12 +691,10 @@ onMounted(async () => {
window.history.replaceState({}, '', url.toString()) window.history.replaceState({}, '', url.toString())
} }
if (socketStore.isConnected && !paginationInfo.value.isLoaded) { try {
try { await stateStore.loadPaginated('history', 1, DEFAULT_PAGE_SIZE, 'DESC')
await stateStore.loadPaginated('history', 1, DEFAULT_PAGE_SIZE, 'DESC') } catch (error) {
} catch (error) { console.error('Failed to load history on mount:', error)
console.error('Failed to load history on mount:', error)
}
} }
if (window?.location && '/' !== window.location.pathname) { if (window?.location && '/' !== window.location.pathname) {
@ -776,6 +797,12 @@ useIntersectionObserver(loadMoreTrigger, ([entry]) => {
transform-origin: top center; transform-origin: top center;
} }
.queue-header {
display: flex;
justify-content: flex-end;
align-items: center;
}
.queue-card-columns { .queue-card-columns {
margin-top: 0.75rem; margin-top: 0.75rem;
} }

View file

@ -1,7 +1,6 @@
<template> <template>
<template v-if="simpleMode"> <template v-if="simpleMode">
<Connection :status="socket.connectionStatus" @reconnect="() => socket.reconnect()" />
<Simple @show_settings="() => show_settings = true" :class="{ 'settings-open': show_settings }" /> <Simple @show_settings="() => show_settings = true" :class="{ 'settings-open': show_settings }" />
</template> </template>
@ -12,7 +11,6 @@
<Shutdown v-if="app_shutdown" /> <Shutdown v-if="app_shutdown" />
<div id="main_container" class="container" :class="{ 'settings-open': show_settings }" v-else> <div id="main_container" class="container" :class="{ 'settings-open': show_settings }" v-else>
<NewVersion v-if="newVersionIsAvailable" /> <NewVersion v-if="newVersionIsAvailable" />
<Connection :status="socket.connectionStatus" @reconnect="() => socket.reconnect()" />
<nav class="navbar is-mobile is-dark"> <nav class="navbar is-mobile is-dark">
<div class="navbar-brand pl-5"> <div class="navbar-brand pl-5">
@ -139,11 +137,8 @@
<NuxtPage v-if="config.is_loaded" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" /> <NuxtPage v-if="config.is_loaded" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
<Message v-if="!config.is_loaded" class="is-info mt-5" title="Loading Configuration" <Message v-if="!config.is_loaded" class="is-info mt-5" title="Loading Configuration"
icon="fas fa-spinner fa-spin"> icon="fas fa-spinner fa-spin">
<p>This usually takes less than a second. <p>This usually takes less than a second. If this is taking too long,
<span v-if="!socket.isConnected" class="mt-2"> <NuxtLink class="button is-text p-0" @click="config.loadConfig">reload configuration</NuxtLink>.
If this is taking too long, please check that the backend server is running and that the WebSocket
connection is functional.
</span>
</p> </p>
<template v-if="socket.error"> <template v-if="socket.error">
<hr> <hr>
@ -162,7 +157,7 @@
</ClientOnly> </ClientOnly>
</div> </div>
<footer class="footer py-5 mt-6 is-unselectable" v-if="socket.isConnected"> <footer class="footer py-5 mt-6 is-unselectable" v-if="config.is_loaded">
<div class="columns is-multiline is-variable is-8"> <div class="columns is-multiline is-variable is-8">
<div class="column is-12-mobile is-6-tablet"> <div class="column is-12-mobile is-6-tablet">
<div class="mb-3"> <div class="mb-3">
@ -286,7 +281,6 @@ import Dialog from '~/components/Dialog.vue'
import Simple from '~/components/Simple.vue' import Simple from '~/components/Simple.vue'
import Shutdown from '~/components/shutdown.vue' import Shutdown from '~/components/shutdown.vue'
import Markdown from '~/components/Markdown.vue' import Markdown from '~/components/Markdown.vue'
import Connection from '~/components/Connection.vue'
const selectedTheme = useStorage('theme', 'auto') const selectedTheme = useStorage('theme', 'auto')
const socket = useSocketStore() const socket = useSocketStore()
@ -423,9 +417,15 @@ const applyPreferredColorScheme = (scheme: string) => {
onMounted(async () => { onMounted(async () => {
try { try {
await handleImage(bg_enable.value) applyPreferredColorScheme(selectedTheme.value)
} catch { } } catch { }
try {
await config.loadConfig()
} catch {
// -- IGNORE --
}
try { try {
const opts = await request('/api/yt-dlp/options') const opts = await request('/api/yt-dlp/options')
if (!opts.ok) { if (!opts.ok) {
@ -435,8 +435,10 @@ onMounted(async () => {
config.ytdlp_options = data config.ytdlp_options = data
} catch { } } catch { }
socket.connect()
try { try {
applyPreferredColorScheme(selectedTheme.value) await handleImage(bg_enable.value)
} catch { } } catch { }
}) })
@ -560,7 +562,7 @@ const { newVersionIsAvailable } = useVersionUpdate()
const closeSettings = () => show_settings.value = false const closeSettings = () => show_settings.value = false
const shutdownApp = async () => { const shutdownApp = async () => {
const { alertDialog, confirmDialog: confirm_message } = useDialog() const { alertDialog, confirmDialog: cDialog } = useDialog()
if (false === config.app.is_native) { if (false === config.app.is_native) {
await alertDialog({ await alertDialog({
@ -570,7 +572,7 @@ const shutdownApp = async () => {
return return
} }
const { status } = await confirm_message({ const { status } = await cDialog({
title: 'Shutdown Application', title: 'Shutdown Application',
message: 'Are you sure you want to shutdown the application?', message: 'Are you sure you want to shutdown the application?',
}) })

View file

@ -342,8 +342,8 @@
</template> </template>
<div class="column is-12" v-if="search && filteredItems.length < 1"> <div class="column is-12" v-if="search && filteredItems.length < 1">
<Message class="is-warning" title="No results" icon="fas fa-filter" :useClose="true" <Message class="is-warning" title="No results" icon="fas fa-filter" :useClose="true" @close="() => search = ''"
@close="() => search = ''" v-if="search"> v-if="search">
<p class="is-block"> <p class="is-block">
No results found for '<span class="is-underlined is-bold">{{ search }}</span>'. No results found for '<span class="is-underlined is-bold">{{ search }}</span>'.
</p> </p>
@ -351,8 +351,7 @@
</div> </div>
<div class="column is-12" v-if="!items || items.length < 1"> <div class="column is-12" v-if="!items || items.length < 1">
<Message title="Loading content" class="is-info" icon="fas fa-refresh fa-spin" <Message title="Loading content" class="is-info" icon="fas fa-refresh fa-spin" v-if="isLoading">
v-if="isLoading">
Loading file browser contents... Loading file browser contents...
</Message> </Message>
<Message v-else title="No Content" class="is-warning" icon="fas fa-exclamation-circle"> <Message v-else title="No Content" class="is-warning" icon="fas fa-exclamation-circle">
@ -395,7 +394,6 @@ import type { FileItem, FileBrowserResponse } from '~/types/filebrowser'
const route = useRoute() const route = useRoute()
const toast = useNotification() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore()
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 })
const dialog = useDialog() const dialog = useDialog()
@ -409,7 +407,6 @@ const selectedElms = ref<string[]>([])
const masterSelectAll = ref(false) const masterSelectAll = ref(false)
const isLoading = ref<boolean>(false) const isLoading = ref<boolean>(false)
const initialLoad = ref<boolean>(true)
const items = ref<FileItem[]>([]) const items = ref<FileItem[]>([])
const path = ref<string>((() => { const path = ref<string>((() => {
const slug = route.params.slug const slug = route.params.slug
@ -486,13 +483,6 @@ const sortedItems = (items: FileItem[]): FileItem[] => {
const model_item = ref<any>() const model_item = ref<any>()
const closeModel = (): void => { model_item.value = null } const closeModel = (): void => { model_item.value = null }
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(path.value, true)
initialLoad.value = false
}
})
const handleClick = (item: FileItem): void => { const handleClick = (item: FileItem): void => {
if (true === ['video', 'audio'].includes(item.content_type)) { if (true === ['video', 'audio'].includes(item.content_type)) {
model_item.value = { model_item.value = {
@ -602,11 +592,8 @@ const event_handler = (e: Event): void => {
} }
onMounted(async () => { onMounted(async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(path.value, true)
}
document.addEventListener('popstate', event_handler as EventListener) document.addEventListener('popstate', event_handler as EventListener)
await reloadContent(path.value, true)
}) })
onBeforeUnmount(() => document.removeEventListener('popstate', event_handler as EventListener)) onBeforeUnmount(() => document.removeEventListener('popstate', event_handler as EventListener))

View file

@ -297,7 +297,6 @@ import type { APIResponse } from '~/types/responses'
type ConditionItemWithUI = Condition & { raw?: boolean } type ConditionItemWithUI = Condition & { raw?: boolean }
const socket = useSocketStore()
const box = useConfirm() const box = useConfirm()
const isMobile = useMediaQuery({ maxWidth: 1024 }) const isMobile = useMediaQuery({ maxWidth: 1024 })
const display_style = useStorage<'list' | 'grid'>('conditions_display_style', 'grid') const display_style = useStorage<'list' | 'grid'>('conditions_display_style', 'grid')
@ -311,7 +310,6 @@ const page = ref<number>(route.query.page ? parseInt(route.query.page as string,
const item = ref<Partial<Condition>>({}) const item = ref<Partial<Condition>>({})
const itemRef = ref<number | null | undefined>(null) const itemRef = ref<number | null | undefined>(null)
const toggleForm = ref(false) const toggleForm = ref(false)
const initialLoad = ref(true)
const query = ref<string>('') const query = ref<string>('')
const toggleFilter = ref(false) const toggleFilter = ref(false)
@ -351,13 +349,6 @@ const isExpanded = (itemId: number | undefined, field: string): boolean => {
return expandedItems[itemId]?.has(field) ?? false return expandedItems[itemId]?.has(field) ?? false
} }
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
await loadContent(page.value)
initialLoad.value = false
}
})
watch(toggleFilter, val => { watch(toggleFilter, val => {
if (!val) { if (!val) {
query.value = '' query.value = ''
@ -414,9 +405,5 @@ const exportItem = (cond: Condition): void => copyText(encode({
_version: '1.2', _version: '1.2',
})) }))
onMounted(async () => { onMounted(async () => await loadContent(page.value))
if (socket.isConnected) {
await loadContent(page.value)
}
})
</script> </script>

View file

@ -257,7 +257,6 @@ type PresetWithUI = Preset & { raw?: boolean, toggle_description?: boolean }
const toast = useNotification() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore()
const box = useConfirm() const box = useConfirm()
const display_style = useStorage<string>('preset_display_style', 'cards') const display_style = useStorage<string>('preset_display_style', 'cards')
@ -271,7 +270,6 @@ const preset = ref<Partial<Preset>>({})
const presetRef = ref<string | null>('') const presetRef = ref<string | null>('')
const toggleForm = ref(false) const toggleForm = ref(false)
const isLoading = ref(true) const isLoading = ref(true)
const initialLoad = ref(true)
const addInProgress = ref(false) const addInProgress = ref(false)
const remove_keys = ['raw', 'toggle_description'] const remove_keys = ['raw', 'toggle_description']
const expandedItems = ref<Record<string, Set<string>>>({}) const expandedItems = ref<Record<string, Set<string>>>({})
@ -303,12 +301,6 @@ const isExpanded = (itemId: string | undefined, field: string): boolean => {
return expandedItems.value[itemId]?.has(field) ?? false return expandedItems.value[itemId]?.has(field) ?? false
} }
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(true)
initialLoad.value = false
}
})
watch(toggleFilter, (val) => { watch(toggleFilter, (val) => {
if (!val) { if (!val) {
@ -440,7 +432,7 @@ const editItem = (item: Preset) => {
toggleForm.value = true toggleForm.value = true
} }
onMounted(async () => (socket.isConnected ? await reloadContent(true) : '')) onMounted(async () => await reloadContent(true))
const exportItem = (item: Preset) => { const exportItem = (item: Preset) => {
const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description'] const excludedKeys = ['id', 'default', 'raw', 'cookies', 'toggle_description']

View file

@ -480,6 +480,7 @@ const box = useConfirm()
const toast = useNotification() const toast = useNotification()
const config = useConfigStore() const config = useConfigStore()
const socket = useSocketStore() const socket = useSocketStore()
const stateStore = useStateStore()
const { confirmDialog: cDialog } = useDialog() const { confirmDialog: cDialog } = useDialog()
const sessionCache = useSessionCache() const sessionCache = useSessionCache()
const display_style = useStorage<string>("tasks_display_style", "cards") const display_style = useStorage<string>("tasks_display_style", "cards")
@ -490,7 +491,6 @@ const task = ref<task_item | Record<string, unknown>>({})
const taskRef = ref<string>('') const taskRef = ref<string>('')
const toggleForm = ref<boolean>(false) const toggleForm = ref<boolean>(false)
const isLoading = ref<boolean>(true) const isLoading = ref<boolean>(true)
const initialLoad = ref<boolean>(true)
const addInProgress = ref<boolean>(false) const addInProgress = ref<boolean>(false)
const selectedElms = ref<Array<string>>([]) const selectedElms = ref<Array<string>>([])
const masterSelectAll = ref(false) const masterSelectAll = ref(false)
@ -538,11 +538,10 @@ watch(masterSelectAll, value => {
}) })
watch(() => socket.isConnected, async () => { watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) { if (!socket.isConnected) {
socket.on('item_status', statusHandler) return
await reloadContent(true)
initialLoad.value = false
} }
socket.on('item_status', statusHandler)
}) })
const CACHE_KEY = 'tasks:handler_support' const CACHE_KEY = 'tasks:handler_support'
@ -858,10 +857,9 @@ const calcPath = (path: string) => {
} }
onMounted(async () => { onMounted(async () => {
if (!socket.isConnected) { if (socket.isConnected) {
return; socket.on('item_status', statusHandler)
} }
socket.on('item_status', statusHandler)
await reloadContent(true) await reloadContent(true)
}); });
@ -946,7 +944,7 @@ const runNow = async (item: task_item, mass: boolean = false) => {
data.auto_start = item.auto_start data.auto_start = item.auto_start
} }
socket.emit('add_url', data) await stateStore.addDownload(data)
if (true === mass) { if (true === mass) {
return return

View file

@ -3,6 +3,7 @@ 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 { request } from '~/utils';
export const useConfigStore = defineStore('config', () => { export const useConfigStore = defineStore('config', () => {
const state = reactive<ConfigState>({ const state = reactive<ConfigState>({
@ -49,8 +50,42 @@ export const useConfigStore = defineStore('config', () => {
ytdlp_options: [], ytdlp_options: [],
paused: false, paused: false,
is_loaded: false, is_loaded: false,
is_loading: false,
}); });
const loadConfig = async () => {
if (state.is_loading) {
return;
}
state.is_loaded = false;
state.is_loading = true;
try {
const resp = await request('/api/system/configuration');
if (!resp.ok) {
return;
}
const data = await resp.json();
const stateStore = useStateStore();
if ('number' === typeof data.history_count) {
stateStore.setHistoryCount(data.history_count);
delete data.history_count;
}
if (data.queue) {
stateStore.addAll('queue', data.queue);
delete data.queue;
}
} catch (e: any) {
console.error('Failed to load configuration', e);
}
finally {
state.is_loaded = true;
state.is_loading = false;
}
}
const add = (key: string, value: any) => { const add = (key: string, value: any) => {
if (key.includes('.')) { if (key.includes('.')) {
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string] const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string]
@ -134,9 +169,8 @@ export const useConfigStore = defineStore('config', () => {
} }
} }
return { return {
...toRefs(state), add, get, update, getAll, setAll, isLoaded, patch ...toRefs(state), add, get, update, getAll, setAll, isLoaded, patch, loadConfig
} as { [K in keyof ConfigState]: Ref<ConfigState[K]> } & { } as { [K in keyof ConfigState]: Ref<ConfigState[K]> } & {
add: typeof add add: typeof add
get: typeof get get: typeof get
@ -145,5 +179,6 @@ export const useConfigStore = defineStore('config', () => {
setAll: typeof setAll setAll: typeof setAll
patch: typeof patch patch: typeof patch
isLoaded: typeof isLoaded isLoaded: typeof isLoaded
loadConfig: typeof loadConfig
} }
}); });

View file

@ -397,10 +397,6 @@ export const useSocketStore = defineStore('socket', () => {
config.patch(configUpdate.feature, configUpdate.action, configUpdate.data) config.patch(configUpdate.feature, configUpdate.action, configUpdate.data)
}) })
if (false === isConnected.value) {
connect()
}
return { return {
connect, reconnect, disconnect, connect, reconnect, disconnect,
on, off, emit, on, off, emit,

View file

@ -183,6 +183,247 @@ export const useStateStore = defineStore('state', () => {
} }
} }
/**
* Load queue data from REST API.
* Uses the /live endpoint to get real-time in-memory data with live progress.
*
* @returns Promise that resolves when queue is loaded
*/
const loadQueue = async (): Promise<void> => {
try {
const response = await request('/api/history/live')
const data = await response.json() as {
"queue": Record<KeyType, StoreItem>,
"history_count": number,
}
state.queue = data.queue || {}
setHistoryCount(data.history_count)
} catch (error) {
console.error('Failed to load queue:', error)
throw error
}
}
/**
* Add a download using WebSocket if connected, fallback to REST API.
*
* @param data - Download data (url, preset, folder, etc.)
* @returns Promise that resolves when download is added
*/
const addDownload = async (data: Record<string, unknown>): Promise<void> => {
const socket = useSocketStore()
const toast = useNotification()
if (socket.isConnected) {
socket.emit('add_url', data)
return
}
try {
const response = await request('/api/history/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
if (!response.ok) {
const error = await response.json()
toast.error(error.error || 'Failed to add download')
throw new Error(error.error || 'Failed to add download')
}
toast.success('Download added successfully')
await loadQueue()
} catch (error) {
console.error('Failed to add download:', error)
if (error instanceof Error && !error.message.includes('Failed to add download')) {
toast.error('Failed to add download')
}
throw error
}
}
/**
* Start one or more downloads using WebSocket if connected, fallback to REST API.
*
* @param ids - Array of item IDs to start
* @returns Promise that resolves when items are started
*/
const startItems = async (ids: string[]): Promise<void> => {
const socket = useSocketStore()
const toast = useNotification()
if (socket.isConnected) {
ids.forEach(id => socket.emit('item_start', id))
return
}
try {
const response = await request('/api/history/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
})
if (!response.ok) {
const error = await response.json()
toast.error(error.error || 'Failed to start items')
throw new Error(error.error || 'Failed to start items')
}
const result = await response.json()
for (const id of ids) {
if ('started' === result[id]) {
const item = get('queue', id)
if (item) {
update('queue', id, { ...item, auto_start: true })
}
}
}
toast.success(`Started ${ids.length} item${1 === ids.length ? '' : 's'}`)
} catch (error) {
console.error('Failed to start items:', error)
if (error instanceof Error && !error.message.includes('Failed to start items')) {
toast.error('Failed to start items')
}
throw error
}
}
/**
* Pause one or more downloads using WebSocket if connected, fallback to REST API.
*
* @param ids - Array of item IDs to pause
* @returns Promise that resolves when items are paused
*/
const pauseItems = async (ids: string[]): Promise<void> => {
const socket = useSocketStore()
const toast = useNotification()
if (socket.isConnected) {
ids.forEach(id => socket.emit('item_pause', id))
return
}
try {
const response = await request('/api/history/pause', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
})
if (!response.ok) {
const error = await response.json()
toast.error(error.error || 'Failed to pause items')
throw new Error(error.error || 'Failed to pause items')
}
const result = await response.json()
for (const id of ids) {
if ('paused' === result[id]) {
const item = get('queue', id)
if (item) {
update('queue', id, { ...item, auto_start: false })
}
}
}
toast.success(`Paused ${ids.length} item${1 === ids.length ? '' : 's'}`)
} catch (error) {
console.error('Failed to pause items:', error)
if (error instanceof Error && !error.message.includes('Failed to pause items')) {
toast.error('Failed to pause items')
}
throw error
}
}
/**
* Cancel one or more downloads using WebSocket if connected, fallback to REST API.
*
* @param ids - Array of item IDs to cancel
* @returns Promise that resolves when items are cancelled
*/
const cancelItems = async (ids: string[]): Promise<void> => {
const socket = useSocketStore()
const toast = useNotification()
if (socket.isConnected) {
ids.forEach(id => socket.emit('item_cancel', id))
return
}
const itemsToMove: Record<string, StoreItem> = {}
for (const id of ids) {
const item = get('queue', id)
if (item) {
itemsToMove[id] = { ...item }
}
}
try {
const response = await request('/api/history/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
})
if (!response.ok) {
const error = await response.json()
toast.error(error.error || 'Failed to cancel items')
throw new Error(error.error || 'Failed to cancel items')
}
const result = await response.json()
for (const id of ids) {
if ('ok' === result[id] && itemsToMove[id]) {
remove('queue', id)
const cancelledItem = { ...itemsToMove[id], status: 'cancelled' } as StoreItem
add('history', id, cancelledItem)
}
}
toast.success(`Cancelled ${ids.length} item${1 === ids.length ? '' : 's'}`)
} catch (error) {
console.error('Failed to cancel items:', error)
if (error instanceof Error && !error.message.includes('Failed to cancel items')) {
toast.error('Failed to cancel items')
}
throw error
}
}
/**
* Remove items using WebSocket if connected, fallback to REST API.
*
* @param type - The store type ('queue' or 'history')
* @param ids - Array of item IDs to remove
* @param removeFile - Whether to remove files from disk (default: false)
* @returns Promise that resolves when items are removed
*/
const removeItems = async (type: StateType, ids: string[], removeFile: boolean = false): Promise<void> => {
const socket = useSocketStore()
const toast = useNotification()
if (socket.isConnected) {
ids.forEach(id => socket.emit('item_delete', { id, remove_file: removeFile }))
return
}
try {
await deleteItems(type, { ids, removeFile })
} catch (error) {
console.error('Failed to remove items:', error)
toast.error('Failed to remove items')
throw error
}
}
/** /**
* Delete items by specific IDs or status filter. * Delete items by specific IDs or status filter.
* *
@ -263,6 +504,12 @@ export const useStateStore = defineStore('state', () => {
reloadCurrentPage, reloadCurrentPage,
getPagination, getPagination,
setHistoryCount, setHistoryCount,
loadQueue,
addDownload,
startItems,
pauseItems,
cancelItems,
removeItems,
deleteItems, deleteItems,
} }
}) })

View file

@ -68,6 +68,8 @@ type ConfigState = {
paused: boolean paused: boolean
/** Indicates if the configuration has been loaded */ /** Indicates if the configuration has been loaded */
is_loaded: boolean is_loaded: boolean
/** Indicates if the configuration is currently loading */
is_loading: boolean
} }
export type { AppConfig, ConfigState } export type { AppConfig, ConfigState }