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)
- [GET /api/history/{id}](#get-apihistoryid)
- [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)
- [POST /api/history/{id}/archive](#post-apihistoryidarchive)
- [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)
- [`connect` (Server → Client)](#connect-server--client)
- [`disconnect` (Server → Client)](#disconnect-server--client)
- [`configuration` (Server → Client)](#configuration-server--client)
- [`config_update` (Server → Client)](#config_update-server--client)
- [`connected` (Server → Client)](#connected-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
**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));
```
##### `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)
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")
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)
@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")
async def item_archive_add(request: Request, queue: DownloadQueue, notify: EventBus) -> Response:
"""

View file

@ -4,18 +4,22 @@ import logging
import os
import shlex
import time
from pathlib import Path
from typing import TYPE_CHECKING
from aiohttp import web
from aiohttp.web import Request, Response
from aiohttp.web_runner import GracefulExit
from app.features.dl_fields.service import DLFields
from app.library.config import Config
from app.library.downloads import DownloadQueue
from app.library.encoder import Encoder
from app.library.Events import EventBus, Events
from app.library.Presets import Presets
from app.library.router import route
from app.library.UpdateChecker import UpdateChecker
from app.library.Utils import list_folders
if TYPE_CHECKING:
from asyncio import Task
@ -25,6 +29,39 @@ if TYPE_CHECKING:
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")
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")
async def stream_terminal(request: Request, config: Config, encoder: Encoder) -> Response | web.StreamResponse:
if not config.console_enabled:

View file

@ -1,71 +1,15 @@
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.Utils import list_folders
LOG: logging.Logger = logging.getLogger(__name__)
class _Data:
subscribers: dict[str, list[str]] = {}
@route(RouteType.SOCKET, "connect", "socket_connect")
async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: str):
notify.emit(
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,
)
async def connect(sid: str):
pass
@route(RouteType.SOCKET, "disconnect", "socket_disconnect")
async def disconnect(sio: WebSocketHub, sid: str, data: str | None = None): # noqa: ARG001
"""
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}")
async def disconnect(sid: str):
pass

View file

@ -163,7 +163,7 @@
<div class="field is-grouped is-grouped-centered">
<div class="control" v-if="item.status != 'finished' || !item.filename">
<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>
</button>
</div>
@ -207,7 +207,7 @@
</NuxtLink>
<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>Add to download form</span>
</NuxtLink>
@ -335,7 +335,7 @@
</div>
<div class="columns is-mobile is-multiline">
<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"><i class="fa-solid fa-rotate-right" /></span>
<span>Retry</span>
@ -391,7 +391,7 @@
</NuxtLink>
<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>Add to download form</span>
</NuxtLink>
@ -452,8 +452,7 @@
<li><code>source_name:task_name</code> - items added by the specified task.</li>
</ul>
</Message>
<Message v-else class="is-primary" title="No items" icon="fas fa-exclamation-triangle"
:new-style="true">
<Message v-else class="is-primary" title="No items" icon="fas fa-exclamation-triangle" :new-style="true">
<p>Download history is empty.</p>
</Message>
</div>
@ -584,7 +583,7 @@ watch(showCompleted, async isShown => {
})
onMounted(async () => {
if (showCompleted.value && !paginationInfo.value.isLoaded && socket.isConnected) {
if (showCompleted.value) {
try {
await stateStore.loadPaginated('history', 1, config.app.default_pagination, 'DESC', true)
} catch (error) {
@ -701,10 +700,7 @@ const deleteSelectedItems = async () => {
return
}
await stateStore.deleteItems('history', {
ids: [...selectedElms.value],
removeFile: config.app.remove_files
})
await stateStore.removeItems('history', [...selectedElms.value], config.app.remove_files)
selectedElms.value = []
}
@ -803,7 +799,7 @@ const retryIncomplete = async () => {
if ('finished' === item.status) {
continue
}
retryItem(item)
await retryItem(item)
}
}
@ -831,7 +827,7 @@ const archiveItem = async (item: StoreItem, opts = {}) => {
if (!(opts as any)?.remove_history) {
return
}
socket.emit('item_delete', { id: item._id, remove_file: false })
await stateStore.removeItems('history', [item._id], false)
}
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> = {
url: item.url,
preset: item.preset,
@ -865,7 +861,7 @@ const retryItem = (item: StoreItem, re_add: boolean = false, remove_file: boolea
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 || '')) {
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)
return
}
socket.emit('add_url', item_req)
await stateStore.addDownload(item_req)
}
const pImg = (e: Event) => {
@ -990,12 +986,12 @@ const removeFromArchive = async (item: StoreItem, opts?: { re_add?: boolean, rem
}
if (opts?.re_add) {
retryItem(item, true, file_delete)
await retryItem(item, true, file_delete)
return
}
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>
<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"
v-if="filteredItems.length > 0">
<div class="column is-narrow">
@ -369,9 +385,71 @@ const show_popover = useStorage<boolean>('show_popover', true)
const selectedElms = ref<string[]>([])
const masterSelectAll = ref(false)
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 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) => {
if (value) {
selectedElms.value = Object.values(stateStore.queue).map((element: StoreItem) => element._id)
@ -570,11 +648,11 @@ const cancelItems = (item: string | string[]) => {
if (0 > items.length) {
return
}
items.forEach(id => socket.emit('item_cancel', id))
stateStore.cancelItems(items)
}
const startItem = (item: StoreItem) => socket.emit('item_start', item._id)
const pauseItem = (item: StoreItem) => socket.emit('item_pause', item._id)
const startItem = async (item: StoreItem) => await stateStore.startItems([item._id])
const pauseItem = async (item: StoreItem) => await stateStore.pauseItems([item._id])
const startItems = async () => {
if (1 > selectedElms.value.length) {
@ -595,7 +673,7 @@ const startItems = async () => {
if (true !== (await box.confirm(`Start '${filtered.length}' selected items?`))) {
return false
}
filtered.forEach(id => socket.emit('item_start', id))
await stateStore.startItems(filtered)
}
const pauseSelected = async () => {
@ -617,7 +695,7 @@ const pauseSelected = async () => {
if (true !== (await box.confirm(`Pause '${filtered.length}' selected items?`))) {
return false
}
filtered.forEach(id => socket.emit('item_pause', id))
await stateStore.pauseItems(filtered)
}
const pImg = (e: Event) => {

View file

@ -155,6 +155,47 @@
</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">
<p class="title is-5 mb-4">
<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 separator = useStorage<string>('url_separator', separators[0]?.value ?? ',')
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 handleKeydown = (e: KeyboardEvent) => {

View file

@ -7,6 +7,11 @@
<template v-if="!isMobile">{{ greetingMessage }}</template>
<template v-else>What would you like to download?</template>
<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')"
v-tooltip="'WebUI Settings'">
<i class="fas fa-cogs" /></span>
@ -77,6 +82,7 @@
<Transition name="queue-fade">
<section v-if="hasAnyItems" key="queue" class="queue-section">
<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">
<article class="queue-card card" :class="{ 'is-history': 'history' === entry.source }">
@ -166,7 +172,7 @@
<span>Download</span>
</a>
<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>Requeue</span>
</button>
@ -252,6 +258,23 @@ const formUrl = ref<string>('')
const formPreset = ref<{ preset: string }>({ preset: app.value.default_preset || '' })
const addInProgress = 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 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 canPause = (item: StoreItem): boolean => !item.status && true === item.auto_start
const startQueueItem = (item: StoreItem): void => {
socketStore.emit('item_start', item._id)
const startQueueItem = async (item: StoreItem): Promise<void> => {
await stateStore.startItems([item._id])
}
const pauseQueueItem = (item: StoreItem): void => {
socketStore.emit('item_pause', item._id)
const pauseQueueItem = async (item: StoreItem): Promise<void> => {
await stateStore.pauseItems([item._id])
}
const cancelDownload = (item: StoreItem): void => {
socketStore.emit('item_cancel', item._id)
const cancelDownload = async (item: StoreItem): Promise<void> => {
await stateStore.cancelItems([item._id])
}
const getDownloadLink = (item: StoreItem): string => {
@ -602,7 +625,7 @@ const getDownloadName = (item: StoreItem): string => {
return segments[segments.length - 1] || 'download'
}
const requeueItem = (item: StoreItem): void => {
const requeueItem = async (item: StoreItem): Promise<void> => {
if (!item.url) {
toast.error('Unable to requeue item; missing URL.')
return
@ -622,12 +645,12 @@ const requeueItem = (item: StoreItem): void => {
payload.extras = JSON.parse(JSON.stringify(item.extras))
}
socketStore.emit('item_delete', { id: item._id, remove_file: false })
socketStore.emit('add_url', payload)
await stateStore.removeItems('history', [item._id], false)
await stateStore.addDownload(payload)
}
const deleteHistoryItem = (item: StoreItem): void => {
socketStore.emit('item_delete', { id: item._id, remove_file: app.value.remove_files })
const deleteHistoryItem = async (item: StoreItem): Promise<void> => {
await stateStore.removeItems('history', [item._id], app.value.remove_files)
toast.info('Removed from history queue.')
}
@ -668,12 +691,10 @@ onMounted(async () => {
window.history.replaceState({}, '', url.toString())
}
if (socketStore.isConnected && !paginationInfo.value.isLoaded) {
try {
await stateStore.loadPaginated('history', 1, DEFAULT_PAGE_SIZE, 'DESC')
} catch (error) {
console.error('Failed to load history on mount:', error)
}
try {
await stateStore.loadPaginated('history', 1, DEFAULT_PAGE_SIZE, 'DESC')
} catch (error) {
console.error('Failed to load history on mount:', error)
}
if (window?.location && '/' !== window.location.pathname) {
@ -776,6 +797,12 @@ useIntersectionObserver(loadMoreTrigger, ([entry]) => {
transform-origin: top center;
}
.queue-header {
display: flex;
justify-content: flex-end;
align-items: center;
}
.queue-card-columns {
margin-top: 0.75rem;
}

View file

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

View file

@ -342,8 +342,8 @@
</template>
<div class="column is-12" v-if="search && filteredItems.length < 1">
<Message class="is-warning" title="No results" icon="fas fa-filter" :useClose="true"
@close="() => search = ''" v-if="search">
<Message class="is-warning" title="No results" icon="fas fa-filter" :useClose="true" @close="() => search = ''"
v-if="search">
<p class="is-block">
No results found for '<span class="is-underlined is-bold">{{ search }}</span>'.
</p>
@ -351,8 +351,7 @@
</div>
<div class="column is-12" v-if="!items || items.length < 1">
<Message title="Loading content" class="is-info" icon="fas fa-refresh fa-spin"
v-if="isLoading">
<Message title="Loading content" class="is-info" icon="fas fa-refresh fa-spin" v-if="isLoading">
Loading file browser contents...
</Message>
<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 toast = useNotification()
const config = useConfigStore()
const socket = useSocketStore()
const isMobile = useMediaQuery({ maxWidth: 1024 })
const dialog = useDialog()
@ -409,7 +407,6 @@ const selectedElms = ref<string[]>([])
const masterSelectAll = ref(false)
const isLoading = ref<boolean>(false)
const initialLoad = ref<boolean>(true)
const items = ref<FileItem[]>([])
const path = ref<string>((() => {
const slug = route.params.slug
@ -486,13 +483,6 @@ const sortedItems = (items: FileItem[]): FileItem[] => {
const model_item = ref<any>()
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 => {
if (true === ['video', 'audio'].includes(item.content_type)) {
model_item.value = {
@ -602,11 +592,8 @@ const event_handler = (e: Event): void => {
}
onMounted(async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(path.value, true)
}
document.addEventListener('popstate', event_handler as EventListener)
await reloadContent(path.value, true)
})
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 }
const socket = useSocketStore()
const box = useConfirm()
const isMobile = useMediaQuery({ maxWidth: 1024 })
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 itemRef = ref<number | null | undefined>(null)
const toggleForm = ref(false)
const initialLoad = ref(true)
const query = ref<string>('')
const toggleFilter = ref(false)
@ -351,13 +349,6 @@ const isExpanded = (itemId: number | undefined, field: string): boolean => {
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 => {
if (!val) {
query.value = ''
@ -414,9 +405,5 @@ const exportItem = (cond: Condition): void => copyText(encode({
_version: '1.2',
}))
onMounted(async () => {
if (socket.isConnected) {
await loadContent(page.value)
}
})
onMounted(async () => await loadContent(page.value))
</script>

View file

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

View file

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

View file

@ -3,6 +3,7 @@ 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 { request } from '~/utils';
export const useConfigStore = defineStore('config', () => {
const state = reactive<ConfigState>({
@ -49,8 +50,42 @@ export const useConfigStore = defineStore('config', () => {
ytdlp_options: [],
paused: 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) => {
if (key.includes('.')) {
const [parentKey, subKey] = key.split('.') as [keyof ConfigState, string]
@ -134,9 +169,8 @@ export const useConfigStore = defineStore('config', () => {
}
}
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]> } & {
add: typeof add
get: typeof get
@ -145,5 +179,6 @@ export const useConfigStore = defineStore('config', () => {
setAll: typeof setAll
patch: typeof patch
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)
})
if (false === isConnected.value) {
connect()
}
return {
connect, reconnect, disconnect,
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.
*
@ -263,6 +504,12 @@ export const useStateStore = defineStore('state', () => {
reloadCurrentPage,
getPagination,
setHistoryCount,
loadQueue,
addDownload,
startItems,
pauseItems,
cancelItems,
removeItems,
deleteItems,
}
})

View file

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