Refactor: swap sqlite to aiosqlite and add queued db updates

This commit is contained in:
arabcoders 2025-12-18 16:31:15 +03:00
parent 8a62f053a5
commit 7a85bcdb5c
19 changed files with 1790 additions and 875 deletions

10
.vscode/settings.json vendored
View file

@ -16,6 +16,7 @@
"ahas", "ahas",
"ahash", "ahash",
"aiocron", "aiocron",
"aiosqlite",
"anyio", "anyio",
"Archiver", "Archiver",
"arrowdown", "arrowdown",
@ -73,6 +74,8 @@
"falsey", "falsey",
"faststart", "faststart",
"Fetc", "Fetc",
"fetchall",
"fetchone",
"filterwarnings", "filterwarnings",
"finaldir", "finaldir",
"flac", "flac",
@ -91,6 +94,7 @@
"hookspath", "hookspath",
"httpx", "httpx",
"imagetools", "imagetools",
"isinstance",
"jmespath", "jmespath",
"jsonschema", "jsonschema",
"kibibytes", "kibibytes",
@ -112,7 +116,10 @@
"mebibytes", "mebibytes",
"MEIPASS", "MEIPASS",
"merch", "merch",
"metaclass",
"Metaclasses",
"metadataparser", "metadataparser",
"mgmt",
"Microformat", "Microformat",
"microformats", "microformats",
"mkvtoolsnix", "mkvtoolsnix",
@ -128,6 +135,7 @@
"noninteractive", "noninteractive",
"noprogress", "noprogress",
"onefile", "onefile",
"oneline",
"parsel", "parsel",
"pathex", "pathex",
"pickleable", "pickleable",
@ -178,6 +186,7 @@
"Unraid", "Unraid",
"upgrader", "upgrader",
"urandom", "urandom",
"urlparse",
"urlsafe", "urlsafe",
"usegmt", "usegmt",
"usermod", "usermod",
@ -190,6 +199,7 @@
"xerror", "xerror",
"youtu", "youtu",
"youtubepot", "youtubepot",
"zstd",
"русский", "русский",
"العربية" "العربية"
], ],

View file

@ -1,27 +1,21 @@
import asyncio
import copy import copy
import json
import logging import logging
from collections import OrderedDict from collections import OrderedDict
from dataclasses import fields from collections.abc import Iterable
from datetime import UTC, datetime
from email.utils import formatdate
from enum import Enum from enum import Enum
from sqlite3 import Connection
from .Download import Download from .Download import Download
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .operations import matches_condition from .operations import matches_condition
from .Utils import init_class from .sqlite_store import SqliteStore
LOG: logging.Logger = logging.getLogger("datastore") LOG = logging.getLogger("datastore")
ITEM_DTO_FIELDS = {f.name for f in fields(ItemDTO)}
"""Caching ItemDTO fields for performance."""
class StoreType(str, Enum): class StoreType(str, Enum):
HISTORY: str = "done" HISTORY = "done"
QUEUE: str = "queue" QUEUE = "queue"
@classmethod @classmethod
def all(cls) -> list[str]: def all(cls) -> list[str]:
@ -29,55 +23,57 @@ class StoreType(str, Enum):
@classmethod @classmethod
def from_value(cls, value: str) -> "StoreType": def from_value(cls, value: str) -> "StoreType":
"""
Returns the StoreType enum member corresponding to the given value.
Args:
value (str): The value to match against the enum members.
Returns:
StoreType: The enum member that matches the value.
Raises:
ValueError: If the value does not match any member.
"""
for member in cls: for member in cls:
if member.value == value: if member.value == value:
return member return member
msg = f"Invalid StoreType value: {value}" msg = f"Invalid StoreType value: {value}"
raise ValueError(msg) raise ValueError(msg)
def __str__(self) -> str: def __str__(self) -> str: # pragma: no cover
return self.value return self.value
def _strip_transient_fields(item: ItemDTO) -> ItemDTO:
stored = copy.deepcopy(item)
if hasattr(stored, "datetime"):
try:
delattr(stored, "datetime")
except AttributeError:
pass
if stored.status == "finished" and hasattr(stored, "live_in"):
try:
delattr(stored, "live_in")
except AttributeError:
pass
return stored
class DataStore: class DataStore:
""" """
Persistent queue. In-memory queue cache + optional write-behind to persistence.
History reads go straight to persistence.
""" """
def __init__(self, type: StoreType, connection: Connection): def __init__(self, type: StoreType, connection: SqliteStore):
self._type = type
self._connection: SqliteStore = connection
self._dict: OrderedDict[str, Download] = OrderedDict() self._dict: OrderedDict[str, Download] = OrderedDict()
"The dictionary of items."
self._type: StoreType = type
"The type of the datastore."
self._connection: Connection = connection
"The database connection."
def load(self) -> None: # cache helpers
for id, item in self.saved_items(): async def load(self) -> None:
self._dict.update({id: Download(info=item)}) saved = await self._connection.fetch_saved(str(self._type))
for key, item in saved:
self._dict[key] = Download(info=item)
async def saved_items(self) -> list[tuple[str, ItemDTO]]:
return await self._connection.fetch_saved(str(self._type))
def exists(self, key: str | None = None, url: str | None = None) -> bool: def exists(self, key: str | None = None, url: str | None = None) -> bool:
if not key and not url: if not key and not url:
msg = "key or url must be provided." msg = "key or url must be provided."
raise KeyError(msg) raise KeyError(msg)
if key and key in self._dict: if key and key in self._dict:
return True return True
return any( return any(
(key and self._dict[i].info._id == key) or (url and self._dict[i].info.url == url) for i in self._dict (key and self._dict[i].info._id == key) or (url and self._dict[i].info.url == url) for i in self._dict
) )
@ -86,261 +82,88 @@ class DataStore:
if not key and not url: if not key and not url:
msg = "key or url must be provided." msg = "key or url must be provided."
raise KeyError(msg) raise KeyError(msg)
for i in self._dict: for i in self._dict:
if (key and self._dict[i].info._id == key) or (url and self._dict[i].info.url == url): if (key and self._dict[i].info._id == key) or (url and self._dict[i].info.url == url):
return self._dict[i] return self._dict[i]
msg: str = f"{key=} or {url=} not found." msg: str = f"{key=} or {url=} not found."
raise KeyError(msg) raise KeyError(msg)
def get_item(self, **kwargs) -> Download | None: def get_item(self, **kwargs) -> Download | None:
"""
Get a specific item from the datastore based on provided attributes with optional operations.
Args:
**kwargs: Arbitrary keyword arguments representing attributes of the ItemDTO.
Each value can be either:
- A direct value (defaults to EQUAL operation): {"title": "test"}
- A tuple of (Operation, value): {"title": (Operation.CONTAIN, "test")}
If no attributes are provided, the method returns None.
If any attribute matches, the corresponding Download object is returned.
Returns:
Download | None: The requested item if found, otherwise None.
Examples:
# Direct equality check (default)
store.get_item(title="Video 1")
# Using operations
store.get_item(title=(Operation.CONTAIN, "test"))
store.get_item(id=(Operation.EQUAL, "123"), status=(Operation.NOT_EQUAL, "error"))
# Mixed usage
store.get_item(title=(Operation.CONTAIN, "test"), folder="downloads")
"""
if not kwargs: if not kwargs:
return None return None
for i in self._dict: for i in self._dict:
if not self._dict[i].info: if not self._dict[i].info:
continue continue
info = self._dict[i].info.__dict__ info = self._dict[i].info.__dict__
if any(matches_condition(key, value, info) for key, value in kwargs.items()): if any(matches_condition(key, value, info) for key, value in kwargs.items()):
return self._dict[i] return self._dict[i]
return None
async def get_by_id(self, id: str) -> Download | None:
val = self._dict.get(id)
if val or self._type == StoreType.QUEUE:
return val
if item := await self._connection.get_by_id(str(self._type), id):
download = Download(info=item)
self._dict[id] = download
return download
return None return None
def get_by_id(self, id: str) -> Download | None: def items(self):
return self._dict.get(id, None)
def items(self) -> OrderedDict[tuple[str, Download]]:
return self._dict.items() return self._dict.items()
def saved_items(self) -> list[tuple[str, ItemDTO]]: async def put(self, value: Download, no_notify: bool = False) -> Download:
items: list[tuple[str, ItemDTO]] = [] _ = no_notify
self._dict[value.info._id] = value
cursor = self._connection.execute( asyncio.create_task(self._connection.enqueue_upsert(str(self._type), _strip_transient_fields(value.info)))
'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = ? ORDER BY "created_at" ASC', return value
(str(self._type),),
)
for row in cursor:
rowDate: datetime = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
data: dict = json.loads(row["data"])
data.pop("_id", None)
item: ItemDTO = init_class(ItemDTO, data, ITEM_DTO_FIELDS)
item._id = row["id"]
item.datetime = formatdate(rowDate.replace(tzinfo=UTC).timestamp())
items.append((row["id"], item))
return items
def put(self, value: Download, no_notify: bool = False) -> Download:
if "error" == value.info.status and not no_notify:
from app.library.Events import EventBus, Events
EventBus.get_instance().emit(Events.ITEM_ERROR, value.info)
self._dict.update({value.info._id: value})
self._update_store_item(self._type, value.info)
return self._dict[value.info._id]
def delete(self, key: str) -> None: def delete(self, key: str) -> None:
self._dict.pop(key, None) self._dict.pop(key, None)
self._delete_store_item(key) asyncio.create_task(self._connection.enqueue_delete(str(self._type), key))
def next(self) -> tuple[str, Download]: def next(self):
return next(iter(self._dict.items())) return next(iter(self._dict.items()))
def empty(self): def empty(self) -> bool:
return not bool(self._dict) return not bool(self._dict)
def has_downloads(self): def has_downloads(self) -> bool:
if 0 == len(self._dict):
return False
return any(self._dict[key].info.auto_start and self._dict[key].started() is False for key in self._dict) return any(self._dict[key].info.auto_start and self._dict[key].started() is False for key in self._dict)
def get_next_download(self) -> Download: def get_next_download(self) -> Download | None:
for key in self._dict: for key in self._dict:
ref = self._dict[key] ref = self._dict[key]
if ref.info.auto_start and ref.started() is False and ref.is_cancelled() is False: if ref.info.auto_start and not ref.started() and not ref.is_cancelled():
return ref return ref
return None return None
async def test(self) -> bool: async def get_total_count(self, status_filter: str | None = None) -> int:
self._connection.execute('SELECT "id" FROM "history" LIMIT 1').fetchone() return await self._connection.count(str(self._type), status_filter=status_filter)
return True
def get_total_count(self) -> int: async def get_items_paginated(
""" self, page: int = 1, per_page: int = 50, order: str = "DESC", status_filter: str | None = None
Get the total count of items in the datastore. ):
Returns:
int: The total number of items in the datastore.
"""
cursor = self._connection.execute(
'SELECT COUNT(*) as count FROM "history" WHERE "type" = ?',
(str(self._type),),
)
row = cursor.fetchone()
return row["count"] if row else 0
def get_items_paginated(
self,
page: int = 1,
per_page: int = 50,
order: str = "DESC",
status_filter: str | None = None,
) -> tuple[list[tuple[str, ItemDTO]], int, int, int]:
"""
Get paginated items from the datastore.
Args:
page (int): The page number (1-indexed). Defaults to 1.
per_page (int): Number of items per page. Defaults to 50.
order (str): Sort order - 'ASC' or 'DESC'. Defaults to 'DESC' (newest first).
status_filter (str | None): Optional status filter. Can be a status value (e.g., 'finished')
to include only that status, or prefixed with '!' (e.g., '!finished') to exclude that status.
Returns:
tuple[list[tuple[str, ItemDTO]], int, int, int]: A tuple containing:
- List of (id, ItemDTO) tuples for the requested page
- Total number of items
- Current page number
- Total number of pages
Raises:
ValueError: If page < 1 or per_page < 1
"""
if page < 1: if page < 1:
msg = "page must be >= 1" msg = "page must be >= 1"
raise ValueError(msg) raise ValueError(msg)
if per_page < 1: if per_page < 1:
msg = "per_page must be >= 1" msg = "per_page must be >= 1"
raise ValueError(msg) raise ValueError(msg)
order = order.upper() order = order.upper()
if order not in ("ASC", "DESC"): if order not in ("ASC", "DESC"):
msg = f"order must be 'ASC' or 'DESC', got '{order}'" msg = f"order must be 'ASC' or 'DESC', got '{order}'"
raise ValueError(msg) raise ValueError(msg)
return await self._connection.paginate(str(self._type), page, per_page, order, status_filter)
order = "ASC" if order == "ASC" else "DESC" async def bulk_delete(self, ids: Iterable[str]) -> int:
deleted = await self._connection.bulk_delete(str(self._type), ids)
for _id in ids:
self._dict.pop(_id, None)
return deleted
# Build SQL query with status filter if provided async def test(self) -> bool:
where_clauses = ['"type" = ?'] await self._connection.count(str(self._type))
query_params: list = [str(self._type)] return True
if status_filter:
# Check if it's an exclusion filter (starts with !)
if status_filter.startswith("!"):
status_value = status_filter[1:]
where_clauses.append("json_extract(data, '$.status') != ?")
query_params.append(status_value)
else:
where_clauses.append("json_extract(data, '$.status') = ?")
query_params.append(status_filter)
where_clause = " AND ".join(where_clauses)
# Get total count with filter
count_query = f'SELECT COUNT(*) as count FROM "history" WHERE {where_clause}' # noqa: S608
count_result = self._connection.execute(count_query, tuple(query_params)).fetchone()
total_items: int = count_result["count"] if count_result else 0
total_pages: int = (total_items + per_page - 1) // per_page if total_items > 0 else 1
# Ensure page is within valid range.
if page > total_pages and total_items > 0:
page = total_pages
offset = (page - 1) * per_page
items: list[tuple[str, ItemDTO]] = []
query_params.extend([per_page, offset])
cursor = self._connection.execute(
f'SELECT "id", "data", "created_at" FROM "history" WHERE {where_clause} ORDER BY "created_at" {order} LIMIT ? OFFSET ?', # noqa: S608
tuple(query_params),
)
for row in cursor:
rowDate: datetime = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S") # noqa: DTZ007
data: dict = json.loads(row["data"])
data.pop("_id", None)
item: ItemDTO = init_class(ItemDTO, data, ITEM_DTO_FIELDS)
item._id = row["id"]
item.datetime = formatdate(rowDate.replace(tzinfo=UTC).timestamp())
items.append((row["id"], item))
return items, total_items, page, total_pages
def _update_store_item(self, type: StoreType, item: ItemDTO) -> None:
sqlStatement = """
INSERT INTO "history" ("id", "type", "url", "data")
VALUES (?, ?, ?, ?)
ON CONFLICT DO UPDATE SET "type" = ?, "url" = ?, "data" = ?, created_at = ?
"""
stored: ItemDTO = copy.deepcopy(item)
if hasattr(stored, "datetime"):
try:
delattr(stored, "datetime")
except AttributeError:
pass
if hasattr(stored, "live_in") and stored.status == "finished":
try:
delattr(stored, "live_in")
except AttributeError:
pass
encoded: str = stored.json()
self._connection.execute(
sqlStatement.strip(),
(
stored._id,
str(type),
stored.url,
encoded,
str(type),
stored.url,
encoded,
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
),
)
def _delete_store_item(self, key: str) -> None:
self._connection.execute('DELETE FROM "history" WHERE "type" = ? AND "id" = ?', (str(self._type), key))

View file

@ -9,7 +9,6 @@ import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from email.utils import formatdate from email.utils import formatdate
from pathlib import Path from pathlib import Path
from sqlite3 import Connection
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import yt_dlp.utils import yt_dlp.utils
@ -25,6 +24,7 @@ from .ItemDTO import Item, ItemDTO
from .Presets import Presets from .Presets import Presets
from .Scheduler import Scheduler from .Scheduler import Scheduler
from .Singleton import Singleton from .Singleton import Singleton
from .sqlite_store import SqliteStore
from .Utils import ( from .Utils import (
archive_add, archive_add,
arg_converter, arg_converter,
@ -49,14 +49,14 @@ class DownloadQueue(metaclass=Singleton):
DownloadQueue class is a singleton class that manages the download queue and the download history. DownloadQueue class is a singleton class that manages the download queue and the download history.
""" """
def __init__(self, connection: Connection, config: Config | None = None): def __init__(self, config: Config | None = None):
self.config: Config = config or Config.get_instance() self.config: Config = config or Config.get_instance()
"Configuration instance." "Configuration instance."
self._notify: EventBus = EventBus.get_instance() self._notify: EventBus = EventBus.get_instance()
"Event bus instance." "Event bus instance."
self.done = DataStore(type=StoreType.HISTORY, connection=connection) self.done = DataStore(type=StoreType.HISTORY, connection=SqliteStore.get_instance(config.db_file))
"DataStore for the completed downloads." "DataStore for the completed downloads."
self.queue = DataStore(type=StoreType.QUEUE, connection=connection) self.queue = DataStore(type=StoreType.QUEUE, connection=SqliteStore.get_instance(config.db_file))
"DataStore for the download queue." "DataStore for the download queue."
self.workers = asyncio.Semaphore(self.config.max_workers) self.workers = asyncio.Semaphore(self.config.max_workers)
"Semaphore to limit the number of concurrent downloads." "Semaphore to limit the number of concurrent downloads."
@ -71,8 +71,7 @@ class DownloadQueue(metaclass=Singleton):
self._active: dict[str, Download] = {} self._active: dict[str, Download] = {}
"""Dictionary of active downloads.""" """Dictionary of active downloads."""
self.done.load() # Loading happens in async initialize to avoid blocking loop creation.
self.queue.load()
self.paused.set() self.paused.set()
@staticmethod @staticmethod
@ -166,6 +165,9 @@ class DownloadQueue(metaclass=Singleton):
""" """
Initialize the download queue. Initialize the download queue.
""" """
await self.done.test()
await self.done.load()
await self.queue.load()
LOG.info( LOG.info(
f"Using '{self.config.max_workers}' workers for downloading and '{self.config.max_workers_per_extractor}' per extractor." f"Using '{self.config.max_workers}' workers for downloading and '{self.config.max_workers_per_extractor}' per extractor."
) )
@ -199,7 +201,7 @@ class DownloadQueue(metaclass=Singleton):
continue continue
item.info.auto_start = True item.info.auto_start = True
updated: Download = self.queue.put(item) updated: Download = await self.queue.put(item)
self._notify.emit(Events.ITEM_UPDATED, data=updated.info) self._notify.emit(Events.ITEM_UPDATED, data=updated.info)
self._notify.emit( self._notify.emit(
Events.ITEM_RESUMED, Events.ITEM_RESUMED,
@ -246,7 +248,7 @@ class DownloadQueue(metaclass=Singleton):
continue continue
item.info.auto_start = False item.info.auto_start = False
updated: Download = self.queue.put(item) updated: Download = await self.queue.put(item)
self._notify.emit(Events.ITEM_UPDATED, data=updated.info) self._notify.emit(Events.ITEM_UPDATED, data=updated.info)
self._notify.emit( self._notify.emit(
Events.ITEM_PAUSED, Events.ITEM_PAUSED,
@ -534,7 +536,7 @@ class DownloadQueue(metaclass=Singleton):
message=nMessage, message=nMessage,
) )
itemDownload: Download = self.done.put(dlInfo) itemDownload: Download = await self.done.put(dlInfo)
elif not hasFormats: elif not hasFormats:
ava: str = entry.get("availability", "public") ava: str = entry.get("availability", "public")
nTitle = "Download Error" nTitle = "Download Error"
@ -547,7 +549,7 @@ class DownloadQueue(metaclass=Singleton):
dlInfo.info.error = nMessage.replace(f" for '{dl.title}'.", ".") + text_logs dlInfo.info.error = nMessage.replace(f" for '{dl.title}'.", ".") + text_logs
dlInfo.info.status = "error" dlInfo.info.status = "error"
itemDownload = self.done.put(dlInfo) itemDownload = await self.done.put(dlInfo)
self._notify.emit( self._notify.emit(
Events.LOG_WARNING, Events.LOG_WARNING,
@ -580,12 +582,12 @@ class DownloadQueue(metaclass=Singleton):
if _requeue: if _requeue:
nEvent = Events.ITEM_ADDED nEvent = Events.ITEM_ADDED
itemDownload = self.queue.put(dlInfo) itemDownload = await self.queue.put(dlInfo)
if item.auto_start: if item.auto_start:
self.event.set() self.event.set()
else: else:
dlInfo.info.status = "not_live" dlInfo.info.status = "not_live"
itemDownload = self.done.put(dlInfo) itemDownload = await self.done.put(dlInfo)
nStore = "history" nStore = "history"
nEvent = Events.ITEM_MOVED nEvent = Events.ITEM_MOVED
nTitle = "Premiering right now" nTitle = "Premiering right now"
@ -596,7 +598,7 @@ class DownloadQueue(metaclass=Singleton):
nEvent = Events.ITEM_ADDED nEvent = Events.ITEM_ADDED
nTitle = "Item Added" nTitle = "Item Added"
nMessage = f"Item '{dlInfo.info.title}' has been added to the download queue." nMessage = f"Item '{dlInfo.info.title}' has been added to the download queue."
itemDownload = self.queue.put(dlInfo) itemDownload = await self.queue.put(dlInfo)
if item.auto_start: if item.auto_start:
self.event.set() self.event.set()
else: else:
@ -732,7 +734,7 @@ class DownloadQueue(metaclass=Singleton):
if archive_file := dlInfo.info.get_ytdlp_opts().get_all().get("download_archive"): if archive_file := dlInfo.info.get_ytdlp_opts().get_all().get("download_archive"):
dlInfo.info.msg += f" Found in archive '{archive_file}'." dlInfo.info.msg += f" Found in archive '{archive_file}'."
self.done.put(dlInfo) await self.done.put(dlInfo)
self._notify.emit( self._notify.emit(
Events.ITEM_MOVED, Events.ITEM_MOVED,
@ -816,7 +818,7 @@ class DownloadQueue(metaclass=Singleton):
extras=item.extras, extras=item.extras,
) )
) )
self.done.put(dlInfo) await self.done.put(dlInfo)
LOG.info(log_message) LOG.info(log_message)
self._notify.emit(Events.LOG_INFO, data={}, title="Ignored Download", message=log_message) self._notify.emit(Events.LOG_INFO, data={}, title="Ignored Download", message=log_message)
@ -910,7 +912,7 @@ class DownloadQueue(metaclass=Singleton):
message=f"Download '{item.info.title}' has been cancelled.", message=f"Download '{item.info.title}' has been cancelled.",
) )
item.info.status = "cancelled" item.info.status = "cancelled"
self.done.put(item) await self.done.put(item)
self._notify.emit( self._notify.emit(
Events.ITEM_MOVED, Events.ITEM_MOVED,
data={"to": "history", "preset": item.info.preset, "item": item.info}, data={"to": "history", "preset": item.info.preset, "item": item.info},
@ -1003,7 +1005,7 @@ class DownloadQueue(metaclass=Singleton):
return status return status
def get(self, mode: str = "all") -> dict[str, list[dict[str, ItemDTO]]]: async def get(self, mode: str = "all") -> dict[str, list[dict[str, ItemDTO]]]:
""" """
Get the download queue and the download history. Get the download queue and the download history.
@ -1017,11 +1019,11 @@ class DownloadQueue(metaclass=Singleton):
items = {"queue": {}, "done": {}} items = {"queue": {}, "done": {}}
if mode in ("all", "queue"): if mode in ("all", "queue"):
for k, v in self.queue.saved_items(): for k, v in await self.queue.saved_items():
items["queue"][k] = self._active[k].info if k in self._active else v items["queue"][k] = self._active[k].info if k in self._active else v
if mode in ("all", "done"): if mode in ("all", "done"):
for k, v in self.done.saved_items(): for k, v in await self.done.saved_items():
v.get_file_sidecar() v.get_file_sidecar()
items["done"][k] = v items["done"][k] = v
@ -1186,7 +1188,7 @@ class DownloadQueue(metaclass=Singleton):
self._notify.emit(Events.ITEM_COMPLETED, data=entry.info, title=nTitle, message=nMessage) self._notify.emit(Events.ITEM_COMPLETED, data=entry.info, title=nTitle, message=nMessage)
self.done.put(entry) await self.done.put(entry)
self._notify.emit( self._notify.emit(
Events.ITEM_MOVED, Events.ITEM_MOVED,
data={"to": "history", "preset": entry.info.preset, "item": entry.info}, data={"to": "history", "preset": entry.info.preset, "item": entry.info},
@ -1288,7 +1290,7 @@ class DownloadQueue(metaclass=Singleton):
) )
) )
except Exception as e: except Exception as e:
self.done.put(item) await self.done.put(item)
LOG.exception(e) LOG.exception(e)
LOG.error(f"Failed to retry item '{item_ref}'. {e!s}") LOG.error(f"Failed to retry item '{item_ref}'. {e!s}")

View file

@ -441,6 +441,7 @@ class Config(metaclass=Singleton):
("urllib3.connectionpool", logging.WARNING), ("urllib3.connectionpool", logging.WARNING),
("apprise", logging.WARNING), ("apprise", logging.WARNING),
("httpcore", logging.INFO), ("httpcore", logging.INFO),
("aiosqlite", logging.INFO),
) )
for _tool, _level in _log_levels: for _tool, _level in _log_levels:
logging.getLogger(_tool).setLevel(_level) logging.getLogger(_tool).setLevel(_level)

321
app/library/migrate.py Normal file
View file

@ -0,0 +1,321 @@
# flake8: noqa: S608
import contextlib
import datetime
import glob
import os.path
import traceback
from collections.abc import AsyncIterator, Iterable, Sequence
from importlib.machinery import ModuleSpec, SourceFileLoader
from importlib.util import module_from_spec, spec_from_loader
from typing import TYPE_CHECKING
import aiosqlite
if TYPE_CHECKING:
from types import ModuleType
UTC_LENGTH = 14
__version__ = "1.0.0"
class Error(Exception):
"""Base class for all Caribou errors."""
class InvalidMigrationError(Error):
"""Thrown when a client migration contains an error."""
class InvalidNameError(Error):
"""Thrown when a client migration has an invalid filename."""
def __init__(self, filename: str):
msg: str = (
f"Migration filenames must start with a UTC timestamp. The following file has an invalid name: {filename}"
)
super().__init__(msg)
@contextlib.asynccontextmanager
async def execute(
conn: aiosqlite.Connection, sql: str, params: Sequence[object] | None = None
) -> AsyncIterator[aiosqlite.Cursor]:
params = [] if params is None else params
cursor = await conn.execute(sql, params)
try:
yield cursor
finally:
await cursor.close()
@contextlib.asynccontextmanager
async def transaction(conn: aiosqlite.Connection) -> AsyncIterator[None]:
try:
yield
await conn.commit()
except Exception:
await conn.rollback()
raise
class Migration:
"""This class represents a migration version."""
def __init__(self, path: str):
self.path: str = path
self.filename: str = os.path.basename(path)
self.module_name, _ = os.path.splitext(self.filename)
self.get_version() # will assert the filename is valid
self.name: str = self.module_name[UTC_LENGTH:]
while self.name.startswith("_"):
self.name: str = self.name[1:]
loader = SourceFileLoader(self.module_name, path)
spec: ModuleSpec | None = spec_from_loader(self.module_name, loader)
if spec is None:
msg = f"Cannot create spec for {path}"
raise ImportError(msg)
try:
module: ModuleType = module_from_spec(spec)
loader.exec_module(module)
self.module: ModuleType = module
except Exception as e:
msg: str = f"Invalid migration {path}: {traceback.format_exc()}"
raise InvalidMigrationError(msg) from e
targets: list[str] = ["upgrade", "downgrade"]
missing: list[str] = [m for m in targets if not self.has_method(m)]
if missing:
msg = f"Migration '{self.path}' is missing required methods: {', '.join(missing)}."
raise InvalidMigrationError(msg)
def get_version(self) -> str:
if len(self.filename) < UTC_LENGTH:
raise InvalidNameError(self.filename)
timestamp: str = self.filename[:UTC_LENGTH]
if not timestamp.isdigit():
raise InvalidNameError(self.filename)
return timestamp
async def upgrade(self, conn: aiosqlite.Connection) -> None:
await self.module.upgrade(conn)
async def downgrade(self, conn: aiosqlite.Connection) -> None:
await self.module.downgrade(conn)
def has_method(self, name: str) -> bool:
return callable(getattr(self.module, name, None))
def __repr__(self) -> str:
return f"Migration(path={self.filename})"
class Database:
def __init__(self, db_url: aiosqlite.Connection | str, version_table: str = "migration_version"):
if not db_url:
msg = "Database requires db_url."
raise ValueError(msg)
self.version_table: str = version_table
self._owns_connection = bool(isinstance(db_url, str))
if self._owns_connection:
self.conn: aiosqlite.Connection | None = None
self.db_url: str = db_url
self._ensure_connection()
else:
self.conn: aiosqlite.Connection = db_url
async def __aenter__(self) -> "Database":
if self._owns_connection:
await self._ensure_connection()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
if not self._owns_connection:
return
await self.close()
async def close(self) -> None:
if self._owns_connection and self.conn:
await self.conn.close()
self.conn = None
async def _ensure_connection(self) -> None:
if self.conn:
return
self.conn = await aiosqlite.connect(self.db_url)
self.conn.row_factory = aiosqlite.Row
async def is_version_controlled(self) -> bool:
await self._ensure_connection()
assert self.conn
sql = """SELECT * FROM sqlite_master WHERE type = 'table' AND name = ?"""
async with execute(self.conn, sql, [self.version_table]) as cursor:
return bool(await cursor.fetchall())
async def upgrade(self, migrations: list[Migration], target_version: str | None = None) -> None:
await self._ensure_connection()
assert self.conn
if target_version:
_assert_migration_exists(migrations, target_version)
migrations.sort(key=lambda x: x.get_version())
database_version = await self.get_version()
for migration in migrations:
current_version: str = migration.get_version()
if database_version is not None and current_version <= database_version:
continue
if target_version and current_version > target_version:
break
await migration.upgrade(self.conn)
new_version: str = migration.get_version()
await self.update_version(new_version)
database_version: str = new_version
async def downgrade(self, migrations: list[Migration], target_version: str | int) -> None:
await self._ensure_connection()
assert self.conn
if target_version not in (0, "0"):
_assert_migration_exists(migrations, target_version)
migrations.sort(key=lambda x: x.get_version(), reverse=True)
database_version: str | None = await self.get_version()
for i, migration in enumerate(migrations):
current_version: str = migration.get_version()
if database_version is not None and current_version > database_version:
continue
if current_version <= target_version:
break
await migration.downgrade(self.conn)
next_version: str | int = 0
if i < len(migrations) - 1:
next_migration: Migration = migrations[i + 1]
next_version = next_migration.get_version()
await self.update_version(str(next_version))
database_version = str(next_version)
async def get_version(self) -> str | None:
"""
Return the database's version, or None if it is not under version
control.
"""
await self._ensure_connection()
assert self.conn
if not await self.is_version_controlled():
return None
sql: str = f"SELECT version FROM {self.version_table}"
async with execute(self.conn, sql) as cursor:
result = await cursor.fetchall()
return result[0][0] if result else "0"
async def update_version(self, version: str) -> None:
await self._ensure_connection()
assert self.conn
sql: str = f"UPDATE {self.version_table} SET version = ?"
async with transaction(self.conn):
await self.conn.execute(sql, [version])
async def initialize_version_control(self) -> None:
await self._ensure_connection()
assert self.conn
sql: str = f"""CREATE TABLE IF NOT EXISTS {self.version_table} ( version TEXT ) """
async with transaction(self.conn):
await self.conn.execute(sql)
await self.conn.execute(f"INSERT INTO {self.version_table} VALUES (0)")
def __repr__(self) -> str:
return f'Database("{self.db_url if self._owns_connection else "external_connection"}")'
def _assert_migration_exists(migrations: Iterable[Migration], version: str | int) -> None:
if version not in (m.get_version() for m in migrations):
msg: str = f"No migration with version {version} exists."
raise Error(msg)
def load_migrations(directory: str) -> list[Migration]:
"""Return the migrations contained in the given directory."""
directory = str(directory)
if not os.path.exists(directory) or not os.path.isdir(directory):
msg: str = f"{directory} is not a directory."
raise Error(msg)
return [Migration(f) for f in glob.glob(os.path.join(directory, "*.py"))]
async def upgrade(db_url: aiosqlite.Connection | str, migration_dir: str, version: str | None = None) -> None:
"""
Upgrade the given database with the migrations contained in the
migrations directory. If a version is not specified, upgrade
to the most recent version.
"""
async with Database(db_url) as db:
if not await db.is_version_controlled():
await db.initialize_version_control()
await db.upgrade(load_migrations(migration_dir), version)
async def downgrade(db_url: str | aiosqlite.Connection, migration_dir: str, version: str) -> None:
"""
Downgrade the database to the given version with the migrations
contained in the given migration directory.
"""
async with Database(db_url) as db:
if not await db.is_version_controlled():
msg = f"The database {db_url} is not version controlled."
raise Error(msg)
migrations = load_migrations(migration_dir)
await db.downgrade(migrations, version)
async def get_version(db_url: aiosqlite.Connection | str) -> str | None:
"""Return the migration version of the given database."""
async with Database(db_url) as db:
return await db.get_version()
def create_migration(name: str, directory: str | None = None) -> str:
"""
Create a migration with the given name. If no directory is specified,
the current working directory will be used.
"""
directory = directory if directory else "."
if not os.path.exists(directory) or not os.path.isdir(directory):
msg: str = f"{directory} is not a directory."
raise Error(msg)
now: datetime.datetime = datetime.datetime.now(tz=datetime.UTC)
version: str = now.strftime("%Y%m%d%H%M%S")
contents: str = MIGRATION_TEMPLATE % {"name": name, "version": version}
sanitized: str = name.replace(" ", "_")
filename: str = f"{version}_{sanitized}.py"
path: str = os.path.join(directory, filename)
with open(path, "w") as migration_file:
migration_file.write(contents)
return path
MIGRATION_TEMPLATE = """\
\"\"\"
This module contains a db migration.
Migration Name: %(name)s
Migration Version: %(version)s
\"\"\"
async def upgrade(c):
# add your upgrade step here
await c.execute("SELECT 1")
async def downgrade(c):
# add your downgrade step here
await c.execute("SELECT 1")
"""

348
app/library/sqlite_store.py Normal file
View file

@ -0,0 +1,348 @@
import asyncio
import contextlib
import json
import logging
import os
from collections.abc import Iterable
from dataclasses import fields
from datetime import UTC, datetime
from email.utils import formatdate
import aiosqlite
from aiohttp import web
from .ItemDTO import ItemDTO
from .Singleton import ThreadSafe
from .Utils import init_class
LOG: logging.Logger = logging.getLogger(__name__)
ITEM_DTO_FIELDS: set[str] = {f.name for f in fields(ItemDTO)}
class Terminator:
pass
class _Op:
"""Queued write operation."""
__slots__ = ("item", "key", "keys", "op", "type_value")
def __init__(
self, op: Terminator | str, type_value: str, item: ItemDTO | None, key: str | None, keys: list[str] | None
):
self.op = op
self.type_value = type_value
self.item = item
self.key = key
self.keys = keys
class SqliteStore(metaclass=ThreadSafe):
"""
Async persistence layer with back-pressure and write-behind queue.
Singleton per process (ThreadSafe). Owns its aiosqlite connection.
"""
@staticmethod
def get_instance(db_path: str | None = None) -> "SqliteStore":
return SqliteStore(db_path)
def attach(self, app: web.Application):
"""Get/create singleton bound to db_path."""
app.on_shutdown.append(self.on_shutdown)
async def on_shutdown(self, _: web.Application):
"""Close singleton on app shutdown."""
LOG.debug("Shutting down SqliteStore...")
await self.close()
LOG.debug("SqliteStore shut down complete.")
def __init__(self, db_path: str | None = None, *, max_pending: int = 200, flush_interval: float = 0.05):
self._db_path = db_path
self._conn: aiosqlite.Connection | None = None
self._queue: asyncio.Queue[_Op] | None = None
self._task: asyncio.Task | None = None
self._flush_interval = flush_interval
self._max_pending = max_pending
self._lock = asyncio.Lock()
async def __aenter__(self) -> "SqliteStore":
await self._ensure_conn()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
await self.close()
# ---------- public API ----------
async def fetch_saved(self, type_value: str) -> list[tuple[str, ItemDTO]]:
await self._ensure_conn()
cursor = await self._conn.execute(
'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = ? ORDER BY "created_at" ASC',
(type_value,),
)
items: list[tuple[str, ItemDTO]] = []
async with cursor:
async for row in cursor:
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
async def get_by_id(self, type_value: str, id: str) -> ItemDTO | None:
await self._ensure_conn()
cursor = await self._conn.execute(
'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = ? AND "id" = ?',
(type_value, id),
)
row = await cursor.fetchone()
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
async def count(self, type_value: str, status_filter: str | None = None) -> int:
await self._ensure_conn()
where_clauses = ['"type" = ?']
query_params: list[str] = [type_value]
if status_filter:
if status_filter.startswith("!"):
status_value = status_filter[1:]
where_clauses.append("json_extract(data, '$.status') != ?")
query_params.append(status_value)
else:
where_clauses.append("json_extract(data, '$.status') = ?")
query_params.append(status_filter)
where_clause = " AND ".join(where_clauses)
count_query = f'SELECT COUNT(*) as count FROM "history" WHERE {where_clause}' # noqa: S608
cursor = await self._conn.execute(count_query, tuple(query_params))
row = await cursor.fetchone()
return row["count"] if row else 0
async def paginate(
self,
type_value: str,
page: int,
per_page: int,
order: str,
status_filter: str | None = None,
) -> tuple[list[tuple[str, ItemDTO]], int, int, int]:
await self._ensure_conn()
where_clauses = ['"type" = ?']
query_params: list[str | int] = [type_value]
if status_filter:
if status_filter.startswith("!"):
status_value = status_filter[1:]
where_clauses.append("json_extract(data, '$.status') != ?")
query_params.append(status_value)
else:
where_clauses.append("json_extract(data, '$.status') = ?")
query_params.append(status_filter)
where_clause = " AND ".join(where_clauses)
count_query = f'SELECT COUNT(*) as count FROM "history" WHERE {where_clause}' # noqa: S608
count_cursor = await self._conn.execute(count_query, tuple(query_params))
count_row = await count_cursor.fetchone()
total_items = count_row["count"] if count_row else 0
total_pages = (total_items + per_page - 1) // per_page if total_items > 0 else 1
if page > total_pages and total_items > 0:
page = total_pages
offset = (page - 1) * per_page
query_params.extend([per_page, offset])
cursor = await self._conn.execute(
f'SELECT "id", "data", "created_at" FROM "history" WHERE {where_clause} ORDER BY "created_at" {order} LIMIT ? OFFSET ?', # noqa: S608
tuple(query_params),
)
items: list[tuple[str, ItemDTO]] = []
async with cursor:
async for row in cursor:
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, total_items, page, total_pages
# direct CRUD helpers (used by DataStore)
async def upsert(self, type_value: str, item: ItemDTO) -> None:
await self._ensure_conn()
await self._upsert_now(type_value, item)
async def delete(self, type_value: str, key: str) -> None:
await self._ensure_conn()
await self._conn.execute('DELETE FROM "history" WHERE "type" = ? AND "id" = ?', (type_value, key))
async def bulk_delete(self, type_value: str, keys: Iterable[str]) -> int:
await self._ensure_conn()
keys_list = list(keys)
if not keys_list:
return 0
placeholders = ",".join("?" for _ in keys_list)
params = [type_value, *keys_list]
cur = await self._conn.execute(
f'DELETE FROM "history" WHERE "type" = ? AND "id" IN ({placeholders})', # noqa: S608
tuple(params),
)
return cur.rowcount if cur else 0
async def commit(self) -> None:
if self._conn:
await self._conn.commit()
async def enqueue_upsert(self, type_value: str, item: ItemDTO) -> None:
await self._enqueue(_Op("upsert", type_value, item, None, None))
async def enqueue_delete(self, type_value: str, key: str) -> None:
await self._enqueue(_Op("delete", type_value, None, key, None))
async def enqueue_bulk_delete(self, type_value: str, keys: Iterable[str]) -> None:
await self._enqueue(_Op("bulk_delete", type_value, None, None, list(keys)))
async def flush(self) -> None:
if self._queue:
await self._queue.join()
if self._conn:
await self._conn.commit()
async def shutdown(self) -> None:
"""Flush pending writes, stop writer task, and commit."""
if self._queue:
try:
await self._queue.put(_Op(Terminator(), "", None, None, None))
LOG.debug("Waiting for SqliteStore queue to drain...")
await asyncio.wait_for(self._queue.join(), timeout=2)
except TimeoutError:
LOG.warning("SqliteStore queue did not drain within timeout; forcing writer shutdown.")
if self._task:
LOG.debug("Waiting for SqliteStore writer task to finish...")
if not self._task.done():
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await asyncio.wait_for(self._task, timeout=2)
if self._conn:
LOG.debug("Committing final changes to SqliteStore...")
await self._conn.commit()
self._task = None
self._queue = None
async def close(self) -> None:
await self.shutdown()
if self._conn:
LOG.debug("Closing SqliteStore connection...")
await self._conn.close()
self._conn = None
async def _enqueue(self, op: _Op) -> None:
self._ensure_worker()
await self._queue.put(op)
def _ensure_worker(self) -> None:
if self._queue is None:
self._queue = asyncio.Queue(maxsize=self._max_pending)
self._task = asyncio.create_task(self._writer(), name="sqlite-store-writer")
async def _writer(self) -> None:
while True:
op = await self._queue.get()
if isinstance(op.op, Terminator):
self._queue.task_done()
break
try:
async with self._lock:
await self._apply(op)
except Exception as ex:
LOG.exception(ex)
finally:
self._queue.task_done()
await asyncio.sleep(self._flush_interval)
async def _apply(self, op: _Op) -> None:
await self._ensure_conn()
if op.op == "upsert" and op.item:
await self._upsert_now(op.type_value, op.item)
elif op.op == "delete" and op.key:
await self._conn.execute('DELETE FROM "history" WHERE "type" = ? AND "id" = ?', (op.type_value, op.key))
elif op.op == "bulk_delete" and op.keys:
placeholders = ",".join("?" for _ in op.keys)
params = [op.type_value, *op.keys]
await self._conn.execute(
f'DELETE FROM "history" WHERE "type" = ? AND "id" IN ({placeholders})', # noqa: S608
tuple(params),
)
await self._conn.commit()
async def _upsert_now(self, type_value: str, item: ItemDTO) -> None:
await self._ensure_conn()
sql = """
INSERT INTO "history" ("id", "type", "url", "data")
VALUES (?, ?, ?, ?)
ON CONFLICT DO UPDATE SET "type" = ?, "url" = ?, "data" = ?, created_at = ?
"""
encoded = item.json()
await self._conn.execute(
sql.strip(),
(
item._id,
type_value,
item.url,
encoded,
type_value,
item.url,
encoded,
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
),
)
async def _ensure_conn(self) -> None:
if self._conn:
return
if not self._db_path:
msg = "SqliteStore requires db_path or injected connection."
raise RuntimeError(msg)
from app.library import migrate
from app.main import ROOT_PATH
if not self._db_path.startswith(":memory"):
os.makedirs(os.path.dirname(self._db_path) or ".", exist_ok=True)
self._conn = await aiosqlite.connect(database=self._db_path, isolation_level=None)
self._conn.row_factory = aiosqlite.Row
version = await migrate.get_version(self._conn)
if version:
LOG.debug(f"DB Version: '{version}'.")
await migrate.upgrade(self._conn, ROOT_PATH / "migrations")
if not version:
version = await migrate.get_version(self._conn)
LOG.debug(f"DB Version after initial migration: '{version}'.")
await self._conn.execute("PRAGMA journal_mode=wal")
await self._conn.execute("PRAGMA busy_timeout=5000")
await self._conn.execute("PRAGMA foreign_keys=ON")
await self._conn.commit()

View file

@ -9,16 +9,15 @@ if APP_ROOT not in sys.path:
import asyncio import asyncio
import logging import logging
import sqlite3
from pathlib import Path from pathlib import Path
import caribou
import magic import magic
from aiohttp import web from aiohttp import web
from app.library.BackgroundWorker import BackgroundWorker from app.library.BackgroundWorker import BackgroundWorker
from app.library.conditions import Conditions from app.library.conditions import Conditions
from app.library.config import Config from app.library.config import Config
from app.library.DataStore import SqliteStore
from app.library.dl_fields import DLFields from app.library.dl_fields import DLFields
from app.library.DownloadQueue import DownloadQueue from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
@ -50,18 +49,6 @@ class Main:
self._check_folders() self._check_folders()
LOG.debug(f"DB Version: '{caribou.get_version(self._config.db_file)}'.")
caribou.upgrade(self._config.db_file, ROOT_PATH / "migrations")
connection = sqlite3.connect(database=self._config.db_file, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=wal")
async def _close_connection(_):
LOG.debug("Closing database connection.")
connection.close()
LOG.debug("Database connection closed.")
try: try:
db_file = Path(self._config.db_file) db_file = Path(self._config.db_file)
if "600" != oct(db_file.stat().st_mode)[-3:]: if "600" != oct(db_file.stat().st_mode)[-3:]:
@ -69,12 +56,10 @@ class Main:
except Exception: except Exception:
pass pass
self._queue = DownloadQueue(connection=connection) self._queue = DownloadQueue(config=self._config)
self._http = HttpAPI(root_path=ROOT_PATH, queue=self._queue) self._http = HttpAPI(root_path=ROOT_PATH, queue=self._queue)
self._socket = HttpSocket(root_path=ROOT_PATH, queue=self._queue) self._socket = HttpSocket(root_path=ROOT_PATH, queue=self._queue)
self._app.on_cleanup.append(_close_connection)
def _check_folders(self): def _check_folders(self):
"""Check if the required folders exist and create them if they do not.""" """Check if the required folders exist and create them if they do not."""
folders = (self._config.download_path, self._config.temp_path, self._config.config_path) folders = (self._config.download_path, self._config.temp_path, self._config.config_path)
@ -136,6 +121,7 @@ class Main:
Conditions.get_instance().attach(self._app) Conditions.get_instance().attach(self._app)
DLFields.get_instance().attach(self._app) DLFields.get_instance().attach(self._app)
TaskDefinitions.get_instance().attach(self._app) TaskDefinitions.get_instance().attach(self._app)
SqliteStore.get_instance().attach(self._app)
self._background_worker.attach(self._app) self._background_worker.attach(self._app)
EventBus.get_instance().emit( EventBus.get_instance().emit(

View file

@ -1,12 +1,12 @@
""" """
This module contains a Caribou migration. This module contains a db migration.
Migration Name: initial Migration Name: initial
Migration Version: 20231115152938 Migration Version: 20231115152938
""" """
def upgrade(connection): async def upgrade(c):
sql = """ sql = """
CREATE TABLE "history" ( CREATE TABLE "history" (
"id" TEXT PRIMARY KEY UNIQUE NOT NULL, "id" TEXT PRIMARY KEY UNIQUE NOT NULL,
@ -16,24 +16,24 @@ def upgrade(connection):
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
""" """
connection.execute(sql) await c.execute(sql)
sql = """ sql = """
CREATE INDEX "history_type" ON "history" ("type"); CREATE INDEX "history_type" ON "history" ("type");
""" """
connection.execute(sql) await c.execute(sql)
sql = """ sql = """
CREATE UNIQUE INDEX "history_url" ON "history" ("url"); CREATE UNIQUE INDEX "history_url" ON "history" ("url");
""" """
connection.execute(sql) await c.execute(sql)
connection.commit() await c.commit()
def downgrade(connection): async def downgrade(c):
sql = """ sql = """
DROP TABLE IF EXISTS "history"; DROP TABLE IF EXISTS "history";
""" """
connection.execute(sql) await c.execute(sql)

View file

@ -1,11 +1,11 @@
""" """
This module contains a Caribou migration. This module contains a db migration.
Migration Name: add_status_index Migration Name: add_status_index
Migration Version: 20251116173731 Migration Version: 20251116173731
""" """
def upgrade(connection): async def upgrade(connection):
""" """
Add index on json_extract(data, '$.status') for better query performance. Add index on json_extract(data, '$.status') for better query performance.
@ -15,13 +15,13 @@ def upgrade(connection):
sql = """ sql = """
CREATE INDEX IF NOT EXISTS "history_status" ON "history" (json_extract("data", '$.status')); CREATE INDEX IF NOT EXISTS "history_status" ON "history" (json_extract("data", '$.status'));
""" """
connection.execute(sql) await connection.execute(sql)
def downgrade(connection): async def downgrade(connection):
""" """
Remove the status index. Remove the status index.
""" """
sql = """ sql = """
DROP INDEX IF EXISTS "history_status"; DROP INDEX IF EXISTS "history_status";
""" """
connection.execute(sql) await connection.execute(sql)

View file

@ -402,7 +402,7 @@ async def path_actions(request: Request, config: Config, queue: DownloadQueue, n
if sidecar_renamed: if sidecar_renamed:
item.info.get_file_sidecar() item.info.get_file_sidecar()
queue.done.put(item) await queue.done.put(item)
notify.emit(Events.ITEM_UPDATED, data=item.info) notify.emit(Events.ITEM_UPDATED, data=item.info)
if "delete" == action: if "delete" == action:
@ -508,7 +508,7 @@ async def path_actions(request: Request, config: Config, queue: DownloadQueue, n
if sidecar_moved: if sidecar_moved:
item.info.get_file_sidecar() item.info.get_file_sidecar()
queue.done.put(item) await queue.done.put(item)
notify.emit(Events.ITEM_UPDATED, data=item.info) notify.emit(Events.ITEM_UPDATED, data=item.info)
return web.json_response(data=operations_status, status=web.HTTPOk.status_code) return web.json_response(data=operations_status, status=web.HTTPOk.status_code)

View file

@ -47,30 +47,21 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder, c
""" """
from app.library.DataStore import StoreType from app.library.DataStore import StoreType
store_type = request.query.get("type", "all").lower() store_type: str = request.query.get("type", "queue").lower()
stores: list[str] = ["all", StoreType.QUEUE.value, StoreType.HISTORY.value] try:
if store_type not in stores: store_type: StoreType = StoreType.from_value(store_type)
except ValueError:
return web.json_response( return web.json_response(
data={"error": f"type must be one of {', '.join(stores)}."}, data={"error": f"type must be one of {', '.join(StoreType.all())}."},
status=web.HTTPBadRequest.status_code, status=web.HTTPBadRequest.status_code,
) )
# Legacy behavior: return all items without pagination, will be removed in future. ds = queue.queue if store_type == StoreType.QUEUE else queue.done
if "all" == store_type:
data: dict = {"queue": [], "history": []}
q = queue.get()
data["queue"].extend([q.get("queue", {}).get(k) for k in q.get("queue", {})])
data["history"].extend([q.get("done", {}).get(k) for k in q.get("done", {})])
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
ds = queue.queue if store_type == StoreType.QUEUE.value else queue.done
try: try:
page = int(request.query.get("page", 1)) page = int(request.query.get("page", 1))
per_page = int(request.query.get("per_page", config.default_pagination)) per_page = int(request.query.get("per_page", config.default_pagination))
order = request.query.get("order", "DESC").upper() order: str = request.query.get("order", "DESC").upper()
except ValueError: except ValueError:
return web.json_response( return web.json_response(
data={"error": "page and per_page must be valid integers."}, data={"error": "page and per_page must be valid integers."},
@ -92,25 +83,28 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder, c
status=web.HTTPBadRequest.status_code, status=web.HTTPBadRequest.status_code,
) )
# Parse status filter
status_filter = request.query.get("status", None) status_filter = request.query.get("status", None)
items, total, current_page, total_pages = ds.get_items_paginated( items, total, current_page, total_pages = await ds.get_items_paginated(
page=page, per_page=per_page, order=order, status_filter=status_filter page=page, per_page=per_page, order=order, status_filter=status_filter
) )
data = {
"pagination": {
"page": current_page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
"has_next": current_page < total_pages,
"has_prev": current_page > 1,
},
"items": [item for _, item in items],
}
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode) return web.json_response(
data={
"type": store_type.value,
"pagination": {
"page": current_page,
"per_page": per_page,
"total": total,
"total_pages": total_pages,
"has_next": current_page < total_pages,
"has_prev": current_page > 1,
},
"items": [item for _, item in items],
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
)
@route("DELETE", "api/history/", "items_delete") @route("DELETE", "api/history/", "items_delete")
@ -137,24 +131,26 @@ async def items_delete(request: Request, queue: DownloadQueue, encoder: Encoder)
ids = data.get("ids") ids = data.get("ids")
remove_file: bool = bool(data.get("remove_file", True)) remove_file: bool = bool(data.get("remove_file", True))
_type = data.get("type", data.get("where")) storeType = data.get("type", data.get("where"))
if not _type: if not storeType:
return web.json_response(data={"error": "Type is required."}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "Type is required."}, status=web.HTTPBadRequest.status_code)
if _type not in [StoreType.QUEUE.value, StoreType.HISTORY.value]: try:
storeType: StoreType = StoreType.from_value(storeType)
except ValueError:
return web.json_response( return web.json_response(
data={"error": f"type must be '{StoreType.QUEUE.value}' or '{StoreType.HISTORY.value}'."}, data={"error": f"type must be one of '{StoreType.all()}'."},
status=web.HTTPBadRequest.status_code, status=web.HTTPBadRequest.status_code,
) )
ds = queue.queue if _type == StoreType.QUEUE.value else queue.done ds = queue.queue if storeType == StoreType.QUEUE else queue.done
if ids: if ids:
return web.json_response( return web.json_response(
data={ data={
"items": await ( "items": await (
queue.cancel(ids) if _type == StoreType.QUEUE.value else queue.clear(ids, remove_file=remove_file) queue.cancel(ids) if storeType == StoreType.QUEUE else queue.clear(ids, remove_file=remove_file)
), ),
"deleted": len(ids), "deleted": len(ids),
}, },
@ -174,7 +170,7 @@ async def items_delete(request: Request, queue: DownloadQueue, encoder: Encoder)
per_page = 1000 per_page = 1000
while True: while True:
items, _, current_page, total_pages = ds.get_items_paginated( items, _, current_page, total_pages = await ds.get_items_paginated(
page=page, per_page=per_page, order="DESC", status_filter=status_filter page=page, per_page=per_page, order="DESC", status_filter=status_filter
) )
@ -192,7 +188,7 @@ async def items_delete(request: Request, queue: DownloadQueue, encoder: Encoder)
data={ data={
"items": await ( "items": await (
queue.cancel(items_to_delete) queue.cancel(items_to_delete)
if _type == StoreType.QUEUE.value if storeType == StoreType.QUEUE
else queue.clear(items_to_delete, remove_file=remove_file) else queue.clear(items_to_delete, remove_file=remove_file)
), ),
"deleted": len(items_to_delete), "deleted": len(items_to_delete),
@ -221,7 +217,7 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) ->
if not id: if not id:
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
item: Download | None = queue.done.get_by_id(id) or queue.queue.get_by_id(id) item: Download | None = await queue.done.get_by_id(id) or await queue.queue.get_by_id(id)
if not item: if not item:
return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code) return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code)
@ -263,7 +259,7 @@ async def item_update(request: Request, queue: DownloadQueue, encoder: Encoder,
if not id: if not id:
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
item: Download | None = queue.done.get_by_id(id) item: Download | None = await queue.done.get_by_id(id)
if not item: if not item:
return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code) return web.json_response(data={"error": "item not found."}, status=web.HTTPNotFound.status_code)
@ -285,7 +281,7 @@ async def item_update(request: Request, queue: DownloadQueue, encoder: Encoder,
LOG.debug(f"Updated '{k}' to '{v}' for '{item.info.id}'") LOG.debug(f"Updated '{k}' to '{v}' for '{item.info.id}'")
if updated: if updated:
queue.done.put(item) await queue.done.put(item)
notify.emit(Events.ITEM_UPDATED, data=item.info) notify.emit(Events.ITEM_UPDATED, data=item.info)
return web.json_response( return web.json_response(
@ -394,7 +390,7 @@ async def item_archive_add(request: Request, queue: DownloadQueue, notify: Event
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
try: try:
item: Download | None = queue.done.get_by_id(id) item: Download | None = await queue.done.get_by_id(id)
if not item: if not item:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code) return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
except KeyError: except KeyError:
@ -425,7 +421,7 @@ async def item_archive_add(request: Request, queue: DownloadQueue, notify: Event
) )
item.info.archive_status(force=True) item.info.archive_status(force=True)
queue.done.put(item, no_notify=True) await queue.done.put(item, no_notify=True)
notify.emit(Events.ITEM_UPDATED, data=item.info) notify.emit(Events.ITEM_UPDATED, data=item.info)
return web.json_response( return web.json_response(
@ -452,7 +448,7 @@ async def item_archive_delete(request: Request, queue: DownloadQueue, notify: Ev
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code) return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
try: try:
item: Download | None = queue.done.get_by_id(id) item: Download | None = await queue.done.get_by_id(id)
if not item: if not item:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code) return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
except KeyError: except KeyError:
@ -483,7 +479,7 @@ async def item_archive_delete(request: Request, queue: DownloadQueue, notify: Ev
) )
item.info.archive_status(force=True) item.info.archive_status(force=True)
queue.done.put(item, no_notify=True) await queue.done.put(item, no_notify=True)
notify.emit(Events.ITEM_UPDATED, data=item.info) notify.emit(Events.ITEM_UPDATED, data=item.info)
return web.json_response( return web.json_response(

View file

@ -44,8 +44,8 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s
base=Path(config.download_path), base=Path(config.download_path),
depth_limit=config.download_path_depth - 1, depth_limit=config.download_path_depth - 1,
), ),
"history_count": queue.done.get_total_count(), "history_count": await queue.done.get_total_count(),
"queue": queue.get("queue")["queue"], "queue": (await queue.get("queue"))["queue"],
}, },
title="Sending initial download data", title="Sending initial download data",
message=f"Sending initial download data to client '{sid}'.", message=f"Sending initial download data to client '{sid}'.",

View file

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""
Caribou migrations CLI.
Provides commands to create, list, and apply database migrations.
"""
from __future__ import annotations
import argparse
import sys
import traceback
from pathlib import Path
from typing import TYPE_CHECKING
APP_ROOT: Path = Path(__file__).resolve().parents[2]
if str(APP_ROOT) not in sys.path:
sys.path.insert(0, str(APP_ROOT))
from app.library import migrate
if TYPE_CHECKING:
from collections.abc import Callable
def e_print(msg: str = "") -> None:
sys.stderr.write(f"{msg}\n")
def o_print(msg: str = "") -> None:
sys.stdout.write(f"{msg}\n")
def cmd_info(_: argparse.Namespace) -> None:
o_print(f"Version: {migrate.__version__}")
def cmd_create(args: argparse.Namespace) -> None:
path = migrate.create_migration(args.name, args.migration_dir)
o_print(f"created migration {path}")
def cmd_version(args: argparse.Namespace) -> None:
version = migrate.get_version(args.database_path)
if version:
o_print(f"the db [{args.database_path}] is at version {version}")
else:
o_print(f"the db [{args.database_path}] is not under version control")
def cmd_upgrade(args: argparse.Namespace) -> None:
if args.version:
o_print(f"upgrading db [{args.database_path}] to version [{args.version}]")
else:
o_print(f"upgrading db [{args.database_path}] to most recent version")
migrate.upgrade(args.database_path, args.migration_dir, args.version)
new_version = migrate.get_version(args.database_path)
if args.version is not None:
assert new_version == args.version
o_print(f"upgraded [{args.database_path}] successfully to version [{new_version}]")
def cmd_downgrade(args: argparse.Namespace) -> None:
o_print(f"downgrading db [{args.database_path}] to version [{args.version}]")
migrate.downgrade(args.database_path, args.migration_dir, args.version)
o_print(f"downgraded [{args.database_path}] successfully to version [{args.version}]")
def cmd_list(args: argparse.Namespace) -> None:
o_print(f"Migrations in [{args.migration_dir}]:\n")
for m in migrate.load_migrations(args.migration_dir):
o_print(f"{m.get_version()}\t{m.name}\t{m.path}")
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="caribou")
sub = p.add_subparsers(dest="command", required=True)
info = sub.add_parser("info", help="print library version")
info.set_defaults(func=cmd_info)
create = sub.add_parser("create", help="create a new migration file")
create.add_argument("name", help="the name of migration")
create.add_argument("-d", "--migration-dir", default=".", help="the migration directory")
create.set_defaults(func=cmd_create)
ver = sub.add_parser("version", help="return the migration version of the database")
ver.add_argument("database_path", help="path to the sqlite database")
ver.set_defaults(func=cmd_version)
up = sub.add_parser(
"upgrade",
help="upgrade the db (if version isn't specified, upgrade to the most recent)",
)
up.add_argument("database_path", help="path to the sqlite database")
up.add_argument("migration_dir", help="the migration directory")
up.add_argument("-v", "--version", type=int, default=None, help="the target migration version")
up.set_defaults(func=cmd_upgrade)
down = sub.add_parser(
"downgrade",
help="downgrade the db to a particular version (use 0 to rollback all changes)",
)
down.add_argument("database_path", help="path to the sqlite database")
down.add_argument("migration_dir", help="the migration directory")
down.add_argument("version", type=int, help="the target migration version")
down.set_defaults(func=cmd_downgrade)
lst = sub.add_parser("list", help="list the migration versions")
lst.add_argument("migration_dir", help="the migration directory")
lst.set_defaults(func=cmd_list)
return p
def run(argv: list[str]) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
func: Callable[[argparse.Namespace], None] = args.func
func(args)
return 0
except migrate.InvalidMigrationError:
e_print(traceback.format_exc())
return 1
except migrate.Error as err:
e_print(f"Error: {err}")
return 1
except Exception:
e_print("an unexpected error occurred:")
e_print(traceback.format_exc())
return 1
def main() -> int:
return run(sys.argv[1:])
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load diff

View file

@ -1,257 +1,210 @@
import json import json
import sqlite3
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest import pytest
import pytest_asyncio
from app.library.DataStore import DataStore, StoreType from app.library.DataStore import DataStore, StoreType
from app.library.ItemDTO import ItemDTO from app.library.ItemDTO import ItemDTO
from app.library.sqlite_store import SqliteStore
async def make_db(data: int = 100) -> SqliteStore:
"""Create a temporary database with test data."""
SqliteStore._reset_singleton()
ins = SqliteStore.get_instance(db_path=":memory:")
await ins._ensure_conn()
base_time = datetime.now(UTC)
for i in range(data):
created_at = base_time.replace(hour=(i // 4) % 24, minute=(i * 15) % 60, second=i % 60).strftime(
"%Y-%m-%d %H:%M:%S"
)
item_data = {
"url": f"https://example.com/video{i}",
"title": f"Test Video {i}",
"id": f"video{i}",
"folder": "/downloads",
"status": "finished",
}
await ins._conn.execute(
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
(
f"test-id-{i}",
str(StoreType.HISTORY),
item_data["url"],
json.dumps(item_data),
created_at,
),
)
if data > 0:
await ins._conn.commit()
return ins
@pytest.mark.asyncio
class TestDataStorePagination: class TestDataStorePagination:
"""Test pagination functionality of DataStore.""" """Test pagination functionality of DataStore."""
@pytest.fixture @pytest_asyncio.fixture
def temp_db(self): async def make_db(self, data: int = 100):
"""Create a temporary database with test data.""" """Fixture to provide a temporary database with test data."""
with TemporaryDirectory() as tmpdir: return await make_db(data=data)
db_path = Path(tmpdir) / "test.db"
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
# Create the history table async def test_get_total_count(self):
conn.execute(
"""
CREATE TABLE IF NOT EXISTS "history" (
"id" TEXT PRIMARY KEY,
"type" TEXT NOT NULL,
"url" TEXT NOT NULL,
"data" TEXT NOT NULL,
"created_at" TEXT NOT NULL
)
"""
)
# Insert test data
base_time = datetime.now(UTC)
for i in range(100):
created_at = base_time.replace(
hour=(i // 4) % 24, minute=(i * 15) % 60, second=i % 60
).strftime("%Y-%m-%d %H:%M:%S")
item_data = {
"url": f"https://example.com/video{i}",
"title": f"Test Video {i}",
"id": f"video{i}",
"folder": "/downloads",
"status": "finished",
}
conn.execute(
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
(
f"test-id-{i}",
str(StoreType.HISTORY),
item_data["url"],
json.dumps(item_data),
created_at,
),
)
conn.commit()
yield conn
conn.close()
def test_get_total_count(self, temp_db):
"""Test getting total count of items.""" """Test getting total count of items."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db) db = await make_db(data=100)
count = datastore.get_total_count() try:
assert count == 100 datastore = DataStore(type=StoreType.HISTORY, connection=db)
count = await datastore.get_total_count()
assert count == 100
finally:
await db.close()
def test_pagination_basic(self, temp_db): async def test_pagination_basic(self):
"""Test basic pagination functionality.""" """Test basic pagination functionality."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db) db = await make_db(data=100)
try:
datastore = DataStore(type=StoreType.HISTORY, connection=db)
items, total, page, total_pages = await datastore.get_items_paginated(page=1, per_page=10)
assert len(items) == 10
assert total == 100
assert page == 1
assert total_pages == 10
for _, item in items:
assert isinstance(item, ItemDTO)
assert item._id.startswith("test-id-")
finally:
await db.close()
# Get first page async def test_pagination_last_page(self):
items, total, page, total_pages = datastore.get_items_paginated(page=1, per_page=10)
assert len(items) == 10
assert total == 100
assert page == 1
assert total_pages == 10
# Verify items are ItemDTO instances
for _item_id, item in items:
assert isinstance(item, ItemDTO)
assert item._id.startswith("test-id-")
def test_pagination_last_page(self, temp_db):
"""Test pagination on last page.""" """Test pagination on last page."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db) db = await make_db(data=100)
try:
datastore = DataStore(type=StoreType.HISTORY, connection=db)
items, total, page, total_pages = await datastore.get_items_paginated(page=10, per_page=10)
assert len(items) == 10
assert total == 100
assert page == 10
assert total_pages == 10
finally:
await db.close()
# Get last page async def test_pagination_partial_page(self):
items, total, page, total_pages = datastore.get_items_paginated(page=10, per_page=10)
assert len(items) == 10
assert total == 100
assert page == 10
assert total_pages == 10
def test_pagination_partial_page(self, temp_db):
"""Test pagination with partial last page.""" """Test pagination with partial last page."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db) db = await make_db(data=100)
try:
datastore = DataStore(type=StoreType.HISTORY, connection=db)
items, total, page, total_pages = await datastore.get_items_paginated(page=4, per_page=30)
assert len(items) == 10 # 100 items / 30 per page = 3 full pages + 10 items
assert total == 100
assert page == 4
assert total_pages == 4
finally:
await db.close()
# Get items with per_page that doesn't divide evenly async def test_pagination_out_of_range(self):
items, total, page, total_pages = datastore.get_items_paginated(page=4, per_page=30)
assert len(items) == 10 # 100 items / 30 per page = 3 full pages + 10 items
assert total == 100
assert page == 4
assert total_pages == 4
def test_pagination_out_of_range(self, temp_db):
"""Test pagination with page number out of range.""" """Test pagination with page number out of range."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db) db = await make_db(data=100)
try:
datastore = DataStore(type=StoreType.HISTORY, connection=db)
items, total, page, total_pages = await datastore.get_items_paginated(page=999, per_page=10)
assert len(items) == 10
assert total == 100
assert page == 10 # Adjusted to last page
assert total_pages == 10
finally:
await db.close()
# Request page beyond total pages - should return last page async def test_pagination_order_desc(self):
items, total, page, total_pages = datastore.get_items_paginated(page=999, per_page=10)
assert len(items) == 10
assert total == 100
assert page == 10 # Adjusted to last page
assert total_pages == 10
def test_pagination_order_desc(self, temp_db):
"""Test pagination with descending order (newest first).""" """Test pagination with descending order (newest first)."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db) db = await make_db(data=100)
try:
datastore = DataStore(type=StoreType.HISTORY, connection=db)
items, _, _, _ = await datastore.get_items_paginated(page=1, per_page=5, order="DESC")
assert len(items) == 5
finally:
await db.close()
items, _, _, _ = datastore.get_items_paginated(page=1, per_page=5, order="DESC") async def test_pagination_order_asc(self):
assert len(items) == 5
# Verify order - should be newest to oldest
# (note: exact order depends on the timestamp generation in fixture)
def test_pagination_order_asc(self, temp_db):
"""Test pagination with ascending order (oldest first).""" """Test pagination with ascending order (oldest first)."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db) db = await make_db(data=100)
try:
datastore = DataStore(type=StoreType.HISTORY, connection=db)
items, _, _, _ = await datastore.get_items_paginated(page=1, per_page=5, order="ASC")
assert len(items) == 5
finally:
await db.close()
items, _, _, _ = datastore.get_items_paginated(page=1, per_page=5, order="ASC") async def test_pagination_invalid_page(self):
assert len(items) == 5
def test_pagination_invalid_page(self, temp_db):
"""Test pagination with invalid page number.""" """Test pagination with invalid page number."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db) db = await make_db(data=100)
try:
datastore = DataStore(type=StoreType.HISTORY, connection=db)
with pytest.raises(ValueError, match="page must be >= 1"):
await datastore.get_items_paginated(page=0, per_page=10)
with pytest.raises(ValueError, match="page must be >= 1"): with pytest.raises(ValueError, match="page must be >= 1"):
datastore.get_items_paginated(page=0, per_page=10) await datastore.get_items_paginated(page=-1, per_page=10)
finally:
await db.close()
with pytest.raises(ValueError, match="page must be >= 1"): async def test_pagination_invalid_per_page(self):
datastore.get_items_paginated(page=-1, per_page=10)
def test_pagination_invalid_per_page(self, temp_db):
"""Test pagination with invalid per_page value.""" """Test pagination with invalid per_page value."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db) db = await make_db(data=100)
try:
datastore = DataStore(type=StoreType.HISTORY, connection=db)
with pytest.raises(ValueError, match="per_page must be >= 1"): with pytest.raises(ValueError, match="per_page must be >= 1"):
datastore.get_items_paginated(page=1, per_page=0) await datastore.get_items_paginated(page=1, per_page=0)
with pytest.raises(ValueError, match="per_page must be >= 1"): with pytest.raises(ValueError, match="per_page must be >= 1"):
datastore.get_items_paginated(page=1, per_page=-10) await datastore.get_items_paginated(page=1, per_page=-10)
finally:
await db.close()
def test_pagination_invalid_order(self, temp_db): async def test_pagination_invalid_order(self):
"""Test pagination with invalid order parameter.""" """Test pagination with invalid order parameter."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db) db = await make_db(data=100)
try:
datastore = DataStore(type=StoreType.HISTORY, connection=db)
with pytest.raises(ValueError, match="order must be 'ASC' or 'DESC'"):
await datastore.get_items_paginated(page=1, per_page=10, order="INVALID")
finally:
await db.close()
with pytest.raises(ValueError, match="order must be 'ASC' or 'DESC'"): async def test_pagination_empty_store(self):
datastore.get_items_paginated(page=1, per_page=10, order="INVALID")
def test_pagination_empty_store(self):
"""Test pagination with empty datastore.""" """Test pagination with empty datastore."""
with TemporaryDirectory() as tmpdir: db = await make_db(data=0)
db_path = Path(tmpdir) / "empty.db" try:
conn = sqlite3.connect(str(db_path)) datastore = DataStore(type=StoreType.HISTORY, connection=db)
conn.row_factory = sqlite3.Row items, total, page, total_pages = await datastore.get_items_paginated(page=1, per_page=10)
# Create empty table
conn.execute(
"""
CREATE TABLE IF NOT EXISTS "history" (
"id" TEXT PRIMARY KEY,
"type" TEXT NOT NULL,
"url" TEXT NOT NULL,
"data" TEXT NOT NULL,
"created_at" TEXT NOT NULL
)
"""
)
conn.commit()
datastore = DataStore(type=StoreType.HISTORY, connection=conn)
items, total, page, total_pages = datastore.get_items_paginated(page=1, per_page=10)
assert len(items) == 0 assert len(items) == 0
assert total == 0 assert total == 0
assert page == 1 assert page == 1
assert total_pages == 1 assert total_pages == 1
finally:
await db.close()
conn.close() async def test_pagination_single_item(self):
def test_pagination_single_item(self):
"""Test pagination with single item.""" """Test pagination with single item."""
with TemporaryDirectory() as tmpdir: db = await make_db(data=1)
db_path = Path(tmpdir) / "single.db" try:
conn = sqlite3.connect(str(db_path)) datastore = DataStore(type=StoreType.HISTORY, connection=db)
conn.row_factory = sqlite3.Row items, total, page, total_pages = await datastore.get_items_paginated(page=1, per_page=10)
# Create table with single item
conn.execute(
"""
CREATE TABLE IF NOT EXISTS "history" (
"id" TEXT PRIMARY KEY,
"type" TEXT NOT NULL,
"url" TEXT NOT NULL,
"data" TEXT NOT NULL,
"created_at" TEXT NOT NULL
)
"""
)
item_data = {
"url": "https://example.com/single",
"title": "Single Video",
"id": "single-video",
"folder": "/downloads",
"status": "finished",
}
conn.execute(
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
(
"single-id",
str(StoreType.HISTORY),
item_data["url"],
json.dumps(item_data),
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
),
)
conn.commit()
datastore = DataStore(type=StoreType.HISTORY, connection=conn)
items, total, page, total_pages = datastore.get_items_paginated(page=1, per_page=10)
assert len(items) == 1 assert len(items) == 1
assert total == 1 assert total == 1
assert page == 1 assert page == 1
assert total_pages == 1 assert total_pages == 1
finally:
await db.close()
conn.close() async def test_pagination_status_filter_include(self):
def test_pagination_status_filter_include(self, temp_db):
"""Test pagination with status filter (inclusion).""" """Test pagination with status filter (inclusion)."""
# Add some items with different status values # Add some items with different status values
item_data_pending = { item_data_pending = {
@ -269,50 +222,51 @@ class TestDataStorePagination:
"status": "downloading", "status": "downloading",
} }
temp_db.execute( db = await make_db(data=100)
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)', try:
( await db._conn.execute(
"pending-id", 'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
str(StoreType.HISTORY), (
item_data_pending["url"], "pending-id",
json.dumps(item_data_pending), str(StoreType.HISTORY),
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"), item_data_pending["url"],
), json.dumps(item_data_pending),
) datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
temp_db.execute( ),
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)', )
( await db._conn.execute(
"downloading-id", 'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
str(StoreType.HISTORY), (
item_data_downloading["url"], "downloading-id",
json.dumps(item_data_downloading), str(StoreType.HISTORY),
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"), item_data_downloading["url"],
), json.dumps(item_data_downloading),
) datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
temp_db.commit() ),
)
await db._conn.commit()
datastore = DataStore(type=StoreType.HISTORY, connection=db)
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db) # Filter for finished items only
items, total, _, __ = await datastore.get_items_paginated(page=1, per_page=50, status_filter="finished")
# Filter for finished items only assert len(items) == 50 # First page of finished items
items, total, _page, _total_pages = datastore.get_items_paginated( assert total == 100 # Only 100 finished items in fixture
page=1, per_page=50, status_filter="finished" for _item_id, item in items:
) assert item.status == "finished"
assert len(items) == 50 # First page of finished items # Filter for pending items only
assert total == 100 # Only 100 finished items in fixture items, total, _page, _total_pages = await datastore.get_items_paginated(
for _item_id, item in items: page=1, per_page=50, status_filter="pending"
assert item.status == "finished" )
# Filter for pending items only assert len(items) == 1
items, total, _page, _total_pages = datastore.get_items_paginated( assert total == 1
page=1, per_page=50, status_filter="pending" assert items[0][1].status == "pending"
) finally:
await db.close()
assert len(items) == 1 async def test_pagination_status_filter_exclude(self):
assert total == 1
assert items[0][1].status == "pending"
def test_pagination_status_filter_exclude(self, temp_db):
"""Test pagination with status filter (exclusion).""" """Test pagination with status filter (exclusion)."""
# Add some items with different status values # Add some items with different status values
item_data_pending = { item_data_pending = {
@ -329,55 +283,62 @@ class TestDataStorePagination:
"folder": "/downloads", "folder": "/downloads",
"status": "error", "status": "error",
} }
db = await make_db(data=0)
try:
await db._conn.execute(
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
(
"pending-id-2",
str(StoreType.HISTORY),
item_data_pending["url"],
json.dumps(item_data_pending),
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
),
)
await db._conn.execute(
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
(
"error-id",
str(StoreType.HISTORY),
item_data_error["url"],
json.dumps(item_data_error),
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
),
)
await db._conn.commit()
temp_db.execute( datastore = DataStore(type=StoreType.HISTORY, connection=db)
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
(
"pending-id-2",
str(StoreType.HISTORY),
item_data_pending["url"],
json.dumps(item_data_pending),
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
),
)
temp_db.execute(
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
(
"error-id",
str(StoreType.HISTORY),
item_data_error["url"],
json.dumps(item_data_error),
datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
),
)
temp_db.commit()
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db) # Exclude finished items
items, total, _page, _total_pages = await datastore.get_items_paginated(
page=1, per_page=50, status_filter="!finished"
)
# Exclude finished items assert total == 2 # Only 2 non-finished items
items, total, _page, _total_pages = datastore.get_items_paginated( assert len(items) == 2
page=1, per_page=50, status_filter="!finished" for _item_id, item in items:
) assert item.status != "finished"
assert total == 2 # Only 2 non-finished items # Verify we have pending and error
assert len(items) == 2 statuses = {item.status for _, item in items}
for _item_id, item in items: assert statuses == {"pending", "error"}
assert item.status != "finished" finally:
await db.close()
# Verify we have pending and error async def test_pagination_status_filter_none_matching(self):
statuses = {item.status for _, item in items}
assert statuses == {"pending", "error"}
def test_pagination_status_filter_none_matching(self, temp_db):
"""Test pagination with status filter that matches no items.""" """Test pagination with status filter that matches no items."""
datastore = DataStore(type=StoreType.HISTORY, connection=temp_db) db = await make_db(data=0)
try:
datastore = DataStore(type=StoreType.HISTORY, connection=db)
# Filter for a status that doesn't exist # Filter for a status that doesn't exist
items, total, page, total_pages = datastore.get_items_paginated( items, total, page, total_pages = await datastore.get_items_paginated(
page=1, per_page=50, status_filter="nonexistent" page=1, per_page=50, status_filter="nonexistent"
) )
assert len(items) == 0 assert len(items) == 0
assert total == 0 assert total == 0
assert page == 1 assert page == 1
assert total_pages == 1 assert total_pages == 1
finally:
await db.close()

View file

@ -0,0 +1,183 @@
from datetime import UTC, datetime, timedelta
import pytest
from app.library.ItemDTO import ItemDTO
from app.library.sqlite_store import SqliteStore
async def make_store() -> SqliteStore:
"""Create an isolated in-memory SqliteStore instance with schema."""
SqliteStore._reset_singleton()
store = SqliteStore.get_instance(db_path=":memory:")
await store._ensure_conn()
assert store._conn is not None
return store
def make_item(idx: int, *, status: str = "finished") -> ItemDTO:
return ItemDTO(
id=f"vid{idx}",
title=f"Video {idx}",
url=f"https://example.com/{idx}",
folder="/downloads",
status=status,
)
@pytest.mark.asyncio
async def test_enqueue_upsert_and_fetch_saved():
store = await make_store()
item = make_item(1)
await store.enqueue_upsert("queue", item)
await store.flush()
saved = await store.fetch_saved("queue")
assert len(saved) == 1
key, loaded = saved[0]
assert key == item._id
assert loaded.title == item.title
await store.close()
@pytest.mark.asyncio
async def test_delete_and_bulk_delete():
store = await make_store()
items = [make_item(i) for i in range(3)]
for itm in items:
await store.enqueue_upsert("queue", itm)
await store.flush()
await store.enqueue_delete("queue", items[0]._id)
await store.enqueue_bulk_delete("queue", [items[1]._id])
await store.flush()
remaining = await store.fetch_saved("queue")
assert len(remaining) == 1
assert remaining[0][0] == items[2]._id
await store.close()
@pytest.mark.asyncio
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)]
for itm in finished_items + pending_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
await store.close()
@pytest.mark.asyncio
async def test_paginate_order_and_bounds():
store = await make_store()
base = datetime(2024, 1, 1, tzinfo=UTC)
_list: list[ItemDTO] = []
for i in range(15):
itm = make_item(i)
encoded = itm.json()
created_at = (base + timedelta(minutes=i)).strftime("%Y-%m-%d %H:%M:%S")
_list.append(itm)
await store._conn.execute(
'INSERT INTO "history" ("id", "type", "url", "data", "created_at") VALUES (?, ?, ?, ?, ?)',
(itm._id, "history", itm.url, encoded, created_at),
)
await store._conn.commit()
items, total, page, total_pages = await store.paginate("history", page=1, per_page=5, order="ASC")
assert total == 15
assert page == 1
assert total_pages == 3
assert items[0][0] == _list[0]._id
items_desc, *_ = await store.paginate("history", page=1, per_page=3, order="DESC")
assert len(items_desc) == 3
assert items_desc[0][0] == _list[14]._id
await store.close()
@pytest.mark.asyncio
async def test_get_by_id():
store = await make_store()
item = make_item(99)
await store.enqueue_upsert("queue", item)
await store.flush()
assert item._id == (await store.get_by_id("queue", item._id))._id
await store.close()
@pytest.mark.asyncio
async def test_shutdown_closes_worker_queue():
store = await make_store()
await store.enqueue_upsert("queue", make_item(99))
await store.shutdown()
assert store._queue is None
await store.close()
@pytest.mark.asyncio
async def test_enqueue_delete_removes_row():
store = await make_store()
item = make_item(5)
await store.enqueue_upsert("queue", item)
await store.flush()
await store.enqueue_delete("queue", item._id)
await store.flush()
rows = await store.fetch_saved("queue")
assert rows == []
await store.close()
@pytest.mark.asyncio
async def test_enqueue_bulk_delete_returns_count_and_bulk_path():
store = await make_store()
items = [make_item(i) for i in range(3)]
for itm in items:
await store.enqueue_upsert("history", itm)
await store.flush()
await store.enqueue_bulk_delete("history", [items[0]._id, items[1]._id])
await store.flush()
remaining = await store.fetch_saved("history")
assert len(remaining) == 1
assert remaining[0][0] == items[2]._id
# direct bulk_delete should report affected rows
count = await store.bulk_delete("history", [items[2]._id])
assert count == 1
await store.close()
@pytest.mark.asyncio
async def test_paginate_out_of_range_returns_last_page():
store = await make_store()
for i in range(7):
await store.enqueue_upsert("history", make_item(i))
await store.flush()
items, total, page, total_pages = await store.paginate("history", page=10, per_page=3, order="DESC")
assert total == 7
assert total_pages == 3
assert page == 3
assert len(items) == 1
await store.close()
@pytest.mark.asyncio
async def test_on_shutdown_closes_connection():
store = await make_store()
await store.enqueue_upsert("queue", make_item(1))
await store.flush()
await store.on_shutdown(None)
assert store._conn is None

View file

@ -15,7 +15,6 @@ dependencies = [
# Crossplatform # Crossplatform
"python-socketio>=5.11.1", "python-socketio>=5.11.1",
"aiohttp>=3.9.3", "aiohttp>=3.9.3",
"caribou>=0.3.0",
"coloredlogs>=15.0.1", "coloredlogs>=15.0.1",
"aiocron>=1.8", "aiocron>=1.8",
"python-dotenv>=1.0.1", "python-dotenv>=1.0.1",
@ -44,6 +43,7 @@ dependencies = [
"parsel>=1.10.0", "parsel>=1.10.0",
"jmespath>=1.0.1", "jmespath>=1.0.1",
"jsonschema>=4.23.0", "jsonschema>=4.23.0",
"aiosqlite>=0.22.0",
] ]
[tool.ruff] [tool.ruff]

22
uv.lock
View file

@ -105,6 +105,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 },
] ]
[[package]]
name = "aiosqlite"
version = "0.22.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/0d/449c024bdabd0678ae07d804e60ed3b9786facd3add66f51eee67a0fccea/aiosqlite-0.22.0.tar.gz", hash = "sha256:7e9e52d72b319fcdeac727668975056c49720c995176dc57370935e5ba162bb9", size = 14707 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/39/b2181148075272edfbbd6d87e6cd78cc71dca243446fa3b381fd4116950b/aiosqlite-0.22.0-py3-none-any.whl", hash = "sha256:96007fac2ce70eda3ca1bba7a3008c435258a592b8fbf2ee3eeaa36d33971a09", size = 17263 },
]
[[package]] [[package]]
name = "altgraph" name = "altgraph"
version = "0.17.5" version = "0.17.5"
@ -224,15 +233,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/6b/6e92009df3b8b7272f85a0992b306b61c34b7ea1c4776643746e61c380ac/brotlicffi-1.2.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:f139a7cdfe4ae7859513067b736eb44d19fae1186f9e99370092f6915216451b", size = 378586 }, { url = "https://files.pythonhosted.org/packages/07/6b/6e92009df3b8b7272f85a0992b306b61c34b7ea1c4776643746e61c380ac/brotlicffi-1.2.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:f139a7cdfe4ae7859513067b736eb44d19fae1186f9e99370092f6915216451b", size = 378586 },
] ]
[[package]]
name = "caribou"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/74/de2c20c5a24811d69a2a6150d5cd841f2af21ffc65ff964a73f46738bb68/caribou-0.4.1.tar.gz", hash = "sha256:13a66fc9ef9b1c9e9ef220876d59a44ee5eff9e579655252271b7514fc0ad787", size = 14019 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/91/795eccdad6abd41b3634502d0971cb696d2e5c9cede67f8b38ebe30d2b1e/caribou-0.4.1-py2.py3-none-any.whl", hash = "sha256:5c7a036584b34021011f1512620152272924e55ca87c7c805073aeef31c5baed", size = 7203 },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2025.11.12" version = "2025.11.12"
@ -1823,13 +1823,13 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiocron" }, { name = "aiocron" },
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "aiosqlite" },
{ name = "anyio" }, { name = "anyio" },
{ name = "apprise" }, { name = "apprise" },
{ name = "async-timeout" }, { name = "async-timeout" },
{ name = "bgutil-ytdlp-pot-provider" }, { name = "bgutil-ytdlp-pot-provider" },
{ name = "brotli" }, { name = "brotli" },
{ name = "brotlicffi" }, { name = "brotlicffi" },
{ name = "caribou" },
{ name = "coloredlogs" }, { name = "coloredlogs" },
{ name = "curl-cffi" }, { name = "curl-cffi" },
{ name = "dateparser" }, { name = "dateparser" },
@ -1874,13 +1874,13 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "aiocron", specifier = ">=1.8" }, { name = "aiocron", specifier = ">=1.8" },
{ name = "aiohttp", specifier = ">=3.9.3" }, { name = "aiohttp", specifier = ">=3.9.3" },
{ name = "aiosqlite", specifier = ">=0.22.0" },
{ name = "anyio" }, { name = "anyio" },
{ name = "apprise", specifier = ">=1.9.3" }, { name = "apprise", specifier = ">=1.9.3" },
{ name = "async-timeout" }, { name = "async-timeout" },
{ name = "bgutil-ytdlp-pot-provider", specifier = ">=1.2.1" }, { name = "bgutil-ytdlp-pot-provider", specifier = ">=1.2.1" },
{ name = "brotli" }, { name = "brotli" },
{ name = "brotlicffi" }, { name = "brotlicffi" },
{ name = "caribou", specifier = ">=0.3.0" },
{ name = "coloredlogs", specifier = ">=15.0.1" }, { name = "coloredlogs", specifier = ">=15.0.1" },
{ name = "curl-cffi", specifier = ">=0.13" }, { name = "curl-cffi", specifier = ">=0.13" },
{ name = "dateparser", specifier = ">=1.2.1" }, { name = "dateparser", specifier = ">=1.2.1" },

View file