Merge pull request #492 from arabcoders/dev

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

View file

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

View file

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

507
API.md
View file

@ -83,11 +83,6 @@ This document describes the available endpoints and their usage. All endpoints r
- [`disconnect` (Built-in)](#disconnect-built-in)
- [`configuration` (Server → Client)](#configuration-server--client)
- [`connected` (Server → Client)](#connected-server--client)
- [Subscription Events](#subscription-events)
- [`subscribe` (Client → Server)](#subscribe-client--server)
- [`unsubscribe` (Client → Server)](#unsubscribe-client--server)
- [`subscribed` (Server → Client)](#subscribed-server--client)
- [`unsubscribed` (Server → Client)](#unsubscribed-server--client)
- [Logging Events](#logging-events)
- [`log_info` (Server → Client)](#log_info-server--client)
- [`log_success` (Server → Client)](#log_success-server--client)
@ -95,20 +90,12 @@ This document describes the available endpoints and their usage. All endpoints r
- [`log_error` (Server → Client)](#log_error-server--client)
- [`log_lines` (Server → Client)](#log_lines-server--client)
- [Download Queue Events](#download-queue-events)
- [`add_url` (Client → Server)](#add_url-client--server)
- [`item_added` (Server → Client)](#item_added-server--client)
- [`item_updated` (Server → Client)](#item_updated-server--client)
- [`item_completed` (Server → Client)](#item_completed-server--client)
- [`item_cancelled` (Server → Client)](#item_cancelled-server--client)
- [`item_deleted` (Server → Client)](#item_deleted-server--client)
- [`item_cancel` (Client → Server)](#item_cancel-client--server)
- [`item_delete` (Client → Server)](#item_delete-client--server)
- [`item_start` (Client → Server)](#item_start-client--server)
- [`item_pause` (Client → Server)](#item_pause-client--server)
- [`item_moved` (Server → Client)](#item_moved-server--client)
- [Queue Control Events](#queue-control-events)
- [`paused` (Server → Client)](#paused-server--client)
- [`resumed` (Server → Client)](#resumed-server--client)
- [Terminal/CLI Events](#terminalcli-events)
- [`cli_post` (Client → Server)](#cli_post-client--server)
- [`cli_output` (Server → Client)](#cli_output-server--client)
@ -116,10 +103,6 @@ This document describes the available endpoints and their usage. All endpoints r
- [Configuration Events](#configuration-events)
- [`presets_update` (Server → Client)](#presets_update-server--client)
- [`dlfields_update` (Server → Client)](#dlfields_update-server--client)
- [Client Implementation Examples](#client-implementation-examples)
- [Vue 3 Composable Pattern](#vue-3-composable-pattern)
- [Python Client Example](#python-client-example)
- [Error Responses](#error-responses)
---
@ -356,18 +339,47 @@ or an error:
---
### DELETE /api/history
**Purpose**: Delete items from either the "queue" or the "done" history.
**Purpose**: Delete items from either the "queue" or the "done" history.
**Body**:
**Body Parameters**:
- `type` (string, required): Type of items - `"queue"` or `"done"`
- `ids` (array, optional): List of specific item IDs to delete. If provided, `status` filter is ignored
- `status` (string, optional): Filter by status (e.g., `"finished"`, `"!finished"`). Required if `ids` not provided
- `remove_file` (boolean, optional): Whether to delete files from disk. Default: `true`.
> [!NOTE]
> `remove_file` is only considered if `YTP_REMOVE_FILES=true` is set. otherwise, files will not be deleted from disk even if `remove_file` is `true`.
**Request Examples**:
**Delete specific items by IDs:**
```json
{
"type": "done",
"ids": ["<id1>", "<id2>"],
"where": "queue" | "done",
"remove_file": true | false // optional, defaults to True, whether to delete the file from disk if enabled.
"remove_file": true
}
```
**Response**:
**Delete all finished items (filter mode):**
```json
{
"type": "done",
"status": "finished",
"remove_file": true
}
```
**Delete all non-finished items (exclusion filter):**
```json
{
"type": "done",
"status": "!finished",
"remove_file": false
}
```
**Response**:
```json
{
"<id1>": "status",
@ -376,7 +388,30 @@ or an error:
}
```
A list or object indicating which items were removed.
**Response (Filter mode with deleted count)**:
```json
{
"items":{
"<id1>": "status",
"<id2>": "status",
...
},
"deleted": 5
}
```
**Error Responses**:
- `400 Bad Request` if parameters are invalid:
```json
{ "error": "type is required." }
{ "error": "either 'ids' or 'status' filter is required." }
{ "error": "type must be 'queue' or 'done'." }
```
**Notes**:
- When using filter mode, all matching items will be deleted.
- Filter mode with `{ "status": "!finished" }` is useful for cleaning up failed/pending downloads.
- Filter mode returns a `deleted` count indicating how many items were removed.
---
@ -455,6 +490,10 @@ or an error:
- `order` (optional): Sort order. Default: `DESC`. Only used when `type != all`
- `DESC`: Newest items first (descending by creation date)
- `ASC`: Oldest items first (ascending by creation date)
- `status` (optional): Filter items by status. Only used when `type != all`
- Use status value to include only items with that status (e.g., `status=finished`)
- Prefix with `!` to exclude items with that status (e.g., `status=!finished`)
- Common status values: `finished`, `downloading`, `pending`, `error`
**Response (when `type=all` or no type set)** - Legacy format:
```json
@ -521,6 +560,24 @@ or an error:
- For large datasets, use paginated requests (`type=queue` or `type=done`) for better performance
- The `items` array contains ItemDTO objects serialized to JSON
**Examples**:
```bash
# Get all finished items from history
GET /api/history?type=done&status=finished
# Get all items except finished ones
GET /api/history?type=done&status=!finished
# Get only downloading items from queue
GET /api/history?type=queue&status=downloading
# Get all items except errors, with pagination
GET /api/history?type=done&status=!error&page=2&per_page=50
# Combine with sorting - oldest pending items first
GET /api/history?type=queue&status=pending&order=ASC
```
---
### DELETE /api/history/{id}/archive
@ -1163,7 +1220,7 @@ Binary image data with the appropriate `Content-Type`.
**Body**:
```json
[
{ "action": "rename", "path": "relative/path/file.ext", "new_name": "newname.ext" },
{ "action": "rename", "path": "relative/path/file.ext", "new_name": "new_name.ext" },
{ "action": "delete", "path": "relative/path/file.ext" },
{ "action": "move", "path": "relative/path/file.ext", "new_path": "new/relative/path" },
{ "action": "directory", "path": "relative/path", "new_dir": "subdirectory" }
@ -1669,6 +1726,10 @@ or an error:
The WebSocket API provides real-time bidirectional communication between the client and server using Socket.IO protocol. It enables live updates for downloads, queue status, notifications, and terminal access.
> ![IMPORTANT]
> The WebSocket API is unstable and many events will be moved to REST endpoints in future releases.
> Please do not rely on the WebSocket API for the time being.
### Connection
**URL**: `ws://localhost:8081/socket.io/` (development) or `https://yourdomain.com/socket.io/` (production)
@ -1743,14 +1804,6 @@ Sends the current application configuration.
- `dl_fields`: Available download fields
- `paused`: Queue pause status (boolean)
**Example**:
```typescript
socket.on('connected', (data: string) => {
const json = JSON.parse(data);
console.log('Current configuration:', json.data.config);
});
```
##### `connected` (Server → Client)
When a client connects, this events sends the folder and current queue.
@ -1758,57 +1811,6 @@ When a client connects, this events sends the folder and current queue.
- `queue`: Current download queue (array of items)
- `folders`: Directory structure for downloads
**Example**:
```typescript
socket.on('connected', (data: string) => {
const json = JSON.parse(data);
const queueItems = json.data.queue || {};
console.log('Connected with', Object.keys(queueItems).length, 'queued downloads');
});
```
#### Subscription Events
##### `subscribe` (Client → Server)
Subscribe to a specific event stream. Only supported events is `log_lines`.
**Data**: Event name as string
```javascript
socket.emit('subscribe', 'log_lines');
```
**Response**: `subscribed` event with confirmation.
##### `unsubscribe` (Client → Server)
Unsubscribe from an event stream.
**Data**: Event name as string
```javascript
socket.emit('unsubscribe', 'log_lines');
```
**Response**: `unsubscribed` event with confirmation.
##### `subscribed` (Server → Client)
Confirmation of successful subscription.
```typescript
socket.on('subscribed', (data: string) => {
const json = JSON.parse(data);
console.log('Subscribed to event:', json.data.event);
});
```
##### `unsubscribed` (Server → Client)
Confirmation of successful unsubscription.
```typescript
socket.on('unsubscribed', (data: string) => {
const json = JSON.parse(data);
console.log('Unsubscribed from event:', json.data.event);
});
```
#### Logging Events
All logging events follow the same structure with JSON-encoded message:
@ -1816,81 +1818,24 @@ All logging events follow the same structure with JSON-encoded message:
##### `log_info` (Server → Client)
General informational message.
```typescript
socket.on('log_info', (stream: string) => {
const json = JSON.parse(stream);
console.info(json.message, json.data);
});
```
##### `log_success` (Server → Client)
Success notification message.
```typescript
socket.on('log_success', (stream: string) => {
const json = JSON.parse(stream);
console.log('✓', json.message);
});
```
##### `log_warning` (Server → Client)
Warning notification message.
```typescript
socket.on('log_warning', (stream: string) => {
const json = JSON.parse(stream);
console.warn('⚠', json.message);
});
```
##### `log_error` (Server → Client)
Error notification message.
```typescript
socket.on('log_error', (stream: string) => {
const json = JSON.parse(stream);
console.error('✗', json.message);
});
```
##### `log_lines` (Server → Client)
Continuous application log lines (requires subscription).
Continuous application log lines (requires subscription event).
**Data Fields**:
- `line`: Log line content
- `timestamp`: Log timestamp
```typescript
socket.on('log_lines', (stream: string) => {
const json = JSON.parse(stream);
console.log('[LOG]', json.data.line);
});
```
### Download Queue Events
#### `add_url` (Client → Server)
Add a new URL to the download queue. Payload is exactly the same as
the POST `/api/queue/` endpoint.
**Data**:
```json
{
"url": "https://youtube.com/watch?v=...",
"preset": "default",
...
}
```
**Responses**: `item_added` or `log_error` event
```typescript
socket.emit('add_url', {
url: 'https://youtube.com/watch?v=dQw4w9WgXcQ',
preset: 'default'
});
```
#### `item_added` (Server → Client)
Emitted when a new item is successfully added to the queue. The response payload is the complete item object.
@ -1954,67 +1899,6 @@ socket.on('item_deleted', (stream: string) => {
});
```
#### `item_cancel` (Client → Server)
Cancel an active download.
**Data**: Item ID (string)
```typescript
socket.emit('item_cancel', 'download-id-here');
```
**Response**: `item_cancelled` event
#### `item_delete` (Client → Server)
Delete an item from history.
**Data**:
```json
{
"id": "item-id",
"remove_file": false
}
```
**Parameters**:
- `id`: Item ID (required)
- `remove_file`: Also delete downloaded file (optional)
```typescript
socket.emit('item_delete', {
id: 'item-id',
remove_file: true // Also delete the file if deleting is enabled server-side.
});
```
**Response**: `item_deleted` event
#### `item_start` (Client → Server)
Start or resume a paused download.
**Data**: Item ID (string) or array of IDs
```typescript
socket.emit('item_start', 'download-id');
// OR
socket.emit('item_start', ['id1', 'id2']);
```
**Response**: Queue status updated via `item_updated`
#### `item_pause` (Client → Server)
Pause an active download.
**Data**: Item ID (string) or array of IDs
```typescript
socket.emit('item_pause', 'download-id');
// OR
socket.emit('item_pause', ['id1', 'id2']);
```
**Response**: Queue status updated via `item_updated`
#### `item_moved` (Server → Client)
Emitted when an item moves between queue and history.
@ -2029,50 +1913,15 @@ socket.on('item_moved', (stream: string) => {
});
```
### Queue Control Events
#### `paused` (Server → Client)
Emitted when the entire download queue is paused.
**Data Fields**:
- `paused`: Boolean true
```typescript
socket.on('paused', (stream: string) => {
const json = JSON.parse(stream);
console.log('Queue paused');
});
```
#### `resumed` (Server → Client)
Emitted when the download queue is resumed.
**Data Fields**:
- `paused`: Boolean false
```typescript
socket.on('resumed', (stream: string) => {
const json = JSON.parse(stream);
console.log('Queue resumed');
});
```
### Terminal/CLI Events
The terminal feature requires `console_enabled: true` in configuration.
The terminal feature requires `YTP_CONSOLE_ENABLED=true`.
#### `cli_post` (Client → Server)
Execute a command via yt-dlp CLI.
**Data**: Command arguments as string (without `yt-dlp` prefix)
```typescript
socket.emit('cli_post', '--help');
socket.emit('cli_post', 'https://youtube.com/watch?v=... --info-json');
```
**Responses**: `cli_output` events followed by `cli_close`
#### `cli_output` (Server → Client)
Output line from the CLI command execution.
@ -2080,34 +1929,12 @@ Output line from the CLI command execution.
- `type`: Output type (`stdout` or `stderr`)
- `line`: Output line content
```typescript
socket.on('cli_output', (stream: string) => {
const json = JSON.parse(stream);
if (json.data.type === 'stderr') {
console.error(json.data.line);
} else {
console.log(json.data.line);
}
});
```
#### `cli_close` (Server → Client)
Emitted when CLI command execution completes.
**Data Fields**:
- `exitcode`: Command exit code (0 = success, non-zero = error)
```typescript
socket.on('cli_close', (stream: string) => {
const json = JSON.parse(stream);
if (json.data.exitcode === 0) {
console.log('Command completed successfully');
} else {
console.error(`Command failed with exit code ${json.data.exitcode}`);
}
});
```
### Configuration Events
#### `presets_update` (Server → Client)
@ -2115,170 +1942,10 @@ Emitted when download presets are updated or created.
**Data**: Array of preset objects
```typescript
socket.on('presets_update', (stream: string) => {
const json = JSON.parse(stream);
const presets = json.data || [];
console.log('Presets updated:', presets);
});
```
#### `dlfields_update` (Server → Client)
Emitted when download fields configuration is updated.
**Data**: Array of field objects
```typescript
socket.on('dlfields_update', (stream: string) => {
const json = JSON.parse(stream);
const fields = json.data || [];
console.log('Download fields updated:', fields);
});
```
### Client Implementation Examples
#### Vue 3 Composable Pattern
```typescript
// composables/useSocket.ts
import { ref, computed } from 'vue'
import { io, type Socket } from 'socket.io-client'
export function useSocket() {
const socket = ref<Socket | null>(null)
const isConnected = ref(false)
const queueItems = ref<Record<string, any>>({})
const historyItems = ref<Record<string, any>>({})
const connect = () => {
socket.value = io('/', {
transports: ['websocket', 'polling'],
withCredentials: true,
reconnection: true,
reconnectionAttempts: 30,
reconnectionDelay: 5000
})
socket.value.on('connect', () => {
isConnected.value = true
console.log('Connected to server')
})
socket.value.on('connected', (stream: string) => {
const json = JSON.parse(stream)
queueItems.value = json.data.queue || {}
historyItems.value = json.data.done || {}
})
socket.value.on('item_added', (stream: string) => {
const json = JSON.parse(stream)
queueItems.value[json.data._id] = json.data
})
socket.value.on('item_updated', (stream: string) => {
const json = JSON.parse(stream)
const item = json.data
if (queueItems.value[item._id]) {
queueItems.value[item._id] = item
}
})
socket.value.on('item_completed', (stream: string) => {
const json = JSON.parse(stream)
const item = json.data
delete queueItems.value[item._id]
historyItems.value[item._id] = item
})
socket.value.on('disconnect', () => {
isConnected.value = false
console.log('Disconnected from server')
})
}
const addDownload = (url: string, preset: string = 'default') => {
socket.value?.emit('add_url', { url, preset })
}
const cancelDownload = (id: string) => {
socket.value?.emit('item_cancel', id)
}
return {
socket,
isConnected,
queueItems,
historyItems,
connect,
addDownload,
cancelDownload
}
}
```
#### Python Client Example
```python
# WebSocket client for YTPTube
import json
import asyncio
import socketio
class YTPTubeClient:
def __init__(self, url: str = 'http://localhost:8081'):
self.sio = socketio.AsyncClient(
transports=['websocket', 'polling'],
reconnection_attempts=30,
reconnection_delay=5
)
self.url = url
self.queue = {}
self.history = {}
async def connect(self):
await self.sio.connect(self.url)
await self.sio.wait()
async def setup_listeners(self):
@self.sio.on('connected')
async def on_connected(data: str):
json_data = json.loads(data)
self.queue = json_data['data'].get('queue', {})
self.history = json_data['data'].get('done', {})
print('Connected to YTPTube')
@self.sio.on('item_added')
async def on_item_added(data: str):
json_data = json.loads(data)
item = json_data['data']
self.queue[item['_id']] = item
print(f"Added: {item['title']}")
@self.sio.on('item_completed')
async def on_item_completed(data: str):
json_data = json.loads(data)
item = json_data['data']
self.queue.pop(item['_id'], None)
self.history[item['_id']] = item
print(f"Completed: {item['title']}")
async def add_download(self, url: str, preset: str = 'default'):
await self.sio.emit('add_url', {'url': url, 'preset': preset})
async def cancel_download(self, item_id: str):
await self.sio.emit('item_cancel', item_id)
# Usage
async def main():
client = YTPTubeClient('http://localhost:8081')
await client.setup_listeners()
await client.connect()
await client.add_download('https://youtube.com/watch?v=...')
if __name__ == '__main__':
asyncio.run(main())
```
---

View file

@ -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:

View file

@ -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}"

View file

@ -252,7 +252,7 @@ def timed_lru_cache(ttl_seconds: int, max_size: int = 128):
def calc_download_path(base_path: str | Path, folder: str | None = None, create_path: bool = True) -> str:
"""
Calculates download path and prevents folder traversal.
Calculates download path.
Args:
base_path (str): Base download path.

View file

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

View file

@ -0,0 +1,27 @@
"""
This module contains a Caribou migration.
Migration Name: add_status_index
Migration Version: 20251116173731
"""
def upgrade(connection):
"""
Add index on json_extract(data, '$.status') for better query performance.
This index improves performance when filtering items by status field,
which is stored in the JSON data column.
"""
sql = """
CREATE INDEX IF NOT EXISTS "history_status" ON "history" (json_extract("data", '$.status'));
"""
connection.execute(sql)
def downgrade(connection):
"""
Remove the status index.
"""
sql = """
DROP INDEX IF EXISTS "history_status";
"""
connection.execute(sql)

View file

@ -6,6 +6,7 @@ from aiohttp import web
from aiohttp.web import Request, Response
from 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,
)

View file

@ -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

View file

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

View file

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

View file

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

View file

@ -48,14 +48,16 @@ export const useStateStore = defineStore('state', () => {
}
const remove = (type: StateType, key: KeyType): void => {
if (!state[type][key]) {
return
}
if ('history' === type && state.pagination.total > 0) {
state.pagination.total -= 1
}
if (state[type][key]) {
const { [key]: _, ...rest } = state[type]
state[type] = rest
}
const { [key]: _, ...rest } = state[type]
state[type] = rest
}
const get = (type: StateType, key: KeyType, defaultValue: StoreItem | null = null): StoreItem | null => {
@ -98,7 +100,7 @@ export const useStateStore = defineStore('state', () => {
return Object.keys(state[type]).length
}
const loadPaginated = async (type: StateType, page: number = 1, per_page: number = 50, order: 'ASC' | 'DESC' = 'DESC', append: boolean = false): Promise<void> => {
const loadPaginated = async (type: StateType, page: number = 1, per_page: number = 50, order: 'ASC' | 'DESC' = 'DESC', append: boolean = false, status?: string): Promise<void> => {
if ('history' !== type) {
throw new Error('Pagination is only supported for history type');
}
@ -106,7 +108,18 @@ export const useStateStore = defineStore('state', () => {
state.pagination.isLoading = true
try {
const search = new URLSearchParams({ type: 'done', page: page.toString(), per_page: per_page.toString(), order });
const params: Record<string, string> = {
type: 'done',
page: page.toString(),
per_page: per_page.toString(),
order
}
if (status) {
params.status = status
}
const search = new URLSearchParams(params)
const response = await request(`/api/history?${search}`)
const data = await response.json()
@ -165,6 +178,69 @@ export const useStateStore = defineStore('state', () => {
const setHistoryCount = (count: number) => state.pagination.total = count
/**
* Delete items by specific IDs or status filter.
*
* @param type - The store type ('queue' or 'history')
* @param options - Delete options
* @param options.ids - Array of item IDs to delete (if provided, status is ignored)
* @param options.status - Status filter (e.g., 'finished' or '!finished')
* @param options.removeFile - Whether to remove files from disk (default: true)
*
* @returns Number of items deleted
*/
const deleteItems = async (
type: StateType,
options: { ids?: string[]; status?: string; removeFile?: boolean } = {}
): Promise<number> => {
const { ids, status, removeFile = true } = options
if (!ids && !status) {
throw new Error('Either ids or status filter must be provided')
}
try {
const body: Record<string, unknown> = {
type: type === 'queue' ? 'queue' : 'done',
remove_file: removeFile
}
if (ids) {
body.ids = ids
}
if (status) {
body.status = status
}
const response = await request('/api/history', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const result = await response.json() as {
items: Record<string, string>,
deleted: number,
error?: string,
message?: string,
}
if (result.error || result.message || !response.ok) {
throw new Error(result.error || result.message || 'Failed to delete items.')
}
for (const id of Object.keys(result.items)) {
remove(type, id)
}
return result.deleted
} catch (error) {
console.error(`Failed to delete items:`, error)
throw error
}
}
return {
...toRefs(state),
add,
@ -182,5 +258,6 @@ export const useStateStore = defineStore('state', () => {
reloadCurrentPage,
getPagination,
setHistoryCount,
deleteItems,
}
})