Merge pull request #492 from arabcoders/dev
Refactor: change how clear in/completed buttons behave
This commit is contained in:
commit
d13ac83425
14 changed files with 608 additions and 504 deletions
163
.github/workflows/main.yml
vendored
163
.github/workflows/main.yml
vendored
|
|
@ -24,6 +24,8 @@ on:
|
|||
|
||||
env:
|
||||
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
|
||||
GHCR_SLUG: ghcr.io/arabcoders/ytptube
|
||||
PNPM_VERSION: 10
|
||||
|
|
@ -31,8 +33,23 @@ env:
|
|||
PYTHON_VERSION: "3.13"
|
||||
|
||||
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:
|
||||
name: Run Tests & Prepare Frontend
|
||||
needs: [validate-build-config]
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -120,22 +137,115 @@ jobs:
|
|||
path: ui/exported/
|
||||
retention-days: 1
|
||||
|
||||
docker-build-arch:
|
||||
name: Build Container (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs: [test]
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
arch: amd64
|
||||
- os: ubuntu-latest
|
||||
arch: arm64
|
||||
docker-build-amd64:
|
||||
name: Build Container (amd64)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, validate-build-config]
|
||||
if: vars.BUILD_AMD64 != 'false'
|
||||
permissions:
|
||||
packages: write
|
||||
contents: write
|
||||
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:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
|
@ -227,8 +337,11 @@ jobs:
|
|||
|
||||
docker-publish-manifest:
|
||||
name: Publish multi-arch manifest
|
||||
needs: [docker-build-arch]
|
||||
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:
|
||||
packages: write
|
||||
contents: write
|
||||
|
|
@ -275,12 +388,26 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
IFS=$'\n'
|
||||
BUILD_AMD64="${{ env.BUILD_AMD64 }}"
|
||||
BUILD_ARM64="${{ env.BUILD_ARM64 }}"
|
||||
|
||||
for tag in $(echo "${{ steps.meta.outputs.tags }}"); do
|
||||
echo "Creating manifest for ${tag}"
|
||||
docker buildx imagetools create \
|
||||
--tag "$tag" \
|
||||
"${tag}-amd64" \
|
||||
"${tag}-arm64"
|
||||
|
||||
IMAGES=()
|
||||
if [ "$BUILD_AMD64" = "true" ]; then
|
||||
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
|
||||
|
||||
- name: Overwrite GitHub release notes
|
||||
|
|
@ -312,7 +439,7 @@ jobs:
|
|||
const commits = comparison.commits.filter(c => 1 === c.parents.length);
|
||||
|
||||
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');
|
||||
|
||||
if (!changelog) {
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
|
|
@ -417,7 +417,7 @@
|
|||
<span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span>
|
||||
</Message>
|
||||
<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>
|
||||
</Message>
|
||||
</div>
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -285,8 +285,8 @@
|
|||
@close="() => emitter('clear_search')" v-if="query">
|
||||
<span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span>
|
||||
</Message>
|
||||
<Message message_class="is-info" title="No items" icon="fas fa-clock" :useClose="false" :newStyle="true"
|
||||
v-else>
|
||||
<Message message_class="is-info" title="No items" icon="fas fa-exclamation-triangle" :useClose="false"
|
||||
:newStyle="true" v-else>
|
||||
<p>The download queue is empty.</p>
|
||||
</Message>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -69,14 +69,14 @@
|
|||
<ul>
|
||||
<li :class="{ 'is-active': activeTab === 'queue' }">
|
||||
<a @click="setActiveTab('queue')">
|
||||
<span class="icon is-small"><i class="fas fa-clock" aria-hidden="true"></i></span>
|
||||
<span>Queue</span>
|
||||
<span class="icon is-small"><i class="fas fa-download" /></span>
|
||||
<span>Downloads</span>
|
||||
<span class="tag is-info is-rounded is-bold ml-2">{{ queueCount }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li :class="{ 'is-active': activeTab === '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 class="tag is-primary is-rounded is-bold ml-2">{{ historyCount }}</span>
|
||||
</a>
|
||||
|
|
@ -204,7 +204,7 @@ const pauseDownload = () => {
|
|||
dialog_confirm.value.visible = true
|
||||
dialog_confirm.value.html_message = `
|
||||
<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>
|
||||
<br>
|
||||
|
|
|
|||
|
|
@ -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