Merge pull request #592 from arabcoders/dev
fix: Slowness in deleting big number of items
This commit is contained in:
commit
4ee7537733
36 changed files with 1750 additions and 290 deletions
81
API.md
81
API.md
|
|
@ -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
1
FAQ.md
|
|
@ -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)` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
49
app/tests/test_history_routes.py
Normal file
49
app/tests/test_history_routes.py
Normal 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}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
415
ui/app/components/LimitsPage.vue
Normal file
415
ui/app/components/LimitsPage.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
2
ui/app/types/config.d.ts
vendored
2
ui/app/types/config.d.ts
vendored
|
|
@ -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
35
ui/app/types/limits.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
1
ui/app/types/sockets.d.ts
vendored
1
ui/app/types/sockets.d.ts
vendored
|
|
@ -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 }>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
54
ui/app/utils/sidebarSwipe.ts
Normal file
54
ui/app/utils/sidebarSwipe.ts
Normal 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 };
|
||||
73
ui/tests/utils/sidebarSwipe.test.ts
Normal file
73
ui/tests/utils/sidebarSwipe.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue