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:
|
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) {
|
||||||
|
|
|
||||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -69,6 +69,7 @@
|
||||||
"euuo",
|
"euuo",
|
||||||
"eventbus",
|
"eventbus",
|
||||||
"excepthook",
|
"excepthook",
|
||||||
|
"exitcode",
|
||||||
"falsey",
|
"falsey",
|
||||||
"faststart",
|
"faststart",
|
||||||
"Fetc",
|
"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)
|
- [`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())
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
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 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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue