Merge pull request #488 from arabcoders/dev

Add app manifest to allow the app to be installed
This commit is contained in:
Abdulmohsen 2025-11-13 23:36:14 +03:00 committed by GitHub
commit 59fe171a9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1343 additions and 343 deletions

95
API.md
View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}'.",

View 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()

View file

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

View file

@ -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">&nbsp;- Selected: {{ selectedElms.length }}</span> <span v-if="selectedElms.length > 0">&nbsp;- 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 }

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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' }
}, },

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View 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"
}
]
}