Merge pull request #592 from arabcoders/dev

fix: Slowness in deleting big number of items
This commit is contained in:
Abdulmohsen 2026-04-25 01:42:01 +03:00 committed by GitHub
commit 4ee7537733
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1750 additions and 290 deletions

81
API.md
View file

@ -96,6 +96,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [POST /api/notifications/test](#post-apinotificationstest)
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
- [GET /api/system/configuration](#get-apisystemconfiguration)
- [GET /api/system/limits](#get-apisystemlimits)
- [POST /api/system/terminal](#post-apisystemterminal)
- [GET /api/system/terminal/active](#get-apisystemterminalactive)
- [GET /api/system/terminal/{session\_id}](#get-apisystemterminalsession_id)
@ -132,6 +133,7 @@ This document describes the available endpoints and their usage. All endpoints r
- [`item_updated`](#item_updated)
- [`item_cancelled`](#item_cancelled)
- [`item_deleted`](#item_deleted)
- [`item_bulk_deleted`](#item_bulk_deleted)
- [`item_moved`](#item_moved)
- [`item_status`](#item_status)
- [`paused`](#paused)
@ -381,7 +383,7 @@ or an error:
**Body Parameters**:
- `type` (string, required): Type of items - `"queue"` or `"done"`
- `ids` (array, optional): List of specific item IDs to delete. If provided, `status` filter is ignored
- `status` (string, optional): Filter by status (e.g., `"finished"`, `"!finished"`). Required if `ids` not provided
- `status` (string, optional): Filter by status (e.g., `"finished"`, `"!finished"`, `"finished,skip"`, `"!finished,!skip"`). Required if `ids` not provided
- `remove_file` (boolean, optional): Whether to delete files from disk. Default: `true`.
> [!NOTE]
@ -416,6 +418,15 @@ or an error:
}
```
**Delete all completed and skipped items in one request:**
```json
{
"type": "done",
"status": "finished,skip",
"remove_file": false
}
```
**Response**:
```json
{
@ -448,6 +459,7 @@ or an error:
**Notes**:
- When using filter mode, all matching items will be deleted.
- Filter mode with `{ "status": "!finished" }` is useful for cleaning up failed/pending downloads.
- `status` also accepts comma-separated include filters (`finished,skip`) and comma-separated exclude filters (`!finished,!skip`).
- Filter mode returns a `deleted` count indicating how many items were removed.
---
@ -2479,6 +2491,55 @@ or an error:
---
### GET /api/system/limits
**Purpose**: Get the system limits.
**Response**:
```json
{
"downloads": {
"paused": false,
"live_bypasses_limits": true,
"global": {
"limit": 20,
"active": 3,
"available": 17,
"live_active": 1,
"queued": 8
},
"per_extractor": {
"default_limit": 2,
"items": [
{
"name": "youtube",
"limit": 3,
"source": "env_override",
"active": 2,
"queued": 4,
"available": 1
}
]
}
},
"extraction": {
"concurrency": 4,
"timeout_seconds": 70,
"info_cache_ttl_seconds": 10800
},
"live": {
"prevent_premiere": true,
"premiere_buffer_minutes": 5
}
}
```
**Notes**:
- `downloads.global` counts only non-live downloads against the worker limit.
- `downloads.global.live_active` is reported separately because live downloads bypass the global and per-extractor worker limits.
- `downloads.per_extractor.items[*].source` is `default` unless an override was provided through `YTP_MAX_WORKERS_FOR_<EXTRACTOR>`.
---
### POST /api/system/terminal
**Purpose**: Start a yt-dlp terminal session. Requires `YTP_CONSOLE_ENABLED=true`.
@ -3127,6 +3188,24 @@ Emitted when a download item is deleted from the queue or history.
---
##### `item_bulk_deleted`
Emitted when multiple items are cleared in bulk operation.
**Event**:
```json
{
"event": "item_bulk_deleted",
"data": {
"count": 10001,
"status": "finished,skip",
"ids": ["id1", "id2", "..."] // optional, included when clear is driven by explicit ids
}
}
```
---
##### `item_moved`
Emitted when a download item is moved between queue and history.

1
FAQ.md
View file

@ -30,7 +30,6 @@ or the `environment:` section in `compose.yaml` file.
| YTP_DEBUG | Whether to turn on debug mode | `false` |
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `(not_set)` |
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use | `(not_set)` |

View file

@ -16,6 +16,7 @@ class NotificationEvents:
ITEM_COMPLETED: str = Events.ITEM_COMPLETED
ITEM_CANCELLED: str = Events.ITEM_CANCELLED
ITEM_DELETED: str = Events.ITEM_DELETED
ITEM_BULK_DELETED: str = Events.ITEM_BULK_DELETED
ITEM_PAUSED: str = Events.ITEM_PAUSED
ITEM_RESUMED: str = Events.ITEM_RESUMED
ITEM_MOVED: str = Events.ITEM_MOVED

View file

@ -120,6 +120,48 @@ class DataStore:
return None
async def get_many_by_ids(self, ids: Iterable[str]) -> list[tuple[str, Download]]:
ids_list = list(ids)
if not ids_list:
return []
items: list[tuple[str, Download]] = []
missing_ids: list[str] = []
for item_id in ids_list:
cached = self._dict.get(item_id)
if cached:
items.append((item_id, cached))
continue
missing_ids.append(item_id)
if StoreType.HISTORY == self._type and missing_ids:
loaded = await self._connection.get_many_by_ids(str(self._type), missing_ids)
for item_id, item in loaded:
self._dict[item_id] = Download(info=item)
items.extend((item_id, download) for item_id in ids_list if (download := self._dict.get(item_id)))
seen: set[str] = set()
ordered: list[tuple[str, Download]] = []
for item_id, download in items:
if item_id in seen:
continue
seen.add(item_id)
ordered.append((item_id, download))
return ordered
async def get_many_by_status(self, status_filter: str) -> list[tuple[str, Download]]:
if StoreType.HISTORY != self._type:
return []
items = await self._connection.get_many_by_status(str(self._type), status_filter)
downloads: list[tuple[str, Download]] = []
for item_id, item in items:
self._dict[item_id] = Download(info=item)
downloads.append((item_id, self._dict[item_id]))
return downloads
def items(self):
return self._dict.items()
@ -175,11 +217,41 @@ class DataStore:
return [(item_id, Download(info=item)) for item_id, item in items], total_items, current_page, total_pages
async def bulk_delete(self, ids: Iterable[str]) -> int:
deleted = await self._connection.bulk_delete(str(self._type), ids)
for _id in ids:
ids_list = list(ids)
deleted = await self._connection.bulk_delete(str(self._type), ids_list)
for _id in ids_list:
self._dict.pop(_id, None)
return deleted
async def bulk_delete_by_status(self, status_filter: str) -> int:
deleted = await self._connection.bulk_delete_by_status(str(self._type), status_filter)
if deleted > 0:
self._drop_cached_by_status(status_filter)
return deleted
def _drop_cached_by_status(self, status_filter: str) -> None:
raw_statuses = [entry.strip() for entry in status_filter.split(",") if entry.strip()]
if not raw_statuses:
return
if all(entry.startswith("!") for entry in raw_statuses):
excluded = {entry[1:].strip() for entry in raw_statuses if entry[1:].strip()}
if not excluded:
return
for item_id, download in list(self._dict.items()):
if download.info and download.info.status not in excluded:
self._dict.pop(item_id, None)
return
included = {entry for entry in raw_statuses if not entry.startswith("!")}
if not included:
return
for item_id, download in list(self._dict.items()):
if download.info and download.info.status in included:
self._dict.pop(item_id, None)
async def test(self) -> bool:
await self._connection.count(str(self._type))
return True

View file

@ -37,6 +37,7 @@ class Events:
ITEM_COMPLETED: str = "item_completed"
ITEM_CANCELLED: str = "item_cancelled"
ITEM_DELETED: str = "item_deleted"
ITEM_BULK_DELETED: str = "item_bulk_deleted"
ITEM_PAUSED: str = "item_paused"
ITEM_RESUMED: str = "item_resumed"
ITEM_MOVED: str = "item_moved"
@ -87,6 +88,7 @@ class Events:
Events.ITEM_UPDATED,
Events.ITEM_CANCELLED,
Events.ITEM_DELETED,
Events.ITEM_BULK_DELETED,
Events.ITEM_MOVED,
Events.ITEM_STATUS,
Events.PAUSED,

View file

@ -129,9 +129,6 @@ class Config(metaclass=Singleton):
apprise_config: str = "{config_path}{os_sep}apprise.yml"
"""The path to the Apprise configuration file."""
ui_update_title: bool = True
"""Update the title of the browser tab with the current status."""
pip_packages: str = ""
"""The pip packages to install."""
@ -284,13 +281,12 @@ class Config(metaclass=Singleton):
"access_log",
"remove_files",
"ignore_ui",
"ui_update_title",
"pip_ignore_updates",
"file_logging",
"console_enabled",
"browser_control_enabled",
"ytdlp_auto_update",
"prevent_premiere_live",
"prevent_live_premiere",
"temp_disabled",
"allow_internal_urls",
"simple_mode",
@ -308,7 +304,6 @@ class Config(metaclass=Singleton):
"output_template",
"started",
"remove_files",
"ui_update_title",
"max_workers",
"max_workers_per_extractor",
"default_preset",

View file

@ -372,6 +372,103 @@ class DownloadQueue(metaclass=Singleton):
return status
async def clear_bulk(self, ids: list[str], remove_file: bool = False) -> dict[str, int | str]:
if not ids:
return {"deleted": 0}
items = await self.done.get_many_by_ids(ids)
if not items:
return {"deleted": 0}
if self.config.remove_files is not True:
remove_file = False
removed_files = 0
deleted_ids: list[str] = []
deleted_titles: list[str] = []
for item_id, item in items:
item_ref: str = f"{item_id=} {item.info.id=} {item.info.title=}"
filename: str = ""
LOG.debug(f"{remove_file=} {item_ref} - Removing local files: {item.info.status=}")
if remove_file and "finished" == item.info.status and item.info.filename:
filename = str(item.info.filename)
if item.info.folder:
filename = f"{item.info.folder}/{item.info.filename}"
try:
rf = Path(
calc_download_path(
base_path=Path(self.config.download_path),
folder=filename,
create_path=False,
)
)
if rf.is_file() and rf.exists():
if rf.stem and rf.suffix:
for file_ref in rf.parent.glob(f"{glob.escape(rf.stem)}.*"):
if file_ref.is_file() and file_ref.exists() and not file_ref.name.startswith("."):
removed_files += 1
LOG.debug(f"Removing '{item_ref}' local file '{file_ref.name}'.")
file_ref.unlink(missing_ok=True)
else:
LOG.debug(f"Removing '{item_ref}' local file '{rf.name}'.")
rf.unlink(missing_ok=True)
removed_files += 1
else:
LOG.warning(f"Failed to remove '{item_ref}' local file '{filename}'. File not found.")
except Exception as e:
LOG.error(f"Unable to remove '{item_ref}' local file '{filename}'. {e!s}")
deleted_ids.append(item_id)
deleted_titles.append(item.info.title or item.info.id or item_id)
deleted_count = await self.done.bulk_delete(deleted_ids)
if deleted_count < 1:
return {"deleted": 0}
title = "History Removed" if removed_files > 0 else "History Cleared"
message = f"Removed {deleted_count} item{'s' if deleted_count != 1 else ''} from history."
if removed_files > 0:
message += f" Also removed {removed_files} local file{'s' if removed_files != 1 else ''}."
self._notify.emit(
Events.ITEM_BULK_DELETED,
data={"ids": deleted_ids, "count": deleted_count},
title=title,
message=message,
)
summary = ", ".join(deleted_titles[:5])
if deleted_count > 5:
summary += ", ..."
LOG.info(f"Bulk cleared {deleted_count} history item(s): {summary}")
return {"deleted": deleted_count}
async def clear_by_status(self, status_filter: str, remove_file: bool = False) -> dict[str, int | str]:
if self.config.remove_files is not True:
remove_file = False
if not remove_file:
deleted_count = await self.done.bulk_delete_by_status(status_filter)
if deleted_count < 1:
return {"deleted": 0}
self._notify.emit(
Events.ITEM_BULK_DELETED,
data={"count": deleted_count, "status": status_filter},
title="History Cleared",
message=f"Cleared {deleted_count} item{'s' if deleted_count != 1 else ''} from history.",
)
LOG.info(f"Bulk cleared {deleted_count} history item(s) by status '{status_filter}'.")
return {"deleted": deleted_count}
items = await self.done.get_many_by_status(status_filter)
return await self.clear_bulk([item_id for item_id, _ in items], remove_file=remove_file)
async def get(self, mode: str = "all") -> dict[str, list[dict[str, ItemDTO]]]:
"""
Get the download queue and the download history.

View file

@ -184,7 +184,8 @@ async def add_video(queue: "DownloadQueue", entry: dict, item: "Item", logs: lis
starts_in: datetime = (
starts_in.replace(tzinfo=UTC) if starts_in.tzinfo is None else starts_in.astimezone(UTC)
)
starts_in = starts_in + timedelta(minutes=5, seconds=dl.extras.get("duration", 0))
buffer_time = queue.config.live_premiere_buffer if queue.config.live_premiere_buffer >= 0 else 5
starts_in = starts_in + timedelta(minutes=buffer_time, seconds=dl.extras.get("duration", 0))
dlInfo.info.error += f" Download will start at {starts_in.astimezone().isoformat()}."
_requeue = False
except Exception as e:

View file

@ -7,6 +7,7 @@ from collections.abc import Iterable
from dataclasses import fields
from datetime import UTC, datetime
from email.utils import formatdate
from urllib.parse import quote_plus
from aiohttp import web
from sqlalchemy import text
@ -25,6 +26,51 @@ LOG: logging.Logger = logging.getLogger(__name__)
ITEM_DTO_FIELDS: set[str] = {f.name for f in fields(ItemDTO)}
def _memory_db_url(db_path: str) -> str:
if db_path == ":memory:":
return "sqlite+aiosqlite:///file::memory:?cache=shared&uri=true"
memory_name = db_path[len(":memory:") :].lstrip(":") or "default"
quoted_name = quote_plus(memory_name)
return f"sqlite+aiosqlite:///file:{quoted_name}?mode=memory&cache=shared&uri=true"
def _decode_row(row: dict) -> ItemDTO:
row_date = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
data = json.loads(row["data"])
data.pop("_id", None)
item = init_class(ItemDTO, data, ITEM_DTO_FIELDS)
item._id = row["id"]
item.datetime = formatdate(row_date.replace(tzinfo=UTC).timestamp())
return item
def _build_status_filter_clause(status_filter: str | None) -> tuple[str | None, dict[str, str]]:
if not status_filter:
return None, {}
raw_statuses = [entry.strip() for entry in status_filter.split(",") if entry.strip()]
if not raw_statuses:
return None, {}
if all(entry.startswith("!") for entry in raw_statuses):
statuses = [entry[1:].strip() for entry in raw_statuses if entry[1:].strip()]
if not statuses:
return None, {}
placeholders = ", ".join(f":status_{index}" for index in range(len(statuses)))
params = {f"status_{index}": status for index, status in enumerate(statuses)}
return f"json_extract(data, '$.status') NOT IN ({placeholders})", params
statuses = [entry for entry in raw_statuses if not entry.startswith("!")]
if not statuses:
return None, {}
placeholders = ", ".join(f":status_{index}" for index in range(len(statuses)))
params = {f"status_{index}": status for index, status in enumerate(statuses)}
return f"json_extract(data, '$.status') IN ({placeholders})", params
class Terminator:
pass
@ -111,16 +157,7 @@ class SqliteStore(metaclass=ThreadSafe):
)
rows = result.mappings().all()
items: list[tuple[str, ItemDTO]] = []
for row in rows:
row_date = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
data = json.loads(row["data"])
data.pop("_id", None)
item = init_class(ItemDTO, data, ITEM_DTO_FIELDS)
item._id = row["id"]
item.datetime = formatdate(row_date.replace(tzinfo=UTC).timestamp())
items.append((row["id"], item))
return items
return [(row["id"], _decode_row(row)) for row in rows]
async def exists(self, type_value: str, key: str | None = None, url: str | None = None) -> bool:
return await self.get(type_value, key=key, url=url) is not None
@ -154,13 +191,7 @@ class SqliteStore(metaclass=ThreadSafe):
if not row:
return None
row_date = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
data = json.loads(row["data"])
data.pop("_id", None)
item = init_class(ItemDTO, data, ITEM_DTO_FIELDS)
item._id = row["id"]
item.datetime = formatdate(row_date.replace(tzinfo=UTC).timestamp())
return item
return _decode_row(row)
async def get_by_id(self, type_value: str, id: str) -> ItemDTO | None:
await self.get_connection()
@ -173,13 +204,43 @@ class SqliteStore(metaclass=ThreadSafe):
if not row:
return None
row_date = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
data = json.loads(row["data"])
data.pop("_id", None)
item = init_class(ItemDTO, data, ITEM_DTO_FIELDS)
item._id = row["id"]
item.datetime = formatdate(row_date.replace(tzinfo=UTC).timestamp())
return item
return _decode_row(row)
async def get_many_by_ids(self, type_value: str, ids: Iterable[str]) -> list[tuple[str, ItemDTO]]:
ids_list = list(ids)
if not ids_list:
return []
await self.get_connection()
placeholders = ", ".join(f":id_{index}" for index in range(len(ids_list)))
params: dict[str, str] = {"type_value": type_value}
params.update({f"id_{index}": item_id for index, item_id in enumerate(ids_list)})
result = await self._conn.execute(
text(
f'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = :type_value AND "id" IN ({placeholders})' # noqa: S608
),
params,
)
rows = result.mappings().all()
order = {item_id: index for index, item_id in enumerate(ids_list)}
rows.sort(key=lambda row: order.get(row["id"], len(ids_list)))
return [(row["id"], _decode_row(row)) for row in rows]
async def get_many_by_status(self, type_value: str, status_filter: str) -> list[tuple[str, ItemDTO]]:
await self.get_connection()
params: dict[str, str] = {"type_value": type_value}
where_clauses = ['"type" = :type_value']
status_clause, status_params = _build_status_filter_clause(status_filter)
if status_clause:
where_clauses.append(status_clause)
params.update(status_params)
query = f'SELECT "id", "data", "created_at" FROM "history" WHERE {" AND ".join(where_clauses)} ORDER BY "created_at" DESC' # noqa: S608
result = await self._conn.execute(text(query), params)
rows = result.mappings().all()
return [(row["id"], _decode_row(row)) for row in rows]
async def get_item(self, type_value: str, **kwargs) -> ItemDTO | None:
"""
@ -265,12 +326,7 @@ class SqliteStore(metaclass=ThreadSafe):
if not row:
return None
row_date = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
data = json.loads(row["data"])
data.pop("_id", None)
item = init_class(ItemDTO, data, ITEM_DTO_FIELDS)
item._id = row["id"]
item.datetime = formatdate(row_date.replace(tzinfo=UTC).timestamp())
item = _decode_row(row)
return item if any(matches_condition(k, v, item.__dict__) for k, v in kwargs.items()) else None
@ -279,14 +335,10 @@ class SqliteStore(metaclass=ThreadSafe):
where_clauses: list[str] = ['"type" = :type_value']
params: dict[str, str] = {"type_value": type_value}
if status_filter:
if status_filter.startswith("!"):
status_value = status_filter[1:]
where_clauses.append("json_extract(data, '$.status') != :status")
params["status"] = status_value
else:
where_clauses.append("json_extract(data, '$.status') = :status")
params["status"] = status_filter
status_clause, status_params = _build_status_filter_clause(status_filter)
if status_clause:
where_clauses.append(status_clause)
params.update(status_params)
where_clause: str = " AND ".join(where_clauses)
count_query: str = f'SELECT COUNT(*) as count FROM "history" WHERE {where_clause}' # noqa: S608
@ -308,14 +360,10 @@ class SqliteStore(metaclass=ThreadSafe):
where_clauses: list[str] = ['"type" = :type_value']
params: dict[str, str | int] = {"type_value": type_value}
if status_filter:
if status_filter.startswith("!"):
status_value = status_filter[1:]
where_clauses.append("json_extract(data, '$.status') != :status")
params["status"] = status_value
else:
where_clauses.append("json_extract(data, '$.status') = :status")
params["status"] = status_filter
status_clause, status_params = _build_status_filter_clause(status_filter)
if status_clause:
where_clauses.append(status_clause)
params.update(status_params)
where_clause: str = " AND ".join(where_clauses)
count_query: str = f'SELECT COUNT(*) as count FROM "history" WHERE {where_clause}' # noqa: S608
@ -338,15 +386,7 @@ class SqliteStore(metaclass=ThreadSafe):
result = await self._conn.execute(text(query), params)
rows = result.mappings().all()
items: list[tuple[str, ItemDTO]] = []
for row in rows:
row_date = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
data = json.loads(row["data"])
data.pop("_id", None)
item = init_class(ItemDTO, data, ITEM_DTO_FIELDS)
item._id = row["id"]
item.datetime = formatdate(row_date.replace(tzinfo=UTC).timestamp())
items.append((row["id"], item))
items = [(row["id"], _decode_row(row)) for row in rows]
return items, total_items, page, total_pages
@ -378,6 +418,23 @@ class SqliteStore(metaclass=ThreadSafe):
await self._conn.commit()
return result.rowcount if result else 0
async def bulk_delete_by_status(self, type_value: str, status_filter: str) -> int:
await self.get_connection()
params: dict[str, str] = {"type_value": type_value}
where_clauses = ['"type" = :type_value']
status_clause, status_params = _build_status_filter_clause(status_filter)
if status_clause:
where_clauses.append(status_clause)
params.update(status_params)
result = await self._conn.execute(
text(f'DELETE FROM "history" WHERE {" AND ".join(where_clauses)}'), # noqa: S608
params,
)
await self._conn.commit()
return result.rowcount if result else 0
async def enqueue_upsert(self, type_value: str, item: ItemDTO) -> None:
await self._enqueue(_Op("upsert", type_value, item, None, None))
@ -553,7 +610,7 @@ class SqliteStore(metaclass=ThreadSafe):
from app.main import ROOT_PATH
if self._db_path.startswith(":memory"):
db_url: str = "sqlite+aiosqlite:///file::memory:?cache=shared&uri=true"
db_url: str = _memory_db_url(self._db_path)
else:
os.makedirs(os.path.dirname(self._db_path) or ".", exist_ok=True)
db_url: str = f"sqlite+aiosqlite:///{self._db_path}"

View file

@ -177,9 +177,15 @@ async def items_delete(request: Request, queue: DownloadQueue, encoder: Encoder)
status=web.HTTPBadRequest.status_code,
)
ds = queue.queue if storeType == StoreType.QUEUE else queue.done
if ids:
if storeType == StoreType.HISTORY:
result = await queue.clear_bulk(ids, remove_file=remove_file)
return web.json_response(
data={"items": {}, "deleted": int(result.get("deleted", 0))},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
return web.json_response(
data={
"items": await (
@ -198,6 +204,23 @@ async def items_delete(request: Request, queue: DownloadQueue, encoder: Encoder)
status=web.HTTPBadRequest.status_code,
)
if storeType == StoreType.HISTORY:
result = await queue.clear_by_status(status_filter, remove_file=remove_file)
deleted = int(result.get("deleted", 0))
if deleted < 1:
return web.json_response(
data={"items": {}, "deleted": 0},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
return web.json_response(
data={"items": {}, "deleted": deleted},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
ds = queue.queue
items_to_delete = []
page = 1
per_page = 1000
@ -223,11 +246,7 @@ async def items_delete(request: Request, queue: DownloadQueue, encoder: Encoder)
return web.json_response(
data={
"items": await (
queue.cancel(items_to_delete)
if storeType == StoreType.QUEUE
else queue.clear(items_to_delete, remove_file=remove_file)
),
"items": await queue.cancel(items_to_delete),
"deleted": len(items_to_delete),
},
status=web.HTTPOk.status_code,

View file

@ -1,5 +1,6 @@
import asyncio
import logging
import os
import time
from pathlib import Path
@ -11,6 +12,7 @@ from app.features.dl_fields.service import DLFields
from app.features.presets.service import Presets
from app.library.config import Config
from app.library.downloads import DownloadQueue
from app.library.downloads.core import Download
from app.library.encoder import Encoder
from app.library.Events import EventBus, Events
from app.library.router import route
@ -363,3 +365,114 @@ async def stream_terminal_session(
{"error": str(exc)},
status=web.HTTPBadRequest.status_code,
)
@route("GET", "api/system/limits", "system.limits")
async def system_limits(queue: DownloadQueue, config: Config, encoder: Encoder) -> Response:
"""Return user-facing operational limits for downloads and extraction."""
active_downloads: dict[str, Download] = queue.pool.get_active_downloads()
queue_items: list[tuple[str, Download]] = list(queue.queue.items())
active_non_live: list[Download] = [download for download in active_downloads.values() if not download.is_live]
active_live: list[Download] = [download for download in active_downloads.values() if download.is_live]
queued_non_live: list[Download] = [
download
for _, download in queue_items
if not download.started() and not download.is_live and getattr(download.info, "auto_start", True) is not False
]
def _get_extractor_override_limits(config: Config) -> dict[str, dict[str, int | str]]:
overrides: dict[str, dict[str, int | str]] = {}
prefix = "YTP_MAX_WORKERS_FOR_"
for env_key, raw_value in os.environ.items():
if not env_key.startswith(prefix):
continue
if not (extractor := env_key.removeprefix(prefix).strip().lower()):
continue
if not raw_value.isdigit() or int(raw_value) < 1:
continue
configured = int(raw_value)
overrides[extractor] = {
"configured": raw_value,
"effective": min(configured, config.max_workers),
"source": "env_override",
}
return overrides
overrides: dict[str, dict[str, int | str]] = _get_extractor_override_limits(config)
per_extractor: dict[str, dict[str, int | str]] = {}
for download in [*active_non_live, *queued_non_live]:
extractor = (download.info.get_extractor() or "unknown").lower()
entry = per_extractor.setdefault(
extractor,
{
"name": extractor,
"limit": config.max_workers_per_extractor,
"source": "default",
"active": 0,
"queued": 0,
"available": config.max_workers_per_extractor,
},
)
if extractor in overrides:
entry["limit"] = overrides[extractor]["effective"]
entry["source"] = overrides[extractor]["source"]
if download in active_non_live:
entry["active"] += 1
else:
entry["queued"] += 1
for extractor, override in overrides.items():
per_extractor.setdefault(
extractor,
{
"name": extractor,
"limit": override["effective"],
"source": override["source"],
"active": 0,
"queued": 0,
"available": override["effective"],
},
)
for entry in per_extractor.values():
entry["available"] = max(int(entry["limit"]) - int(entry["active"]), 0)
return web.json_response(
data={
"downloads": {
"paused": queue.is_paused(),
"live_bypasses_limits": True,
"global": {
"limit": config.max_workers,
"active": len(active_non_live),
"available": max(config.max_workers - len(active_non_live), 0),
"live_active": len(active_live),
"queued": len(queued_non_live),
},
"per_extractor": {
"default_limit": config.max_workers_per_extractor,
"items": sorted(per_extractor.values(), key=lambda item: str(item["name"])),
},
},
"extraction": {
"concurrency": config.extract_info_concurrency,
"timeout_seconds": config.extract_info_timeout,
"info_cache_ttl_seconds": config.download_info_expires,
},
"live": {
"prevent_premiere": config.prevent_live_premiere,
"premiere_buffer_minutes": config.live_premiere_buffer,
},
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)

View file

@ -4,6 +4,8 @@ from collections import OrderedDict
from dataclasses import asdict
from datetime import UTC, datetime
from email.utils import formatdate
from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest
@ -28,9 +30,9 @@ async def reset_sqlite_store() -> None:
async def make_db(data: int = 0) -> SqliteStore:
"""Create a temporary database with test data."""
"""Create a named in-memory database with test data."""
await reset_sqlite_store()
ins = SqliteStore.get_instance(db_path=":memory:")
ins = SqliteStore.get_instance(db_path=f":memory:test-datastore-{uuid4().hex}")
await ins.get_connection()
base_time = datetime.now(UTC)
@ -216,6 +218,31 @@ class TestDataStore:
ok = await store.test()
assert ok is True
@pytest.mark.asyncio
async def test_bulk_delete_by_status_drops_matching_cached_items(self) -> None:
connection = Mock()
connection.bulk_delete_by_status = AsyncMock(return_value=2)
store = DataStore(StoreType.HISTORY, connection)
finished = make_item(id="finished")
finished.status = "finished"
skipped = make_item(id="skipped")
skipped.status = "skip"
pending = make_item(id="pending")
pending.status = "pending"
store._dict[finished._id] = StubDownload(info=finished)
store._dict[skipped._id] = StubDownload(info=skipped)
store._dict[pending._id] = StubDownload(info=pending)
deleted = await store.bulk_delete_by_status("finished,skip")
assert deleted == 2
connection.bulk_delete_by_status.assert_awaited_once_with("done", "finished,skip")
assert finished._id not in store._dict
assert skipped._id not in store._dict
assert pending._id in store._dict
@pytest.mark.asyncio
async def test_get_item_returns_none_when_no_kwargs(self) -> None:
"""Test that get_item returns None when no kwargs provided."""

View file

@ -1,5 +1,6 @@
import json
from datetime import UTC, datetime
from uuid import uuid4
import pytest
import pytest_asyncio
@ -25,9 +26,10 @@ async def reset_sqlite_store() -> None:
async def make_db(data: int = 100) -> SqliteStore:
"""Create a temporary database with test data."""
"""Create a named in-memory database with test data."""
await reset_sqlite_store()
ins = SqliteStore.get_instance(db_path=":memory:")
db_path = f":memory:test-datastore-pagination-{uuid4().hex}"
ins = SqliteStore.get_instance(db_path=db_path)
await ins.get_connection()
base_time = datetime.now(UTC)
@ -235,6 +237,13 @@ class TestDataStorePagination:
"folder": "/downloads",
"status": "downloading",
}
item_data_skip = {
"url": "https://example.com/skip",
"title": "Skipped Video",
"id": "skip-video",
"folder": "/downloads",
"status": "skip",
}
db = await make_db(data=100)
try:
@ -258,6 +267,16 @@ class TestDataStorePagination:
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
),
)
await db.execute_raw(
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
(
"skip-id",
str(StoreType.HISTORY),
item_data_skip["url"],
json.dumps(item_data_skip),
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
),
)
datastore = DataStore(type=StoreType.HISTORY, connection=db)
# Filter for finished items only
@ -276,6 +295,14 @@ class TestDataStorePagination:
assert len(items) == 1
assert total == 1
assert items[0][1].info.status == "pending"
items, total, _page, _total_pages = await datastore.get_items_paginated(
page=1, per_page=200, status_filter="finished,skip"
)
assert total == 101
assert len(items) == 101
assert {item.info.status for _, item in items} == {"finished", "skip"}
finally:
await db.close()
@ -296,6 +323,13 @@ class TestDataStorePagination:
"folder": "/downloads",
"status": "error",
}
item_data_skip = {
"url": "https://example.com/skip2",
"title": "Skipped Video 2",
"id": "skip-video-2",
"folder": "/downloads",
"status": "skip",
}
db = await make_db(data=0)
try:
await db.execute_raw(
@ -318,6 +352,16 @@ class TestDataStorePagination:
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
),
)
await db.execute_raw(
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
(
"skip-id-2",
str(StoreType.HISTORY),
item_data_skip["url"],
json.dumps(item_data_skip),
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
),
)
datastore = DataStore(type=StoreType.HISTORY, connection=db)
@ -326,14 +370,22 @@ class TestDataStorePagination:
page=1, per_page=50, status_filter="!finished"
)
assert total == 2 # Only 2 non-finished items
assert len(items) == 2
assert total == 3
assert len(items) == 3
for _item_id, item in items:
assert item.info.status != "finished"
# Verify we have pending and error
# Verify we have pending, error, and skip
statuses = {item.info.status for _, item in items}
assert statuses == {"pending", "error"}
assert statuses == {"pending", "error", "skip"}
items, total, _page, _total_pages = await datastore.get_items_paginated(
page=1, per_page=50, status_filter="!finished,!skip"
)
assert total == 2
assert len(items) == 2
assert {item.info.status for _, item in items} == {"pending", "error"}
finally:
await db.close()

View file

@ -1362,6 +1362,78 @@ class TestQueueManager:
done_store._connection.flush.assert_awaited_once()
assert status[item.info._id] == "ok", "Clear should still report success after flushing deletes"
@pytest.mark.asyncio
async def test_clear_bulk_uses_bulk_delete_and_aggregate_notification(self) -> None:
queue_manager = object.__new__(DownloadQueue)
queue_manager.config = Mock(remove_files=False, download_path="/tmp")
queue_manager._notify = Mock()
item_one = Mock()
item_one.info = make_item(id="done-id-1", title="Finished clip 1")
item_one.info._id = "done-id-1"
item_one.info.status = "finished"
item_two = Mock()
item_two.info = make_item(id="done-id-2", title="Finished clip 2")
item_two.info._id = "done-id-2"
item_two.info.status = "finished"
done_store = Mock()
done_store.get_many_by_ids = AsyncMock(return_value=[("done-id-1", item_one), ("done-id-2", item_two)])
done_store.bulk_delete = AsyncMock(return_value=2)
queue_manager.done = done_store
result = await DownloadQueue.clear_bulk(queue_manager, ["done-id-1", "done-id-2"], remove_file=False)
assert result == {"deleted": 2}
done_store.get_many_by_ids.assert_awaited_once_with(["done-id-1", "done-id-2"])
done_store.bulk_delete.assert_awaited_once_with(["done-id-1", "done-id-2"])
queue_manager._notify.emit.assert_called_once()
assert queue_manager._notify.emit.call_args.args[0] == Events.ITEM_BULK_DELETED
assert queue_manager._notify.emit.call_args.kwargs["data"] == {"ids": ["done-id-1", "done-id-2"], "count": 2}
@pytest.mark.asyncio
async def test_clear_by_status_uses_status_fetch_before_bulk_delete(self) -> None:
queue_manager = object.__new__(DownloadQueue)
queue_manager.config = Mock(remove_files=False, download_path="/tmp")
queue_manager._notify = Mock()
done_store = Mock()
done_store.bulk_delete_by_status = AsyncMock(return_value=1)
done_store.get_many_by_status = AsyncMock()
queue_manager.done = done_store
result = await DownloadQueue.clear_by_status(queue_manager, "finished", remove_file=False)
assert result == {"deleted": 1}
done_store.bulk_delete_by_status.assert_awaited_once_with("finished")
done_store.get_many_by_status.assert_not_called()
queue_manager._notify.emit.assert_called_once()
assert queue_manager._notify.emit.call_args.args[0] == Events.ITEM_BULK_DELETED
assert queue_manager._notify.emit.call_args.kwargs["data"] == {"count": 1, "status": "finished"}
@pytest.mark.asyncio
async def test_clear_by_status_with_file_removal_fetches_matching_items(self) -> None:
queue_manager = object.__new__(DownloadQueue)
queue_manager.config = Mock(remove_files=True, download_path="/tmp")
queue_manager._notify = Mock()
item = Mock()
item.info = make_item(id="done-id", title="Finished clip")
item.info._id = "done-id"
item.info.status = "finished"
done_store = Mock()
done_store.get_many_by_status = AsyncMock(return_value=[("done-id", item)])
queue_manager.done = done_store
queue_manager.clear_bulk = AsyncMock(return_value={"deleted": 1})
result = await DownloadQueue.clear_by_status(queue_manager, "finished", remove_file=True)
assert result == {"deleted": 1}
done_store.get_many_by_status.assert_awaited_once_with("finished")
queue_manager.clear_bulk.assert_awaited_once_with(["done-id"], remove_file=True)
class TestPoolManager:
@pytest.mark.asyncio

View file

@ -10,31 +10,6 @@ from app.library.Events import Event, EventBus, EventListener, Events
class TestEvents:
"""Test the Events constants class."""
def test_events_constants_exist(self):
"""Test that all expected event constants exist."""
# Basic lifecycle events
assert Events.STARTUP == "startup"
assert Events.LOADED == "loaded"
assert Events.STARTED == "started"
assert Events.SHUTDOWN == "shutdown"
# Connection events
assert Events.CONNECTED == "connected"
assert Events.CONFIG_UPDATE == "config_update"
# Log events
assert Events.LOG_INFO == "log_info"
assert Events.LOG_WARNING == "log_warning"
assert Events.LOG_ERROR == "log_error"
assert Events.LOG_SUCCESS == "log_success"
# Item events
assert Events.ITEM_ADDED == "item_added"
assert Events.ITEM_UPDATED == "item_updated"
assert Events.ITEM_COMPLETED == "item_completed"
assert Events.ITEM_CANCELLED == "item_cancelled"
assert Events.ITEM_DELETED == "item_deleted"
def test_events_get_all(self):
"""Test Events.get_all() method returns all constants."""
all_events = Events.get_all()
@ -59,6 +34,7 @@ class TestEvents:
"item_completed",
"item_cancelled",
"item_deleted",
"item_bulk_deleted",
"item_paused",
"item_resumed",
"item_moved",
@ -94,6 +70,7 @@ class TestEvents:
Events.LOG_SUCCESS,
Events.ITEM_ADDED,
Events.ITEM_UPDATED,
Events.ITEM_BULK_DELETED,
]
for expected in expected_frontend:

View file

@ -0,0 +1,49 @@
import json
from typing import Any
from unittest.mock import AsyncMock, Mock
import pytest
from app.library.encoder import Encoder
from app.library.DataStore import StoreType
from app.routes.api.history import items_delete
class _FakeRequest:
def __init__(self, *, payload: dict[str, Any] | None = None) -> None:
self._payload = payload or {}
self.query: dict[str, str] = {}
self.match_info: dict[str, str] = {}
async def json(self) -> dict[str, Any]:
return self._payload
@pytest.mark.asyncio
async def test_items_delete_uses_bulk_history_status_clear() -> None:
request = _FakeRequest(payload={"type": StoreType.HISTORY.value, "status": "finished,skip", "remove_file": False})
queue = Mock()
queue.clear_by_status = AsyncMock(return_value={"deleted": 12})
encoder = Encoder()
response = await items_delete(request, queue, encoder)
assert response.status == 200
queue.clear_by_status.assert_awaited_once_with("finished,skip", remove_file=False)
body = json.loads(response.body.decode("utf-8"))
assert body == {"items": {}, "deleted": 12}
@pytest.mark.asyncio
async def test_items_delete_uses_bulk_history_id_clear() -> None:
request = _FakeRequest(payload={"type": StoreType.HISTORY.value, "ids": ["a", "b"], "remove_file": False})
queue = Mock()
queue.clear_bulk = AsyncMock(return_value={"deleted": 2})
encoder = Encoder()
response = await items_delete(request, queue, encoder)
assert response.status == 200
queue.clear_bulk.assert_awaited_once_with(["a", "b"], remove_file=False)
body = json.loads(response.body.decode("utf-8"))
assert body == {"items": {}, "deleted": 2}

View file

@ -1,4 +1,7 @@
from datetime import UTC, datetime, timedelta
import os
from tempfile import mkstemp
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy import text
@ -9,9 +12,11 @@ from app.library.sqlite_store import SqliteStore
async def make_store() -> SqliteStore:
"""Create an isolated in-memory SqliteStore instance with schema."""
"""Create an isolated temporary SqliteStore instance with schema."""
SqliteStore._reset_singleton()
store = SqliteStore.get_instance(db_path=":memory:")
fd, db_path = mkstemp(prefix="ytptube-sqlite-store-", suffix=".db")
os.close(fd)
store = SqliteStore.get_instance(db_path=db_path)
await store.get_connection()
assert store._engine is not None, "Engine should be initialized after _ensure_conn"
return store
@ -76,6 +81,31 @@ async def test_sqlalchemy_engine_disposed_on_close() -> None:
assert store._sessionmaker is None, "Sessionmaker should be None after close"
@pytest.mark.asyncio
async def test_named_in_memory_databases_are_isolated() -> None:
SqliteStore._reset_singleton()
first = SqliteStore.get_instance(db_path=":memory:named-a")
await first.get_connection()
await first.execute_raw(
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
(
"first",
"queue",
"https://example.com/a",
'{"id":"a","title":"A","url":"https://example.com/a","folder":"/downloads","status":"finished"}',
"2024-01-01 00:00:00",
),
)
await first.close()
SqliteStore._reset_singleton()
second = SqliteStore.get_instance(db_path=":memory:named-b")
await second.get_connection()
rows = await second.fetch_raw('SELECT "id" FROM "history" WHERE "id" = ?', ("first",))
assert rows == []
await second.close()
@pytest.mark.asyncio
async def test_enqueue_upsert_and_fetch_saved():
store = await make_store()
@ -131,14 +161,17 @@ async def test_count_with_status_filters():
store = await make_store()
finished_items = [make_item(i, status="finished") for i in range(2)]
pending_items = [make_item(i + 10, status="pending") for i in range(3)]
skipped_items = [make_item(i + 20, status="skip") for i in range(2)]
for itm in finished_items + pending_items:
for itm in finished_items + pending_items + skipped_items:
await store.enqueue_upsert("history", itm)
await store.flush()
assert await store.count("history", status_filter="finished") == 2
assert await store.count("history", status_filter="pending") == 3
assert await store.count("history", status_filter="!finished") == 3
assert await store.count("history", status_filter="finished,skip") == 4
assert await store.count("history", status_filter="!finished") == 5
assert await store.count("history", status_filter="!finished,!skip") == 3
await store.close()
@ -227,6 +260,44 @@ async def test_enqueue_bulk_delete_returns_count_and_bulk_path():
await store.close()
@pytest.mark.asyncio
async def test_get_many_by_ids_and_status_and_bulk_delete_by_status():
store = await make_store()
finished = [make_item(i, status="finished") for i in range(2)]
pending = [make_item(i + 10, status="pending") for i in range(2)]
skipped = [make_item(i + 20, status="skip") for i in range(2)]
for item in finished + pending + skipped:
await store.enqueue_upsert("history", item)
await store.flush()
by_ids = await store.get_many_by_ids("history", [pending[1]._id, finished[0]._id])
assert [item_id for item_id, _ in by_ids] == [pending[1]._id, finished[0]._id]
by_status = await store.get_many_by_status("history", "finished")
assert {item_id for item_id, _ in by_status} == {finished[0]._id, finished[1]._id}
completed = await store.get_many_by_status("history", "finished,skip")
assert {item_id for item_id, _ in completed} == {
finished[0]._id,
finished[1]._id,
skipped[0]._id,
skipped[1]._id,
}
deleted = await store.bulk_delete_by_status("history", "finished")
assert deleted == 2
remaining = await store.fetch_saved("history")
assert {item_id for item_id, _ in remaining} == {
pending[0]._id,
pending[1]._id,
skipped[0]._id,
skipped[1]._id,
}
await store.close()
@pytest.mark.asyncio
async def test_paginate_out_of_range_returns_last_page():
store = await make_store()

View file

@ -1,10 +1,60 @@
import pytest
import json
from dataclasses import dataclass
from unittest.mock import AsyncMock, patch
import pytest
from app.library.config import Config
from app.library.encoder import Encoder
from app.library.UpdateChecker import UpdateChecker
from app.routes.api.system import check_updates
from app.routes.api.system import check_updates, system_limits
@dataclass
class DummyInfo:
extractor: str | None
def get_extractor(self) -> str | None:
return self.extractor
@dataclass
class DummyDownload:
info: DummyInfo
is_live: bool = False
_started: bool = False
def started(self) -> bool:
return self._started
class DummyPool:
def __init__(self, active: dict[str, DummyDownload] | None = None, paused: bool = False):
self._active = active or {}
self._paused = paused
def get_active_downloads(self) -> dict[str, DummyDownload]:
return self._active.copy()
def is_paused(self) -> bool:
return self._paused
class DummyStore:
def __init__(self, items: dict[str, DummyDownload] | None = None):
self._items = items or {}
def items(self):
return self._items.items()
class DummyQueue:
def __init__(self, active: dict[str, DummyDownload] | None = None, queued: dict[str, DummyDownload] | None = None):
self.pool = DummyPool(active=active)
self.queue = DummyStore(items=queued)
def is_paused(self) -> bool:
return self.pool.is_paused()
class TestCheckUpdatesEndpoint:
@ -64,3 +114,83 @@ class TestCheckUpdatesEndpoint:
body = response.body.decode("utf-8")
assert "v1.0.5" in body, "Response should include new version"
assert "update_available" in body, "Response should include update_available status"
class TestSystemLimitsEndpoint:
def setup_method(self):
Config._reset_singleton()
@pytest.mark.asyncio
async def test_system_limits_returns_user_facing_limits(self):
config = Config.get_instance()
config.max_workers = 10
config.max_workers_per_extractor = 3
config.extract_info_concurrency = 4
config.extract_info_timeout = 70
config.download_info_expires = 10800
config.prevent_live_premiere = True
config.live_premiere_buffer = 7
config.default_pagination = 50
active = {
"a": DummyDownload(info=DummyInfo("youtube"), is_live=False, _started=True),
"b": DummyDownload(info=DummyInfo("youtube"), is_live=False, _started=True),
"c": DummyDownload(info=DummyInfo("twitch"), is_live=True, _started=True),
}
queued = {
"q1": DummyDownload(info=DummyInfo("youtube"), is_live=False, _started=False),
"q2": DummyDownload(info=DummyInfo("vimeo"), is_live=False, _started=False),
}
queue = DummyQueue(active=active, queued=queued)
encoder = Encoder()
with patch.dict("os.environ", {"YTP_MAX_WORKERS_FOR_YOUTUBE": "2"}, clear=False):
response = await system_limits(queue, config, encoder)
assert 200 == response.status
body = json.loads(response.body.decode("utf-8"))
assert body["downloads"]["paused"] is False
assert body["downloads"]["live_bypasses_limits"] is True
assert body["downloads"]["global"] == {
"limit": 10,
"active": 2,
"available": 8,
"live_active": 1,
"queued": 2,
}
assert body["extraction"] == {
"concurrency": 4,
"timeout_seconds": 70,
"info_cache_ttl_seconds": 10800,
}
assert body["live"] == {
"prevent_premiere": True,
"premiere_buffer_minutes": 7,
}
per_extractor = {item["name"]: item for item in body["downloads"]["per_extractor"]["items"]}
assert body["downloads"]["per_extractor"]["default_limit"] == 3
assert per_extractor["youtube"] == {
"name": "youtube",
"limit": 2,
"source": "env_override",
"active": 2,
"queued": 1,
"available": 0,
}
assert per_extractor["vimeo"] == {
"name": "vimeo",
"limit": 3,
"source": "default",
"active": 0,
"queued": 1,
"available": 3,
}
def test_config_reads_prevent_live_premiere_boolean_env(self):
with patch.dict("os.environ", {"YTP_PREVENT_LIVE_PREMIERE": "false"}, clear=False):
Config._reset_singleton()
config = Config.get_instance()
assert config.prevent_live_premiere is False

View file

@ -33,6 +33,22 @@
}
@layer components {
.shell-surface {
background:
radial-gradient(circle at top left, rgb(99 102 241 / 0.1), transparent 28%),
radial-gradient(circle at top right, rgb(245 158 11 / 0.08), transparent 22%),
linear-gradient(180deg, rgb(255 255 255 / 0.84), rgb(248 250 252 / 0.9));
backdrop-filter: blur(16px);
}
.dark .shell-surface {
background:
radial-gradient(circle at top left, rgb(99 102 241 / 0.12), transparent 26%),
radial-gradient(circle at top right, rgb(245 158 11 / 0.07), transparent 20%),
linear-gradient(180deg, rgb(9 12 18 / 0.84), rgb(17 24 39 / 0.9));
backdrop-filter: blur(16px);
}
.play-overlay {
position: relative;
display: block;

View file

@ -0,0 +1,415 @@
<template>
<div class="w-full min-w-0 max-w-full space-y-5 p-4 sm:p-5">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 space-y-2">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-gauge" class="size-4 text-toned" />
<span>Queue capacity overview</span>
</div>
<p class="max-w-3xl text-sm text-toned">
Snapshot is based on the current queue state and configured backend limits.
<template v-if="limits">
{{ ' ' + queueLimitDescription }}
</template>
</p>
</div>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-refresh-cw"
:loading="limitsLoading"
:disabled="limitsLoading"
@click="void loadLimits(true)"
>
Refresh
</UButton>
</div>
<div
v-if="!limits && limitsLoading"
class="flex min-h-72 items-center justify-center rounded-md border border-default bg-default/90"
>
<div class="flex flex-col items-center gap-3 text-center text-toned">
<UIcon name="i-lucide-loader-circle" class="size-10 animate-spin text-info" />
<div class="space-y-1">
<p class="text-sm font-medium text-default">Loading limits</p>
<p class="text-xs">Fetching current queue capacity and runtime rules.</p>
</div>
</div>
</div>
<UAlert
v-else-if="!limits && limitsError"
color="error"
variant="soft"
icon="i-lucide-triangle-alert"
title="Failed to load limits"
:description="limitsError"
/>
<div v-else-if="limits" class="space-y-5">
<UAlert
v-if="limitsError"
color="warning"
variant="soft"
icon="i-lucide-triangle-alert"
title="Showing last successful snapshot"
:description="limitsError"
/>
<UAlert
v-if="limits.downloads.paused"
color="warning"
variant="soft"
icon="i-lucide-pause"
title="Download queue is paused"
/>
<div class="rounded-md border border-default bg-default shadow-sm">
<div class="grid gap-0 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<section
class="space-y-4 border-b border-default px-4 py-4 sm:px-5 lg:border-r lg:border-b-0"
>
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-download" class="size-4 text-toned" />
<span>Download capacity</span>
</div>
<div class="space-y-3">
<div
v-for="row in capacityRows"
:key="row.label"
class="rounded-md border border-default bg-muted/20 px-3 py-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 space-y-1">
<p class="text-sm font-medium text-default">{{ row.label }}</p>
<p class="text-xs text-toned">{{ row.description }}</p>
</div>
<div class="shrink-0 text-right">
<p class="text-lg font-semibold text-default">{{ row.value }}</p>
<p v-if="row.meta" class="text-xs text-toned">{{ row.meta }}</p>
</div>
</div>
</div>
</div>
</section>
<section class="space-y-4 px-4 py-4 sm:px-5">
<div class="space-y-3">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-settings-2" class="size-4 text-toned" />
<span>Extraction rules</span>
</div>
<div class="space-y-3">
<div
v-for="row in extractionRows"
:key="row.label"
class="rounded-md border border-default bg-muted/20 px-3 py-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 space-y-1">
<p class="text-sm font-medium text-default">{{ row.label }}</p>
<p class="text-xs text-toned">{{ row.description }}</p>
</div>
<div class="shrink-0 text-right">
<p class="text-lg font-semibold text-default">{{ row.value }}</p>
<p v-if="row.meta" class="text-xs text-toned">{{ row.meta }}</p>
</div>
</div>
</div>
</div>
</div>
<div class="space-y-3 border-t border-default pt-4">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-clock-3" class="size-4 text-toned" />
<span>Premiere handling</span>
</div>
<div class="space-y-3">
<div
v-for="row in premiereRows"
:key="row.label"
class="rounded-md border border-default bg-muted/20 px-3 py-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 space-y-1">
<p class="text-sm font-medium text-default">{{ row.label }}</p>
<p class="text-xs text-toned">{{ row.description }}</p>
</div>
<div class="shrink-0 text-right">
<p class="text-lg font-semibold text-default">{{ row.value }}</p>
<p v-if="row.meta" class="text-xs text-toned">{{ row.meta }}</p>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
<section class="space-y-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 space-y-2">
<div class="flex items-center gap-2 text-sm font-semibold text-highlighted">
<UIcon name="i-lucide-list" class="size-4 text-toned" />
<span>Per extractor</span>
</div>
<p class="text-sm text-toned">
Extractor-specific usage and overrides currently in effect.
</p>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-toned">
<span class="inline-flex items-center gap-1 rounded-sm border border-default px-2 py-1">
<UIcon name="i-lucide-gauge" class="size-3.5 shrink-0" />
<span>{{ limits.downloads.per_extractor.default_limit }} default per extractor</span>
</span>
<span class="inline-flex items-center gap-1 rounded-sm border border-default px-2 py-1">
<UIcon name="i-lucide-list" class="size-3.5 shrink-0" />
<span>{{ trackedExtractorCount }} tracked</span>
</span>
</div>
</div>
<UAlert
v-if="trackedExtractorCount === 0"
color="info"
variant="soft"
icon="i-lucide-info"
title="No extractor-specific activity"
description="Overrides and extractor usage appear here once downloads have active or queued work."
/>
<div
v-else
class="w-full min-w-0 max-w-full overflow-hidden rounded-md border border-default bg-default"
>
<div class="w-full max-w-full overflow-x-auto overscroll-x-contain">
<table class="min-w-180 w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-toned">
<tr
class="text-left [&>th]:border-r [&>th]:border-default/60 [&>th]:px-3 [&>th]:py-3 [&>th]:font-semibold [&>th:last-child]:border-r-0"
>
<th class="w-full min-w-56">Extractor</th>
<th class="w-36 whitespace-nowrap">Source</th>
<th class="w-24 whitespace-nowrap">Active</th>
<th class="w-24 whitespace-nowrap">Limit</th>
<th class="w-28 whitespace-nowrap">Available</th>
<th class="w-24 whitespace-nowrap">Queued</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="item in extractorItems"
:key="item.name"
class="transition-colors hover:bg-elevated/70 [&>td]:border-r [&>td]:border-default/60 [&>td:last-child]:border-r-0"
>
<td class="px-3 py-3 align-middle">
<div class="font-medium text-default">{{ item.name }}</div>
</td>
<td class="px-3 py-3 align-middle whitespace-nowrap text-toned">
<span
class="inline-flex items-center gap-1 rounded-sm border border-default px-2 py-1"
>
<UIcon :name="extractorSourceIcon(item.source)" class="size-3.5 shrink-0" />
<span>{{ extractorSourceLabel(item.source) }}</span>
</span>
</td>
<td class="px-3 py-3 align-middle font-medium whitespace-nowrap text-default">
{{ item.active }}
</td>
<td class="px-3 py-3 align-middle font-medium whitespace-nowrap text-default">
{{ item.limit }}
</td>
<td class="px-3 py-3 align-middle whitespace-nowrap text-toned">
{{ item.available }}
</td>
<td class="px-3 py-3 align-middle whitespace-nowrap text-toned">
{{ item.queued }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import moment from 'moment';
import { parse_api_error, parse_api_response, request } from '~/utils';
import type { SystemLimitsExtractor, SystemLimitsResponse } from '~/types/limits';
type DetailRow = {
label: string;
description: string;
value: string;
meta?: string;
};
const limits = ref<SystemLimitsResponse | null>(null);
const limitsLoading = ref(false);
const limitsError = ref('');
const formatDuration = (seconds: number): string => {
return moment.duration(seconds, 'seconds').humanize();
};
const extractorSourceLabel = (source: string): string => {
return source === 'env_override' ? 'Override' : 'Default';
};
const extractorSourceIcon = (source: string): string => {
return source === 'env_override' ? 'i-lucide-settings-2' : 'i-lucide-circle-check-big';
};
const queueLimitDescription = computed(() => {
if (limits.value?.downloads.live_bypasses_limits === false) {
return 'Regular worker slots apply to all downloads, including live downloads.';
}
return 'Regular worker slots apply only to non-live downloads.';
});
const capacityRows = computed<Array<DetailRow>>(() => {
if (!limits.value) {
return [];
}
const { downloads } = limits.value;
const { global } = downloads;
return [
{
label: 'Regular workers',
description: 'Slots available for non-live downloads.',
value: `${global.active}/${global.limit}`,
meta: `${global.available} available`,
},
{
label: 'Waiting queue',
description: 'Downloads waiting for a regular worker slot.',
value: `${global.queued}`,
meta: global.queued === 1 ? 'item queued' : 'items queued',
},
{
label: 'Live downloads',
description: downloads.live_bypasses_limits
? 'Live downloads bypass the regular worker limit.'
: 'Live downloads count toward the regular worker limit.',
value: `${global.live_active}`,
meta: global.live_active === 1 ? 'live item active' : 'live items active',
},
];
});
const extractionRows = computed<Array<DetailRow>>(() => {
if (!limits.value) {
return [];
}
return [
{
label: 'Concurrent requests',
description: 'Maximum info extraction jobs running at once.',
value: `${limits.value.extraction.concurrency}`,
},
{
label: 'Request timeout',
description: 'Per extraction attempt before the backend gives up.',
value: `${limits.value.extraction.timeout_seconds}s`,
},
{
label: 'Cached info TTL',
description: 'How long extracted info can be reused before refresh.',
value: formatDuration(limits.value.extraction.info_cache_ttl_seconds),
meta: `${limits.value.extraction.info_cache_ttl_seconds} seconds`,
},
];
});
const premiereRows = computed<Array<DetailRow>>(() => {
if (!limits.value) {
return [];
}
return [
{
label: 'Initial premiere capture',
description: 'Whether the first premiere capture is delayed until the buffer window passes.',
value: limits.value.live.prevent_premiere ? 'Delayed' : 'Immediate',
},
{
label: 'Premiere buffer',
description: 'Extra time added after the scheduled start before download begins.',
value: `${limits.value.live.premiere_buffer_minutes}m`,
meta: `${limits.value.live.premiere_buffer_minutes} minute buffer`,
},
];
});
const extractorItems = computed<Array<SystemLimitsExtractor>>(() => {
return limits.value?.downloads.per_extractor.items ?? [];
});
const trackedExtractorCount = computed(() => extractorItems.value.length);
const loadLimits = async (force: boolean = false): Promise<void> => {
if (limitsLoading.value) {
return;
}
if (limits.value && !force) {
return;
}
limitsError.value = '';
try {
limitsLoading.value = true;
const response = await request('/api/system/limits');
if (!response.ok) {
try {
limitsError.value = await parse_api_error(response.clone().json());
} catch {
limitsError.value = response.statusText || 'Failed to load limits.';
}
return;
}
limits.value = await parse_api_response<SystemLimitsResponse>(response.json());
} catch (e) {
limitsError.value = e instanceof Error ? e.message : 'Failed to load limits.';
} finally {
limitsLoading.value = false;
}
};
onMounted(() => {
void loadLimits(true);
});
</script>

View file

@ -285,7 +285,7 @@
import { useStorage } from '@vueuse/core';
import { watch } from 'vue';
import Hls from 'hls.js';
import { disableOpacity, enableOpacity } from '~/utils';
import { disableOpacity, enableOpacity, formatPageTitle } from '~/utils';
import { useKeyboardShortcuts } from '~/composables/useKeyboardShortcuts';
import type { StoreItem } from '~/types/store';
@ -319,6 +319,8 @@ const havePoster = ref(false);
const showHelp = ref(false);
const usingHls = ref(false);
useHead(() => (title.value ? { title: formatPageTitle(`Playing: ${title.value}`) } : {}));
let unbindMediaSessionListeners: null | (() => void) = null;
let hls: Hls | null = null;
let cleanupKeyboardShortcuts: null | (() => void) = null;
@ -679,10 +681,6 @@ onBeforeUnmount(() => {
unbindMediaSessionListeners = null;
}
if (title.value) {
window.document.title = 'YTPTube';
}
if (!video.value) {
return;
}
@ -706,10 +704,6 @@ const prepareVideoPlayer = () => {
applyMediaSessionMetadata();
if (title.value) {
window.document.title = `YTPTube - Playing: ${title.value}`;
}
if (!video.value) {
return;
}

View file

@ -15,7 +15,6 @@ const state = reactive<ConfigState>({
app: {
download_path: '/downloads',
remove_files: false,
ui_update_title: true,
output_template: '',
ytdlp_version: '',
max_workers: 20,

View file

@ -39,7 +39,7 @@
<Simple @show_settings="show_settings = true" />
</div>
<div v-else key="regular" class="shell-stage flex flex-col">
<div v-else key="regular" class="shell-stage shell-surface flex flex-col">
<Shutdown v-if="app_shutdown" />
<div id="main_container" class="shell-root flex flex-col" v-else>
@ -55,9 +55,15 @@
</template>
<template #actions>
<UButton color="neutral" variant="link" size="sm" class="px-0" @click="reloadPage">
Reload app
</UButton>
<div class="flex items-center gap-3">
<UButton to="/changelog" color="neutral" variant="link" size="sm" class="px-0">
View changelog
</UButton>
<UButton color="neutral" variant="link" size="sm" class="px-0" @click="reloadPage">
Reload app
</UButton>
</div>
</template>
</UAlert>
@ -167,19 +173,30 @@
<template #right>
<div class="flex items-center gap-1 sm:gap-2">
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-gauge"
@click="showLimits = true"
>
<span class="hidden xl:inline">Limits</span>
</UButton>
<NotifyDropdown />
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-refresh-cw"
@click="$router.go(0)"
>
<span class="hidden xl:inline">Reload</span>
</UButton>
icon="i-lucide-search"
aria-label="Search routes and actions"
title="Search routes and actions"
class="shrink-0 lg:hidden"
@click="showRouteSearch = true"
/>
<UDashboardSearchButton class="shrink-0" />
<UDashboardSearchButton class="hidden shrink-0 lg:inline-flex" />
<UButton
color="neutral"
@ -193,6 +210,16 @@
<span class="hidden xl:inline">{{ colorModeButtonTitle }}</span>
</UButton>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-refresh-cw"
@click="$router.go(0)"
>
<span class="hidden xl:inline">Reload</span>
</UButton>
<UButton
color="neutral"
variant="ghost"
@ -372,11 +399,7 @@
>
<UIcon name="i-lucide-info" class="size-4 text-warning" />
<span>Update available:</span>
<NuxtLink
:href="`https://github.com/ArabCoders/ytptube/releases/tag/${config.app.new_version}`"
target="_blank"
class="font-semibold text-highlighted"
>
<NuxtLink to="/changelog" class="font-semibold text-highlighted">
{{ config.app.new_version }}
</NuxtLink>
</p>
@ -475,6 +498,18 @@
placeholder="Search routes and actions"
:ui="{ modal: 'sm:max-w-3xl h-full sm:h-[28rem]' }"
/>
<UModal
v-if="showLimits"
:open="showLimits"
title="Download Limits"
:ui="{ content: 'sm:max-w-4xl', body: 'p-0' }"
@update:open="(open) => !open && (showLimits = false)"
>
<template #body>
<LimitsPage />
</template>
</UModal>
</UDashboardGroup>
</div>
</div>
@ -497,6 +532,9 @@ import { useStorage } from '@vueuse/core';
import moment from 'moment';
import { useMediaQuery } from '~/composables/useMediaQuery';
import type { toastPosition } from '~/composables/useNotification';
import LimitsPage from '~/components/LimitsPage.vue';
import { formatPageTitle, parse_api_response, request, syncOpacity, uri } from '~/utils';
import { getSidebarSwipeMode } from '~/utils/sidebarSwipe';
import type { YTDLPOption } from '~/types/ytdlp';
import { useDialog } from '~/composables/useDialog';
import Dialog from '~/components/Dialog.vue';
@ -520,7 +558,6 @@ type SidebarSection = {
type ColorModePreference = 'system' | 'light' | 'dark';
type SwipeMode = 'open' | 'close';
const MOBILE_SIDEBAR_EDGE_WIDTH = 32;
const MOBILE_SIDEBAR_MIN_SWIPE_DISTANCE = 64;
const socket = useAppSocket();
@ -538,6 +575,7 @@ const checkingUpdates = ref(false);
const updateCheckMessage = ref('Up to date - Click to check');
const showRouteSearch = ref(false);
const showSidebar = ref(false);
const showLimits = ref(false);
const { alertDialog, confirmDialog } = useDialog();
const isMobile = useMediaQuery({ query: '(max-width: 1023px)' });
@ -617,11 +655,11 @@ const handleSwipeStart = (event: TouchEvent): void => {
return;
}
const swipeMode: SwipeMode | null = showSidebar.value
? 'close'
: touch.clientX <= MOBILE_SIDEBAR_EDGE_WIDTH
? 'open'
: null;
const swipeMode: SwipeMode | null = getSidebarSwipeMode(
showSidebar.value,
touch.clientX,
navigator,
);
if (!swipeMode) {
resetSwipe();
@ -681,7 +719,7 @@ const handleSwipeCancel = (): void => {
};
const dashboardSidebarUi = {
root: 'border-r border-default bg-default/95 backdrop-blur-sm',
root: 'shell-surface border-r border-default bg-default/95 backdrop-blur-sm',
header: 'border-b border-default px-2.5 py-3',
body: 'gap-3 px-2.5 py-3',
footer: 'border-t border-default px-2.5 py-3',
@ -750,10 +788,17 @@ const sidebarItems = computed<
.filter((section) => section.items.some((group) => group.length > 0));
});
const pageTitle = computed(() => {
const match = getActiveNavItem(route, navigationAvailability.value);
return match?.navbarTitle || match?.label || 'YTPTube';
});
const activeNavItem = computed(() => getActiveNavItem(route, navigationAvailability.value));
const pageTitle = computed(
() => activeNavItem.value?.navbarTitle || activeNavItem.value?.label || 'YTPTube',
);
const documentTitle = computed(() => activeNavItem.value?.pageLabel || pageTitle.value);
useHead(() => ({
title: formatPageTitle(documentTitle.value),
}));
const buildTooltip = computed(
() =>

View file

@ -527,6 +527,7 @@ import moment from 'moment';
import { useStorage } from '@vueuse/core';
import type { DropdownMenuItem } from '@nuxt/ui';
import type { FileItem } from '~/types/filebrowser';
import { formatPageTitle } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
const route = useRoute();
@ -559,6 +560,7 @@ const pageShell = requirePageShell('files');
const hasItems = computed(() => filteredItems.value.length > 0);
const hasSelected = computed(() => selectedElms.value.length > 0);
const displayedItemPaths = computed(() => filteredItems.value.map((item) => item.path));
const browserPageTitle = computed(() => `File Browser: /${sTrim(browserPath.value || '/', '/')}`);
const currentDirectoryName = computed(
() => breadcrumbItems.value[breadcrumbItems.value.length - 1]?.name || 'Home',
);
@ -752,6 +754,10 @@ const itemSizeLabel = (item: FileItem): string => {
return item.type === 'file' ? formatBytes(item.size) : ucFirst(item.type);
};
useHead(() => ({
title: formatPageTitle(decodeURIComponent(browserPageTitle.value)),
}));
const updateUrl = (dir: string, page?: number): void => {
const normalizedDir = dir.replace(/^\/+/, '').replace(/\/+$/, '');
const displayDir = normalizedDir ? normalizedDir : '/';
@ -762,8 +768,6 @@ const updateUrl = (dir: string, page?: number): void => {
if (fullUrl !== window.location.href) {
history.replaceState({ path: normalizedDir || '/', title }, title, stateUrl);
}
useHead({ title: decodeURIComponent(title) });
};
const handleClick = (item: FileItem): void => {

View file

@ -193,8 +193,6 @@ const toast = useNotification();
const config = useYtpConfig();
const pageShell = requirePageShell('changelog');
useHead({ title: 'CHANGELOG' });
const PROJECT = 'ytptube';
const REPO = `https://github.com/arabcoders/${PROJECT}`;
const REPO_URL = `https://arabcoders.github.io/${PROJECT}/CHANGELOG.json?version={version}`;

View file

@ -327,8 +327,6 @@ import type { AutoCompleteOptions } from '~/types/autocomplete';
import { disableOpacity, enableOpacity } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
useHead({ title: 'Console' });
const MAX_HISTORY_ITEMS = 50;
const ACTIVE_SESSION_STATUSES = ['starting', 'running', 'reconnecting'];

View file

@ -49,6 +49,7 @@
<script setup lang="ts">
import Markdown from '~/components/Markdown.vue';
import { DOCS_ENTRIES, getDocsEntryBySlug } from '~/composables/useDocs';
import { formatPageTitle } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
const route = useRoute();
@ -71,7 +72,7 @@ const docEntry = computed(() => {
const pageShell = computed(() => requirePageShell(docEntry.value.id));
useHead(() => ({
title: docEntry.value.title,
title: formatPageTitle(docEntry.value.title),
}));
const pageCardUi = {

View file

@ -1131,16 +1131,13 @@ const clearCompleted = async (): Promise<void> => {
selectedElms.value = [];
await Promise.all([
deleteHistoryItems({ status: 'finished', removeFile: false }),
deleteHistoryItems({ status: 'skip', removeFile: false }),
]);
await deleteHistoryItems({ status: 'finished,skip', removeFile: false });
await reloadHistory({ order: 'DESC', perPage: config.app.default_pagination });
};
const clearIncomplete = async (): Promise<void> => {
if (false === (await box.confirm('Clear all in-complete downloads?'))) {
if (false === (await box.confirm('Clear all incomplete downloads?'))) {
return;
}

View file

@ -781,8 +781,7 @@ const embed_url = ref('');
const isRefreshing = ref(false);
const autoRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null);
const queueCount = computed(() => stateStore.count());
const hasQueueContent = computed(() => queueCount.value > 0 || query.value.trim().length > 0);
const hasQueueContent = computed(() => stateStore.count() > 0 || query.value.trim().length > 0);
const contentStyle = computed<'grid' | 'list'>(() =>
isMobile.value ? 'grid' : display_style.value,
);
@ -812,14 +811,6 @@ const thumbnailRatioClass = computed(() =>
thumbnail_ratio.value === 'is-16by9' ? 'aspect-video' : 'aspect-[3/1]',
);
const getTitle = (): string => {
if (!config.app.ui_update_title) {
return 'YTPTube';
}
return `YTPTube: ( ${stateStore.count()}/${config.app.max_workers}:${config.app.max_workers_per_extractor} )`;
};
const refreshQueue = async (): Promise<void> => {
if (isRefreshing.value) {
return;
@ -871,8 +862,6 @@ onMounted(async () => {
window.history.replaceState({}, '', url.toString());
}
useHead({ title: getTitle() });
if (Object.keys(pendingDownloadFormItem.value).length > 0) {
await toNewDownload(pendingDownloadFormItem.value);
pendingDownloadFormItem.value = {};
@ -891,18 +880,6 @@ watch(toggleFilter, () => {
}
});
watch(
() => stateStore.queue,
() => {
if (!config.app.ui_update_title) {
return;
}
useHead({ title: getTitle() });
},
{ deep: true },
);
watch(
() => socket.isConnected,
(connected) => {

View file

@ -38,7 +38,7 @@
icon="i-lucide-arrow-down"
@click="scrollToBottom(false)"
>
Jump to Live Tail
Scroll to bottom
</UButton>
<UButton
@ -79,69 +79,69 @@
<UPageCard variant="naked" :ui="pageCardUi">
<template #body>
<div class="w-full min-w-0 max-w-full space-y-3">
<div class="w-full min-w-0 max-w-full overflow-hidden">
<div class="overflow-hidden rounded-sm border border-default bg-default shadow-sm">
<div
ref="logContainer"
class="logbox overflow-x-auto overflow-y-auto bg-elevated/30 font-mono text-sm text-default overscroll-x-contain"
@scroll.passive="handleScroll"
>
<div
ref="logContainer"
class="logbox overflow-x-auto overflow-y-auto overscroll-x-contain"
@scroll.passive="handleScroll"
v-if="reachedEnd && !hasActiveFilter"
class="flex justify-center border-b border-default/40 px-4 py-3"
>
<div
:class="[
'space-y-2 font-mono text-[12px] leading-6 text-default',
textWrap ? 'w-full min-w-0' : 'w-max min-w-full',
]"
role="log"
aria-live="polite"
class="rounded-full border border-default bg-elevated/40 px-3 py-1 text-[11px] text-toned"
>
<div v-if="reachedEnd && !hasActiveFilter" class="flex justify-center">
<div
class="rounded-full border border-default bg-muted/40 px-3 py-1 text-[11px] text-toned"
>
No older lines remain in this file.
</div>
No older lines remain in this file.
</div>
</div>
<template v-if="filteredLogs.length > 0">
<article
v-for="(entry, index) in filteredLogs"
:key="entry.log.id"
:class="logRowClass(entry, index)"
>
<div class="log-timestamp" :title="logTimeTitle(entry.log.datetime)">
{{ logTimeLabel(entry.log.datetime) }}
</div>
<div v-if="filteredLogs.length > 0" class="space-y-1.5">
<article
v-for="(entry, index) in filteredLogs"
:key="entry.log.id"
:class="[
logRowClass(entry, index),
textWrap ? 'log-row--wrap-fit' : 'log-row--nowrap-fit',
]"
>
<span class="log-timestamp" :title="logTimeTitle(entry.log.datetime)">
{{ logTimeLabel(entry.log.datetime) }}
</span>
<p class="log-line" :class="textWrap ? 'log-line--wrap' : 'log-line--nowrap'">
{{ entry.log.line }}
</p>
</article>
</div>
<div v-else class="space-y-3 font-sans text-sm leading-normal">
<UAlert
v-if="query"
color="warning"
variant="soft"
icon="i-lucide-search"
title="No Results"
:description="`No log lines found for the query: ${query}. Please try a different search term.`"
/>
<UAlert
v-else
color="warning"
variant="soft"
icon="i-lucide-circle-alert"
title="No log lines"
description="No log lines are available yet."
<div
class="log-content"
:class="textWrap ? 'log-content--wrap' : 'log-content--nowrap'"
>
<span
class="log-tone-dot"
:class="`log-tone-dot--${detectLogTone(entry.log.line)}`"
/>
<p class="log-line" :class="textWrap ? 'log-line--wrap' : 'log-line--nowrap'">
{{ entry.log.line }}
</p>
</div>
</article>
</template>
<div ref="bottomMarker" />
<div
v-else
class="flex min-h-[55vh] flex-col items-center justify-center gap-3 px-6 py-8 text-center font-sans"
>
<UIcon
:name="query ? 'i-lucide-filter-x' : 'i-lucide-circle-off'"
class="size-6 text-toned"
/>
<div class="space-y-1">
<p class="text-sm font-medium text-default">
{{ query ? 'No logs match this query' : 'No log lines available' }}
</p>
<p class="text-sm text-toned">
{{
query
? `No log lines found for the query: ${query}. Please try a different search term.`
: 'No log lines are available yet.'
}}
</p>
</div>
</div>
</div>
@ -157,7 +157,7 @@ import type { EventSourceMessage } from '@microsoft/fetch-event-source';
import moment from 'moment';
import { useStorage } from '@vueuse/core';
import type { log_line } from '~/types/logs';
import { disableOpacity, enableOpacity, parse_api_error, request, uri } from '~/utils';
import { parse_api_error, request, uri } from '~/utils';
import { requirePageShell } from '~/utils/topLevelNavigation';
type FilteredLogEntry = {
@ -178,7 +178,6 @@ const route = useRoute();
const pageShell = requirePageShell('logs');
const logContainer = useTemplateRef<HTMLDivElement>('logContainer');
const bottomMarker = useTemplateRef<HTMLDivElement>('bottomMarker');
const textWrap = useStorage<boolean>('logs_wrap', true);
const sseController = ref<AbortController | null>(null);
@ -277,6 +276,19 @@ const filteredLogs = computed<FilteredLogEntry[]>(() => {
return result;
});
const scrollLogContainerToBottom = async (behavior: ScrollBehavior = 'auto'): Promise<void> => {
await nextTick();
if (!logContainer.value) {
return;
}
logContainer.value.scrollTo({
top: logContainer.value.scrollHeight,
behavior,
});
};
const fetchLogs = async (): Promise<void> => {
loading.value = true;
@ -311,11 +323,9 @@ const fetchLogs = async (): Promise<void> => {
reachedEnd.value = true;
}
nextTick(() => {
if (autoScroll.value && bottomMarker.value) {
bottomMarker.value.scrollIntoView({ behavior: 'auto' });
}
});
if (autoScroll.value) {
await scrollLogContainerToBottom();
}
} catch (error) {
console.error('Failed to fetch logs:', error);
} finally {
@ -347,11 +357,9 @@ const handleScroll = (): void => {
}
};
const scrollToBottom = (fast = false): void => {
const scrollToBottom = async (fast = false): Promise<void> => {
autoScroll.value = true;
nextTick(() => {
bottomMarker.value?.scrollIntoView({ behavior: fast ? 'auto' : 'smooth' });
});
await scrollLogContainerToBottom(fast ? 'auto' : 'smooth');
};
const handleStreamMessage = (event: EventSourceMessage): void => {
@ -372,11 +380,9 @@ const handleStreamMessage = (event: EventSourceMessage): void => {
logs.value.push(payload);
nextTick(() => {
if (autoScroll.value && bottomMarker.value) {
bottomMarker.value.scrollIntoView({ behavior: 'smooth' });
}
});
if (autoScroll.value) {
void scrollLogContainerToBottom('smooth');
}
};
const startLogStream = async (): Promise<void> => {
@ -483,22 +489,18 @@ onMounted(async () => {
return;
}
disableOpacity();
await fetchLogs();
await startLogStream();
});
onBeforeUnmount(() => {
sseController.value?.abort();
enableOpacity();
if (scrollTimeout) {
clearTimeout(scrollTimeout);
scrollTimeout = null;
}
});
useHead({ title: 'Logs' });
</script>
<style scoped>
@ -509,67 +511,94 @@ useHead({ title: 'Logs' });
min-height: 55vh;
max-height: 60vh;
background: transparent;
padding: 0.75rem;
}
.log-row {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 0.75rem;
border: 1px solid transparent;
border-radius: 0.75rem;
border-bottom: 1px solid color-mix(in oklab, var(--ui-border) 40%, transparent);
box-sizing: border-box;
padding: 0.625rem 0.75rem;
background: var(--ui-bg);
background: transparent;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.log-row--wrap-fit {
width: 100%;
}
.log-row--nowrap-fit {
min-width: 100%;
width: max-content;
.log-row:last-child {
border-bottom: 0;
}
.log-row--alt {
background: var(--ui-bg-elevated);
background: color-mix(in oklab, var(--ui-bg-elevated) 40%, transparent);
}
.log-row--match {
border-color: color-mix(in oklab, var(--ui-warning) 35%, transparent);
background: color-mix(in oklab, var(--ui-warning) 12%, var(--ui-bg) 88%);
background: color-mix(in oklab, var(--ui-warning) 10%, var(--ui-bg-elevated) 90%);
}
.log-row--context {
border-color: color-mix(in oklab, var(--ui-border) 75%, transparent);
background: color-mix(in oklab, var(--ui-bg-muted) 30%, var(--ui-bg) 70%);
background: color-mix(in oklab, var(--ui-bg-muted) 30%, transparent);
}
.log-row:hover {
border-color: var(--ui-border-accented);
background: color-mix(in oklab, var(--ui-bg-elevated) 65%, var(--ui-bg-muted) 35%);
background: color-mix(in oklab, var(--ui-bg-elevated) 70%, var(--ui-bg-muted) 30%);
}
.log-timestamp {
display: inline-flex;
min-width: 4.75rem;
display: flex;
width: 5.5rem;
min-width: 5.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
border: 1px solid color-mix(in oklab, var(--ui-border) 80%, transparent);
background: color-mix(in oklab, var(--ui-bg-muted) 50%, var(--ui-bg) 50%);
padding: 0.2rem 0.55rem;
justify-content: flex-end;
border-right: 1px solid color-mix(in oklab, var(--ui-border) 40%, transparent);
background: color-mix(in oklab, var(--ui-bg-elevated) 55%, transparent);
padding: 0.65rem 0.75rem;
font-size: 11px;
font-weight: 600;
color: var(--ui-text-toned);
}
.log-content {
display: flex;
min-width: 0;
flex: 1;
align-items: flex-start;
gap: 0.65rem;
padding: 0.65rem 0.75rem;
line-height: 1.6;
}
.log-content--nowrap {
width: max-content;
min-width: calc(100% - 5.5rem);
}
.log-tone-dot {
display: inline-flex;
width: 0.5rem;
min-width: 0.5rem;
height: 0.5rem;
margin-top: 0.45rem;
border-radius: 9999px;
}
.log-tone-dot--default {
background: color-mix(in oklab, var(--ui-text-toned) 55%, transparent);
}
.log-tone-dot--info {
background: var(--ui-info);
}
.log-tone-dot--warning {
background: var(--ui-warning);
}
.log-tone-dot--error {
background: var(--ui-error);
}
.log-line {
flex: 1;
min-width: 0;

View file

@ -7,8 +7,6 @@ type AppConfig = {
download_path: string;
/** Indicates if files should be removed after download */
remove_files: boolean;
/** Indicates if the UI should update the title with the current download status */
ui_update_title: boolean;
/** Output template for yt-dlp, e.g. "%(title)s.%(ext)s" */
output_template: string;
/** yt-dlp version, e.g. "2023.10.01" */

35
ui/app/types/limits.ts Normal file
View file

@ -0,0 +1,35 @@
export type SystemLimitsExtractor = {
name: string;
limit: number;
source: string;
active: number;
queued: number;
available: number;
};
export type SystemLimitsResponse = {
downloads: {
paused: boolean;
live_bypasses_limits: boolean;
global: {
limit: number;
active: number;
available: number;
live_active: number;
queued: number;
};
per_extractor: {
default_limit: number;
items: SystemLimitsExtractor[];
};
};
extraction: {
concurrency: number;
timeout_seconds: number;
info_cache_ttl_seconds: number;
};
live: {
prevent_premiere: boolean;
premiere_buffer_minutes: number;
};
};

View file

@ -25,6 +25,7 @@ export type WSEP = {
item_updated: EventPayload<StoreItem>;
item_cancelled: EventPayload<StoreItem>;
item_deleted: EventPayload<StoreItem>;
item_bulk_deleted: EventPayload<{ count: number; status?: string; ids?: string[] }>;
item_moved: EventPayload<{ to: 'queue' | 'history'; item: StoreItem }>;
item_status: EventPayload<{ status?: string; msg?: string; preset?: string }>;
paused: EventPayload<{ paused?: boolean }>;

View file

@ -3,6 +3,7 @@ import type { convert_args_response, Paginated } from '~/types/responses';
import type { StoreItem } from '~/types/store';
const AG_SEPARATOR = '.';
const APP_TITLE = 'YTPTube';
const separators = [
{ name: 'Comma', value: ',' },
@ -971,7 +972,18 @@ const parse_api_error = async (json: unknown): Promise<string> => {
return 'Unknown error occurred';
};
const formatPageTitle = (title?: string | null): string => {
const normalized = title?.trim();
if (!normalized || normalized === APP_TITLE) {
return APP_TITLE;
}
return `${normalized} | ${APP_TITLE}`;
};
export {
APP_TITLE,
separators,
convertCliOptions,
getSeparatorsName,
@ -1019,4 +1031,5 @@ export {
parse_list_response,
parse_api_response,
parse_api_error,
formatPageTitle,
};

View file

@ -0,0 +1,54 @@
type SidebarSwipeMode = 'open' | 'close';
type NavigatorLike = {
userAgent?: string;
platform?: string;
maxTouchPoints?: number;
};
const MOBILE_SIDEBAR_EDGE_WIDTH = 32;
const IOS_NAVIGATION_EDGE_WIDTH = 24;
const isAppleMobileTouchNavigator = (nav?: NavigatorLike): boolean => {
if (!nav) {
return false;
}
const userAgent = nav.userAgent ?? '';
const platform = nav.platform ?? '';
const maxTouchPoints = Number(nav.maxTouchPoints ?? 0);
const isiPhoneLike = /(iPhone|iPod|iPad)/i.test(userAgent);
const isiPadDesktopMode = platform === 'MacIntel' && maxTouchPoints > 1;
return /AppleWebKit/i.test(userAgent) && (isiPhoneLike || isiPadDesktopMode);
};
const canStartSidebarOpenSwipe = (
touchX: number,
nav?: NavigatorLike,
edgeWidth: number = MOBILE_SIDEBAR_EDGE_WIDTH,
): boolean => {
const reservedEdge = isAppleMobileTouchNavigator(nav) ? IOS_NAVIGATION_EDGE_WIDTH : 0;
return touchX > reservedEdge && touchX <= reservedEdge + edgeWidth;
};
const getSidebarSwipeMode = (
sidebarOpen: boolean,
touchX: number,
nav?: NavigatorLike,
): SidebarSwipeMode | null => {
if (sidebarOpen) {
return 'close';
}
return canStartSidebarOpenSwipe(touchX, nav) ? 'open' : null;
};
export {
MOBILE_SIDEBAR_EDGE_WIDTH,
IOS_NAVIGATION_EDGE_WIDTH,
canStartSidebarOpenSwipe,
getSidebarSwipeMode,
isAppleMobileTouchNavigator,
};
export type { NavigatorLike, SidebarSwipeMode };

View file

@ -0,0 +1,73 @@
import { describe, expect, it } from 'bun:test';
import {
IOS_NAVIGATION_EDGE_WIDTH,
MOBILE_SIDEBAR_EDGE_WIDTH,
canStartSidebarOpenSwipe,
getSidebarSwipeMode,
isAppleMobileTouchNavigator,
} from '~/utils/sidebarSwipe';
describe('sidebarSwipe', () => {
it('detects apple mobile webkit navigators', () => {
expect(
isAppleMobileTouchNavigator({
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 Version/18.4 Mobile/15E148 Safari/604.1',
platform: 'iPhone',
maxTouchPoints: 5,
}),
).toBe(true);
expect(
isAppleMobileTouchNavigator({
userAgent:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Version/18.4 Mobile/15E148 Safari/604.1',
platform: 'MacIntel',
maxTouchPoints: 5,
}),
).toBe(true);
expect(
isAppleMobileTouchNavigator({
userAgent:
'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/147.0.0.0 Mobile Safari/537.36',
platform: 'Linux armv8l',
maxTouchPoints: 5,
}),
).toBe(false);
});
it('reserves the ios navigation edge and starts the sidebar swipe just inside it', () => {
const nav = {
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_4 like Mac OS X) AppleWebKit/605.1.15 Version/18.4 Mobile/15E148 Safari/604.1',
platform: 'iPhone',
maxTouchPoints: 5,
};
expect(canStartSidebarOpenSwipe(IOS_NAVIGATION_EDGE_WIDTH, nav)).toBe(false);
expect(canStartSidebarOpenSwipe(IOS_NAVIGATION_EDGE_WIDTH + 1, nav)).toBe(true);
expect(canStartSidebarOpenSwipe(IOS_NAVIGATION_EDGE_WIDTH + MOBILE_SIDEBAR_EDGE_WIDTH, nav)).toBe(true);
expect(canStartSidebarOpenSwipe(IOS_NAVIGATION_EDGE_WIDTH + MOBILE_SIDEBAR_EDGE_WIDTH + 1, nav)).toBe(
false,
);
});
it('keeps the original left-edge open band on non-apple mobile browsers', () => {
const nav = {
userAgent:
'Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 Chrome/147.0.0.0 Mobile Safari/537.36',
platform: 'Linux armv8l',
maxTouchPoints: 5,
};
expect(canStartSidebarOpenSwipe(1, nav)).toBe(true);
expect(canStartSidebarOpenSwipe(MOBILE_SIDEBAR_EDGE_WIDTH, nav)).toBe(true);
expect(canStartSidebarOpenSwipe(MOBILE_SIDEBAR_EDGE_WIDTH + 1, nav)).toBe(false);
});
it('returns close while the sidebar is already open', () => {
expect(getSidebarSwipeMode(true, 4)).toBe('close');
});
});