Merge pull request #492 from arabcoders/dev

Refactor: change how clear in/completed buttons behave
This commit is contained in:
Abdulmohsen 2025-11-16 20:07:50 +03:00 committed by GitHub
commit d13ac83425
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 608 additions and 504 deletions

View file

@ -24,6 +24,8 @@ on:
env: env:
REGISTRY: ${{ vars.REGISTRY != '' && vars.REGISTRY || '' }} REGISTRY: ${{ vars.REGISTRY != '' && vars.REGISTRY || '' }}
BUILD_AMD64: ${{ vars.BUILD_AMD64 != 'false' && 'true' || 'false' }}
BUILD_ARM64: ${{ vars.BUILD_ARM64 != 'false' && 'true' || 'false' }}
DOCKERHUB_SLUG: arabcoders/ytptube DOCKERHUB_SLUG: arabcoders/ytptube
GHCR_SLUG: ghcr.io/arabcoders/ytptube GHCR_SLUG: ghcr.io/arabcoders/ytptube
PNPM_VERSION: 10 PNPM_VERSION: 10
@ -31,8 +33,23 @@ env:
PYTHON_VERSION: "3.13" PYTHON_VERSION: "3.13"
jobs: jobs:
validate-build-config:
name: Validate Build Configuration
runs-on: ubuntu-latest
steps:
- name: Check if at least one architecture is enabled
run: |
if [ "${{ vars.BUILD_AMD64 }}" = "false" ] && [ "${{ vars.BUILD_ARM64 }}" = "false" ]; then
echo "::error::Both BUILD_AMD64 and BUILD_ARM64 are disabled. At least one architecture must be enabled."
exit 1
fi
echo "Build configuration is valid"
echo "BUILD_AMD64: ${{ vars.BUILD_AMD64 != 'false' && 'true' || 'false' }}"
echo "BUILD_ARM64: ${{ vars.BUILD_ARM64 != 'false' && 'true' || 'false' }}"
test: test:
name: Run Tests & Prepare Frontend name: Run Tests & Prepare Frontend
needs: [validate-build-config]
permissions: permissions:
contents: read contents: read
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -120,22 +137,115 @@ jobs:
path: ui/exported/ path: ui/exported/
retention-days: 1 retention-days: 1
docker-build-arch: docker-build-amd64:
name: Build Container (${{ matrix.arch }}) name: Build Container (amd64)
runs-on: ${{ matrix.os }} runs-on: ubuntu-latest
needs: [test] needs: [test, validate-build-config]
strategy: if: vars.BUILD_AMD64 != 'false'
matrix:
include:
- os: ubuntu-latest
arch: amd64
- os: ubuntu-latest
arch: arm64
permissions: permissions:
packages: write packages: write
contents: write contents: write
env: env:
ARCH: ${{ matrix.arch }} ARCH: amd64
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download frontend build
uses: actions/download-artifact@v4
if: env.REGISTRY == ''
with:
name: frontend-build
path: ui/exported/
- name: Update app/library/version.py
run: |
VERSION="${GITHUB_REF##*/}"
SHA=$(git rev-parse HEAD)
DATE=$(date -u +"%Y%m%d")
BRANCH=$(echo "${GITHUB_REF#refs/heads/}" | sed 's/\//-/g')
echo "APP_VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "APP_SHA=${SHA}" >> "$GITHUB_ENV"
echo "APP_DATE=${DATE}" >> "$GITHUB_ENV"
echo "APP_BRANCH=${BRANCH}" >> "$GITHUB_ENV"
sed -i \
-e "s/^APP_VERSION = \".*\"/APP_VERSION = \"${VERSION}\"/" \
-e "s/^APP_COMMIT_SHA = \".*\"/APP_COMMIT_SHA = \"${SHA}\"/" \
-e "s/^APP_BUILD_DATE = \".*\"/APP_BUILD_DATE = \"${DATE}\"/" \
-e "s/^APP_BRANCH = \".*\"/APP_BRANCH = \"${BRANCH}\"/" \
app/library/version.py
echo "Updated version info:"
cat app/library/version.py
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
name=${{ env.DOCKERHUB_SLUG }},enable=${{ env.REGISTRY == '' }}
name=${{ env.GHCR_SLUG }},enable=${{ env.REGISTRY == '' }}
name=${{ env.REGISTRY }}/${{ github.repository }},enable=${{ env.REGISTRY != '' }}
flavor: |
latest=false
suffix=-${{ env.ARCH }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Login to Private Registry
uses: docker/login-action@v3
if: env.REGISTRY != ''
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GIT_TOKEN && secrets.GIT_TOKEN || secrets.GITHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
if: env.REGISTRY == ''
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3
if: env.REGISTRY == ''
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/${{ env.ARCH }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-to: type=gha,mode=max,scope=${{ github.workflow }}
cache-from: type=gha,scope=${{ github.workflow }}
provenance: true
docker-build-arm64:
name: Build Container (arm64)
runs-on: ubuntu-latest
needs: [test, validate-build-config]
if: vars.BUILD_ARM64 != 'false'
permissions:
packages: write
contents: write
env:
ARCH: arm64
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -227,8 +337,11 @@ jobs:
docker-publish-manifest: docker-publish-manifest:
name: Publish multi-arch manifest name: Publish multi-arch manifest
needs: [docker-build-arch]
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [docker-build-amd64, docker-build-arm64]
if: |
!cancelled() &&
(needs.docker-build-amd64.result == 'success' || needs.docker-build-arm64.result == 'success')
permissions: permissions:
packages: write packages: write
contents: write contents: write
@ -275,12 +388,26 @@ jobs:
shell: bash shell: bash
run: | run: |
IFS=$'\n' IFS=$'\n'
BUILD_AMD64="${{ env.BUILD_AMD64 }}"
BUILD_ARM64="${{ env.BUILD_ARM64 }}"
for tag in $(echo "${{ steps.meta.outputs.tags }}"); do for tag in $(echo "${{ steps.meta.outputs.tags }}"); do
echo "Creating manifest for ${tag}" echo "Creating manifest for ${tag}"
docker buildx imagetools create \
--tag "$tag" \ IMAGES=()
"${tag}-amd64" \ if [ "$BUILD_AMD64" = "true" ]; then
"${tag}-arm64" IMAGES+=("${tag}-amd64")
fi
if [ "$BUILD_ARM64" = "true" ]; then
IMAGES+=("${tag}-arm64")
fi
if [ ${#IMAGES[@]} -eq 0 ]; then
echo "::error::No architectures enabled for manifest creation"
exit 1
fi
docker buildx imagetools create --tag "$tag" "${IMAGES[@]}"
done done
- name: Overwrite GitHub release notes - name: Overwrite GitHub release notes
@ -312,7 +439,7 @@ jobs:
const commits = comparison.commits.filter(c => 1 === c.parents.length); const commits = comparison.commits.filter(c => 1 === c.parents.length);
const changelog = commits.map( const changelog = commits.map(
c => `- ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]} by @${c.commit.author.name}` c => `- ${c.sha.substring(0, 7)} ${c.commit.message.split('\n')[0]}`
).join('\n'); ).join('\n');
if (!changelog) { if (!changelog) {

View file

@ -69,6 +69,7 @@
"euuo", "euuo",
"eventbus", "eventbus",
"excepthook", "excepthook",
"exitcode",
"falsey", "falsey",
"faststart", "faststart",
"Fetc", "Fetc",

507
API.md
View file

@ -83,11 +83,6 @@ This document describes the available endpoints and their usage. All endpoints r
- [`disconnect` (Built-in)](#disconnect-built-in) - [`disconnect` (Built-in)](#disconnect-built-in)
- [`configuration` (Server → Client)](#configuration-server--client) - [`configuration` (Server → Client)](#configuration-server--client)
- [`connected` (Server → Client)](#connected-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) - [Logging Events](#logging-events)
- [`log_info` (Server → Client)](#log_info-server--client) - [`log_info` (Server → Client)](#log_info-server--client)
- [`log_success` (Server → Client)](#log_success-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_error` (Server → Client)](#log_error-server--client)
- [`log_lines` (Server → Client)](#log_lines-server--client) - [`log_lines` (Server → Client)](#log_lines-server--client)
- [Download Queue Events](#download-queue-events) - [Download Queue Events](#download-queue-events)
- [`add_url` (Client → Server)](#add_url-client--server)
- [`item_added` (Server → Client)](#item_added-server--client) - [`item_added` (Server → Client)](#item_added-server--client)
- [`item_updated` (Server → Client)](#item_updated-server--client) - [`item_updated` (Server → Client)](#item_updated-server--client)
- [`item_completed` (Server → Client)](#item_completed-server--client) - [`item_completed` (Server → Client)](#item_completed-server--client)
- [`item_cancelled` (Server → Client)](#item_cancelled-server--client) - [`item_cancelled` (Server → Client)](#item_cancelled-server--client)
- [`item_deleted` (Server → Client)](#item_deleted-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) - [`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) - [Terminal/CLI Events](#terminalcli-events)
- [`cli_post` (Client → Server)](#cli_post-client--server) - [`cli_post` (Client → Server)](#cli_post-client--server)
- [`cli_output` (Server → Client)](#cli_output-server--client) - [`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) - [Configuration Events](#configuration-events)
- [`presets_update` (Server → Client)](#presets_update-server--client) - [`presets_update` (Server → Client)](#presets_update-server--client)
- [`dlfields_update` (Server → Client)](#dlfields_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 ### 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 ```json
{ {
"type": "done",
"ids": ["<id1>", "<id2>"], "ids": ["<id1>", "<id2>"],
"where": "queue" | "done", "remove_file": true
"remove_file": true | false // optional, defaults to True, whether to delete the file from disk if enabled.
} }
``` ```
**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 ```json
{ {
"<id1>": "status", "<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` - `order` (optional): Sort order. Default: `DESC`. Only used when `type != all`
- `DESC`: Newest items first (descending by creation date) - `DESC`: Newest items first (descending by creation date)
- `ASC`: Oldest items first (ascending 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: **Response (when `type=all` or no type set)** - Legacy format:
```json ```json
@ -521,6 +560,24 @@ or an error:
- For large datasets, use paginated requests (`type=queue` or `type=done`) for better performance - For large datasets, use paginated requests (`type=queue` or `type=done`) for better performance
- The `items` array contains ItemDTO objects serialized to JSON - 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 ### DELETE /api/history/{id}/archive
@ -1163,7 +1220,7 @@ Binary image data with the appropriate `Content-Type`.
**Body**: **Body**:
```json ```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": "delete", "path": "relative/path/file.ext" },
{ "action": "move", "path": "relative/path/file.ext", "new_path": "new/relative/path" }, { "action": "move", "path": "relative/path/file.ext", "new_path": "new/relative/path" },
{ "action": "directory", "path": "relative/path", "new_dir": "subdirectory" } { "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. 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 ### Connection
**URL**: `ws://localhost:8081/socket.io/` (development) or `https://yourdomain.com/socket.io/` (production) **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 - `dl_fields`: Available download fields
- `paused`: Queue pause status (boolean) - `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) ##### `connected` (Server → Client)
When a client connects, this events sends the folder and current queue. 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) - `queue`: Current download queue (array of items)
- `folders`: Directory structure for downloads - `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 #### Logging Events
All logging events follow the same structure with JSON-encoded message: 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) ##### `log_info` (Server → Client)
General informational message. 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) ##### `log_success` (Server → Client)
Success notification message. Success notification message.
```typescript
socket.on('log_success', (stream: string) => {
const json = JSON.parse(stream);
console.log('✓', json.message);
});
```
##### `log_warning` (Server → Client) ##### `log_warning` (Server → Client)
Warning notification message. Warning notification message.
```typescript
socket.on('log_warning', (stream: string) => {
const json = JSON.parse(stream);
console.warn('⚠', json.message);
});
```
##### `log_error` (Server → Client) ##### `log_error` (Server → Client)
Error notification message. Error notification message.
```typescript
socket.on('log_error', (stream: string) => {
const json = JSON.parse(stream);
console.error('✗', json.message);
});
```
##### `log_lines` (Server → Client) ##### `log_lines` (Server → Client)
Continuous application log lines (requires subscription). Continuous application log lines (requires subscription event).
**Data Fields**: **Data Fields**:
- `line`: Log line content - `line`: Log line content
- `timestamp`: Log timestamp - `timestamp`: Log timestamp
```typescript
socket.on('log_lines', (stream: string) => {
const json = JSON.parse(stream);
console.log('[LOG]', json.data.line);
});
```
### Download Queue Events ### 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) #### `item_added` (Server → Client)
Emitted when a new item is successfully added to the queue. The response payload is the complete item object. 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) #### `item_moved` (Server → Client)
Emitted when an item moves between queue and history. 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 ### Terminal/CLI Events
The terminal feature requires `console_enabled: true` in configuration. The terminal feature requires `YTP_CONSOLE_ENABLED=true`.
#### `cli_post` (Client → Server) #### `cli_post` (Client → Server)
Execute a command via yt-dlp CLI. Execute a command via yt-dlp CLI.
**Data**: Command arguments as string (without `yt-dlp` prefix) **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) #### `cli_output` (Server → Client)
Output line from the CLI command execution. Output line from the CLI command execution.
@ -2080,34 +1929,12 @@ Output line from the CLI command execution.
- `type`: Output type (`stdout` or `stderr`) - `type`: Output type (`stdout` or `stderr`)
- `line`: Output line content - `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) #### `cli_close` (Server → Client)
Emitted when CLI command execution completes. Emitted when CLI command execution completes.
**Data Fields**: **Data Fields**:
- `exitcode`: Command exit code (0 = success, non-zero = error) - `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 ### Configuration Events
#### `presets_update` (Server → Client) #### `presets_update` (Server → Client)
@ -2115,170 +1942,10 @@ Emitted when download presets are updated or created.
**Data**: Array of preset objects **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) #### `dlfields_update` (Server → Client)
Emitted when download fields configuration is updated. Emitted when download fields configuration is updated.
**Data**: Array of field objects **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())
``` ```
--- ---

View file

@ -215,6 +215,7 @@ class DataStore:
page: int = 1, page: int = 1,
per_page: int = 50, per_page: int = 50,
order: str = "DESC", order: str = "DESC",
status_filter: str | None = None,
) -> tuple[list[tuple[str, ItemDTO]], int, int, int]: ) -> tuple[list[tuple[str, ItemDTO]], int, int, int]:
""" """
Get paginated items from the datastore. Get paginated items from the datastore.
@ -223,6 +224,8 @@ class DataStore:
page (int): The page number (1-indexed). Defaults to 1. page (int): The page number (1-indexed). Defaults to 1.
per_page (int): Number of items per page. Defaults to 50. per_page (int): Number of items per page. Defaults to 50.
order (str): Sort order - 'ASC' or 'DESC'. Defaults to 'DESC' (newest first). 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: Returns:
tuple[list[tuple[str, ItemDTO]], int, int, int]: A tuple containing: tuple[list[tuple[str, ItemDTO]], int, int, int]: A tuple containing:
@ -250,7 +253,26 @@ class DataStore:
order = "ASC" if order == "ASC" else "DESC" 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 total_pages: int = (total_items + per_page - 1) // per_page if total_items > 0 else 1
# Ensure page is within valid range. # Ensure page is within valid range.
@ -261,9 +283,11 @@ class DataStore:
items: list[tuple[str, ItemDTO]] = [] items: list[tuple[str, ItemDTO]] = []
query_params.extend([per_page, offset])
cursor = self._connection.execute( cursor = self._connection.execute(
f'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = ? ORDER BY "created_at" {order} LIMIT ? OFFSET ?', # noqa: S608 f'SELECT "id", "data", "created_at" FROM "history" WHERE {where_clause} ORDER BY "created_at" {order} LIMIT ? OFFSET ?', # noqa: S608
(str(self._type), per_page, offset), tuple(query_params),
) )
for row in cursor: for row in cursor:

View file

@ -869,7 +869,7 @@ class DownloadQueue(metaclass=Singleton):
dict: The status of the operation. dict: The status of the operation.
""" """
status: dict[str, str] = {"status": "ok"} status: dict[str, str] = {}
for id in ids: for id in ids:
try: try:
@ -917,13 +917,13 @@ class DownloadQueue(metaclass=Singleton):
Args: Args:
ids (list): The list of ids to clear. 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: Returns:
dict: The status of the operation. dict: The status of the operation.
""" """
status: dict[str, str] = {"status": "ok"} status: dict[str, str] = {}
for id in ids: for id in ids:
try: try:
@ -938,11 +938,12 @@ class DownloadQueue(metaclass=Singleton):
removed_files = 0 removed_files = 0
filename: str = "" filename: str = ""
LOG.debug( if self.config.remove_files is not True:
f"{remove_file=} {itemRef} - Removing local files: {self.config.remove_files}, {item.info.status=}" 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) filename = str(item.info.filename)
if item.info.folder: if item.info.folder:
filename = f"{item.info.folder}/{item.info.filename}" filename = f"{item.info.folder}/{item.info.filename}"

View file

@ -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: 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: Args:
base_path (str): Base download path. base_path (str): Base download path.

View file

@ -50,6 +50,7 @@ class Main:
self._check_folders() self._check_folders()
LOG.debug(f"DB Version: '{caribou.get_version(self._config.db_file)}'.")
caribou.upgrade(self._config.db_file, ROOT_PATH / "migrations") caribou.upgrade(self._config.db_file, ROOT_PATH / "migrations")
connection = sqlite3.connect(database=self._config.db_file, isolation_level=None) connection = sqlite3.connect(database=self._config.db_file, isolation_level=None)

View 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)

View file

@ -6,6 +6,7 @@ from aiohttp import web
from aiohttp.web import Request, Response from aiohttp.web import Request, Response
from app.library.config import Config from app.library.config import Config
from app.library.DataStore import StoreType
from app.library.Download import Download from app.library.Download import Download
from app.library.DownloadQueue import DownloadQueue from app.library.DownloadQueue import DownloadQueue
from app.library.encoder import Encoder from app.library.encoder import Encoder
@ -17,6 +18,7 @@ from app.library.router import route
if TYPE_CHECKING: if TYPE_CHECKING:
from library.Download import Download from library.Download import Download
LOG: logging.Logger = logging.getLogger(__name__) 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" 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" 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" 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 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, 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 = { data = {
"pagination": { "pagination": {
"page": current_page, "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) return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("DELETE", "api/history/", "item_delete") @route("DELETE", "api/history/", "items_delete")
async def item_delete(request: Request, queue: DownloadQueue, encoder: Encoder) -> Response: 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: Args:
request (Request): The request object. request (Request): The request object.
@ -117,17 +126,77 @@ async def item_delete(request: Request, queue: DownloadQueue, encoder: Encoder)
Returns: Returns:
Response: The response object. 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() 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)) 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( 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, status=web.HTTPOk.status_code,
dumps=encoder.encode, dumps=encoder.encode,
) )

View file

@ -250,3 +250,134 @@ class TestDataStorePagination:
assert total_pages == 1 assert total_pages == 1
conn.close() 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

View file

@ -417,7 +417,7 @@
<span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span> <span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span>
</Message> </Message>
<Message message_class="is-primary" title="No items" icon="fas fa-exclamation-triangle" <Message message_class="is-primary" title="No items" icon="fas fa-exclamation-triangle"
v-else-if="socket.isConnected && paginationInfo.total" :new-style="true"> v-else-if="socket.isConnected" :new-style="true">
<p>Your download history is empty. Once queued downloads are completed, they will appear here.</p> <p>Your download history is empty. Once queued downloads are completed, they will appear here.</p>
</Message> </Message>
</div> </div>
@ -655,59 +655,38 @@ const deleteSelectedItems = async () => {
toast.error('No items selected.') toast.error('No items selected.')
return return
} }
let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${selectedElms.value.length}' items?` let msg = `${config.app.remove_files ? 'Remove' : 'Clear'} '${selectedElms.value.length}' items?`
if (true === config.app.remove_files) { if (true === config.app.remove_files) {
msg += ' This will remove any associated files if they exists.' msg += ' This will remove any associated files if they exists.'
} }
if (false === (await box.confirm(msg))) { if (false === (await box.confirm(msg))) {
return return
} }
for (const key in selectedElms.value) {
const item_id = selectedElms.value[key] await stateStore.deleteItems('history', {
if (!item_id) { ids: [...selectedElms.value],
continue removeFile: config.app.remove_files
} })
const item = stateStore.get('history', item_id, {} as StoreItem) as StoreItem
socket.emit('item_delete', {
id: item._id,
remove_file: config.app.remove_files,
})
}
selectedElms.value = [] selectedElms.value = []
} }
const clearCompleted = async () => { 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))) { if (false === (await box.confirm(msg))) {
return return
} }
for (const key in stateStore.history) { selectedElms.value = []
if ('finished' === ag(stateStore.get('history', key, {} as StoreItem), 'status')) { await stateStore.deleteItems('history', { status: 'finished', removeFile: false })
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)
}
}
}
} }
const clearIncomplete = async () => { const clearIncomplete = async () => {
if (false === (await box.confirm('Clear all in-complete downloads?'))) { if (false === (await box.confirm('Clear all in-complete downloads?'))) {
return return
} }
for (const key in stateStore.history) { selectedElms.value = []
if ((stateStore.history[key] as StoreItem).status !== 'finished') { await stateStore.deleteItems('history', { status: '!finished', removeFile: false })
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)
}
}
}
} }
const setIcon = (item: StoreItem) => { const setIcon = (item: StoreItem) => {

View file

@ -285,8 +285,8 @@
@close="() => emitter('clear_search')" v-if="query"> @close="() => emitter('clear_search')" v-if="query">
<span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span> <span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span>
</Message> </Message>
<Message message_class="is-info" title="No items" icon="fas fa-clock" :useClose="false" :newStyle="true" <Message message_class="is-info" title="No items" icon="fas fa-exclamation-triangle" :useClose="false"
v-else> :newStyle="true" v-else>
<p>The download queue is empty.</p> <p>The download queue is empty.</p>
</Message> </Message>
</div> </div>

View file

@ -69,14 +69,14 @@
<ul> <ul>
<li :class="{ 'is-active': activeTab === 'queue' }"> <li :class="{ 'is-active': activeTab === 'queue' }">
<a @click="setActiveTab('queue')"> <a @click="setActiveTab('queue')">
<span class="icon is-small"><i class="fas fa-clock" aria-hidden="true"></i></span> <span class="icon is-small"><i class="fas fa-download" /></span>
<span>Queue</span> <span>Downloads</span>
<span class="tag is-info is-rounded is-bold ml-2">{{ queueCount }}</span> <span class="tag is-info is-rounded is-bold ml-2">{{ queueCount }}</span>
</a> </a>
</li> </li>
<li :class="{ 'is-active': activeTab === 'history' }"> <li :class="{ 'is-active': activeTab === 'history' }">
<a @click="setActiveTab('history')"> <a @click="setActiveTab('history')">
<span class="icon is-small"><i class="fas fa-history" aria-hidden="true"></i></span> <span class="icon is-small"><i class="fas fa-history" /></span>
<span>History</span> <span>History</span>
<span class="tag is-primary is-rounded is-bold ml-2">{{ historyCount }}</span> <span class="tag is-primary is-rounded is-bold ml-2">{{ historyCount }}</span>
</a> </a>
@ -204,7 +204,7 @@ const pauseDownload = () => {
dialog_confirm.value.visible = true dialog_confirm.value.visible = true
dialog_confirm.value.html_message = ` dialog_confirm.value.html_message = `
<span class="icon-text"> <span class="icon-text">
<span class="icon"><i class="fa-solid fa-exclamation-triangle"></i></span> <span class="icon"><i class="fa-solid fa-exclamation-triangle"/></span>
<span class="is-bold">Pause All non-active downloads?</span> <span class="is-bold">Pause All non-active downloads?</span>
</span> </span>
<br> <br>

View file

@ -48,14 +48,16 @@ export const useStateStore = defineStore('state', () => {
} }
const remove = (type: StateType, key: KeyType): void => { const remove = (type: StateType, key: KeyType): void => {
if (!state[type][key]) {
return
}
if ('history' === type && state.pagination.total > 0) { if ('history' === type && state.pagination.total > 0) {
state.pagination.total -= 1 state.pagination.total -= 1
} }
if (state[type][key]) { const { [key]: _, ...rest } = state[type]
const { [key]: _, ...rest } = state[type] state[type] = rest
state[type] = rest
}
} }
const get = (type: StateType, key: KeyType, defaultValue: StoreItem | null = null): StoreItem | null => { 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 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) { if ('history' !== type) {
throw new Error('Pagination is only supported for 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 state.pagination.isLoading = true
try { 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 response = await request(`/api/history?${search}`)
const data = await response.json() const data = await response.json()
@ -165,6 +178,69 @@ export const useStateStore = defineStore('state', () => {
const setHistoryCount = (count: number) => state.pagination.total = count 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 { return {
...toRefs(state), ...toRefs(state),
add, add,
@ -182,5 +258,6 @@ export const useStateStore = defineStore('state', () => {
reloadCurrentPage, reloadCurrentPage,
getPagination, getPagination,
setHistoryCount, setHistoryCount,
deleteItems,
} }
}) })