Skip to content

Commit 4ee7537

Browse files
authored
Merge pull request #592 from arabcoders/dev
fix: Slowness in deleting big number of items
2 parents fdc0898 + 73410f2 commit 4ee7537

36 files changed

Lines changed: 1749 additions & 289 deletions

API.md

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ This document describes the available endpoints and their usage. All endpoints r
9696
- [POST /api/notifications/test](#post-apinotificationstest)
9797
- [GET /api/yt-dlp/options](#get-apiyt-dlpoptions)
9898
- [GET /api/system/configuration](#get-apisystemconfiguration)
99+
- [GET /api/system/limits](#get-apisystemlimits)
99100
- [POST /api/system/terminal](#post-apisystemterminal)
100101
- [GET /api/system/terminal/active](#get-apisystemterminalactive)
101102
- [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
132133
- [`item_updated`](#item_updated)
133134
- [`item_cancelled`](#item_cancelled)
134135
- [`item_deleted`](#item_deleted)
136+
- [`item_bulk_deleted`](#item_bulk_deleted)
135137
- [`item_moved`](#item_moved)
136138
- [`item_status`](#item_status)
137139
- [`paused`](#paused)
@@ -381,7 +383,7 @@ or an error:
381383
**Body Parameters**:
382384
- `type` (string, required): Type of items - `"queue"` or `"done"`
383385
- `ids` (array, optional): List of specific item IDs to delete. If provided, `status` filter is ignored
384-
- `status` (string, optional): Filter by status (e.g., `"finished"`, `"!finished"`). Required if `ids` not provided
386+
- `status` (string, optional): Filter by status (e.g., `"finished"`, `"!finished"`, `"finished,skip"`, `"!finished,!skip"`). Required if `ids` not provided
385387
- `remove_file` (boolean, optional): Whether to delete files from disk. Default: `true`.
386388

387389
> [!NOTE]
@@ -416,6 +418,15 @@ or an error:
416418
}
417419
```
418420

421+
**Delete all completed and skipped items in one request:**
422+
```json
423+
{
424+
"type": "done",
425+
"status": "finished,skip",
426+
"remove_file": false
427+
}
428+
```
429+
419430
**Response**:
420431
```json
421432
{
@@ -448,6 +459,7 @@ or an error:
448459
**Notes**:
449460
- When using filter mode, all matching items will be deleted.
450461
- Filter mode with `{ "status": "!finished" }` is useful for cleaning up failed/pending downloads.
462+
- `status` also accepts comma-separated include filters (`finished,skip`) and comma-separated exclude filters (`!finished,!skip`).
451463
- Filter mode returns a `deleted` count indicating how many items were removed.
452464

453465
---
@@ -2479,6 +2491,55 @@ or an error:
24792491

24802492
---
24812493

2494+
### GET /api/system/limits
2495+
**Purpose**: Get the system limits.
2496+
2497+
**Response**:
2498+
```json
2499+
{
2500+
"downloads": {
2501+
"paused": false,
2502+
"live_bypasses_limits": true,
2503+
"global": {
2504+
"limit": 20,
2505+
"active": 3,
2506+
"available": 17,
2507+
"live_active": 1,
2508+
"queued": 8
2509+
},
2510+
"per_extractor": {
2511+
"default_limit": 2,
2512+
"items": [
2513+
{
2514+
"name": "youtube",
2515+
"limit": 3,
2516+
"source": "env_override",
2517+
"active": 2,
2518+
"queued": 4,
2519+
"available": 1
2520+
}
2521+
]
2522+
}
2523+
},
2524+
"extraction": {
2525+
"concurrency": 4,
2526+
"timeout_seconds": 70,
2527+
"info_cache_ttl_seconds": 10800
2528+
},
2529+
"live": {
2530+
"prevent_premiere": true,
2531+
"premiere_buffer_minutes": 5
2532+
}
2533+
}
2534+
```
2535+
2536+
**Notes**:
2537+
- `downloads.global` counts only non-live downloads against the worker limit.
2538+
- `downloads.global.live_active` is reported separately because live downloads bypass the global and per-extractor worker limits.
2539+
- `downloads.per_extractor.items[*].source` is `default` unless an override was provided through `YTP_MAX_WORKERS_FOR_<EXTRACTOR>`.
2540+
2541+
---
2542+
24822543
### POST /api/system/terminal
24832544
**Purpose**: Start a yt-dlp terminal session. Requires `YTP_CONSOLE_ENABLED=true`.
24842545

@@ -3127,6 +3188,24 @@ Emitted when a download item is deleted from the queue or history.
31273188

31283189
---
31293190

3191+
##### `item_bulk_deleted`
3192+
3193+
Emitted when multiple items are cleared in bulk operation.
3194+
3195+
**Event**:
3196+
```json
3197+
{
3198+
"event": "item_bulk_deleted",
3199+
"data": {
3200+
"count": 10001,
3201+
"status": "finished,skip",
3202+
"ids": ["id1", "id2", "..."] // optional, included when clear is driven by explicit ids
3203+
}
3204+
}
3205+
```
3206+
3207+
---
3208+
31303209
##### `item_moved`
31313210

31323211
Emitted when a download item is moved between queue and history.

FAQ.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ or the `environment:` section in `compose.yaml` file.
3030
| YTP_DEBUG | Whether to turn on debug mode | `false` |
3131
| YTP_DEBUGPY_PORT | The port to use for the debugpy debugger | `5678` |
3232
| YTP_EXTRACT_INFO_TIMEOUT | The timeout for extracting video information | `70` |
33-
| YTP_UI_UPDATE_TITLE | Whether to update the title of the page with the current stats | `true` |
3433
| YTP_PIP_PACKAGES | A space separated list of pip packages to install | `(not_set)` |
3534
| YTP_PIP_IGNORE_UPDATES | Do not update the custom pip packages | `false` |
3635
| YTP_PICTURES_BACKENDS | A comma separated list of pictures urls to use | `(not_set)` |

app/features/notifications/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class NotificationEvents:
1616
ITEM_COMPLETED: str = Events.ITEM_COMPLETED
1717
ITEM_CANCELLED: str = Events.ITEM_CANCELLED
1818
ITEM_DELETED: str = Events.ITEM_DELETED
19+
ITEM_BULK_DELETED: str = Events.ITEM_BULK_DELETED
1920
ITEM_PAUSED: str = Events.ITEM_PAUSED
2021
ITEM_RESUMED: str = Events.ITEM_RESUMED
2122
ITEM_MOVED: str = Events.ITEM_MOVED

app/library/DataStore.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,48 @@ async def get_by_id(self, id: str) -> Download | None:
120120

121121
return None
122122

123+
async def get_many_by_ids(self, ids: Iterable[str]) -> list[tuple[str, Download]]:
124+
ids_list = list(ids)
125+
if not ids_list:
126+
return []
127+
128+
items: list[tuple[str, Download]] = []
129+
missing_ids: list[str] = []
130+
131+
for item_id in ids_list:
132+
cached = self._dict.get(item_id)
133+
if cached:
134+
items.append((item_id, cached))
135+
continue
136+
missing_ids.append(item_id)
137+
138+
if StoreType.HISTORY == self._type and missing_ids:
139+
loaded = await self._connection.get_many_by_ids(str(self._type), missing_ids)
140+
for item_id, item in loaded:
141+
self._dict[item_id] = Download(info=item)
142+
143+
items.extend((item_id, download) for item_id in ids_list if (download := self._dict.get(item_id)))
144+
145+
seen: set[str] = set()
146+
ordered: list[tuple[str, Download]] = []
147+
for item_id, download in items:
148+
if item_id in seen:
149+
continue
150+
seen.add(item_id)
151+
ordered.append((item_id, download))
152+
return ordered
153+
154+
async def get_many_by_status(self, status_filter: str) -> list[tuple[str, Download]]:
155+
if StoreType.HISTORY != self._type:
156+
return []
157+
158+
items = await self._connection.get_many_by_status(str(self._type), status_filter)
159+
downloads: list[tuple[str, Download]] = []
160+
for item_id, item in items:
161+
self._dict[item_id] = Download(info=item)
162+
downloads.append((item_id, self._dict[item_id]))
163+
return downloads
164+
123165
def items(self):
124166
return self._dict.items()
125167

@@ -175,11 +217,41 @@ async def get_items_paginated(
175217
return [(item_id, Download(info=item)) for item_id, item in items], total_items, current_page, total_pages
176218

177219
async def bulk_delete(self, ids: Iterable[str]) -> int:
178-
deleted = await self._connection.bulk_delete(str(self._type), ids)
179-
for _id in ids:
220+
ids_list = list(ids)
221+
deleted = await self._connection.bulk_delete(str(self._type), ids_list)
222+
for _id in ids_list:
180223
self._dict.pop(_id, None)
181224
return deleted
182225

226+
async def bulk_delete_by_status(self, status_filter: str) -> int:
227+
deleted = await self._connection.bulk_delete_by_status(str(self._type), status_filter)
228+
if deleted > 0:
229+
self._drop_cached_by_status(status_filter)
230+
return deleted
231+
232+
def _drop_cached_by_status(self, status_filter: str) -> None:
233+
raw_statuses = [entry.strip() for entry in status_filter.split(",") if entry.strip()]
234+
if not raw_statuses:
235+
return
236+
237+
if all(entry.startswith("!") for entry in raw_statuses):
238+
excluded = {entry[1:].strip() for entry in raw_statuses if entry[1:].strip()}
239+
if not excluded:
240+
return
241+
242+
for item_id, download in list(self._dict.items()):
243+
if download.info and download.info.status not in excluded:
244+
self._dict.pop(item_id, None)
245+
return
246+
247+
included = {entry for entry in raw_statuses if not entry.startswith("!")}
248+
if not included:
249+
return
250+
251+
for item_id, download in list(self._dict.items()):
252+
if download.info and download.info.status in included:
253+
self._dict.pop(item_id, None)
254+
183255
async def test(self) -> bool:
184256
await self._connection.count(str(self._type))
185257
return True

app/library/Events.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class Events:
3737
ITEM_COMPLETED: str = "item_completed"
3838
ITEM_CANCELLED: str = "item_cancelled"
3939
ITEM_DELETED: str = "item_deleted"
40+
ITEM_BULK_DELETED: str = "item_bulk_deleted"
4041
ITEM_PAUSED: str = "item_paused"
4142
ITEM_RESUMED: str = "item_resumed"
4243
ITEM_MOVED: str = "item_moved"
@@ -87,6 +88,7 @@ def frontend() -> list:
8788
Events.ITEM_UPDATED,
8889
Events.ITEM_CANCELLED,
8990
Events.ITEM_DELETED,
91+
Events.ITEM_BULK_DELETED,
9092
Events.ITEM_MOVED,
9193
Events.ITEM_STATUS,
9294
Events.PAUSED,

app/library/config.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,6 @@ class Config(metaclass=Singleton):
129129
apprise_config: str = "{config_path}{os_sep}apprise.yml"
130130
"""The path to the Apprise configuration file."""
131131

132-
ui_update_title: bool = True
133-
"""Update the title of the browser tab with the current status."""
134-
135132
pip_packages: str = ""
136133
"""The pip packages to install."""
137134

@@ -284,13 +281,12 @@ class Config(metaclass=Singleton):
284281
"access_log",
285282
"remove_files",
286283
"ignore_ui",
287-
"ui_update_title",
288284
"pip_ignore_updates",
289285
"file_logging",
290286
"console_enabled",
291287
"browser_control_enabled",
292288
"ytdlp_auto_update",
293-
"prevent_premiere_live",
289+
"prevent_live_premiere",
294290
"temp_disabled",
295291
"allow_internal_urls",
296292
"simple_mode",
@@ -308,7 +304,6 @@ class Config(metaclass=Singleton):
308304
"output_template",
309305
"started",
310306
"remove_files",
311-
"ui_update_title",
312307
"max_workers",
313308
"max_workers_per_extractor",
314309
"default_preset",

app/library/downloads/queue_manager.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,103 @@ async def clear(self, ids: list[str], remove_file: bool = False) -> dict[str, st
372372

373373
return status
374374

375+
async def clear_bulk(self, ids: list[str], remove_file: bool = False) -> dict[str, int | str]:
376+
if not ids:
377+
return {"deleted": 0}
378+
379+
items = await self.done.get_many_by_ids(ids)
380+
if not items:
381+
return {"deleted": 0}
382+
383+
if self.config.remove_files is not True:
384+
remove_file = False
385+
386+
removed_files = 0
387+
deleted_ids: list[str] = []
388+
deleted_titles: list[str] = []
389+
390+
for item_id, item in items:
391+
item_ref: str = f"{item_id=} {item.info.id=} {item.info.title=}"
392+
filename: str = ""
393+
394+
LOG.debug(f"{remove_file=} {item_ref} - Removing local files: {item.info.status=}")
395+
396+
if remove_file and "finished" == item.info.status and item.info.filename:
397+
filename = str(item.info.filename)
398+
if item.info.folder:
399+
filename = f"{item.info.folder}/{item.info.filename}"
400+
401+
try:
402+
rf = Path(
403+
calc_download_path(
404+
base_path=Path(self.config.download_path),
405+
folder=filename,
406+
create_path=False,
407+
)
408+
)
409+
if rf.is_file() and rf.exists():
410+
if rf.stem and rf.suffix:
411+
for file_ref in rf.parent.glob(f"{glob.escape(rf.stem)}.*"):
412+
if file_ref.is_file() and file_ref.exists() and not file_ref.name.startswith("."):
413+
removed_files += 1
414+
LOG.debug(f"Removing '{item_ref}' local file '{file_ref.name}'.")
415+
file_ref.unlink(missing_ok=True)
416+
else:
417+
LOG.debug(f"Removing '{item_ref}' local file '{rf.name}'.")
418+
rf.unlink(missing_ok=True)
419+
removed_files += 1
420+
else:
421+
LOG.warning(f"Failed to remove '{item_ref}' local file '{filename}'. File not found.")
422+
except Exception as e:
423+
LOG.error(f"Unable to remove '{item_ref}' local file '{filename}'. {e!s}")
424+
425+
deleted_ids.append(item_id)
426+
deleted_titles.append(item.info.title or item.info.id or item_id)
427+
428+
deleted_count = await self.done.bulk_delete(deleted_ids)
429+
if deleted_count < 1:
430+
return {"deleted": 0}
431+
432+
title = "History Removed" if removed_files > 0 else "History Cleared"
433+
message = f"Removed {deleted_count} item{'s' if deleted_count != 1 else ''} from history."
434+
if removed_files > 0:
435+
message += f" Also removed {removed_files} local file{'s' if removed_files != 1 else ''}."
436+
437+
self._notify.emit(
438+
Events.ITEM_BULK_DELETED,
439+
data={"ids": deleted_ids, "count": deleted_count},
440+
title=title,
441+
message=message,
442+
)
443+
444+
summary = ", ".join(deleted_titles[:5])
445+
if deleted_count > 5:
446+
summary += ", ..."
447+
LOG.info(f"Bulk cleared {deleted_count} history item(s): {summary}")
448+
449+
return {"deleted": deleted_count}
450+
451+
async def clear_by_status(self, status_filter: str, remove_file: bool = False) -> dict[str, int | str]:
452+
if self.config.remove_files is not True:
453+
remove_file = False
454+
455+
if not remove_file:
456+
deleted_count = await self.done.bulk_delete_by_status(status_filter)
457+
if deleted_count < 1:
458+
return {"deleted": 0}
459+
460+
self._notify.emit(
461+
Events.ITEM_BULK_DELETED,
462+
data={"count": deleted_count, "status": status_filter},
463+
title="History Cleared",
464+
message=f"Cleared {deleted_count} item{'s' if deleted_count != 1 else ''} from history.",
465+
)
466+
LOG.info(f"Bulk cleared {deleted_count} history item(s) by status '{status_filter}'.")
467+
return {"deleted": deleted_count}
468+
469+
items = await self.done.get_many_by_status(status_filter)
470+
return await self.clear_bulk([item_id for item_id, _ in items], remove_file=remove_file)
471+
375472
async def get(self, mode: str = "all") -> dict[str, list[dict[str, ItemDTO]]]:
376473
"""
377474
Get the download queue and the download history.

0 commit comments

Comments
 (0)