Refactor: change how clear in/completed buttons behave due to changes in how we load history now.
This commit is contained in:
parent
46fb943243
commit
6d2deedc21
11 changed files with 456 additions and 479 deletions
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -69,6 +69,7 @@
|
|||
"euuo",
|
||||
"eventbus",
|
||||
"excepthook",
|
||||
"exitcode",
|
||||
"falsey",
|
||||
"faststart",
|
||||
"Fetc",
|
||||
|
|
|
|||
507
API.md
507
API.md
|
|
@ -83,11 +83,6 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [`disconnect` (Built-in)](#disconnect-built-in)
|
||||
- [`configuration` (Server → Client)](#configuration-server--client)
|
||||
- [`connected` (Server → Client)](#connected-server--client)
|
||||
- [Subscription Events](#subscription-events)
|
||||
- [`subscribe` (Client → Server)](#subscribe-client--server)
|
||||
- [`unsubscribe` (Client → Server)](#unsubscribe-client--server)
|
||||
- [`subscribed` (Server → Client)](#subscribed-server--client)
|
||||
- [`unsubscribed` (Server → Client)](#unsubscribed-server--client)
|
||||
- [Logging Events](#logging-events)
|
||||
- [`log_info` (Server → Client)](#log_info-server--client)
|
||||
- [`log_success` (Server → Client)](#log_success-server--client)
|
||||
|
|
@ -95,20 +90,12 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [`log_error` (Server → Client)](#log_error-server--client)
|
||||
- [`log_lines` (Server → Client)](#log_lines-server--client)
|
||||
- [Download Queue Events](#download-queue-events)
|
||||
- [`add_url` (Client → Server)](#add_url-client--server)
|
||||
- [`item_added` (Server → Client)](#item_added-server--client)
|
||||
- [`item_updated` (Server → Client)](#item_updated-server--client)
|
||||
- [`item_completed` (Server → Client)](#item_completed-server--client)
|
||||
- [`item_cancelled` (Server → Client)](#item_cancelled-server--client)
|
||||
- [`item_deleted` (Server → Client)](#item_deleted-server--client)
|
||||
- [`item_cancel` (Client → Server)](#item_cancel-client--server)
|
||||
- [`item_delete` (Client → Server)](#item_delete-client--server)
|
||||
- [`item_start` (Client → Server)](#item_start-client--server)
|
||||
- [`item_pause` (Client → Server)](#item_pause-client--server)
|
||||
- [`item_moved` (Server → Client)](#item_moved-server--client)
|
||||
- [Queue Control Events](#queue-control-events)
|
||||
- [`paused` (Server → Client)](#paused-server--client)
|
||||
- [`resumed` (Server → Client)](#resumed-server--client)
|
||||
- [Terminal/CLI Events](#terminalcli-events)
|
||||
- [`cli_post` (Client → Server)](#cli_post-client--server)
|
||||
- [`cli_output` (Server → Client)](#cli_output-server--client)
|
||||
|
|
@ -116,10 +103,6 @@ This document describes the available endpoints and their usage. All endpoints r
|
|||
- [Configuration Events](#configuration-events)
|
||||
- [`presets_update` (Server → Client)](#presets_update-server--client)
|
||||
- [`dlfields_update` (Server → Client)](#dlfields_update-server--client)
|
||||
- [Client Implementation Examples](#client-implementation-examples)
|
||||
- [Vue 3 Composable Pattern](#vue-3-composable-pattern)
|
||||
- [Python Client Example](#python-client-example)
|
||||
- [Error Responses](#error-responses)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -356,18 +339,47 @@ or an error:
|
|||
---
|
||||
|
||||
### DELETE /api/history
|
||||
**Purpose**: Delete items from either the "queue" or the "done" history.
|
||||
**Purpose**: Delete items from either the "queue" or the "done" history.
|
||||
|
||||
**Body**:
|
||||
**Body Parameters**:
|
||||
- `type` (string, required): Type of items - `"queue"` or `"done"`
|
||||
- `ids` (array, optional): List of specific item IDs to delete. If provided, `status` filter is ignored
|
||||
- `status` (string, optional): Filter by status (e.g., `"finished"`, `"!finished"`). Required if `ids` not provided
|
||||
- `remove_file` (boolean, optional): Whether to delete files from disk. Default: `true`.
|
||||
|
||||
> [!NOTE]
|
||||
> `remove_file` is only considered if `YTP_REMOVE_FILES=true` is set. otherwise, files will not be deleted from disk even if `remove_file` is `true`.
|
||||
|
||||
**Request Examples**:
|
||||
|
||||
**Delete specific items by IDs:**
|
||||
```json
|
||||
{
|
||||
"type": "done",
|
||||
"ids": ["<id1>", "<id2>"],
|
||||
"where": "queue" | "done",
|
||||
"remove_file": true | false // optional, defaults to True, whether to delete the file from disk if enabled.
|
||||
"remove_file": true
|
||||
}
|
||||
```
|
||||
**Response**:
|
||||
|
||||
**Delete all finished items (filter mode):**
|
||||
```json
|
||||
{
|
||||
"type": "done",
|
||||
"status": "finished",
|
||||
"remove_file": true
|
||||
}
|
||||
```
|
||||
|
||||
**Delete all non-finished items (exclusion filter):**
|
||||
```json
|
||||
{
|
||||
"type": "done",
|
||||
"status": "!finished",
|
||||
"remove_file": false
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"<id1>": "status",
|
||||
|
|
@ -376,7 +388,30 @@ or an error:
|
|||
}
|
||||
```
|
||||
|
||||
A list or object indicating which items were removed.
|
||||
**Response (Filter mode with deleted count)**:
|
||||
```json
|
||||
{
|
||||
"items":{
|
||||
"<id1>": "status",
|
||||
"<id2>": "status",
|
||||
...
|
||||
},
|
||||
"deleted": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses**:
|
||||
- `400 Bad Request` if parameters are invalid:
|
||||
```json
|
||||
{ "error": "type is required." }
|
||||
{ "error": "either 'ids' or 'status' filter is required." }
|
||||
{ "error": "type must be 'queue' or 'done'." }
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
- When using filter mode, all matching items will be deleted.
|
||||
- Filter mode with `{ "status": "!finished" }` is useful for cleaning up failed/pending downloads.
|
||||
- Filter mode returns a `deleted` count indicating how many items were removed.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -455,6 +490,10 @@ or an error:
|
|||
- `order` (optional): Sort order. Default: `DESC`. Only used when `type != all`
|
||||
- `DESC`: Newest items first (descending by creation date)
|
||||
- `ASC`: Oldest items first (ascending by creation date)
|
||||
- `status` (optional): Filter items by status. Only used when `type != all`
|
||||
- Use status value to include only items with that status (e.g., `status=finished`)
|
||||
- Prefix with `!` to exclude items with that status (e.g., `status=!finished`)
|
||||
- Common status values: `finished`, `downloading`, `pending`, `error`
|
||||
|
||||
**Response (when `type=all` or no type set)** - Legacy format:
|
||||
```json
|
||||
|
|
@ -521,6 +560,24 @@ or an error:
|
|||
- For large datasets, use paginated requests (`type=queue` or `type=done`) for better performance
|
||||
- The `items` array contains ItemDTO objects serialized to JSON
|
||||
|
||||
**Examples**:
|
||||
```bash
|
||||
# Get all finished items from history
|
||||
GET /api/history?type=done&status=finished
|
||||
|
||||
# Get all items except finished ones
|
||||
GET /api/history?type=done&status=!finished
|
||||
|
||||
# Get only downloading items from queue
|
||||
GET /api/history?type=queue&status=downloading
|
||||
|
||||
# Get all items except errors, with pagination
|
||||
GET /api/history?type=done&status=!error&page=2&per_page=50
|
||||
|
||||
# Combine with sorting - oldest pending items first
|
||||
GET /api/history?type=queue&status=pending&order=ASC
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### DELETE /api/history/{id}/archive
|
||||
|
|
@ -1163,7 +1220,7 @@ Binary image data with the appropriate `Content-Type`.
|
|||
**Body**:
|
||||
```json
|
||||
[
|
||||
{ "action": "rename", "path": "relative/path/file.ext", "new_name": "newname.ext" },
|
||||
{ "action": "rename", "path": "relative/path/file.ext", "new_name": "new_name.ext" },
|
||||
{ "action": "delete", "path": "relative/path/file.ext" },
|
||||
{ "action": "move", "path": "relative/path/file.ext", "new_path": "new/relative/path" },
|
||||
{ "action": "directory", "path": "relative/path", "new_dir": "subdirectory" }
|
||||
|
|
@ -1669,6 +1726,10 @@ or an error:
|
|||
|
||||
The WebSocket API provides real-time bidirectional communication between the client and server using Socket.IO protocol. It enables live updates for downloads, queue status, notifications, and terminal access.
|
||||
|
||||
> ![IMPORTANT]
|
||||
> The WebSocket API is unstable and many events will be moved to REST endpoints in future releases.
|
||||
> Please do not rely on the WebSocket API for the time being.
|
||||
|
||||
### Connection
|
||||
|
||||
**URL**: `ws://localhost:8081/socket.io/` (development) or `https://yourdomain.com/socket.io/` (production)
|
||||
|
|
@ -1743,14 +1804,6 @@ Sends the current application configuration.
|
|||
- `dl_fields`: Available download fields
|
||||
- `paused`: Queue pause status (boolean)
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
socket.on('connected', (data: string) => {
|
||||
const json = JSON.parse(data);
|
||||
console.log('Current configuration:', json.data.config);
|
||||
});
|
||||
```
|
||||
|
||||
##### `connected` (Server → Client)
|
||||
When a client connects, this events sends the folder and current queue.
|
||||
|
||||
|
|
@ -1758,57 +1811,6 @@ When a client connects, this events sends the folder and current queue.
|
|||
- `queue`: Current download queue (array of items)
|
||||
- `folders`: Directory structure for downloads
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
socket.on('connected', (data: string) => {
|
||||
const json = JSON.parse(data);
|
||||
const queueItems = json.data.queue || {};
|
||||
console.log('Connected with', Object.keys(queueItems).length, 'queued downloads');
|
||||
});
|
||||
```
|
||||
|
||||
#### Subscription Events
|
||||
|
||||
##### `subscribe` (Client → Server)
|
||||
Subscribe to a specific event stream. Only supported events is `log_lines`.
|
||||
|
||||
**Data**: Event name as string
|
||||
```javascript
|
||||
socket.emit('subscribe', 'log_lines');
|
||||
```
|
||||
|
||||
**Response**: `subscribed` event with confirmation.
|
||||
|
||||
##### `unsubscribe` (Client → Server)
|
||||
Unsubscribe from an event stream.
|
||||
|
||||
**Data**: Event name as string
|
||||
```javascript
|
||||
socket.emit('unsubscribe', 'log_lines');
|
||||
```
|
||||
|
||||
**Response**: `unsubscribed` event with confirmation.
|
||||
|
||||
##### `subscribed` (Server → Client)
|
||||
Confirmation of successful subscription.
|
||||
|
||||
```typescript
|
||||
socket.on('subscribed', (data: string) => {
|
||||
const json = JSON.parse(data);
|
||||
console.log('Subscribed to event:', json.data.event);
|
||||
});
|
||||
```
|
||||
|
||||
##### `unsubscribed` (Server → Client)
|
||||
Confirmation of successful unsubscription.
|
||||
|
||||
```typescript
|
||||
socket.on('unsubscribed', (data: string) => {
|
||||
const json = JSON.parse(data);
|
||||
console.log('Unsubscribed from event:', json.data.event);
|
||||
});
|
||||
```
|
||||
|
||||
#### Logging Events
|
||||
|
||||
All logging events follow the same structure with JSON-encoded message:
|
||||
|
|
@ -1816,81 +1818,24 @@ All logging events follow the same structure with JSON-encoded message:
|
|||
##### `log_info` (Server → Client)
|
||||
General informational message.
|
||||
|
||||
```typescript
|
||||
socket.on('log_info', (stream: string) => {
|
||||
const json = JSON.parse(stream);
|
||||
console.info(json.message, json.data);
|
||||
});
|
||||
```
|
||||
|
||||
##### `log_success` (Server → Client)
|
||||
Success notification message.
|
||||
|
||||
```typescript
|
||||
socket.on('log_success', (stream: string) => {
|
||||
const json = JSON.parse(stream);
|
||||
console.log('✓', json.message);
|
||||
});
|
||||
```
|
||||
|
||||
##### `log_warning` (Server → Client)
|
||||
Warning notification message.
|
||||
|
||||
```typescript
|
||||
socket.on('log_warning', (stream: string) => {
|
||||
const json = JSON.parse(stream);
|
||||
console.warn('⚠', json.message);
|
||||
});
|
||||
```
|
||||
|
||||
##### `log_error` (Server → Client)
|
||||
Error notification message.
|
||||
|
||||
```typescript
|
||||
socket.on('log_error', (stream: string) => {
|
||||
const json = JSON.parse(stream);
|
||||
console.error('✗', json.message);
|
||||
});
|
||||
```
|
||||
|
||||
##### `log_lines` (Server → Client)
|
||||
Continuous application log lines (requires subscription).
|
||||
Continuous application log lines (requires subscription event).
|
||||
|
||||
**Data Fields**:
|
||||
- `line`: Log line content
|
||||
- `timestamp`: Log timestamp
|
||||
|
||||
```typescript
|
||||
socket.on('log_lines', (stream: string) => {
|
||||
const json = JSON.parse(stream);
|
||||
console.log('[LOG]', json.data.line);
|
||||
});
|
||||
```
|
||||
|
||||
### Download Queue Events
|
||||
|
||||
#### `add_url` (Client → Server)
|
||||
Add a new URL to the download queue. Payload is exactly the same as
|
||||
the POST `/api/queue/` endpoint.
|
||||
|
||||
**Data**:
|
||||
```json
|
||||
{
|
||||
"url": "https://youtube.com/watch?v=...",
|
||||
"preset": "default",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Responses**: `item_added` or `log_error` event
|
||||
|
||||
```typescript
|
||||
socket.emit('add_url', {
|
||||
url: 'https://youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
preset: 'default'
|
||||
});
|
||||
```
|
||||
|
||||
#### `item_added` (Server → Client)
|
||||
Emitted when a new item is successfully added to the queue. The response payload is the complete item object.
|
||||
|
||||
|
|
@ -1954,67 +1899,6 @@ socket.on('item_deleted', (stream: string) => {
|
|||
});
|
||||
```
|
||||
|
||||
#### `item_cancel` (Client → Server)
|
||||
Cancel an active download.
|
||||
|
||||
**Data**: Item ID (string)
|
||||
|
||||
```typescript
|
||||
socket.emit('item_cancel', 'download-id-here');
|
||||
```
|
||||
|
||||
**Response**: `item_cancelled` event
|
||||
|
||||
#### `item_delete` (Client → Server)
|
||||
Delete an item from history.
|
||||
|
||||
**Data**:
|
||||
```json
|
||||
{
|
||||
"id": "item-id",
|
||||
"remove_file": false
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
- `id`: Item ID (required)
|
||||
- `remove_file`: Also delete downloaded file (optional)
|
||||
|
||||
```typescript
|
||||
socket.emit('item_delete', {
|
||||
id: 'item-id',
|
||||
remove_file: true // Also delete the file if deleting is enabled server-side.
|
||||
});
|
||||
```
|
||||
|
||||
**Response**: `item_deleted` event
|
||||
|
||||
#### `item_start` (Client → Server)
|
||||
Start or resume a paused download.
|
||||
|
||||
**Data**: Item ID (string) or array of IDs
|
||||
|
||||
```typescript
|
||||
socket.emit('item_start', 'download-id');
|
||||
// OR
|
||||
socket.emit('item_start', ['id1', 'id2']);
|
||||
```
|
||||
|
||||
**Response**: Queue status updated via `item_updated`
|
||||
|
||||
#### `item_pause` (Client → Server)
|
||||
Pause an active download.
|
||||
|
||||
**Data**: Item ID (string) or array of IDs
|
||||
|
||||
```typescript
|
||||
socket.emit('item_pause', 'download-id');
|
||||
// OR
|
||||
socket.emit('item_pause', ['id1', 'id2']);
|
||||
```
|
||||
|
||||
**Response**: Queue status updated via `item_updated`
|
||||
|
||||
#### `item_moved` (Server → Client)
|
||||
Emitted when an item moves between queue and history.
|
||||
|
||||
|
|
@ -2029,50 +1913,15 @@ socket.on('item_moved', (stream: string) => {
|
|||
});
|
||||
```
|
||||
|
||||
### Queue Control Events
|
||||
|
||||
#### `paused` (Server → Client)
|
||||
Emitted when the entire download queue is paused.
|
||||
|
||||
**Data Fields**:
|
||||
- `paused`: Boolean true
|
||||
|
||||
```typescript
|
||||
socket.on('paused', (stream: string) => {
|
||||
const json = JSON.parse(stream);
|
||||
console.log('Queue paused');
|
||||
});
|
||||
```
|
||||
|
||||
#### `resumed` (Server → Client)
|
||||
Emitted when the download queue is resumed.
|
||||
|
||||
**Data Fields**:
|
||||
- `paused`: Boolean false
|
||||
|
||||
```typescript
|
||||
socket.on('resumed', (stream: string) => {
|
||||
const json = JSON.parse(stream);
|
||||
console.log('Queue resumed');
|
||||
});
|
||||
```
|
||||
|
||||
### Terminal/CLI Events
|
||||
|
||||
The terminal feature requires `console_enabled: true` in configuration.
|
||||
The terminal feature requires `YTP_CONSOLE_ENABLED=true`.
|
||||
|
||||
#### `cli_post` (Client → Server)
|
||||
Execute a command via yt-dlp CLI.
|
||||
|
||||
**Data**: Command arguments as string (without `yt-dlp` prefix)
|
||||
|
||||
```typescript
|
||||
socket.emit('cli_post', '--help');
|
||||
socket.emit('cli_post', 'https://youtube.com/watch?v=... --info-json');
|
||||
```
|
||||
|
||||
**Responses**: `cli_output` events followed by `cli_close`
|
||||
|
||||
#### `cli_output` (Server → Client)
|
||||
Output line from the CLI command execution.
|
||||
|
||||
|
|
@ -2080,34 +1929,12 @@ Output line from the CLI command execution.
|
|||
- `type`: Output type (`stdout` or `stderr`)
|
||||
- `line`: Output line content
|
||||
|
||||
```typescript
|
||||
socket.on('cli_output', (stream: string) => {
|
||||
const json = JSON.parse(stream);
|
||||
if (json.data.type === 'stderr') {
|
||||
console.error(json.data.line);
|
||||
} else {
|
||||
console.log(json.data.line);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### `cli_close` (Server → Client)
|
||||
Emitted when CLI command execution completes.
|
||||
|
||||
**Data Fields**:
|
||||
- `exitcode`: Command exit code (0 = success, non-zero = error)
|
||||
|
||||
```typescript
|
||||
socket.on('cli_close', (stream: string) => {
|
||||
const json = JSON.parse(stream);
|
||||
if (json.data.exitcode === 0) {
|
||||
console.log('Command completed successfully');
|
||||
} else {
|
||||
console.error(`Command failed with exit code ${json.data.exitcode}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Configuration Events
|
||||
|
||||
#### `presets_update` (Server → Client)
|
||||
|
|
@ -2115,170 +1942,10 @@ Emitted when download presets are updated or created.
|
|||
|
||||
**Data**: Array of preset objects
|
||||
|
||||
```typescript
|
||||
socket.on('presets_update', (stream: string) => {
|
||||
const json = JSON.parse(stream);
|
||||
const presets = json.data || [];
|
||||
console.log('Presets updated:', presets);
|
||||
});
|
||||
```
|
||||
|
||||
#### `dlfields_update` (Server → Client)
|
||||
Emitted when download fields configuration is updated.
|
||||
|
||||
**Data**: Array of field objects
|
||||
|
||||
```typescript
|
||||
socket.on('dlfields_update', (stream: string) => {
|
||||
const json = JSON.parse(stream);
|
||||
const fields = json.data || [];
|
||||
console.log('Download fields updated:', fields);
|
||||
});
|
||||
```
|
||||
|
||||
### Client Implementation Examples
|
||||
|
||||
#### Vue 3 Composable Pattern
|
||||
|
||||
```typescript
|
||||
// composables/useSocket.ts
|
||||
import { ref, computed } from 'vue'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
|
||||
export function useSocket() {
|
||||
const socket = ref<Socket | null>(null)
|
||||
const isConnected = ref(false)
|
||||
const queueItems = ref<Record<string, any>>({})
|
||||
const historyItems = ref<Record<string, any>>({})
|
||||
|
||||
const connect = () => {
|
||||
socket.value = io('/', {
|
||||
transports: ['websocket', 'polling'],
|
||||
withCredentials: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 30,
|
||||
reconnectionDelay: 5000
|
||||
})
|
||||
|
||||
socket.value.on('connect', () => {
|
||||
isConnected.value = true
|
||||
console.log('Connected to server')
|
||||
})
|
||||
|
||||
socket.value.on('connected', (stream: string) => {
|
||||
const json = JSON.parse(stream)
|
||||
queueItems.value = json.data.queue || {}
|
||||
historyItems.value = json.data.done || {}
|
||||
})
|
||||
|
||||
socket.value.on('item_added', (stream: string) => {
|
||||
const json = JSON.parse(stream)
|
||||
queueItems.value[json.data._id] = json.data
|
||||
})
|
||||
|
||||
socket.value.on('item_updated', (stream: string) => {
|
||||
const json = JSON.parse(stream)
|
||||
const item = json.data
|
||||
if (queueItems.value[item._id]) {
|
||||
queueItems.value[item._id] = item
|
||||
}
|
||||
})
|
||||
|
||||
socket.value.on('item_completed', (stream: string) => {
|
||||
const json = JSON.parse(stream)
|
||||
const item = json.data
|
||||
delete queueItems.value[item._id]
|
||||
historyItems.value[item._id] = item
|
||||
})
|
||||
|
||||
socket.value.on('disconnect', () => {
|
||||
isConnected.value = false
|
||||
console.log('Disconnected from server')
|
||||
})
|
||||
}
|
||||
|
||||
const addDownload = (url: string, preset: string = 'default') => {
|
||||
socket.value?.emit('add_url', { url, preset })
|
||||
}
|
||||
|
||||
const cancelDownload = (id: string) => {
|
||||
socket.value?.emit('item_cancel', id)
|
||||
}
|
||||
|
||||
return {
|
||||
socket,
|
||||
isConnected,
|
||||
queueItems,
|
||||
historyItems,
|
||||
connect,
|
||||
addDownload,
|
||||
cancelDownload
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Python Client Example
|
||||
|
||||
```python
|
||||
# WebSocket client for YTPTube
|
||||
import json
|
||||
import asyncio
|
||||
import socketio
|
||||
|
||||
class YTPTubeClient:
|
||||
def __init__(self, url: str = 'http://localhost:8081'):
|
||||
self.sio = socketio.AsyncClient(
|
||||
transports=['websocket', 'polling'],
|
||||
reconnection_attempts=30,
|
||||
reconnection_delay=5
|
||||
)
|
||||
self.url = url
|
||||
self.queue = {}
|
||||
self.history = {}
|
||||
|
||||
async def connect(self):
|
||||
await self.sio.connect(self.url)
|
||||
await self.sio.wait()
|
||||
|
||||
async def setup_listeners(self):
|
||||
@self.sio.on('connected')
|
||||
async def on_connected(data: str):
|
||||
json_data = json.loads(data)
|
||||
self.queue = json_data['data'].get('queue', {})
|
||||
self.history = json_data['data'].get('done', {})
|
||||
print('Connected to YTPTube')
|
||||
|
||||
@self.sio.on('item_added')
|
||||
async def on_item_added(data: str):
|
||||
json_data = json.loads(data)
|
||||
item = json_data['data']
|
||||
self.queue[item['_id']] = item
|
||||
print(f"Added: {item['title']}")
|
||||
|
||||
@self.sio.on('item_completed')
|
||||
async def on_item_completed(data: str):
|
||||
json_data = json.loads(data)
|
||||
item = json_data['data']
|
||||
self.queue.pop(item['_id'], None)
|
||||
self.history[item['_id']] = item
|
||||
print(f"Completed: {item['title']}")
|
||||
|
||||
async def add_download(self, url: str, preset: str = 'default'):
|
||||
await self.sio.emit('add_url', {'url': url, 'preset': preset})
|
||||
|
||||
async def cancel_download(self, item_id: str):
|
||||
await self.sio.emit('item_cancel', item_id)
|
||||
|
||||
# Usage
|
||||
async def main():
|
||||
client = YTPTubeClient('http://localhost:8081')
|
||||
await client.setup_listeners()
|
||||
await client.connect()
|
||||
|
||||
await client.add_download('https://youtube.com/watch?v=...')
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ class DataStore:
|
|||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
order: str = "DESC",
|
||||
status_filter: str | None = None,
|
||||
) -> tuple[list[tuple[str, ItemDTO]], int, int, int]:
|
||||
"""
|
||||
Get paginated items from the datastore.
|
||||
|
|
@ -223,6 +224,8 @@ class DataStore:
|
|||
page (int): The page number (1-indexed). Defaults to 1.
|
||||
per_page (int): Number of items per page. Defaults to 50.
|
||||
order (str): Sort order - 'ASC' or 'DESC'. Defaults to 'DESC' (newest first).
|
||||
status_filter (str | None): Optional status filter. Can be a status value (e.g., 'finished')
|
||||
to include only that status, or prefixed with '!' (e.g., '!finished') to exclude that status.
|
||||
|
||||
Returns:
|
||||
tuple[list[tuple[str, ItemDTO]], int, int, int]: A tuple containing:
|
||||
|
|
@ -250,7 +253,26 @@ class DataStore:
|
|||
|
||||
order = "ASC" if order == "ASC" else "DESC"
|
||||
|
||||
total_items: int = self.get_total_count()
|
||||
# Build SQL query with status filter if provided
|
||||
where_clauses = ['"type" = ?']
|
||||
query_params: list = [str(self._type)]
|
||||
|
||||
if status_filter:
|
||||
# Check if it's an exclusion filter (starts with !)
|
||||
if status_filter.startswith("!"):
|
||||
status_value = status_filter[1:]
|
||||
where_clauses.append("json_extract(data, '$.status') != ?")
|
||||
query_params.append(status_value)
|
||||
else:
|
||||
where_clauses.append("json_extract(data, '$.status') = ?")
|
||||
query_params.append(status_filter)
|
||||
|
||||
where_clause = " AND ".join(where_clauses)
|
||||
|
||||
# Get total count with filter
|
||||
count_query = f'SELECT COUNT(*) as count FROM "history" WHERE {where_clause}' # noqa: S608
|
||||
count_result = self._connection.execute(count_query, tuple(query_params)).fetchone()
|
||||
total_items: int = count_result["count"] if count_result else 0
|
||||
total_pages: int = (total_items + per_page - 1) // per_page if total_items > 0 else 1
|
||||
|
||||
# Ensure page is within valid range.
|
||||
|
|
@ -261,9 +283,11 @@ class DataStore:
|
|||
|
||||
items: list[tuple[str, ItemDTO]] = []
|
||||
|
||||
query_params.extend([per_page, offset])
|
||||
|
||||
cursor = self._connection.execute(
|
||||
f'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = ? ORDER BY "created_at" {order} LIMIT ? OFFSET ?', # noqa: S608
|
||||
(str(self._type), per_page, offset),
|
||||
f'SELECT "id", "data", "created_at" FROM "history" WHERE {where_clause} ORDER BY "created_at" {order} LIMIT ? OFFSET ?', # noqa: S608
|
||||
tuple(query_params),
|
||||
)
|
||||
|
||||
for row in cursor:
|
||||
|
|
|
|||
|
|
@ -869,7 +869,7 @@ class DownloadQueue(metaclass=Singleton):
|
|||
dict: The status of the operation.
|
||||
|
||||
"""
|
||||
status: dict[str, str] = {"status": "ok"}
|
||||
status: dict[str, str] = {}
|
||||
|
||||
for id in ids:
|
||||
try:
|
||||
|
|
@ -917,13 +917,13 @@ class DownloadQueue(metaclass=Singleton):
|
|||
|
||||
Args:
|
||||
ids (list): The list of ids to clear.
|
||||
remove_file (bool): True to remove the file, False otherwise. Default is False.
|
||||
remove_file (bool): Only considered if config.remove_files is True.
|
||||
|
||||
Returns:
|
||||
dict: The status of the operation.
|
||||
|
||||
"""
|
||||
status: dict[str, str] = {"status": "ok"}
|
||||
status: dict[str, str] = {}
|
||||
|
||||
for id in ids:
|
||||
try:
|
||||
|
|
@ -938,11 +938,12 @@ class DownloadQueue(metaclass=Singleton):
|
|||
removed_files = 0
|
||||
filename: str = ""
|
||||
|
||||
LOG.debug(
|
||||
f"{remove_file=} {itemRef} - Removing local files: {self.config.remove_files}, {item.info.status=}"
|
||||
)
|
||||
if self.config.remove_files is not True:
|
||||
remove_file = False
|
||||
|
||||
if remove_file and self.config.remove_files and "finished" == item.info.status:
|
||||
LOG.debug(f"{remove_file=} {itemRef} - Removing local files: {item.info.status=}")
|
||||
|
||||
if remove_file and "finished" == item.info.status:
|
||||
filename = str(item.info.filename)
|
||||
if item.info.folder:
|
||||
filename = f"{item.info.folder}/{item.info.filename}"
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ def timed_lru_cache(ttl_seconds: int, max_size: int = 128):
|
|||
|
||||
def calc_download_path(base_path: str | Path, folder: str | None = None, create_path: bool = True) -> str:
|
||||
"""
|
||||
Calculates download path and prevents folder traversal.
|
||||
Calculates download path.
|
||||
|
||||
Args:
|
||||
base_path (str): Base download path.
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ class Main:
|
|||
|
||||
self._check_folders()
|
||||
|
||||
LOG.debug(f"DB Version: '{caribou.get_version(self._config.db_file)}'.")
|
||||
caribou.upgrade(self._config.db_file, ROOT_PATH / "migrations")
|
||||
|
||||
connection = sqlite3.connect(database=self._config.db_file, isolation_level=None)
|
||||
|
|
|
|||
27
app/migrations/20251116173731_add_status_index.py
Normal file
27
app/migrations/20251116173731_add_status_index.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""
|
||||
This module contains a Caribou migration.
|
||||
|
||||
Migration Name: add_status_index
|
||||
Migration Version: 20251116173731
|
||||
"""
|
||||
|
||||
def upgrade(connection):
|
||||
"""
|
||||
Add index on json_extract(data, '$.status') for better query performance.
|
||||
|
||||
This index improves performance when filtering items by status field,
|
||||
which is stored in the JSON data column.
|
||||
"""
|
||||
sql = """
|
||||
CREATE INDEX IF NOT EXISTS "history_status" ON "history" (json_extract("data", '$.status'));
|
||||
"""
|
||||
connection.execute(sql)
|
||||
|
||||
def downgrade(connection):
|
||||
"""
|
||||
Remove the status index.
|
||||
"""
|
||||
sql = """
|
||||
DROP INDEX IF EXISTS "history_status";
|
||||
"""
|
||||
connection.execute(sql)
|
||||
|
|
@ -6,6 +6,7 @@ from aiohttp import web
|
|||
from aiohttp.web import Request, Response
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.DataStore import StoreType
|
||||
from app.library.Download import Download
|
||||
from app.library.DownloadQueue import DownloadQueue
|
||||
from app.library.encoder import Encoder
|
||||
|
|
@ -17,6 +18,7 @@ from app.library.router import route
|
|||
if TYPE_CHECKING:
|
||||
from library.Download import Download
|
||||
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -39,6 +41,8 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder, c
|
|||
page (int): Page number for pagination (1-indexed). Only used when type != "all"
|
||||
per_page (int): Items per page. Default: 50, Max: 1000. Only used when type != "all"
|
||||
order (str): Sort order - "ASC" or "DESC". Default: "DESC". Only used when type != "all"
|
||||
status (str): Filter by status. Use "!status" to exclude a status. Only used when type != "all"
|
||||
Examples: "?status=finished" or "?status=!finished"
|
||||
|
||||
"""
|
||||
from app.library.DataStore import StoreType
|
||||
|
|
@ -88,7 +92,12 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder, c
|
|||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
items, total, current_page, total_pages = ds.get_items_paginated(page=page, per_page=per_page, order=order)
|
||||
# Parse status filter
|
||||
status_filter = request.query.get("status", None)
|
||||
|
||||
items, total, current_page, total_pages = ds.get_items_paginated(
|
||||
page=page, per_page=per_page, order=order, status_filter=status_filter
|
||||
)
|
||||
data = {
|
||||
"pagination": {
|
||||
"page": current_page,
|
||||
|
|
@ -104,10 +113,10 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder, c
|
|||
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("DELETE", "api/history/", "item_delete")
|
||||
async def item_delete(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
|
||||
@route("DELETE", "api/history/", "items_delete")
|
||||
async def items_delete(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Delete an item from the queue.
|
||||
Delete items from the queue or history.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
|
|
@ -117,17 +126,77 @@ async def item_delete(request: Request, queue: DownloadQueue, encoder: Encoder)
|
|||
Returns:
|
||||
Response: The response object.
|
||||
|
||||
Request Body:
|
||||
type (str): "queue" or "done"
|
||||
status (str, optional): Filter by status (e.g., "finished" or "!finished")
|
||||
ids (list[str], optional): Specific IDs to delete (if provided, ignores status filter)
|
||||
remove_file (bool, optional): Whether to remove files. Default: True
|
||||
|
||||
"""
|
||||
data = await request.json()
|
||||
ids = data.get("ids")
|
||||
where = data.get("where")
|
||||
if not ids or where not in ["queue", "done"]:
|
||||
return web.json_response(data={"error": "ids and where are required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
ids = data.get("ids")
|
||||
remove_file: bool = bool(data.get("remove_file", True))
|
||||
_type = data.get("type", data.get("where"))
|
||||
|
||||
if not _type:
|
||||
return web.json_response(data={"error": "Type is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if _type not in [StoreType.QUEUE.value, StoreType.HISTORY.value]:
|
||||
return web.json_response(
|
||||
data={"error": f"type must be '{StoreType.QUEUE.value}' or '{StoreType.HISTORY.value}'."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
ds = queue.queue if _type == StoreType.QUEUE.value else queue.done
|
||||
|
||||
if ids:
|
||||
return web.json_response(
|
||||
data={
|
||||
"items": await (
|
||||
queue.cancel(ids) if _type == StoreType.QUEUE.value else queue.clear(ids, remove_file=remove_file)
|
||||
),
|
||||
"deleted": len(ids),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
status_filter = data.get("status")
|
||||
if not status_filter:
|
||||
return web.json_response(
|
||||
data={"error": "either 'ids' or 'status' filter is required."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
items_to_delete = []
|
||||
page = 1
|
||||
per_page = 1000
|
||||
|
||||
while True:
|
||||
items, _, current_page, total_pages = ds.get_items_paginated(
|
||||
page=page, per_page=per_page, order="DESC", status_filter=status_filter
|
||||
)
|
||||
|
||||
items_to_delete.extend([item_id for item_id, _ in items])
|
||||
|
||||
if current_page >= total_pages:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
if not items_to_delete:
|
||||
return web.json_response(data={"error": "No items matched the filter."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
return web.json_response(
|
||||
data=await (queue.cancel(ids) if where == "queue" else queue.clear(ids, remove_file=remove_file)),
|
||||
data={
|
||||
"items": await (
|
||||
queue.cancel(items_to_delete)
|
||||
if _type == StoreType.QUEUE.value
|
||||
else queue.clear(items_to_delete, remove_file=remove_file)
|
||||
),
|
||||
"deleted": len(items_to_delete),
|
||||
},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -250,3 +250,134 @@ class TestDataStorePagination:
|
|||
assert total_pages == 1
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_pagination_status_filter_include(self, temp_db):
|
||||
"""Test pagination with status filter (inclusion)."""
|
||||
# Add some items with different status values
|
||||
item_data_pending = {
|
||||
"url": "https://example.com/pending",
|
||||
"title": "Pending Video",
|
||||
"id": "pending-video",
|
||||
"folder": "/downloads",
|
||||
"status": "pending",
|
||||
}
|
||||
item_data_downloading = {
|
||||
"url": "https://example.com/downloading",
|
||||
"title": "Downloading Video",
|
||||
"id": "downloading-video",
|
||||
"folder": "/downloads",
|
||||
"status": "downloading",
|
||||
}
|
||||
|
||||
temp_db.execute(
|
||||
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
|
||||
(
|
||||
"pending-id",
|
||||
str(StoreType.HISTORY),
|
||||
item_data_pending["url"],
|
||||
json.dumps(item_data_pending),
|
||||
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
temp_db.execute(
|
||||
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
|
||||
(
|
||||
"downloading-id",
|
||||
str(StoreType.HISTORY),
|
||||
item_data_downloading["url"],
|
||||
json.dumps(item_data_downloading),
|
||||
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
temp_db.commit()
|
||||
|
||||
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
|
||||
|
||||
# Filter for finished items only
|
||||
items, total, _page, _total_pages = datastore.get_items_paginated(
|
||||
page=1, per_page=50, status_filter="finished"
|
||||
)
|
||||
|
||||
assert len(items) == 50 # First page of finished items
|
||||
assert total == 100 # Only 100 finished items in fixture
|
||||
for _item_id, item in items:
|
||||
assert item.status == "finished"
|
||||
|
||||
# Filter for pending items only
|
||||
items, total, _page, _total_pages = datastore.get_items_paginated(
|
||||
page=1, per_page=50, status_filter="pending"
|
||||
)
|
||||
|
||||
assert len(items) == 1
|
||||
assert total == 1
|
||||
assert items[0][1].status == "pending"
|
||||
|
||||
def test_pagination_status_filter_exclude(self, temp_db):
|
||||
"""Test pagination with status filter (exclusion)."""
|
||||
# Add some items with different status values
|
||||
item_data_pending = {
|
||||
"url": "https://example.com/pending2",
|
||||
"title": "Pending Video 2",
|
||||
"id": "pending-video-2",
|
||||
"folder": "/downloads",
|
||||
"status": "pending",
|
||||
}
|
||||
item_data_error = {
|
||||
"url": "https://example.com/error",
|
||||
"title": "Error Video",
|
||||
"id": "error-video",
|
||||
"folder": "/downloads",
|
||||
"status": "error",
|
||||
}
|
||||
|
||||
temp_db.execute(
|
||||
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
|
||||
(
|
||||
"pending-id-2",
|
||||
str(StoreType.HISTORY),
|
||||
item_data_pending["url"],
|
||||
json.dumps(item_data_pending),
|
||||
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
temp_db.execute(
|
||||
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
|
||||
(
|
||||
"error-id",
|
||||
str(StoreType.HISTORY),
|
||||
item_data_error["url"],
|
||||
json.dumps(item_data_error),
|
||||
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
temp_db.commit()
|
||||
|
||||
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
|
||||
|
||||
# Exclude finished items
|
||||
items, total, _page, _total_pages = datastore.get_items_paginated(
|
||||
page=1, per_page=50, status_filter="!finished"
|
||||
)
|
||||
|
||||
assert total == 2 # Only 2 non-finished items
|
||||
assert len(items) == 2
|
||||
for _item_id, item in items:
|
||||
assert item.status != "finished"
|
||||
|
||||
# Verify we have pending and error
|
||||
statuses = {item.status for _, item in items}
|
||||
assert statuses == {"pending", "error"}
|
||||
|
||||
def test_pagination_status_filter_none_matching(self, temp_db):
|
||||
"""Test pagination with status filter that matches no items."""
|
||||
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
|
||||
|
||||
# Filter for a status that doesn't exist
|
||||
items, total, page, total_pages = datastore.get_items_paginated(
|
||||
page=1, per_page=50, status_filter="nonexistent"
|
||||
)
|
||||
|
||||
assert len(items) == 0
|
||||
assert total == 0
|
||||
assert page == 1
|
||||
assert total_pages == 1
|
||||
|
|
|
|||
|
|
@ -655,59 +655,38 @@ const deleteSelectedItems = async () => {
|
|||
toast.error('No items selected.')
|
||||
return
|
||||
}
|
||||
|
||||
let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${selectedElms.value.length}' items?`
|
||||
if (true === config.app.remove_files) {
|
||||
msg += ' This will remove any associated files if they exists.'
|
||||
}
|
||||
|
||||
if (false === (await box.confirm(msg))) {
|
||||
return
|
||||
}
|
||||
for (const key in selectedElms.value) {
|
||||
const item_id = selectedElms.value[key]
|
||||
if (!item_id) {
|
||||
continue
|
||||
}
|
||||
const item = stateStore.get('history', item_id, {} as StoreItem) as StoreItem
|
||||
socket.emit('item_delete', {
|
||||
id: item._id,
|
||||
remove_file: config.app.remove_files,
|
||||
})
|
||||
}
|
||||
|
||||
await stateStore.deleteItems('history', {
|
||||
ids: [...selectedElms.value],
|
||||
removeFile: config.app.remove_files
|
||||
})
|
||||
selectedElms.value = []
|
||||
}
|
||||
|
||||
const clearCompleted = async () => {
|
||||
const msg = 'Clear all completed downloads?'
|
||||
const msg = 'Clear all completed downloads? No files will be removed.'
|
||||
if (false === (await box.confirm(msg))) {
|
||||
return
|
||||
}
|
||||
for (const key in stateStore.history) {
|
||||
if ('finished' === ag(stateStore.get('history', key, {} as StoreItem), 'status')) {
|
||||
socket.emit('item_delete', { id: stateStore.history[key]?._id, remove_file: false, })
|
||||
if (selectedElms.value.includes(stateStore.history[key]?._id || '')) {
|
||||
selectedElms.value = selectedElms.value.filter(i => i !== stateStore.history[key]?._id)
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedElms.value = []
|
||||
await stateStore.deleteItems('history', { status: 'finished', removeFile: false })
|
||||
}
|
||||
|
||||
const clearIncomplete = async () => {
|
||||
if (false === (await box.confirm('Clear all in-complete downloads?'))) {
|
||||
return
|
||||
}
|
||||
for (const key in stateStore.history) {
|
||||
if ((stateStore.history[key] as StoreItem).status !== 'finished') {
|
||||
socket.emit('item_delete', {
|
||||
id: stateStore.history[key]?._id,
|
||||
remove_file: false,
|
||||
})
|
||||
|
||||
if (selectedElms.value.includes(stateStore.history[key]?._id || '')) {
|
||||
selectedElms.value = selectedElms.value.filter(i => i !== stateStore.history[key]?._id)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
selectedElms.value = []
|
||||
await stateStore.deleteItems('history', { status: '!finished', removeFile: false })
|
||||
}
|
||||
|
||||
const setIcon = (item: StoreItem) => {
|
||||
|
|
|
|||
|
|
@ -48,14 +48,16 @@ export const useStateStore = defineStore('state', () => {
|
|||
}
|
||||
|
||||
const remove = (type: StateType, key: KeyType): void => {
|
||||
if (!state[type][key]) {
|
||||
return
|
||||
}
|
||||
|
||||
if ('history' === type && state.pagination.total > 0) {
|
||||
state.pagination.total -= 1
|
||||
}
|
||||
|
||||
if (state[type][key]) {
|
||||
const { [key]: _, ...rest } = state[type]
|
||||
state[type] = rest
|
||||
}
|
||||
const { [key]: _, ...rest } = state[type]
|
||||
state[type] = rest
|
||||
}
|
||||
|
||||
const get = (type: StateType, key: KeyType, defaultValue: StoreItem | null = null): StoreItem | null => {
|
||||
|
|
@ -98,7 +100,7 @@ export const useStateStore = defineStore('state', () => {
|
|||
return Object.keys(state[type]).length
|
||||
}
|
||||
|
||||
const loadPaginated = async (type: StateType, page: number = 1, per_page: number = 50, order: 'ASC' | 'DESC' = 'DESC', append: boolean = false): Promise<void> => {
|
||||
const loadPaginated = async (type: StateType, page: number = 1, per_page: number = 50, order: 'ASC' | 'DESC' = 'DESC', append: boolean = false, status?: string): Promise<void> => {
|
||||
if ('history' !== type) {
|
||||
throw new Error('Pagination is only supported for history type');
|
||||
}
|
||||
|
|
@ -106,7 +108,18 @@ export const useStateStore = defineStore('state', () => {
|
|||
state.pagination.isLoading = true
|
||||
|
||||
try {
|
||||
const search = new URLSearchParams({ type: 'done', page: page.toString(), per_page: per_page.toString(), order });
|
||||
const params: Record<string, string> = {
|
||||
type: 'done',
|
||||
page: page.toString(),
|
||||
per_page: per_page.toString(),
|
||||
order
|
||||
}
|
||||
|
||||
if (status) {
|
||||
params.status = status
|
||||
}
|
||||
|
||||
const search = new URLSearchParams(params)
|
||||
|
||||
const response = await request(`/api/history?${search}`)
|
||||
const data = await response.json()
|
||||
|
|
@ -165,6 +178,69 @@ export const useStateStore = defineStore('state', () => {
|
|||
|
||||
const setHistoryCount = (count: number) => state.pagination.total = count
|
||||
|
||||
/**
|
||||
* Delete items by specific IDs or status filter.
|
||||
*
|
||||
* @param type - The store type ('queue' or 'history')
|
||||
* @param options - Delete options
|
||||
* @param options.ids - Array of item IDs to delete (if provided, status is ignored)
|
||||
* @param options.status - Status filter (e.g., 'finished' or '!finished')
|
||||
* @param options.removeFile - Whether to remove files from disk (default: true)
|
||||
*
|
||||
* @returns Number of items deleted
|
||||
*/
|
||||
const deleteItems = async (
|
||||
type: StateType,
|
||||
options: { ids?: string[]; status?: string; removeFile?: boolean } = {}
|
||||
): Promise<number> => {
|
||||
const { ids, status, removeFile = true } = options
|
||||
|
||||
if (!ids && !status) {
|
||||
throw new Error('Either ids or status filter must be provided')
|
||||
}
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
type: type === 'queue' ? 'queue' : 'done',
|
||||
remove_file: removeFile
|
||||
}
|
||||
|
||||
if (ids) {
|
||||
body.ids = ids
|
||||
}
|
||||
|
||||
if (status) {
|
||||
body.status = status
|
||||
}
|
||||
|
||||
const response = await request('/api/history', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
const result = await response.json() as {
|
||||
items: Record<string, string>,
|
||||
deleted: number,
|
||||
error?: string,
|
||||
message?: string,
|
||||
}
|
||||
|
||||
if (result.error || result.message || !response.ok) {
|
||||
throw new Error(result.error || result.message || 'Failed to delete items.')
|
||||
}
|
||||
|
||||
for (const id of Object.keys(result.items)) {
|
||||
remove(type, id)
|
||||
}
|
||||
|
||||
return result.deleted
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete items:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
add,
|
||||
|
|
@ -182,5 +258,6 @@ export const useStateStore = defineStore('state', () => {
|
|||
reloadCurrentPage,
|
||||
getPagination,
|
||||
setHistoryCount,
|
||||
deleteItems,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue