From 486888aea820a4d195d0684ecb01b789e65526c1 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sat, 15 Nov 2025 22:53:04 +0300 Subject: [PATCH 1/5] Refactor: make it possible to disable ARM builds in private repos --- .github/workflows/main.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f2c08cfd..df0d458b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,6 +24,7 @@ on: env: REGISTRY: ${{ vars.REGISTRY != '' && vars.REGISTRY || '' }} + BUILD_ARM64: ${{ vars.BUILD_ARM64 != 'false' && 'true' || 'false' }} DOCKERHUB_SLUG: arabcoders/ytptube GHCR_SLUG: ghcr.io/arabcoders/ytptube PNPM_VERSION: 10 @@ -131,6 +132,9 @@ jobs: arch: amd64 - os: ubuntu-latest arch: arm64 + enabled: ${{ vars.BUILD_ARM64 != 'false' }} + exclude: + - enabled: false permissions: packages: write contents: write @@ -277,10 +281,16 @@ jobs: IFS=$'\n' for tag in $(echo "${{ steps.meta.outputs.tags }}"); do echo "Creating manifest for ${tag}" - docker buildx imagetools create \ - --tag "$tag" \ - "${tag}-amd64" \ - "${tag}-arm64" + if [ "${{ env.BUILD_ARM64 }}" = "true" ]; then + docker buildx imagetools create \ + --tag "$tag" \ + "${tag}-amd64" \ + "${tag}-arm64" + else + docker buildx imagetools create \ + --tag "$tag" \ + "${tag}-amd64" + fi done - name: Overwrite GitHub release notes From abe5a5f571aaceed62c56dd4963ce9c359f36ef9 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sat, 15 Nov 2025 23:12:34 +0300 Subject: [PATCH 2/5] Fix: build --- .github/workflows/main.yml | 171 +++++++++++++++++++++++++++++++------ 1 file changed, 145 insertions(+), 26 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index df0d458b..2b01a7ae 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -24,6 +24,7 @@ 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 @@ -32,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 @@ -121,25 +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 - enabled: ${{ vars.BUILD_ARM64 != 'false' }} - exclude: - - enabled: false + 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 @@ -231,7 +337,12 @@ jobs: docker-publish-manifest: name: Publish multi-arch manifest - needs: [docker-build-arch] + needs: [docker-build-amd64, docker-build-arm64] + if: | + always() && + !contains(needs.*.result, 'failure') && + !contains(needs.*.result, 'cancelled') && + (contains(needs.*.result, 'success')) runs-on: ubuntu-latest permissions: packages: write @@ -279,18 +390,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}" - if [ "${{ env.BUILD_ARM64 }}" = "true" ]; then - docker buildx imagetools create \ - --tag "$tag" \ - "${tag}-amd64" \ - "${tag}-arm64" - else - docker buildx imagetools create \ - --tag "$tag" \ - "${tag}-amd64" + + IMAGES="" + if [ "$BUILD_AMD64" = "true" ]; then + IMAGES="${IMAGES} ${tag}-amd64" fi + if [ "$BUILD_ARM64" = "true" ]; then + IMAGES="${IMAGES} ${tag}-arm64" + fi + + if [ -z "$IMAGES" ]; 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 @@ -322,7 +441,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) { From 2274af0a2b468a4bc513f17acb61964190ee4e2f Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sat, 15 Nov 2025 23:21:45 +0300 Subject: [PATCH 3/5] Fix: build #2 --- .github/workflows/main.yml | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2b01a7ae..e046b49b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -337,13 +337,11 @@ jobs: docker-publish-manifest: name: Publish multi-arch manifest + runs-on: ubuntu-latest needs: [docker-build-amd64, docker-build-arm64] if: | - always() && - !contains(needs.*.result, 'failure') && - !contains(needs.*.result, 'cancelled') && - (contains(needs.*.result, 'success')) - runs-on: ubuntu-latest + !cancelled() && + (needs.docker-build-amd64.result == 'success' || needs.docker-build-arm64.result == 'success') permissions: packages: write contents: write @@ -396,20 +394,20 @@ jobs: for tag in $(echo "${{ steps.meta.outputs.tags }}"); do echo "Creating manifest for ${tag}" - IMAGES="" + IMAGES=() if [ "$BUILD_AMD64" = "true" ]; then - IMAGES="${IMAGES} ${tag}-amd64" + IMAGES+=("${tag}-amd64") fi if [ "$BUILD_ARM64" = "true" ]; then - IMAGES="${IMAGES} ${tag}-arm64" + IMAGES+=("${tag}-arm64") fi - if [ -z "$IMAGES" ]; then + if [ ${#IMAGES[@]} -eq 0 ]; then echo "::error::No architectures enabled for manifest creation" exit 1 fi - docker buildx imagetools create --tag "$tag" $IMAGES + docker buildx imagetools create --tag "$tag" "${IMAGES[@]}" done - name: Overwrite GitHub release notes From 46fb943243b623e9a1f596aa9e22fd51f1e8be76 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sun, 16 Nov 2025 00:46:02 +0300 Subject: [PATCH 4/5] Fix: change icons and show no items in history. --- ui/app/components/History.vue | 2 +- ui/app/components/Queue.vue | 4 ++-- ui/app/pages/index.vue | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/ui/app/components/History.vue b/ui/app/components/History.vue index 0cc0c0fa..e2b2775d 100644 --- a/ui/app/components/History.vue +++ b/ui/app/components/History.vue @@ -417,7 +417,7 @@ No results found for '{{ query }}'. + v-else-if="socket.isConnected" :new-style="true">

Your download history is empty. Once queued downloads are completed, they will appear here.

diff --git a/ui/app/components/Queue.vue b/ui/app/components/Queue.vue index 63482be0..c376a0eb 100644 --- a/ui/app/components/Queue.vue +++ b/ui/app/components/Queue.vue @@ -285,8 +285,8 @@ @close="() => emitter('clear_search')" v-if="query"> No results found for '{{ query }}'. - +

The download queue is empty.

diff --git a/ui/app/pages/index.vue b/ui/app/pages/index.vue index 237a3266..5490480c 100644 --- a/ui/app/pages/index.vue +++ b/ui/app/pages/index.vue @@ -69,14 +69,14 @@
  • - - Queue + + Downloads {{ queueCount }}
  • - + History {{ historyCount }} @@ -204,7 +204,7 @@ const pauseDownload = () => { dialog_confirm.value.visible = true dialog_confirm.value.html_message = ` - + Pause All non-active downloads?
    From 6d2deedc21b5623ef0b6c59ba850c366fd90d311 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sun, 16 Nov 2025 17:00:48 +0300 Subject: [PATCH 5/5] Refactor: change how clear in/completed buttons behave due to changes in how we load history now. --- .vscode/settings.json | 1 + API.md | 507 +++--------------- app/library/DataStore.py | 30 +- app/library/DownloadQueue.py | 15 +- app/library/Utils.py | 2 +- app/main.py | 1 + .../20251116173731_add_status_index.py | 27 + app/routes/api/history.py | 87 ++- app/tests/test_datastore_pagination.py | 131 +++++ ui/app/components/History.vue | 45 +- ui/app/stores/StateStore.ts | 89 ++- 11 files changed, 456 insertions(+), 479 deletions(-) create mode 100644 app/migrations/20251116173731_add_status_index.py diff --git a/.vscode/settings.json b/.vscode/settings.json index d05959ce..e2d04f93 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -69,6 +69,7 @@ "euuo", "eventbus", "excepthook", + "exitcode", "falsey", "faststart", "Fetc", diff --git a/API.md b/API.md index b71649e4..1ad552a2 100644 --- a/API.md +++ b/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": ["", ""], - "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 { "": "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":{ + "": "status", + "": "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(null) - const isConnected = ref(false) - const queueItems = ref>({}) - const historyItems = ref>({}) - - 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()) ``` --- diff --git a/app/library/DataStore.py b/app/library/DataStore.py index c4bdb581..9fcad9d3 100644 --- a/app/library/DataStore.py +++ b/app/library/DataStore.py @@ -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: diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index 68b02946..fe0fde00 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -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}" diff --git a/app/library/Utils.py b/app/library/Utils.py index d815a2b8..ff8c5487 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -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. diff --git a/app/main.py b/app/main.py index 1a138d0f..121e4484 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/migrations/20251116173731_add_status_index.py b/app/migrations/20251116173731_add_status_index.py new file mode 100644 index 00000000..51b9d7f6 --- /dev/null +++ b/app/migrations/20251116173731_add_status_index.py @@ -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) diff --git a/app/routes/api/history.py b/app/routes/api/history.py index a1920ecf..4a4ef53a 100644 --- a/app/routes/api/history.py +++ b/app/routes/api/history.py @@ -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, ) diff --git a/app/tests/test_datastore_pagination.py b/app/tests/test_datastore_pagination.py index f44c7b89..da38f3f1 100644 --- a/app/tests/test_datastore_pagination.py +++ b/app/tests/test_datastore_pagination.py @@ -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 diff --git a/ui/app/components/History.vue b/ui/app/components/History.vue index e2b2775d..a5141d9e 100644 --- a/ui/app/components/History.vue +++ b/ui/app/components/History.vue @@ -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) => { diff --git a/ui/app/stores/StateStore.ts b/ui/app/stores/StateStore.ts index 06e131c0..27172e49 100644 --- a/ui/app/stores/StateStore.ts +++ b/ui/app/stores/StateStore.ts @@ -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 => { + const loadPaginated = async (type: StateType, page: number = 1, per_page: number = 50, order: 'ASC' | 'DESC' = 'DESC', append: boolean = false, status?: string): Promise => { 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 = { + 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 => { + const { ids, status, removeFile = true } = options + + if (!ids && !status) { + throw new Error('Either ids or status filter must be provided') + } + + try { + const body: Record = { + 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, + 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, } })