Add YTP_MAX_HISTORY setting to cap number of history items
This commit is contained in:
commit
9de88cc822
6 changed files with 155 additions and 1 deletions
8
FAQ.md
8
FAQ.md
|
|
@ -52,6 +52,7 @@ or the `environment:` section in `compose.yaml` file.
|
||||||
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
|
| YTP_SIMPLE_MODE | Switch default interface to Simple mode. | `false` |
|
||||||
| YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` |
|
| YTP_STATIC_UI_PATH | Path to custom static UI files. | `(not_set)` |
|
||||||
| YTP_AUTO_CLEAR_HISTORY_DAYS | Number of days after which completed download history is cleared. | `0` |
|
| YTP_AUTO_CLEAR_HISTORY_DAYS | Number of days after which completed download history is cleared. | `0` |
|
||||||
|
| YTP_MAX_HISTORY | Maximum number of history items to keep. `0` means unlimited. | `0` |
|
||||||
| YTP_DEFAULT_PAGINATION | The default number of items per page for history. | `50` |
|
| YTP_DEFAULT_PAGINATION | The default number of items per page for history. | `50` |
|
||||||
| YTP_TASK_HANDLER_RANDOM_DELAY | The maximum random delay in seconds before starting a task handler. | `60` |
|
| YTP_TASK_HANDLER_RANDOM_DELAY | The maximum random delay in seconds before starting a task handler. | `60` |
|
||||||
| YTP_IGNORE_ARCHIVED_ITEMS | Don't report archived items in the download history. | `false` |
|
| YTP_IGNORE_ARCHIVED_ITEMS | Don't report archived items in the download history. | `false` |
|
||||||
|
|
@ -71,6 +72,13 @@ or the `environment:` section in `compose.yaml` file.
|
||||||
- `0` days means no automatic clearing of the download history. lowest value that will trigger the clearing is `1` day.
|
- `0` days means no automatic clearing of the download history. lowest value that will trigger the clearing is `1` day.
|
||||||
- This setting will **NOT** delete the downloaded files, it will only clear the history from the database.
|
- This setting will **NOT** delete the downloaded files, it will only clear the history from the database.
|
||||||
|
|
||||||
|
## Notes about YTP_MAX_HISTORY
|
||||||
|
|
||||||
|
- `0` means no limit on the number of history items kept.
|
||||||
|
- When set to a positive value (e.g. `YTP_MAX_HISTORY=100`), the oldest history items will be automatically deleted to keep at most that many items.
|
||||||
|
- The check runs every 5 minutes.
|
||||||
|
- This setting will **NOT** delete the downloaded files, it will only remove the records from the database.
|
||||||
|
|
||||||
# Browser extensions & bookmarklets
|
# Browser extensions & bookmarklets
|
||||||
|
|
||||||
## Simple bookmarklet
|
## Simple bookmarklet
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,9 @@ class Config(metaclass=Singleton):
|
||||||
auto_clear_history_days: int = 0
|
auto_clear_history_days: int = 0
|
||||||
"""Number of days after which completed download history is automatically cleared. 0 to disable."""
|
"""Number of days after which completed download history is automatically cleared. 0 to disable."""
|
||||||
|
|
||||||
|
max_history: int = 0
|
||||||
|
"""Maximum number of history items to keep. 0 means unlimited."""
|
||||||
|
|
||||||
default_pagination: int = 50
|
default_pagination: int = 50
|
||||||
"""The default number of items per page for pagination."""
|
"""The default number of items per page for pagination."""
|
||||||
|
|
||||||
|
|
@ -268,6 +271,7 @@ class Config(metaclass=Singleton):
|
||||||
"download_path_depth",
|
"download_path_depth",
|
||||||
"download_info_expires",
|
"download_info_expires",
|
||||||
"auto_clear_history_days",
|
"auto_clear_history_days",
|
||||||
|
"max_history",
|
||||||
"default_pagination",
|
"default_pagination",
|
||||||
"extract_info_concurrency",
|
"extract_info_concurrency",
|
||||||
"flaresolverr_max_timeout",
|
"flaresolverr_max_timeout",
|
||||||
|
|
|
||||||
|
|
@ -171,3 +171,26 @@ async def delete_old_history(queue: "DownloadQueue") -> None:
|
||||||
|
|
||||||
if titles:
|
if titles:
|
||||||
LOG.info(f"Automatically cleared '{', '.join(titles)}' from download history due to age.")
|
LOG.info(f"Automatically cleared '{', '.join(titles)}' from download history due to age.")
|
||||||
|
|
||||||
|
|
||||||
|
async def enforce_max_history(queue: "DownloadQueue") -> None:
|
||||||
|
"""
|
||||||
|
Enforce the maximum number of history items by deleting the oldest excess items.
|
||||||
|
|
||||||
|
When config.max_history is set to a positive value, this function trims the
|
||||||
|
history store so that only the most recent max_history items are kept.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
queue: DownloadQueue instance
|
||||||
|
|
||||||
|
"""
|
||||||
|
if queue.config.max_history < 1 or queue.is_paused():
|
||||||
|
return
|
||||||
|
|
||||||
|
deleted_ids: list[str] = await queue.done._connection.trim_history("done", queue.config.max_history)
|
||||||
|
|
||||||
|
for _id in deleted_ids:
|
||||||
|
queue.done._dict.pop(_id, None)
|
||||||
|
|
||||||
|
if deleted_ids:
|
||||||
|
LOG.info(f"Trimmed '{len(deleted_ids)}' oldest items from history to enforce max_history={queue.config.max_history}.")
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from app.library.Utils import calc_download_path
|
||||||
|
|
||||||
from .core import Download
|
from .core import Download
|
||||||
from .item_adder import add as add_impl
|
from .item_adder import add as add_impl
|
||||||
from .monitors import check_for_stale, check_live, delete_old_history
|
from .monitors import check_for_stale, check_live, delete_old_history, enforce_max_history
|
||||||
from .pool_manager import PoolManager
|
from .pool_manager import PoolManager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -74,6 +74,13 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
id=delete_old_history.__name__,
|
id=delete_old_history.__name__,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.config.max_history > 0:
|
||||||
|
Scheduler.get_instance().add(
|
||||||
|
timer="*/5 * * * *",
|
||||||
|
func=functools.partial(enforce_max_history, self),
|
||||||
|
id=enforce_max_history.__name__,
|
||||||
|
)
|
||||||
|
|
||||||
# app.on_shutdown.append(self.on_shutdown)
|
# app.on_shutdown.append(self.on_shutdown)
|
||||||
|
|
||||||
async def test(self) -> bool:
|
async def test(self) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,43 @@ class SqliteStore(metaclass=ThreadSafe):
|
||||||
|
|
||||||
return row["count"] if row else 0
|
return row["count"] if row else 0
|
||||||
|
|
||||||
|
async def trim_history(self, type_value: str, max_items: int) -> list[str]:
|
||||||
|
"""
|
||||||
|
Delete oldest history items exceeding the max_items limit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
type_value: The history type to trim (e.g., "done").
|
||||||
|
max_items: Maximum number of items to keep. Must be > 0.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]: List of deleted item IDs.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if max_items < 1:
|
||||||
|
return []
|
||||||
|
|
||||||
|
await self.get_connection()
|
||||||
|
|
||||||
|
total = await self.count(type_value)
|
||||||
|
if total <= max_items:
|
||||||
|
return []
|
||||||
|
|
||||||
|
excess = total - max_items
|
||||||
|
# Select the oldest excess items
|
||||||
|
result = await self._conn.execute(
|
||||||
|
text(
|
||||||
|
'SELECT "id" FROM "history" WHERE "type" = :type_value ORDER BY "created_at" ASC LIMIT :limit'
|
||||||
|
),
|
||||||
|
{"type_value": type_value, "limit": excess},
|
||||||
|
)
|
||||||
|
rows = result.mappings().all()
|
||||||
|
ids_to_delete = [row["id"] for row in rows]
|
||||||
|
|
||||||
|
if ids_to_delete:
|
||||||
|
await self.bulk_delete(type_value, ids_to_delete)
|
||||||
|
|
||||||
|
return ids_to_delete
|
||||||
|
|
||||||
async def paginate(
|
async def paginate(
|
||||||
self,
|
self,
|
||||||
type_value: str,
|
type_value: str,
|
||||||
|
|
|
||||||
|
|
@ -308,3 +308,78 @@ async def test_exists_and_get_raise_without_key_or_url():
|
||||||
with pytest.raises(KeyError):
|
with pytest.raises(KeyError):
|
||||||
await store.get("queue")
|
await store.get("queue")
|
||||||
await store.close()
|
await store.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trim_history_removes_oldest_items():
|
||||||
|
"""Test that trim_history deletes the oldest items when count exceeds limit."""
|
||||||
|
store = await make_store()
|
||||||
|
base = datetime(2024, 1, 1, tzinfo=UTC)
|
||||||
|
|
||||||
|
# Insert 10 items with known timestamps, track their IDs
|
||||||
|
items = []
|
||||||
|
for i in range(10):
|
||||||
|
itm = make_item(i)
|
||||||
|
items.append(itm)
|
||||||
|
encoded = itm.json()
|
||||||
|
created_at = (base + timedelta(minutes=i)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
await store._conn.execute(
|
||||||
|
text(
|
||||||
|
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") '
|
||||||
|
"VALUES (:id, :type, :url, :data, :created_at)"
|
||||||
|
),
|
||||||
|
{"id": itm._id, "type": "done", "url": itm.url, "data": encoded, "created_at": created_at},
|
||||||
|
)
|
||||||
|
await store._conn.commit()
|
||||||
|
|
||||||
|
assert await store.count("done") == 10
|
||||||
|
|
||||||
|
# Trim to keep only 7 items
|
||||||
|
deleted = await store.trim_history("done", max_items=7)
|
||||||
|
assert len(deleted) == 3
|
||||||
|
assert await store.count("done") == 7
|
||||||
|
|
||||||
|
# Verify the oldest 3 items were deleted (items[0], items[1], items[2])
|
||||||
|
remaining = await store.fetch_saved("done")
|
||||||
|
remaining_ids = [r[0] for r in remaining]
|
||||||
|
for i in range(3):
|
||||||
|
assert items[i]._id not in remaining_ids
|
||||||
|
for i in range(3, 10):
|
||||||
|
assert items[i]._id in remaining_ids
|
||||||
|
|
||||||
|
await store.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trim_history_no_op_when_under_limit():
|
||||||
|
"""Test that trim_history does nothing when item count is within the limit."""
|
||||||
|
store = await make_store()
|
||||||
|
|
||||||
|
# Use unique index offset to avoid URL conflicts with shared DB
|
||||||
|
for i in range(100, 105):
|
||||||
|
await store.enqueue_upsert("done", make_item(i))
|
||||||
|
await store.flush()
|
||||||
|
|
||||||
|
count_before = await store.count("done")
|
||||||
|
deleted = await store.trim_history("done", max_items=count_before + 10)
|
||||||
|
assert deleted == []
|
||||||
|
assert await store.count("done") == count_before
|
||||||
|
|
||||||
|
await store.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trim_history_no_op_for_zero_or_negative_max():
|
||||||
|
"""Test that trim_history returns empty list for invalid max_items values."""
|
||||||
|
store = await make_store()
|
||||||
|
|
||||||
|
for i in range(200, 203):
|
||||||
|
await store.enqueue_upsert("done", make_item(i))
|
||||||
|
await store.flush()
|
||||||
|
|
||||||
|
count_before = await store.count("done")
|
||||||
|
assert await store.trim_history("done", max_items=0) == []
|
||||||
|
assert await store.trim_history("done", max_items=-1) == []
|
||||||
|
assert await store.count("done") == count_before
|
||||||
|
|
||||||
|
await store.close()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue