Merge pull request #488 from arabcoders/dev
Add app manifest to allow the app to be installed
This commit is contained in:
commit
59fe171a9d
28 changed files with 1343 additions and 343 deletions
95
API.md
95
API.md
|
|
@ -81,6 +81,7 @@ This document describes the available endpoints and their usage. All endpoints r
|
||||||
- [Connection Events](#connection-events)
|
- [Connection Events](#connection-events)
|
||||||
- [`connect` (Built-in)](#connect-built-in)
|
- [`connect` (Built-in)](#connect-built-in)
|
||||||
- [`disconnect` (Built-in)](#disconnect-built-in)
|
- [`disconnect` (Built-in)](#disconnect-built-in)
|
||||||
|
- [`configuration` (Server → Client)](#configuration-server--client)
|
||||||
- [`connected` (Server → Client)](#connected-server--client)
|
- [`connected` (Server → Client)](#connected-server--client)
|
||||||
- [Subscription Events](#subscription-events)
|
- [Subscription Events](#subscription-events)
|
||||||
- [`subscribe` (Client → Server)](#subscribe-client--server)
|
- [`subscribe` (Client → Server)](#subscribe-client--server)
|
||||||
|
|
@ -442,22 +443,84 @@ or an error:
|
||||||
---
|
---
|
||||||
|
|
||||||
### GET /api/history
|
### GET /api/history
|
||||||
**Purpose**: Returns the download queue and the download history.
|
**Purpose**: Returns the download queue and/or download history with optional pagination support.
|
||||||
|
|
||||||
**Response**:
|
**Query Parameters**:
|
||||||
|
- `type` (optional): Type of items to return. Default: `all`
|
||||||
|
- `all` (default): Returns both queue and history (legacy behavior, no pagination)
|
||||||
|
- `queue`: Returns only queue items (with pagination)
|
||||||
|
- `done`: Returns only history items (with pagination)
|
||||||
|
- `page` (optional): Page number (1-indexed). Default: `1`. Only used when `type != all`
|
||||||
|
- `per_page` (optional): Items per page. Default: `config.default_pagination`, Max: `200`. Only used when `type != all`
|
||||||
|
- `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)
|
||||||
|
|
||||||
|
**Response (when `type=all` or no type set)** - Legacy format:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"queue": [
|
"queue": [
|
||||||
{ ... },
|
{
|
||||||
|
"id": "abc123",
|
||||||
|
"url": "https://example.com/video",
|
||||||
|
"title": "Video Title",
|
||||||
|
"status": "downloading",
|
||||||
|
...
|
||||||
|
},
|
||||||
...
|
...
|
||||||
],
|
],
|
||||||
"history": [
|
"history": [
|
||||||
{ ... },
|
{
|
||||||
|
"id": "def456",
|
||||||
|
"url": "https://example.com/video2",
|
||||||
|
"title": "Completed Video",
|
||||||
|
"status": "finished",
|
||||||
|
...
|
||||||
|
},
|
||||||
...
|
...
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Response (when `type=queue` or `type=done`)** - Paginated format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 50,
|
||||||
|
"total": 1234,
|
||||||
|
"total_pages": 25,
|
||||||
|
"has_next": true,
|
||||||
|
"has_prev": false
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "abc123",
|
||||||
|
"url": "https://example.com/video",
|
||||||
|
"title": "Video Title",
|
||||||
|
"status": "finished",
|
||||||
|
...
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses**:
|
||||||
|
- `400 Bad Request` if parameters are invalid:
|
||||||
|
```json
|
||||||
|
{ "error": "type must be one of all, queue, done." }
|
||||||
|
{ "error": "page must be >= 1." }
|
||||||
|
{ "error": "per_page must be between 1 and 1000." }
|
||||||
|
{ "error": "order must be ASC or DESC." }
|
||||||
|
{ "error": "page and per_page must be valid integers." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
- The `type=all` behavior is considered legacy and will be removed in future versions
|
||||||
|
- For large datasets, use paginated requests (`type=queue` or `type=done`) for better performance
|
||||||
|
- The `items` array contains ItemDTO objects serialized to JSON
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### DELETE /api/history/{id}/archive
|
### DELETE /api/history/{id}/archive
|
||||||
|
|
@ -1671,25 +1734,35 @@ Fired when WebSocket connection is closed. No data payload.
|
||||||
socket.on('disconnect', (reason: string) => console.log('WebSocket disconnected:', reason));
|
socket.on('disconnect', (reason: string) => console.log('WebSocket disconnected:', reason));
|
||||||
```
|
```
|
||||||
|
|
||||||
##### `connected` (Server → Client)
|
##### `configuration` (Server → Client)
|
||||||
Initial connection event with full application state.
|
Sends the current application configuration.
|
||||||
|
|
||||||
**Data Fields**:
|
**Data Fields**:
|
||||||
- `config`: Global configuration object
|
- `config`: Global configuration object
|
||||||
- `queue`: Current download queue (array of items)
|
|
||||||
- `done`: Download history (array of completed items)
|
|
||||||
- `tasks`: Scheduled tasks
|
|
||||||
- `presets`: Available download presets
|
- `presets`: Available download presets
|
||||||
- `dl_fields`: Available download fields
|
- `dl_fields`: Available download fields
|
||||||
- `folders`: Directory structure for downloads
|
|
||||||
- `paused`: Queue pause status (boolean)
|
- `paused`: Queue pause status (boolean)
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
socket.on('connected', (data: string) => {
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
console.log('Current configuration:', json.data.config);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
##### `connected` (Server → Client)
|
||||||
|
When a client connects, this events sends the folder and current queue.
|
||||||
|
|
||||||
|
**Data Fields**:
|
||||||
|
- `queue`: Current download queue (array of items)
|
||||||
|
- `folders`: Directory structure for downloads
|
||||||
|
|
||||||
**Example**:
|
**Example**:
|
||||||
```typescript
|
```typescript
|
||||||
socket.on('connected', (data: string) => {
|
socket.on('connected', (data: string) => {
|
||||||
const json = JSON.parse(data);
|
const json = JSON.parse(data);
|
||||||
const queueItems = json.data.queue || {};
|
const queueItems = json.data.queue || {};
|
||||||
const historyItems = json.data.done || {};
|
|
||||||
console.log('Connected with', Object.keys(queueItems).length, 'queued downloads');
|
console.log('Connected with', Object.keys(queueItems).length, 'queued downloads');
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
|
||||||
1
FAQ.md
1
FAQ.md
|
|
@ -48,6 +48,7 @@ or the `environment:` section in `compose.yaml` file.
|
||||||
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
|
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
|
||||||
| YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` |
|
| YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` |
|
||||||
| YTP_AUTO_CLEAR_HISTORY_DAYS | Number of days after which completed download history is cleared. | `0` |
|
| YTP_AUTO_CLEAR_HISTORY_DAYS | Number of days after which completed download history is cleared. | `0` |
|
||||||
|
| YTP_DEFAULT_PAGINATION | The default number of items per page for history. | `50` |
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`.
|
> To raise the maximum workers for specific extractor, you need to add a ENV variable that follows the pattern `YTP_MAX_WORKERS_FOR_<EXTRACTOR_NAME>`.
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,88 @@ class DataStore:
|
||||||
self._connection.execute('SELECT "id" FROM "history" LIMIT 1').fetchone()
|
self._connection.execute('SELECT "id" FROM "history" LIMIT 1').fetchone()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def get_total_count(self) -> int:
|
||||||
|
"""
|
||||||
|
Get the total count of items in the datastore.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: The total number of items in the datastore.
|
||||||
|
|
||||||
|
"""
|
||||||
|
cursor = self._connection.execute(
|
||||||
|
'SELECT COUNT(*) as count FROM "history" WHERE "type" = ?',
|
||||||
|
(str(self._type),),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row["count"] if row else 0
|
||||||
|
|
||||||
|
def get_items_paginated(
|
||||||
|
self,
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 50,
|
||||||
|
order: str = "DESC",
|
||||||
|
) -> tuple[list[tuple[str, ItemDTO]], int, int, int]:
|
||||||
|
"""
|
||||||
|
Get paginated items from the datastore.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
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).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[list[tuple[str, ItemDTO]], int, int, int]: A tuple containing:
|
||||||
|
- List of (id, ItemDTO) tuples for the requested page
|
||||||
|
- Total number of items
|
||||||
|
- Current page number
|
||||||
|
- Total number of pages
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If page < 1 or per_page < 1
|
||||||
|
|
||||||
|
"""
|
||||||
|
if page < 1:
|
||||||
|
msg = "page must be >= 1"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
if per_page < 1:
|
||||||
|
msg = "per_page must be >= 1"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
order = order.upper()
|
||||||
|
if order not in ("ASC", "DESC"):
|
||||||
|
msg = f"order must be 'ASC' or 'DESC', got '{order}'"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
order = "ASC" if order == "ASC" else "DESC"
|
||||||
|
|
||||||
|
total_items = self.get_total_count()
|
||||||
|
total_pages = (total_items + per_page - 1) // per_page if total_items > 0 else 1
|
||||||
|
|
||||||
|
# Ensure page is within valid range.
|
||||||
|
if page > total_pages and total_items > 0:
|
||||||
|
page = total_pages
|
||||||
|
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
items: list[tuple[str, ItemDTO]] = []
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in cursor:
|
||||||
|
rowDate: datetime = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
|
||||||
|
data: dict = json.loads(row["data"])
|
||||||
|
data.pop("_id", None)
|
||||||
|
item: ItemDTO = init_class(ItemDTO, data)
|
||||||
|
item._id = row["id"]
|
||||||
|
item.datetime = formatdate(rowDate.replace(tzinfo=UTC).timestamp())
|
||||||
|
items.append((row["id"], item))
|
||||||
|
|
||||||
|
return items, total_items, page, total_pages
|
||||||
|
|
||||||
def _update_store_item(self, type: StoreType, item: ItemDTO) -> None:
|
def _update_store_item(self, type: StoreType, item: ItemDTO) -> None:
|
||||||
sqlStatement = """
|
sqlStatement = """
|
||||||
INSERT INTO "history" ("id", "type", "url", "data")
|
INSERT INTO "history" ("id", "type", "url", "data")
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,9 @@ class Config(metaclass=Singleton):
|
||||||
auto_clear_history_days: int = 0
|
auto_clear_history_days: int = 0
|
||||||
"""Number of days after which completed download history is automatically cleared. 0 to disable."""
|
"""Number of days after which completed download history is automatically cleared. 0 to disable."""
|
||||||
|
|
||||||
|
default_pagination: int = 50
|
||||||
|
"""The default number of items per page for pagination."""
|
||||||
|
|
||||||
pictures_backends: list[str] = [
|
pictures_backends: list[str] = [
|
||||||
"https://unsplash.it/1920/1080?random",
|
"https://unsplash.it/1920/1080?random",
|
||||||
"https://picsum.photos/1920/1080",
|
"https://picsum.photos/1920/1080",
|
||||||
|
|
@ -234,6 +237,7 @@ class Config(metaclass=Singleton):
|
||||||
"download_path_depth",
|
"download_path_depth",
|
||||||
"download_info_expires",
|
"download_info_expires",
|
||||||
"auto_clear_history_days",
|
"auto_clear_history_days",
|
||||||
|
"default_pagination",
|
||||||
)
|
)
|
||||||
"The variables that are integers."
|
"The variables that are integers."
|
||||||
|
|
||||||
|
|
@ -281,6 +285,7 @@ class Config(metaclass=Singleton):
|
||||||
"app_commit_sha",
|
"app_commit_sha",
|
||||||
"app_build_date",
|
"app_build_date",
|
||||||
"app_branch",
|
"app_branch",
|
||||||
|
"default_pagination",
|
||||||
)
|
)
|
||||||
"The variables that are relevant to the frontend."
|
"The variables that are relevant to the frontend."
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ EXT_TO_MIME: dict = {
|
||||||
".js": "application/javascript",
|
".js": "application/javascript",
|
||||||
".json": "application/json",
|
".json": "application/json",
|
||||||
".ico": "image/x-icon",
|
".ico": "image/x-icon",
|
||||||
|
".webmanifest": "application/manifest+json",
|
||||||
}
|
}
|
||||||
|
|
||||||
FRONTEND_ROUTES: list[str] = [
|
FRONTEND_ROUTES: list[str] = [
|
||||||
|
|
@ -35,7 +36,6 @@ FRONTEND_ROUTES: list[str] = [
|
||||||
"/browser/{path:.*}",
|
"/browser/{path:.*}",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def serve_static_file(request: Request, config: Config) -> Response:
|
async def serve_static_file(request: Request, config: Config) -> Response:
|
||||||
"""
|
"""
|
||||||
Preload static files from the ui/exported folder.
|
Preload static files from the ui/exported folder.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from aiohttp.web import Request, Response
|
from aiohttp.web import Request, Response
|
||||||
|
|
||||||
|
from app.library.config import Config
|
||||||
from app.library.Download import Download
|
from app.library.Download import Download
|
||||||
from app.library.DownloadQueue import DownloadQueue
|
from app.library.DownloadQueue import DownloadQueue
|
||||||
from app.library.encoder import Encoder
|
from app.library.encoder import Encoder
|
||||||
|
|
@ -20,23 +21,85 @@ LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@route("GET", r"api/history/", "items_list")
|
@route("GET", r"api/history/", "items_list")
|
||||||
async def items_list(queue: DownloadQueue, encoder: Encoder) -> Response:
|
async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder, config: Config) -> Response:
|
||||||
"""
|
"""
|
||||||
Get the history.
|
Get the history with optional pagination support.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
request (Request): The request object.
|
||||||
queue (DownloadQueue): The download queue instance.
|
queue (DownloadQueue): The download queue instance.
|
||||||
encoder (Encoder): The encoder instance.
|
encoder (Encoder): The encoder instance.
|
||||||
|
config (Config): The configuration instance.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response: The response object.
|
Response: The response object.
|
||||||
|
|
||||||
"""
|
Query Parameters:
|
||||||
data: dict = {"queue": [], "history": []}
|
type (str): Type of items to return - "all", "queue", or "done". Default: "all"
|
||||||
q = queue.get()
|
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"
|
||||||
|
|
||||||
data["queue"].extend([q.get("queue", {}).get(k) for k in q.get("queue", {})])
|
"""
|
||||||
data["history"].extend([q.get("done", {}).get(k) for k in q.get("done", {})])
|
from app.library.DataStore import StoreType
|
||||||
|
|
||||||
|
store_type = request.query.get("type", "all").lower()
|
||||||
|
stores: list[str] = ["all", StoreType.QUEUE.value, StoreType.HISTORY.value]
|
||||||
|
if store_type not in stores:
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": f"type must be one of {', '.join(stores)}."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy behavior: return all items without pagination, will be removed in future.
|
||||||
|
if "all" == store_type:
|
||||||
|
data: dict = {"queue": [], "history": []}
|
||||||
|
q = queue.get()
|
||||||
|
|
||||||
|
data["queue"].extend([q.get("queue", {}).get(k) for k in q.get("queue", {})])
|
||||||
|
data["history"].extend([q.get("done", {}).get(k) for k in q.get("done", {})])
|
||||||
|
|
||||||
|
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
|
|
||||||
|
ds = queue.queue if store_type == StoreType.QUEUE.value else queue.done
|
||||||
|
|
||||||
|
try:
|
||||||
|
page = int(request.query.get("page", 1))
|
||||||
|
per_page = int(request.query.get("per_page", config.default_pagination))
|
||||||
|
order = request.query.get("order", "DESC").upper()
|
||||||
|
except ValueError:
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": "page and per_page must be valid integers."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
if page < 1:
|
||||||
|
return web.json_response(data={"error": "page must be >= 1."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
if per_page < 1 or per_page > 1000:
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": "per_page must be between 1 and 1000."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
if order not in ("ASC", "DESC"):
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": "order must be ASC or DESC."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
items, total, current_page, total_pages = ds.get_items_paginated(page=page, per_page=per_page, order=order)
|
||||||
|
data = {
|
||||||
|
"pagination": {
|
||||||
|
"page": current_page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"total": total,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"has_next": current_page < total_pages,
|
||||||
|
"has_prev": current_page > 1,
|
||||||
|
},
|
||||||
|
"items": [item for _, item in items],
|
||||||
|
}
|
||||||
|
|
||||||
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s
|
||||||
base=Path(config.download_path),
|
base=Path(config.download_path),
|
||||||
depth_limit=config.download_path_depth - 1,
|
depth_limit=config.download_path_depth - 1,
|
||||||
),
|
),
|
||||||
**queue.get(),
|
"history_count": queue.done.get_total_count(),
|
||||||
|
**queue.get()["queue"],
|
||||||
},
|
},
|
||||||
title="Sending initial download data",
|
title="Sending initial download data",
|
||||||
message=f"Sending initial download data to client '{sid}'.",
|
message=f"Sending initial download data to client '{sid}'.",
|
||||||
|
|
|
||||||
252
app/tests/test_datastore_pagination.py
Normal file
252
app/tests/test_datastore_pagination.py
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.library.DataStore import DataStore, StoreType
|
||||||
|
from app.library.ItemDTO import ItemDTO
|
||||||
|
|
||||||
|
|
||||||
|
class TestDataStorePagination:
|
||||||
|
"""Test pagination functionality of DataStore."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_db(self):
|
||||||
|
"""Create a temporary database with test data."""
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "test.db"
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# Create the history table
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "history" (
|
||||||
|
"id" TEXT PRIMARY KEY,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"data" TEXT NOT NULL,
|
||||||
|
"created_at" TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert test data
|
||||||
|
base_time = datetime.now(UTC)
|
||||||
|
for i in range(100):
|
||||||
|
created_at = base_time.replace(
|
||||||
|
hour=(i // 4) % 24, minute=(i * 15) % 60, second=i % 60
|
||||||
|
).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
item_data = {
|
||||||
|
"url": f"https://example.com/video{i}",
|
||||||
|
"title": f"Test Video {i}",
|
||||||
|
"id": f"video{i}",
|
||||||
|
"folder": "/downloads",
|
||||||
|
"status": "finished",
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
|
||||||
|
(
|
||||||
|
f"test-id-{i}",
|
||||||
|
str(StoreType.HISTORY),
|
||||||
|
item_data["url"],
|
||||||
|
json.dumps(item_data),
|
||||||
|
created_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
yield conn
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_get_total_count(self, temp_db):
|
||||||
|
"""Test getting total count of items."""
|
||||||
|
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
|
||||||
|
count = datastore.get_total_count()
|
||||||
|
assert count == 100
|
||||||
|
|
||||||
|
def test_pagination_basic(self, temp_db):
|
||||||
|
"""Test basic pagination functionality."""
|
||||||
|
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
|
||||||
|
|
||||||
|
# Get first page
|
||||||
|
items, total, page, total_pages = datastore.get_items_paginated(page=1, per_page=10)
|
||||||
|
|
||||||
|
assert len(items) == 10
|
||||||
|
assert total == 100
|
||||||
|
assert page == 1
|
||||||
|
assert total_pages == 10
|
||||||
|
|
||||||
|
# Verify items are ItemDTO instances
|
||||||
|
for _item_id, item in items:
|
||||||
|
assert isinstance(item, ItemDTO)
|
||||||
|
assert item._id.startswith("test-id-")
|
||||||
|
|
||||||
|
def test_pagination_last_page(self, temp_db):
|
||||||
|
"""Test pagination on last page."""
|
||||||
|
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
|
||||||
|
|
||||||
|
# Get last page
|
||||||
|
items, total, page, total_pages = datastore.get_items_paginated(page=10, per_page=10)
|
||||||
|
|
||||||
|
assert len(items) == 10
|
||||||
|
assert total == 100
|
||||||
|
assert page == 10
|
||||||
|
assert total_pages == 10
|
||||||
|
|
||||||
|
def test_pagination_partial_page(self, temp_db):
|
||||||
|
"""Test pagination with partial last page."""
|
||||||
|
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
|
||||||
|
|
||||||
|
# Get items with per_page that doesn't divide evenly
|
||||||
|
items, total, page, total_pages = datastore.get_items_paginated(page=4, per_page=30)
|
||||||
|
|
||||||
|
assert len(items) == 10 # 100 items / 30 per page = 3 full pages + 10 items
|
||||||
|
assert total == 100
|
||||||
|
assert page == 4
|
||||||
|
assert total_pages == 4
|
||||||
|
|
||||||
|
def test_pagination_out_of_range(self, temp_db):
|
||||||
|
"""Test pagination with page number out of range."""
|
||||||
|
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
|
||||||
|
|
||||||
|
# Request page beyond total pages - should return last page
|
||||||
|
items, total, page, total_pages = datastore.get_items_paginated(page=999, per_page=10)
|
||||||
|
|
||||||
|
assert len(items) == 10
|
||||||
|
assert total == 100
|
||||||
|
assert page == 10 # Adjusted to last page
|
||||||
|
assert total_pages == 10
|
||||||
|
|
||||||
|
def test_pagination_order_desc(self, temp_db):
|
||||||
|
"""Test pagination with descending order (newest first)."""
|
||||||
|
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
|
||||||
|
|
||||||
|
items, _, _, _ = datastore.get_items_paginated(page=1, per_page=5, order="DESC")
|
||||||
|
|
||||||
|
assert len(items) == 5
|
||||||
|
# Verify order - should be newest to oldest
|
||||||
|
# (note: exact order depends on the timestamp generation in fixture)
|
||||||
|
|
||||||
|
def test_pagination_order_asc(self, temp_db):
|
||||||
|
"""Test pagination with ascending order (oldest first)."""
|
||||||
|
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
|
||||||
|
|
||||||
|
items, _, _, _ = datastore.get_items_paginated(page=1, per_page=5, order="ASC")
|
||||||
|
|
||||||
|
assert len(items) == 5
|
||||||
|
|
||||||
|
def test_pagination_invalid_page(self, temp_db):
|
||||||
|
"""Test pagination with invalid page number."""
|
||||||
|
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="page must be >= 1"):
|
||||||
|
datastore.get_items_paginated(page=0, per_page=10)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="page must be >= 1"):
|
||||||
|
datastore.get_items_paginated(page=-1, per_page=10)
|
||||||
|
|
||||||
|
def test_pagination_invalid_per_page(self, temp_db):
|
||||||
|
"""Test pagination with invalid per_page value."""
|
||||||
|
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="per_page must be >= 1"):
|
||||||
|
datastore.get_items_paginated(page=1, per_page=0)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="per_page must be >= 1"):
|
||||||
|
datastore.get_items_paginated(page=1, per_page=-10)
|
||||||
|
|
||||||
|
def test_pagination_invalid_order(self, temp_db):
|
||||||
|
"""Test pagination with invalid order parameter."""
|
||||||
|
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="order must be 'ASC' or 'DESC'"):
|
||||||
|
datastore.get_items_paginated(page=1, per_page=10, order="INVALID")
|
||||||
|
|
||||||
|
def test_pagination_empty_store(self):
|
||||||
|
"""Test pagination with empty datastore."""
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "empty.db"
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# Create empty table
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "history" (
|
||||||
|
"id" TEXT PRIMARY KEY,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"data" TEXT NOT NULL,
|
||||||
|
"created_at" TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
datastore = DataStore(type=StoreType.HISTORY, connection=conn)
|
||||||
|
|
||||||
|
items, total, page, total_pages = datastore.get_items_paginated(page=1, per_page=10)
|
||||||
|
|
||||||
|
assert len(items) == 0
|
||||||
|
assert total == 0
|
||||||
|
assert page == 1
|
||||||
|
assert total_pages == 1
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_pagination_single_item(self):
|
||||||
|
"""Test pagination with single item."""
|
||||||
|
with TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "single.db"
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# Create table with single item
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "history" (
|
||||||
|
"id" TEXT PRIMARY KEY,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"data" TEXT NOT NULL,
|
||||||
|
"created_at" TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
item_data = {
|
||||||
|
"url": "https://example.com/single",
|
||||||
|
"title": "Single Video",
|
||||||
|
"id": "single-video",
|
||||||
|
"folder": "/downloads",
|
||||||
|
"status": "finished",
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
|
||||||
|
(
|
||||||
|
"single-id",
|
||||||
|
str(StoreType.HISTORY),
|
||||||
|
item_data["url"],
|
||||||
|
json.dumps(item_data),
|
||||||
|
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
datastore = DataStore(type=StoreType.HISTORY, connection=conn)
|
||||||
|
|
||||||
|
items, total, page, total_pages = datastore.get_items_paginated(page=1, per_page=10)
|
||||||
|
|
||||||
|
assert len(items) == 1
|
||||||
|
assert total == 1
|
||||||
|
assert page == 1
|
||||||
|
assert total_pages == 1
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
@ -2,19 +2,26 @@
|
||||||
<vTooltip @show="loadContent" @hide="stopTimer">
|
<vTooltip @show="loadContent" @hide="stopTimer">
|
||||||
<slot />
|
<slot />
|
||||||
<template #popper>
|
<template #popper>
|
||||||
<span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin" /></span>
|
<template v-if="error_msg">
|
||||||
<div v-else>
|
<Message message_class="is-danger" :newStyle="true">
|
||||||
<div style="min-width: 300px; width: 25vw; height: auto;" class="m-1">
|
{{ error_msg }}
|
||||||
<div class="is-block" style="word-break: all;" v-if="props.title">
|
</Message>
|
||||||
<span style="font-size: 120%;">{{ props.title }}</span>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="icon" v-if="!url"><i class="fas fa-circle-notch fa-spin" /></span>
|
||||||
|
<div v-else>
|
||||||
|
<div style="min-width: 300px; width: 25vw; height: auto;" class="m-1">
|
||||||
|
<div class="is-block" style="word-break: all;" v-if="props.title">
|
||||||
|
<span style="font-size: 120%;">{{ props.title }}</span>
|
||||||
|
</div>
|
||||||
|
<figure :class="['image', thumbnail_ratio, 'is-hidden-mobile']">
|
||||||
|
<img @load="e => pImg(e)" :src="url" :alt="props.title" @error="clearCache"
|
||||||
|
:crossorigin="props.privacy ? 'anonymous' : 'use-credentials'"
|
||||||
|
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
|
||||||
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
<figure :class="['image', thumbnail_ratio, 'is-hidden-mobile']">
|
|
||||||
<img @load="e => pImg(e)" :src="url" :alt="props.title" @error="clearCache"
|
|
||||||
:crossorigin="props.privacy ? 'anonymous' : 'use-credentials'"
|
|
||||||
:referrerpolicy="props.privacy ? 'no-referrer' : 'origin'" />
|
|
||||||
</figure>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</vTooltip>
|
</vTooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -36,6 +43,7 @@ const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'i
|
||||||
const url = ref<string | null>(null)
|
const url = ref<string | null>(null)
|
||||||
const error = ref(false)
|
const error = ref(false)
|
||||||
const isPreloading = ref(false)
|
const isPreloading = ref(false)
|
||||||
|
const error_msg = ref<string | null>(null)
|
||||||
|
|
||||||
const loadTimer: ReturnType<typeof setTimeout> | null = null
|
const loadTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const cancelRequest = new AbortController()
|
const cancelRequest = new AbortController()
|
||||||
|
|
@ -54,7 +62,7 @@ const defaultLoader = async (): Promise<void> => {
|
||||||
const response = await request(props.image, { signal: cancelRequest.signal })
|
const response = await request(props.image, { signal: cancelRequest.signal })
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(`ImageView Request error. ${response.status}: ${response.statusText}`)
|
error_msg.value = `ImageView Request error. ${response.status}: ${response.statusText}`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@
|
||||||
<i :class="showCompleted ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
|
<i :class="showCompleted ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
History <span v-if="hasItems">({{ stateStore.count('history') }})</span>
|
History
|
||||||
|
<span v-if="stateStore.count('history')">({{ stateStore.count('history') }})</span>
|
||||||
<span v-if="selectedElms.length > 0"> - Selected: {{ selectedElms.length }}</span>
|
<span v-if="selectedElms.length > 0"> - Selected: {{ selectedElms.length }}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -77,6 +78,34 @@
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="column is-1-tablet">
|
||||||
|
<button type="button" class="button is-info is-fullwidth" @click="() => stateStore.reloadCurrentPage('history')"
|
||||||
|
:disabled="paginationInfo.isLoading" :class="{ 'is-loading': paginationInfo.isLoading }">
|
||||||
|
<span class="icon-text is-block">
|
||||||
|
<span class="icon"><i class="fas fa-refresh" /></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns is-multiline" v-if="paginationInfo.isLoading && !hasItems">
|
||||||
|
<div class="column is-12">
|
||||||
|
<div class="message is-info">
|
||||||
|
<div class="message-body">
|
||||||
|
<span class="icon-text">
|
||||||
|
<span class="icon"> <i class="fa-solid fa-spinner fa-spin fa-2x" /> </span>
|
||||||
|
<span class="ml-3">Loading history...</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns is-multiline" v-if="paginationInfo.isLoaded && paginationInfo.total_pages > 1">
|
||||||
|
<div class="column is-12 has-text-centered">
|
||||||
|
<Pager :page="paginationInfo.page" :last_page="paginationInfo.total_pages" :isLoading="paginationInfo.isLoading"
|
||||||
|
@navigate="navigateToPage" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="'list' === display_style">
|
<div class="columns is-multiline" v-if="'list' === display_style">
|
||||||
|
|
@ -410,19 +439,30 @@
|
||||||
</LateLoader>
|
</LateLoader>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="!hasItems">
|
<div class="columns is-multiline" v-if="!hasItems && !paginationInfo.isLoading">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<Message message_class="has-background-warning-90 has-text-dark" title="No results for downloaded items."
|
<Message message_class="is-warning" title="Filter items" icon="fas fa-search" :useClose="true"
|
||||||
icon="fas fa-search" :useClose="true" @close="() => emitter('clear_search')" v-if="query">
|
@close="() => emitter('clear_search')" v-if="query" :newStyle="true">
|
||||||
<span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span>
|
<span class="is-block">No results found for '<span class="is-underlined is-bold">{{ query }}</span>'.</span>
|
||||||
</Message>
|
</Message>
|
||||||
<Message message_class="has-background-success-90 has-text-dark" title="No records in history."
|
<Message message_class="is-warning" title="Page is empty." icon="fas fa-exclamation-triangle"
|
||||||
icon="fas fa-circle-check" v-else-if="socket.isConnected" />
|
v-else-if="socket.isConnected && paginationInfo.total" :new-style="true">
|
||||||
|
<span class="is-block">The current page has no items. Try navigating to a different page.</span>
|
||||||
|
</Message>
|
||||||
|
<Message message_class="is-success" title="No records in history."
|
||||||
|
icon="fas fa-circle-check" v-else-if="socket.isConnected" :new-style="true" />
|
||||||
<Message message_class="has-background-info-90 has-text-dark" title="Connecting.." icon="fas fa-spinner fa-spin"
|
<Message message_class="has-background-info-90 has-text-dark" title="Connecting.." icon="fas fa-spinner fa-spin"
|
||||||
v-else />
|
v-else />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="columns is-multiline" v-if="paginationInfo.isLoaded && paginationInfo.total_pages > 1">
|
||||||
|
<div class="column is-12 has-text-centered">
|
||||||
|
<Pager :page="paginationInfo.page" :last_page="paginationInfo.total_pages" :isLoading="paginationInfo.isLoading"
|
||||||
|
@navigate="navigateToPage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal is-active" v-if="video_item">
|
<div class="modal is-active" v-if="video_item">
|
||||||
<div class="modal-background" @click="closeVideo"></div>
|
<div class="modal-background" @click="closeVideo"></div>
|
||||||
<div class="modal-content is-unbounded-model">
|
<div class="modal-content is-unbounded-model">
|
||||||
|
|
@ -495,6 +535,92 @@ const dialog_confirm = ref<{
|
||||||
options: [],
|
options: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const paginationInfo = computed(() => stateStore.getPagination())
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const currentPageFromUrl = computed(() => {
|
||||||
|
const pageParam = route.query.page
|
||||||
|
if (!pageParam) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
const pageStr = Array.isArray(pageParam) ? pageParam[0] : pageParam
|
||||||
|
if (!pageStr) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
const page = parseInt(pageStr, 10)
|
||||||
|
return isNaN(page) || page < 1 ? 1 : page
|
||||||
|
})
|
||||||
|
|
||||||
|
const navigateToPage = async (page: number) => {
|
||||||
|
if (page < 1 || page > paginationInfo.value.total_pages || paginationInfo.value.isLoading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await router.push({ query: { ...route.query, page: page.toString() } })
|
||||||
|
await stateStore.loadPaginated('history', page, config.app.default_pagination, 'DESC')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to navigate to page:', error)
|
||||||
|
toast.error('Failed to load page')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(showCompleted, async isShown => {
|
||||||
|
if (isShown && !paginationInfo.value.isLoaded && socket.isConnected) {
|
||||||
|
try {
|
||||||
|
const pageToLoad = currentPageFromUrl.value
|
||||||
|
await stateStore.loadPaginated('history', pageToLoad, config.app.default_pagination, 'DESC')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load history:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => route.query.page, async newPage => {
|
||||||
|
if (!showCompleted.value || !paginationInfo.value.isLoaded) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageStr = newPage ? (Array.isArray(newPage) ? newPage[0] : newPage) : undefined
|
||||||
|
const page = pageStr ? parseInt(pageStr, 10) : 1
|
||||||
|
if (isNaN(page) || page < 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page !== paginationInfo.value.page) {
|
||||||
|
try {
|
||||||
|
await stateStore.loadPaginated('history', page, config.app.default_pagination, 'DESC')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load page from URL:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (showCompleted.value && !paginationInfo.value.isLoaded && socket.isConnected) {
|
||||||
|
try {
|
||||||
|
const pageToLoad = currentPageFromUrl.value
|
||||||
|
await stateStore.loadPaginated('history', pageToLoad, config.app.default_pagination, 'DESC')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load history on mount:', error)
|
||||||
|
toast.error('Failed to load history')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => socket.isConnected, async connected => {
|
||||||
|
if (connected && showCompleted.value && !paginationInfo.value.isLoaded) {
|
||||||
|
try {
|
||||||
|
const pageToLoad = currentPageFromUrl.value
|
||||||
|
await stateStore.loadPaginated('history', pageToLoad, config.app.default_pagination, 'DESC')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load history after socket connection:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const showThumbnails = computed(() => (props.thumbnails ?? true) && !hideThumbnail.value)
|
const showThumbnails = computed(() => (props.thumbnails ?? true) && !hideThumbnail.value)
|
||||||
|
|
||||||
const playVideo = (item: StoreItem) => { video_item.value = item }
|
const playVideo = (item: StoreItem) => { video_item.value = item }
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,65 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="notification" :class="props.message_class">
|
<div :class="[newStyle ? 'message' : 'notification', message_class]">
|
||||||
<button class="delete" @click="emit('close')" v-if="!props.useToggle && props.useClose"></button>
|
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose && !newStyle"></button>
|
||||||
|
<div @click="$emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="useToggle">
|
||||||
<div @click="emit('toggle')" class="is-clickable is-pulled-right is-unselectable" v-if="props.useToggle">
|
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fas" :class="{ 'fa-arrow-up': props.toggle, 'fa-arrow-down': !props.toggle }"></i>
|
<i class="fas" :class="{'fa-arrow-up':toggle,'fa-arrow-down':!toggle}"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ props.toggle ? 'Close' : 'Open' }}</span>
|
<span>{{ toggle ? 'Close' : 'Open' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="is-unselectable"
|
||||||
<div class="notification-title is-unselectable" :class="{ 'is-clickable': props.useToggle }"
|
:class="{'is-clickable':useToggle, 'notification-title': !newStyle, 'message-header': newStyle}"
|
||||||
v-if="props.title || props.icon" @click="props.useToggle ? emit('toggle', props.toggle) : null">
|
v-if="title || icon"
|
||||||
<template v-if="props.icon">
|
@click="true === useToggle ? $emit('toggle', toggle): null">
|
||||||
|
<template v-if="icon">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i :class="props.icon"></i></span>
|
<span class="icon"><i :class="icon"></i></span>
|
||||||
<span>{{ props.title }}</span>
|
<span>{{ title }}</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>{{ props.title }}</template>
|
<template v-else>{{ title }}</template>
|
||||||
|
<button class="delete" @click="$emit('close')" v-if="!useToggle && useClose && newStyle"/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="content is-text-break" v-if="false === useToggle || toggle"
|
||||||
<div class="notification-content content" v-if="!props.useToggle || props.toggle">
|
:class="{'notification-body': !newStyle, 'message-body': newStyle}">
|
||||||
<template v-if="props.message">{{ props.message }}</template>
|
<template v-if="message">{{ message }}</template>
|
||||||
<slot />
|
<slot/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps < {
|
withDefaults(defineProps<{
|
||||||
title?: string
|
/** Title text for the notification */
|
||||||
icon?: string
|
title?: string | null
|
||||||
message?: string
|
/** Icon class for the notification */
|
||||||
|
icon?: string | null
|
||||||
|
/** Main message content */
|
||||||
|
message?: string | null
|
||||||
|
/** CSS class for the notification */
|
||||||
message_class?: string
|
message_class?: string
|
||||||
|
/** If true, show toggle button */
|
||||||
useToggle?: boolean
|
useToggle?: boolean
|
||||||
|
/** Current toggle state */
|
||||||
toggle?: boolean
|
toggle?: boolean
|
||||||
useClose?: boolean
|
/** If true, show close button */
|
||||||
} > ()
|
useClose?: boolean,
|
||||||
|
newStyle?: boolean
|
||||||
|
}>(), {
|
||||||
|
title: null,
|
||||||
|
icon: null,
|
||||||
|
message: null,
|
||||||
|
message_class: 'is-info',
|
||||||
|
useToggle: false,
|
||||||
|
toggle: false,
|
||||||
|
useClose: false,
|
||||||
|
newStyle: false
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits < {
|
defineEmits<{
|
||||||
(e: 'toggle', value ?: boolean): void
|
/** Emitted when the toggle button is clicked */
|
||||||
|
(e: 'toggle', value?: boolean): void
|
||||||
|
/** Emitted when the close button is clicked */
|
||||||
(e: 'close'): void
|
(e: 'close'): void
|
||||||
}> ()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -139,10 +139,11 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-unselectable" for="ytdlpCookies">
|
<label class="label is-unselectable" for="ytdlpCookies">
|
||||||
<span class="icon"><i class="fa-solid fa-cookie" /></span>
|
<span class="icon"><i class="fa-solid fa-cookie" /></span>
|
||||||
<span>Cookies</span>
|
<span>Cookies - <NuxtLink @click="cookiesDropzoneRef?.triggerFileSelect()">Upload file</NuxtLink>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<TextDropzone id="ytdlpCookies" v-model="form.cookies" :disabled="!socket.isConnected || addInProgress"
|
<TextDropzone ref="cookiesDropzoneRef" id="ytdlpCookies" v-model="form.cookies"
|
||||||
@error="(msg: string) => toast.error(msg)"
|
:disabled="!socket.isConnected || addInProgress" @error="(msg: string) => toast.error(msg)"
|
||||||
:placeholder="getDefault('cookies', 'Leave empty to use default cookies. Or drag & drop a cookie file here.')" />
|
:placeholder="getDefault('cookies', 'Leave empty to use default cookies. Or drag & drop a cookie file here.')" />
|
||||||
<span class="help is-bold">
|
<span class="help is-bold">
|
||||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||||
|
|
@ -255,6 +256,7 @@
|
||||||
import 'assets/css/bulma-switch.css'
|
import 'assets/css/bulma-switch.css'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||||
|
import TextDropzone from '~/components/TextDropzone.vue'
|
||||||
import type { item_request } from '~/types/item'
|
import type { item_request } from '~/types/item'
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||||
import { navigateTo } from '#app'
|
import { navigateTo } from '#app'
|
||||||
|
|
@ -284,6 +286,7 @@ const showTestResults = ref<boolean>(false)
|
||||||
const testResultsData = ref<any>(null)
|
const testResultsData = ref<any>(null)
|
||||||
const dlFieldsExtra = ['--no-download-archive']
|
const dlFieldsExtra = ['--no-download-archive']
|
||||||
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||||
|
const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null)
|
||||||
|
|
||||||
const form = useStorage<item_request>('local_config_v1', {
|
const form = useStorage<item_request>('local_config_v1', {
|
||||||
id: null,
|
id: null,
|
||||||
|
|
|
||||||
70
ui/app/components/Pager.vue
Normal file
70
ui/app/components/Pager.vue
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<template>
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control">
|
||||||
|
<button rel="first" class="button" v-if="page !== 1" @click="changePage(1)" :disabled="isLoading"
|
||||||
|
:class="{'is-loading':isLoading}">
|
||||||
|
<span class="icon"><i class="fas fa-angle-double-left"></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button rel="prev" class="button" v-if="page > 1 && (page-1) !== 1" @click="changePage(page-1)"
|
||||||
|
:disabled="isLoading" :class="{'is-loading':isLoading}">
|
||||||
|
<span class="icon"><i class="fas fa-angle-left"></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select">
|
||||||
|
<select id="pager_list" v-model="currentPage" @change="changePage(currentPage)" :disabled="isLoading">
|
||||||
|
<option v-for="(item, index) in makePagination(page, last_page)" :key="`pager-${index}`"
|
||||||
|
:value="item.page" :disabled="0 === item.page">
|
||||||
|
{{ item.text }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button rel="next" class="button" v-if="page !== last_page && ( page + 1 ) !== last_page"
|
||||||
|
@click="changePage( page + 1 )" :disabled="isLoading" :class="{ 'is-loading': isLoading }">
|
||||||
|
<span class="icon"><i class="fas fa-angle-right"></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button rel="last" class="button" v-if="page !== last_page" @click="changePage(last_page)"
|
||||||
|
:disabled="isLoading" :class="{ 'is-loading': isLoading }">
|
||||||
|
<span class="icon"><i class="fas fa-angle-double-right"></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {makePagination} from '~/utils/index'
|
||||||
|
|
||||||
|
const emitter = defineEmits(['navigate'])
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
page: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
last_page: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const changePage = p => {
|
||||||
|
if (p < 1 || p > props.last_page) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emitter('navigate', p)
|
||||||
|
currentPage.value = p
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPage = ref(props.page)
|
||||||
|
</script>
|
||||||
|
|
@ -165,10 +165,10 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label is-inline" for="cookies" v-tooltip="'Netscape HTTP Cookie format.'">
|
<label class="label is-inline" for="cookies" v-tooltip="'Netscape HTTP Cookie format.'">
|
||||||
<span class="icon"><i class="fa-solid fa-cookie" /></span>
|
<span class="icon"><i class="fa-solid fa-cookie" /></span>
|
||||||
Cookies
|
Cookies - <NuxtLink @click="cookiesDropzoneRef?.triggerFileSelect()">Upload file</NuxtLink>
|
||||||
</label>
|
</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<TextDropzone id="cookies" v-model="form.cookies" :disabled="addInProgress"
|
<TextDropzone ref="cookiesDropzoneRef" id="cookies" v-model="form.cookies" :disabled="addInProgress"
|
||||||
@error="(msg: string) => toast.error(msg)"
|
@error="(msg: string) => toast.error(msg)"
|
||||||
placeholder="Leave empty to use default cookies. Or drag & drop a cookie file here." />
|
placeholder="Leave empty to use default cookies. Or drag & drop a cookie file here." />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -234,6 +234,8 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||||
|
import TextDropzone from '~/components/TextDropzone.vue'
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
import type { AutoCompleteOptions } from '~/types/autocomplete';
|
||||||
import type { Preset, PresetImport } from '~/types/presets'
|
import type { Preset, PresetImport } from '~/types/presets'
|
||||||
|
|
||||||
|
|
@ -257,6 +259,7 @@ const showImport = useStorage<boolean>('showImport', false)
|
||||||
const selected_preset = ref<string>('')
|
const selected_preset = ref<string>('')
|
||||||
const showOptions = ref<boolean>(false)
|
const showOptions = ref<boolean>(false)
|
||||||
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||||
|
const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null)
|
||||||
|
|
||||||
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||||
.filter(opt => !opt.ignored)
|
.filter(opt => !opt.ignored)
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,15 @@ const readFileAsText = (file: File): Promise<string> => {
|
||||||
reader.readAsText(file)
|
reader.readAsText(file)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const triggerFileSelect = (): void => {
|
||||||
|
if (props.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ triggerFileSelect })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@ import { useStorage } from '@vueuse/core'
|
||||||
import { POSITION, useToast } from "vue-toastification"
|
import { POSITION, useToast } from "vue-toastification"
|
||||||
|
|
||||||
export type notificationType = 'info' | 'success' | 'warning' | 'error'
|
export type notificationType = 'info' | 'success' | 'warning' | 'error'
|
||||||
|
export type notificationTarget = 'toast' | 'browser'
|
||||||
|
|
||||||
export interface Notification {
|
export interface notification {
|
||||||
id: string;
|
id: string;
|
||||||
message: string
|
message: string
|
||||||
level: notificationType
|
level: notificationType
|
||||||
|
|
@ -25,9 +26,77 @@ export interface notificationOptions {
|
||||||
const allowToast = useStorage<boolean>('allow_toasts', true)
|
const allowToast = useStorage<boolean>('allow_toasts', true)
|
||||||
const toastPosition = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT)
|
const toastPosition = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT)
|
||||||
const toastDismissOnClick = useStorage<boolean>('toast_dismiss_on_click', true)
|
const toastDismissOnClick = useStorage<boolean>('toast_dismiss_on_click', true)
|
||||||
|
const toastTarget = useStorage<notificationTarget>('toast_target', 'toast')
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
function notify(type: notificationType, message: string, opts?: notificationOptions): void {
|
const requestBrowserPermission = async (): Promise<NotificationPermission> => {
|
||||||
|
if (!('Notification' in window)) {
|
||||||
|
return 'denied'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
return 'granted'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Notification.permission !== 'denied') {
|
||||||
|
return await Notification.requestPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Notification.permission
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage = (type: notificationType, id: string, message: string, opts?: notificationOptions): void => {
|
||||||
|
const notificationStore = useNotificationStore()
|
||||||
|
|
||||||
|
const useToastNotification = !window.isSecureContext || 'toast' === toastTarget.value ||
|
||||||
|
!('Notification' in window) || 'granted' !== Notification.permission;
|
||||||
|
|
||||||
|
console.log('useToastNotification', useToastNotification,{
|
||||||
|
windowIsSecureContext: window.isSecureContext,
|
||||||
|
notificationTarget: toastTarget.value,
|
||||||
|
notificationInWindow: 'Notification' in window,
|
||||||
|
notificationPermission: Notification.permission
|
||||||
|
});
|
||||||
|
|
||||||
|
if (useToastNotification) {
|
||||||
|
switch (type) {
|
||||||
|
case 'info':
|
||||||
|
toast.info(message, opts)
|
||||||
|
break;
|
||||||
|
case 'success':
|
||||||
|
toast.success(message, opts)
|
||||||
|
break;
|
||||||
|
case 'warning':
|
||||||
|
toast.warning(message, opts)
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
toast.error(message, opts)
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
toast.error(`Unknown notification type: ${type}. ${message}`, opts)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = new Notification('YTPTube', {
|
||||||
|
body: message,
|
||||||
|
icon: '/favicon.ico',
|
||||||
|
badge: '/favicon.ico',
|
||||||
|
tag: `ytptube-${type}`,
|
||||||
|
silent: false
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => notification.close(), 5000)
|
||||||
|
|
||||||
|
notification.onclick = () => {
|
||||||
|
notificationStore.markRead(id)
|
||||||
|
window.focus()
|
||||||
|
notification.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notify = (type: notificationType, message: string, opts?: notificationOptions): void => {
|
||||||
const notificationStore = useNotificationStore()
|
const notificationStore = useNotificationStore()
|
||||||
|
|
||||||
if (!opts) {
|
if (!opts) {
|
||||||
|
|
@ -63,23 +132,7 @@ function notify(type: notificationType, message: string, opts?: notificationOpti
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
sendMessage(type, id, message, opts)
|
||||||
case 'info':
|
|
||||||
toast.info(message, opts)
|
|
||||||
break;
|
|
||||||
case 'success':
|
|
||||||
toast.success(message, opts)
|
|
||||||
break;
|
|
||||||
case 'warning':
|
|
||||||
toast.warning(message, opts)
|
|
||||||
break;
|
|
||||||
case 'error':
|
|
||||||
toast.error(message, opts)
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
toast.error(`Unknown notification type: ${type}. ${message}`, opts)
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useNotification = () => {
|
export const useNotification = () => {
|
||||||
|
|
@ -88,6 +141,7 @@ export const useNotification = () => {
|
||||||
success: (message: string, opts?: notificationOptions) => notify('success', message, opts),
|
success: (message: string, opts?: notificationOptions) => notify('success', message, opts),
|
||||||
warning: (message: string, opts?: notificationOptions) => notify('warning', message, opts),
|
warning: (message: string, opts?: notificationOptions) => notify('warning', message, opts),
|
||||||
error: (message: string, opts?: notificationOptions) => notify('error', message, opts),
|
error: (message: string, opts?: notificationOptions) => notify('error', message, opts),
|
||||||
notify
|
notify,
|
||||||
|
requestBrowserPermission,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,17 +154,18 @@
|
||||||
<div>
|
<div>
|
||||||
<NuxtLoadingIndicator />
|
<NuxtLoadingIndicator />
|
||||||
<NuxtPage v-if="config.is_loaded" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
|
<NuxtPage v-if="config.is_loaded" :isLoading="loadingImage" @reload_bg="() => loadImage(true)" />
|
||||||
<Message v-if="!config.is_loaded" class="mt-5" :newStyle="true"
|
<Message v-if="!config.is_loaded" class="mt-5" :newStyle="true" title="Loading Configuration"
|
||||||
title="Loading Configuration" icon="fas fa-spinner fa-spin">
|
icon="fas fa-spinner fa-spin">
|
||||||
<p>Loading application configuration. This usually takes less than a second.</p>
|
<p>This usually takes less than a second.
|
||||||
<p v-if="!socket.isConnected" class="mt-2">
|
<span v-if="!socket.isConnected" class="mt-2">
|
||||||
If this is taking too long, please check that the backend server is running and that the WebSocket
|
If this is taking too long, please check that the backend server is running and that the WebSocket
|
||||||
connection is functional.
|
connection is functional.
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="socket.error" class="has-text-danger">
|
<p v-if="socket.error" class="has-text-danger">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fas fa-triangle-exclamation" /></span>
|
<span class="icon"><i class="fas fa-triangle-exclamation" /></span>
|
||||||
{{ socket.error }}
|
{{ socket.error }}. Check the developer console for more information.
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</Message>
|
</Message>
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@
|
||||||
<span class="field title is-4">
|
<span class="field title is-4">
|
||||||
<span class="icon-text">
|
<span class="icon-text">
|
||||||
<span class="icon"><i class="fas fa-bell" /></span>
|
<span class="icon"><i class="fas fa-bell" /></span>
|
||||||
<p class="card-header-title">On-Screen Notifications</p>
|
<p class="card-header-title">Notifications</p>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="field is-horizontal" />
|
<span class="field is-horizontal" />
|
||||||
|
|
@ -222,6 +222,33 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field is-horizontal" v-if="allow_toasts">
|
<div class="field is-horizontal" v-if="allow_toasts">
|
||||||
|
<div class="field-label is-normal">
|
||||||
|
<label class="label">Notification target</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-body">
|
||||||
|
<div class="field is-narrow">
|
||||||
|
<div class="control">
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select v-model="toast_target" @change="onNotificationTargetChange">
|
||||||
|
<option value="toast">Toast</option>
|
||||||
|
<option value="browser" :disabled="!isSecureContext">Browser</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="help">
|
||||||
|
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
|
||||||
|
<template v-if="!isSecureContext">
|
||||||
|
Browser notifications require HTTPS connection.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Choose where to display notifications. Browser requires permission.
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field is-horizontal" v-if="allow_toasts && toast_target === 'toast'">
|
||||||
<div class="field-label is-normal">
|
<div class="field-label is-normal">
|
||||||
<label class="label">Notifications position</label>
|
<label class="label">Notifications position</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -243,7 +270,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field is-horizontal" v-if="allow_toasts">
|
<div class="field is-horizontal" v-if="allow_toasts && toast_target === 'toast'">
|
||||||
<div class="field-label is-normal">
|
<div class="field-label is-normal">
|
||||||
<label class="label">Dismiss notification on click</label>
|
<label class="label">Dismiss notification on click</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -266,13 +293,17 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import 'assets/css/bulma-switch.css'
|
import 'assets/css/bulma-switch.css'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
import { POSITION } from 'vue-toastification'
|
import { POSITION } from 'vue-toastification'
|
||||||
import { useConfigStore } from '~/stores/ConfigStore'
|
import { useConfigStore } from '~/stores/ConfigStore'
|
||||||
|
import { useNotification } from '~/composables/useNotification'
|
||||||
|
import type { notificationTarget } from '~/composables/useNotification'
|
||||||
|
|
||||||
defineProps<{ isLoading: boolean }>()
|
defineProps<{ isLoading: boolean }>()
|
||||||
defineEmits<{ (e: 'reload_bg'): void }>()
|
defineEmits<{ (e: 'reload_bg'): void }>()
|
||||||
|
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
|
const notification = useNotification()
|
||||||
|
|
||||||
const bg_enable = useStorage<boolean>('random_bg', true)
|
const bg_enable = useStorage<boolean>('random_bg', true)
|
||||||
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
|
const bg_opacity = useStorage<number>('random_bg_opacity', 0.95)
|
||||||
|
|
@ -280,8 +311,28 @@ const selectedTheme = useStorage<'auto' | 'light' | 'dark'>('theme', 'auto')
|
||||||
const allow_toasts = useStorage<boolean>('allow_toasts', true)
|
const allow_toasts = useStorage<boolean>('allow_toasts', true)
|
||||||
const toast_position = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT)
|
const toast_position = useStorage<POSITION>('toast_position', POSITION.TOP_RIGHT)
|
||||||
const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true)
|
const toast_dismiss_on_click = useStorage<boolean>('toast_dismiss_on_click', true)
|
||||||
|
const toast_target = useStorage<notificationTarget>('toast_target', 'toast')
|
||||||
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
|
||||||
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
|
const thumbnail_ratio = useStorage<'is-16by9' | 'is-3by1'>('thumbnail_ratio', 'is-3by1')
|
||||||
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',')
|
const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',')
|
||||||
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
|
||||||
|
const isSecureContext = ref<boolean>(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
isSecureContext.value = window.isSecureContext
|
||||||
|
await nextTick()
|
||||||
|
if ('browser' === toast_target.value && !isSecureContext.value) {
|
||||||
|
toast_target.value = 'toast'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onNotificationTargetChange = async (): Promise<void> => {
|
||||||
|
if ('browser' === toast_target.value) {
|
||||||
|
const permission = await notification.requestBrowserPermission()
|
||||||
|
if ('granted' !== permission) {
|
||||||
|
toast_target.value = 'toast'
|
||||||
|
notification.warning('Browser notification permission denied. Reverting to toast notifications.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export const useConfigStore = defineStore('config', () => {
|
||||||
started: 0,
|
started: 0,
|
||||||
app_env: 'production',
|
app_env: 'production',
|
||||||
simple_mode: false,
|
simple_mode: false,
|
||||||
|
default_pagination: 50,
|
||||||
},
|
},
|
||||||
presets: [
|
presets: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import type { Notification, notificationType } from '~/composables/useNotification'
|
import type { notification, notificationType } from '~/composables/useNotification'
|
||||||
|
|
||||||
export const useNotificationStore = defineStore('notifications', () => {
|
export const useNotificationStore = defineStore('notifications', () => {
|
||||||
const notifications = useStorage<Notification[]>('notifications', [])
|
const notifications = useStorage<notification[]>('notifications', [])
|
||||||
const unreadCount = computed<number>(() => notifications.value.filter(n => !n.seen).length)
|
const unreadCount = computed<number>(() => notifications.value.filter(n => !n.seen).length)
|
||||||
|
|
||||||
const add = (level: notificationType, message: string, seen: boolean = false): string => {
|
const add = (level: notificationType, message: string, seen: boolean = false): string => {
|
||||||
|
|
@ -39,7 +39,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
||||||
n.seen = true
|
n.seen = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const get = (id: string): Notification | undefined => notifications.value.find(n => n.id === id)
|
const get = (id: string): notification | undefined => notifications.value.find(n => n.id === id)
|
||||||
|
|
||||||
const remove = (id: string) => notifications.value = notifications.value.filter(n => n.id !== id)
|
const remove = (id: string) => notifications.value = notifications.value.filter(n => n.id !== id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,6 @@ export const useSocketStore = defineStore('socket', () => {
|
||||||
const json = JSON.parse(stream)
|
const json = JSON.parse(stream)
|
||||||
config.setAll({
|
config.setAll({
|
||||||
app: json.data.config,
|
app: json.data.config,
|
||||||
tasks: json.data.tasks,
|
|
||||||
presets: json.data.presets,
|
presets: json.data.presets,
|
||||||
dl_fields: json.data.dl_fields,
|
dl_fields: json.data.dl_fields,
|
||||||
paused: Boolean(json.data.paused)
|
paused: Boolean(json.data.paused)
|
||||||
|
|
@ -108,9 +107,22 @@ export const useSocketStore = defineStore('socket', () => {
|
||||||
|
|
||||||
on('connected', stream => {
|
on('connected', stream => {
|
||||||
const json = JSON.parse(stream);
|
const json = JSON.parse(stream);
|
||||||
config.add('folders', json.data.folders)
|
if (!json?.data) {
|
||||||
stateStore.addAll('queue', json.data.queue || {})
|
return;
|
||||||
stateStore.addAll('history', json.data.done || {})
|
}
|
||||||
|
|
||||||
|
if (json.data?.folder) {
|
||||||
|
config.add('folders', json.data.folders)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.data?.queue) {
|
||||||
|
stateStore.addAll('queue', json.data.queue || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof json.data?.history_count === 'number') {
|
||||||
|
stateStore.setHistoryCount(json.data.history_count)
|
||||||
|
}
|
||||||
|
|
||||||
error.value = null;
|
error.value = null;
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { StoreItem } from '~/types/store'
|
import type { StoreItem } from '~/types/store'
|
||||||
|
import { request } from '~/utils'
|
||||||
|
|
||||||
type StateType = 'queue' | 'history'
|
type StateType = 'queue' | 'history'
|
||||||
type KeyType = string
|
type KeyType = string
|
||||||
|
|
@ -7,10 +8,33 @@ type KeyType = string
|
||||||
interface State {
|
interface State {
|
||||||
queue: Record<KeyType, StoreItem>
|
queue: Record<KeyType, StoreItem>
|
||||||
history: Record<KeyType, StoreItem>
|
history: Record<KeyType, StoreItem>
|
||||||
|
pagination: {
|
||||||
|
page: number
|
||||||
|
per_page: number
|
||||||
|
total: number
|
||||||
|
total_pages: number
|
||||||
|
has_next: boolean
|
||||||
|
has_prev: boolean
|
||||||
|
isLoaded: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStateStore = defineStore('state', () => {
|
export const useStateStore = defineStore('state', () => {
|
||||||
const state = reactive<State>({ queue: {}, history: {} })
|
const state = reactive<State>({
|
||||||
|
queue: {},
|
||||||
|
history: {},
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
per_page: 50,
|
||||||
|
total: 0,
|
||||||
|
total_pages: 0,
|
||||||
|
has_next: false,
|
||||||
|
has_prev: false,
|
||||||
|
isLoaded: false,
|
||||||
|
isLoading: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const add = (type: StateType, key: KeyType, value: StoreItem): void => {
|
const add = (type: StateType, key: KeyType, value: StoreItem): void => {
|
||||||
state[type][key] = value
|
state[type][key] = value
|
||||||
|
|
@ -37,6 +61,15 @@ export const useStateStore = defineStore('state', () => {
|
||||||
|
|
||||||
const clearAll = (type: StateType): void => {
|
const clearAll = (type: StateType): void => {
|
||||||
state[type] = {}
|
state[type] = {}
|
||||||
|
if ('queue' === type) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pagination.total = 0
|
||||||
|
state.pagination.page = 1
|
||||||
|
state.pagination.total_pages = 0
|
||||||
|
state.pagination.has_next = false
|
||||||
|
state.pagination.has_prev = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const addAll = (type: StateType, data: Record<KeyType, StoreItem>): void => {
|
const addAll = (type: StateType, data: Record<KeyType, StoreItem>): void => {
|
||||||
|
|
@ -52,8 +85,94 @@ export const useStateStore = defineStore('state', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = (type: StateType): number => {
|
const count = (type: StateType): number => {
|
||||||
|
if ('history' === type && state.pagination.total > 0) {
|
||||||
|
return state.pagination.total
|
||||||
|
}
|
||||||
return Object.keys(state[type]).length
|
return Object.keys(state[type]).length
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...toRefs(state), add, update, remove, get, has, clearAll, addAll, move, count }
|
const loadPaginated = async (type: StateType, page: number = 1, per_page: number = 50, order: 'ASC' | 'DESC' = 'DESC'): Promise<void> => {
|
||||||
|
if ('history' !== type) {
|
||||||
|
throw new Error('Pagination is only supported for history type');
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pagination.isLoading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const search = new URLSearchParams({ type: 'done', page: page.toString(), per_page: per_page.toString(), order });
|
||||||
|
|
||||||
|
const response = await request(`/api/history?${search}`)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data.pagination) {
|
||||||
|
state.pagination = { ...data.pagination, isLoaded: true, isLoading: false, }
|
||||||
|
const items: Record<KeyType, StoreItem> = {}
|
||||||
|
for (const item of data.items || []) {
|
||||||
|
items[item._id] = item
|
||||||
|
}
|
||||||
|
state[type] = items
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load ${type} page ${page}:`, error)
|
||||||
|
state.pagination.isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadNextPage = async (type: StateType): Promise<void> => {
|
||||||
|
if ('history' !== type) {
|
||||||
|
throw new Error('Pagination is only supported for history type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.pagination.has_next || state.pagination.isLoading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadPaginated(type, state.pagination.page + 1, state.pagination.per_page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPreviousPage = async (type: StateType): Promise<void> => {
|
||||||
|
if ('history' !== type) {
|
||||||
|
throw new Error('Pagination is only supported for history type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.pagination.has_prev || state.pagination.isLoading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadPaginated(type, state.pagination.page - 1, state.pagination.per_page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadCurrentPage = async (type: StateType): Promise<void> => {
|
||||||
|
if ('history' !== type) {
|
||||||
|
throw new Error('Pagination is only supported for history type');
|
||||||
|
}
|
||||||
|
if (!state.pagination.isLoaded) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadPaginated(type, state.pagination.page, state.pagination.per_page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPagination = () => state.pagination
|
||||||
|
|
||||||
|
const setHistoryCount = (count: number) => state.pagination.total = count
|
||||||
|
|
||||||
|
return {
|
||||||
|
...toRefs(state),
|
||||||
|
add,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
get,
|
||||||
|
has,
|
||||||
|
clearAll,
|
||||||
|
addAll,
|
||||||
|
move,
|
||||||
|
count,
|
||||||
|
loadPaginated,
|
||||||
|
loadNextPage,
|
||||||
|
loadPreviousPage,
|
||||||
|
reloadCurrentPage,
|
||||||
|
getPagination,
|
||||||
|
setHistoryCount,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
2
ui/app/types/config.d.ts
vendored
2
ui/app/types/config.d.ts
vendored
|
|
@ -42,6 +42,8 @@ type AppConfig = {
|
||||||
started: number,
|
started: number,
|
||||||
/** Application environment, e.g. "production", "development" */
|
/** Application environment, e.g. "production", "development" */
|
||||||
app_env: "production" | "development"
|
app_env: "production" | "development"
|
||||||
|
/** Default number of items per page for pagination */
|
||||||
|
default_pagination: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type Preset = {
|
type Preset = {
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,18 @@ export default defineNuxtConfig({
|
||||||
{ "charset": "utf-8" },
|
{ "charset": "utf-8" },
|
||||||
{ "name": "viewport", "content": "width=device-width, initial-scale=1.0, maximum-scale=1.0" },
|
{ "name": "viewport", "content": "width=device-width, initial-scale=1.0, maximum-scale=1.0" },
|
||||||
{ "name": "theme-color", "content": "#000000" },
|
{ "name": "theme-color", "content": "#000000" },
|
||||||
|
{ "name": "mobile-web-app-capable", "content": "yes" },
|
||||||
|
{ "name": "apple-mobile-web-app-capable", "content": "yes" },
|
||||||
|
{ "name": "apple-mobile-web-app-status-bar-style", "content": "black-translucent" },
|
||||||
|
{ "name": "apple-mobile-web-app-title", "content": "YTPTube" },
|
||||||
],
|
],
|
||||||
base: { "href": "/" },
|
base: { "href": "/" },
|
||||||
link: [{ rel: 'icon', type: 'image/x-icon', href: 'favicon.ico?v=100' }]
|
link: [
|
||||||
|
{ rel: 'icon', type: 'image/x-icon', href: 'favicon.ico?v=100' },
|
||||||
|
{ rel: 'manifest', href: 'manifest.webmanifest?v=100' },
|
||||||
|
{ rel: 'apple-touch-icon', href: 'images/favicon.png' },
|
||||||
|
{ rel: 'apple-touch-startup-image', href: 'images/logo.png' }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
pageTransition: { name: 'page', mode: 'out-in' }
|
pageTransition: { name: 'page', mode: 'out-in' }
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/eslint": "^1.10.0",
|
"@nuxt/eslint": "^1.10.0",
|
||||||
"@nuxt/eslint-config": "^1.10.0",
|
"@nuxt/eslint-config": "^1.10.0",
|
||||||
"@typescript-eslint/parser": "^8.46.3",
|
"@typescript-eslint/parser": "^8.46.4",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.0.8",
|
"vitest": "^4.0.8",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
BIN
ui/public/images/favicon.png
Normal file
BIN
ui/public/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
35
ui/public/manifest.webmanifest
Normal file
35
ui/public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"short_name": "YTP",
|
||||||
|
"name": "YTPTube",
|
||||||
|
"description": "Web-based GUI for yt-dlp. Download videos, playlists, channels, and live streams from various platforms.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#000000",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"orientation": "any",
|
||||||
|
"categories": [
|
||||||
|
"downloaders",
|
||||||
|
"video",
|
||||||
|
"audio"
|
||||||
|
],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "32x32",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "images/favicon.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "images/logo.png",
|
||||||
|
"sizes": "800x600",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue