refactor: make the UI work without websocket
This commit is contained in:
parent
501455029c
commit
c3eb7091af
17 changed files with 763 additions and 182 deletions
132
API.md
132
API.md
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
2
ui/app/types/config.d.ts
vendored
2
ui/app/types/config.d.ts
vendored
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Reference in a new issue