diff --git a/.vscode/settings.json b/.vscode/settings.json index 1fb8c714..2439e05e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,6 +17,7 @@ "ahash", "aiocron", "aiosqlite", + "alnum", "anyio", "Archiver", "arrowdown", diff --git a/app/library/DataStore.py b/app/library/DataStore.py index 08872999..16491e96 100644 --- a/app/library/DataStore.py +++ b/app/library/DataStore.py @@ -61,17 +61,20 @@ class DataStore: 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: + async def exists(self, key: str | None = None, url: str | None = None) -> bool: if not key and not url: msg = "key or url must be provided." raise KeyError(msg) + if key and key in self._dict: return True - return any( - (key and self._dict[i].info._id == key) or (url and self._dict[i].info.url == url) for i in self._dict - ) - def get(self, key: str | None = None, url: str | None = None) -> Download: + if any((key and self._dict[i].info._id == key) or (url and self._dict[i].info.url == url) for i in self._dict): + return True + + return StoreType.HISTORY == self._type and await self._connection.exists(str(self._type), key=key, url=url) + + async def get(self, key: str | None = None, url: str | None = None) -> Download: if not key and not url: msg = "key or url must be provided." raise KeyError(msg) @@ -80,10 +83,14 @@ class DataStore: if (key and self._dict[i].info._id == key) or (url and self._dict[i].info.url == url): return self._dict[i] + if StoreType.HISTORY == self._type and (item := await self._connection.get(str(self._type), key=key, url=url)): + self._dict[item._id] = Download(info=item) + return self._dict[item._id] + msg: str = f"{key=} or {url=} not found." raise KeyError(msg) - def get_item(self, **kwargs) -> Download | None: + async def get_item(self, **kwargs) -> Download | None: if not kwargs: return None @@ -96,6 +103,10 @@ class DataStore: if any(matches_condition(key, value, info) for key, value in kwargs.items()): return self._dict[i] + if StoreType.HISTORY == self._type and (item := await self._connection.get_item(str(self._type), **kwargs)): + self._dict[item._id] = Download(info=item) + return self._dict[item._id] + return None async def get_by_id(self, id: str) -> Download | None: @@ -104,8 +115,7 @@ class DataStore: return val if item := await self._connection.get_by_id(str(self._type), id): - download = Download(info=item) - self._dict[id] = download + self._dict[item._id] = Download(info=item) return self._dict[id] return None diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index 8133ee00..86f14acf 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -71,7 +71,6 @@ class DownloadQueue(metaclass=Singleton): self._active: dict[str, Download] = {} """Dictionary of active downloads.""" - # Loading happens in async initialize to avoid blocking loop creation. self.paused.set() @staticmethod @@ -170,7 +169,6 @@ class DownloadQueue(metaclass=Singleton): f"Using '{self.config.max_workers}' workers for downloading and '{self.config.max_workers_per_extractor}' per extractor." ) asyncio.create_task(self._download_pool(), name="download_pool") - await self.done.load() async def start_items(self, ids: list[str]) -> dict[str, str]: """ @@ -188,7 +186,7 @@ class DownloadQueue(metaclass=Singleton): for item_id in ids: try: - item: Download = self.queue.get(key=item_id) + item: Download = await self.queue.get(key=item_id) except KeyError as e: status[item_id] = f"not found: {e!s}" status["status"] = "error" @@ -231,7 +229,7 @@ class DownloadQueue(metaclass=Singleton): for item_id in ids: try: - item: Download = self.queue.get(key=item_id) + item: Download = await self.queue.get(key=item_id) except KeyError as e: status[item_id] = f"not found: {e!s}" status["status"] = "error" @@ -444,7 +442,7 @@ class DownloadQueue(metaclass=Singleton): LOG.debug(f"Entry id '{entry.get('id')}' url '{entry.get('webpage_url')} - {entry.get('url')}'.") try: - _item: Download = self.done.get(key=entry.get("id"), url=entry.get("webpage_url") or entry.get("url")) + _item: Download = await self.done.get(key=entry.get("id"), url=entry.get("webpage_url") or entry.get("url")) err_msg: str = f"Removing {_item.info.name()} from history list." LOG.warning(err_msg) await self.clear([_item.info._id], remove_file=False) @@ -452,7 +450,7 @@ class DownloadQueue(metaclass=Singleton): pass try: - _item: Download = self.queue.get( + _item: Download = await self.queue.get( key=str(entry.get("id")), url=str(entry.get("webpage_url") or entry.get("url")) ) err_msg: str = f"Item {_item.info.name()} is already in download queue." @@ -713,7 +711,7 @@ class DownloadQueue(metaclass=Singleton): if item.is_archived(): if archive_id: - store_type, _ = self.get_item(archive_id=archive_id) + store_type, _ = await self.get_item(archive_id=archive_id) if not store_type: dlInfo = Download( info=ItemDTO( @@ -801,7 +799,7 @@ class DownloadQueue(metaclass=Singleton): log_message = f"Ignoring download of '{item_title}' as per condition '{condition.name}'{extra_msg}." - store_type, _ = self.get_item(archive_id=archive_id) + store_type, _ = await self.get_item(archive_id=archive_id) if not store_type: dlInfo = Download( info=ItemDTO( @@ -886,7 +884,7 @@ class DownloadQueue(metaclass=Singleton): for id in ids: try: - item = self.queue.get(key=id) + item = await self.queue.get(key=id) except KeyError as e: status[id] = str(e) status["status"] = "error" @@ -940,7 +938,7 @@ class DownloadQueue(metaclass=Singleton): for id in ids: try: - item: Download = self.done.get(key=id) + item: Download = await self.done.get(key=id) except KeyError as e: status[id] = str(e) status["status"] = "error" @@ -1041,7 +1039,7 @@ class DownloadQueue(metaclass=Singleton): return items - def get_item(self, **kwargs) -> tuple[StoreType, Download] | tuple[None, None]: + async def get_item(self, **kwargs) -> tuple[StoreType, Download] | tuple[None, None]: """ Get a specific item from the download queue or history. @@ -1052,10 +1050,10 @@ class DownloadQueue(metaclass=Singleton): (StoreType, Download) | None: The requested item if found, otherwise None. """ - if item := self.queue.get_item(**kwargs): + if item := await self.queue.get_item(**kwargs): return (StoreType.QUEUE, item) - if item := self.done.get_item(**kwargs): + if item := await self.done.get_item(**kwargs): return (StoreType.HISTORY, item) return (None, None) @@ -1166,7 +1164,7 @@ class DownloadQueue(metaclass=Singleton): await entry.close() - if self.queue.exists(key=id): + if await self.queue.exists(key=id): LOG.debug(f"Download Task '{id}' is completed. Removing from queue.") await self.queue.delete(key=id) diff --git a/app/library/Tasks.py b/app/library/Tasks.py index e6cf86da..349c38d8 100644 --- a/app/library/Tasks.py +++ b/app/library/Tasks.py @@ -828,11 +828,11 @@ class HandleTask: if archive_file and archive_id in downloaded: continue - if download_queue.queue.exists(url=url): + if await download_queue.queue.exists(url=url): continue try: - done = download_queue.done.get(url=url) + done = await download_queue.done.get(url=url) if "error" != done.info.status: continue except KeyError: diff --git a/app/library/sqlite_store.py b/app/library/sqlite_store.py index 536cf8cf..24eb933c 100644 --- a/app/library/sqlite_store.py +++ b/app/library/sqlite_store.py @@ -12,6 +12,7 @@ import aiosqlite from aiohttp import web from .ItemDTO import ItemDTO +from .operations import Operation, matches_condition from .Singleton import ThreadSafe from .Utils import init_class @@ -70,7 +71,6 @@ class SqliteStore(metaclass=ThreadSafe): 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( @@ -89,6 +89,42 @@ class SqliteStore(metaclass=ThreadSafe): items.append((row["id"], item)) return items + async def exists(self, type_value: str, key: str | None = None, url: str | None = None) -> bool: + return await self.get(type_value, key=key, url=url) is not None + + async def get(self, type_value: str, key: str | None = None, url: str | None = None) -> bool: + if not key and not url: + msg = "key or url must be provided." + raise KeyError(msg) + + await self._ensure_conn() + + clauses: list[str] = [] + params: list[str] = [] + + if key: + clauses.append('"id" = ?') + params.append(key) + + if url: + clauses.append("json_extract(data, '$.url') = ?") + params.append(url) + + where_clause = " OR ".join(clauses) + query = f'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = ? AND ({where_clause}) LIMIT 1' # noqa: S608 + cursor = await self._conn.execute(query, (type_value, *params)) + 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 get_by_id(self, type_value: str, id: str) -> ItemDTO | None: await self._ensure_conn() cursor = await self._conn.execute( @@ -107,6 +143,94 @@ class SqliteStore(metaclass=ThreadSafe): item.datetime = formatdate(row_date.replace(tzinfo=UTC).timestamp()) return item + async def get_item(self, type_value: str, **kwargs) -> ItemDTO | None: + """ + Return first item of type matching *any* condition. + + Mirrors :meth:`DataStore.get_item` semantics: if any provided condition + matches (OR logic) return the first row by creation time. Returns None + when kwargs is empty or no row matches. + """ + if not kwargs: + return None + + await self._ensure_conn() + + clauses: list[str] = [] + params: list[str | float | int] = [] + + def _safe_key(key: str) -> str | None: + return key if key.replace("_", "").isalnum() else None + + for key, raw_value in kwargs.items(): + safe_key = _safe_key(key) + if not safe_key: + continue + + if isinstance(raw_value, tuple) and len(raw_value) == 2: + operation, value = raw_value + else: + operation, value = Operation.EQUAL, raw_value + + if isinstance(operation, str): + try: + operation = Operation(operation) + except ValueError: + operation = Operation.EQUAL + + path = f"$.{safe_key}" + json_extract = f"json_extract(data, '{path}')" + + if Operation.EQUAL == operation: + clauses.append(f"{json_extract} = ?") + params.append(value) + elif Operation.NOT_EQUAL == operation: + clauses.append(f"{json_extract} != ?") + params.append(value) + elif Operation.CONTAIN == operation: + clauses.append(f"{json_extract} LIKE ? ESCAPE '\\'") + params.append(f"%{value}%") + elif Operation.NOT_CONTAIN == operation: + clauses.append(f"({json_extract} IS NULL OR {json_extract} NOT LIKE ? ESCAPE '\\')") + params.append(f"%{value}%") + elif Operation.STARTS_WITH == operation: + clauses.append(f"{json_extract} LIKE ? ESCAPE '\\'") + params.append(f"{value}%") + elif Operation.ENDS_WITH == operation: + clauses.append(f"{json_extract} LIKE ? ESCAPE '\\'") + params.append(f"%{value}") + elif Operation.GREATER_THAN == operation: + clauses.append(f"{json_extract} > ?") + params.append(value) + elif Operation.LESS_THAN == operation: + clauses.append(f"{json_extract} < ?") + params.append(value) + elif Operation.GREATER_EQUAL == operation: + clauses.append(f"{json_extract} >= ?") + params.append(value) + elif Operation.LESS_EQUAL == operation: + clauses.append(f"{json_extract} <= ?") + params.append(value) + + if not clauses: + return None + + where_clause = " OR ".join(f"({clause})" for clause in clauses) + query = f'SELECT "id", "data", "created_at" FROM "history" WHERE "type" = ? AND ({where_clause}) ORDER BY "created_at" ASC LIMIT 1' # noqa: S608 + cursor = await self._conn.execute(query, (type_value, *params)) + 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 if any(matches_condition(k, v, item.__dict__) for k, v in kwargs.items()) else None + async def count(self, type_value: str, status_filter: str | None = None) -> int: await self._ensure_conn() where_clauses = ['"type" = ?'] diff --git a/app/routes/api/browser.py b/app/routes/api/browser.py index 8266f583..80cf1257 100644 --- a/app/routes/api/browser.py +++ b/app/routes/api/browser.py @@ -397,7 +397,7 @@ async def path_actions(request: Request, config: Config, queue: DownloadQueue, n for old_sidecar, new_sidecar in sidecar_renamed: record(old_sidecar, ok=True, action=action, extra={"new_path": new_sidecar}) - if item := queue.done.get_item(filename=str(path.relative_to(config.download_path))): + if item := await queue.done.get_item(filename=str(path.relative_to(config.download_path))): item.info.filename = str(renamed.relative_to(config.download_path)) if sidecar_renamed: item.info.get_file_sidecar() @@ -503,7 +503,7 @@ async def path_actions(request: Request, config: Config, queue: DownloadQueue, n for old_sidecar, new_sidecar in sidecar_moved: record(old_sidecar, ok=True, action=action, extra={"new_path": new_sidecar}) - if item := queue.done.get_item(filename=str(path.relative_to(config.download_path))): + if item := await queue.done.get_item(filename=str(path.relative_to(config.download_path))): item.info.filename = str(moved.relative_to(config.download_path)) if sidecar_moved: item.info.get_file_sidecar() diff --git a/app/tests/test_datastore.py b/app/tests/test_datastore.py index a45ba191..7d25261c 100644 --- a/app/tests/test_datastore.py +++ b/app/tests/test_datastore.py @@ -145,19 +145,19 @@ class TestDataStore: await store.put(d) await store._connection.flush() - assert store.exists(key=item._id) is True - assert store.exists(url=item.url) is True + assert await store.exists(key=item._id) is True + assert await store.exists(url=item.url) is True with pytest.raises(KeyError): - store.exists() + await store.exists() - got = store.get(key=item._id) + got = await store.get(key=item._id) assert got.info._id == item._id - got2 = store.get(url=item.url) + got2 = await store.get(url=item.url) assert got2.info.url == item.url with pytest.raises(KeyError): - store.get() + await store.get() with pytest.raises(KeyError): - store.get(key="missing") + await store.get(key="missing") await store._connection.close() @pytest.mark.asyncio @@ -212,7 +212,7 @@ class TestDataStore: db = await make_db() store = DataStore(StoreType.QUEUE, db) - result = store.get_item() + result = await store.get_item() assert result is None await db.close() @@ -236,19 +236,19 @@ class TestDataStore: await store._connection.flush() # Test finding by title - result = store.get_item(title="Video 1") + result = await store.get_item(title="Video 1") assert result is not None assert result.info._id == "id1" assert result.info.title == "Video 1" # Test finding by folder - result = store.get_item(folder="folder2") + result = await store.get_item(folder="folder2") assert result is not None assert result.info._id == "id2" assert result.info.folder == "folder2" # Test finding by url - result = store.get_item(url="http://example.com/1") + result = await store.get_item(url="http://example.com/1") assert result is not None assert result.info._id == "id1" await db.close() @@ -272,12 +272,12 @@ class TestDataStore: await store._connection.flush() # Test finding by multiple attributes where one matches - result = store.get_item(title="Video 1", folder="wrong_folder") + result = await store.get_item(title="Video 1", folder="wrong_folder") assert result is not None assert result.info._id == "id1" # Test finding where second attribute matches - result = store.get_item(title="Wrong Title", folder="folder2") + result = await store.get_item(title="Wrong Title", folder="folder2") assert result is not None assert result.info._id == "id2" await db.close() @@ -295,11 +295,11 @@ class TestDataStore: await store._connection.flush() # Test with non-matching attribute - result = store.get_item(title="Nonexistent Video") + result = await store.get_item(title="Nonexistent Video") assert result is None # Test with non-existent attribute key - result = store.get_item(nonexistent_field="value") + result = await store.get_item(nonexistent_field="value") assert result is None await db.close() @@ -324,7 +324,7 @@ class TestDataStore: store._dict["broken"] = BrokenDownload() # Should still find the valid item - result = store.get_item(title="Video 1") + result = await store.get_item(title="Video 1") assert result is not None assert result.info._id == "id1" await db.close() @@ -349,7 +349,7 @@ class TestDataStore: await store._connection.flush() # Should return first match (note: OrderedDict maintains insertion order) - result = store.get_item(title="Same Title") + result = await store.get_item(title="Same Title") assert result is not None assert result.info._id == "id1" await db.close() @@ -788,17 +788,17 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item1)) # Test with explicit EQUAL operation - result = store.get_item(title=(Operation.EQUAL, "Exact Match")) + result = await store.get_item(title=(Operation.EQUAL, "Exact Match")) assert result is not None assert result.info._id == "id1" # Test default behavior (no operation specified) - result = store.get_item(title="Exact Match") + result = await store.get_item(title="Exact Match") assert result is not None assert result.info._id == "id1" # Test no match - result = store.get_item(title=(Operation.EQUAL, "No Match")) + result = await store.get_item(title=(Operation.EQUAL, "No Match")) assert result is None await db.close() @@ -816,7 +816,7 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item2)) # Find item where title is not "Video 1" - result = store.get_item(title=(Operation.NOT_EQUAL, "Video 1")) + result = await store.get_item(title=(Operation.NOT_EQUAL, "Video 1")) assert result is not None assert result.info._id == "id2" await db.close() @@ -835,17 +835,17 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item2)) # Find item with "Python" in title - result = store.get_item(title=(Operation.CONTAIN, "Python")) + result = await store.get_item(title=(Operation.CONTAIN, "Python")) assert result is not None assert result.info._id == "id1" # Find item with "Tutorial" in title - result = store.get_item(title=(Operation.CONTAIN, "Tutorial")) + result = await store.get_item(title=(Operation.CONTAIN, "Tutorial")) assert result is not None assert result.info._id == "id1" # No match - result = store.get_item(title=(Operation.CONTAIN, "Rust")) + result = await store.get_item(title=(Operation.CONTAIN, "Rust")) assert result is None await db.close() @@ -863,7 +863,7 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item2)) # Find item that doesn't contain "Python" - result = store.get_item(title=(Operation.NOT_CONTAIN, "Python")) + result = await store.get_item(title=(Operation.NOT_CONTAIN, "Python")) assert result is not None assert result.info._id == "id2" await db.close() @@ -882,17 +882,17 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item2)) # Find item starting with "Tutorial" - result = store.get_item(title=(Operation.STARTS_WITH, "Tutorial")) + result = await store.get_item(title=(Operation.STARTS_WITH, "Tutorial")) assert result is not None assert result.info._id == "id1" # Find item starting with "Course" - result = store.get_item(title=(Operation.STARTS_WITH, "Course")) + result = await store.get_item(title=(Operation.STARTS_WITH, "Course")) assert result is not None assert result.info._id == "id2" # No match - result = store.get_item(title=(Operation.STARTS_WITH, "Video")) + result = await store.get_item(title=(Operation.STARTS_WITH, "Video")) assert result is None await db.close() @@ -910,17 +910,17 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item2)) # Find item ending with "Python" - result = store.get_item(title=(Operation.ENDS_WITH, "Python")) + result = await store.get_item(title=(Operation.ENDS_WITH, "Python")) assert result is not None assert result.info._id == "id1" # Find item ending with "JavaScript" - result = store.get_item(title=(Operation.ENDS_WITH, "JavaScript")) + result = await store.get_item(title=(Operation.ENDS_WITH, "JavaScript")) assert result is not None assert result.info._id == "id2" # No match - result = store.get_item(title=(Operation.ENDS_WITH, "Course")) + result = await store.get_item(title=(Operation.ENDS_WITH, "Course")) assert result is None await db.close() @@ -940,12 +940,12 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item2)) # Find item with filesize > 1500 - result = store.get_item(filesize=(Operation.GREATER_THAN, 1500)) + result = await store.get_item(filesize=(Operation.GREATER_THAN, 1500)) assert result is not None assert result.info._id == "id2" # Find item with filesize > 500 (should return first match) - result = store.get_item(filesize=(Operation.GREATER_THAN, 500)) + result = await store.get_item(filesize=(Operation.GREATER_THAN, 500)) assert result is not None assert result.info._id == "id1" await db.close() @@ -966,7 +966,7 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item2)) # Find item with filesize < 1500 - result = store.get_item(filesize=(Operation.LESS_THAN, 1500)) + result = await store.get_item(filesize=(Operation.LESS_THAN, 1500)) assert result is not None assert result.info._id == "id1" await db.close() @@ -983,17 +983,17 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item1)) # Test >= with exact match - result = store.get_item(filesize=(Operation.GREATER_EQUAL, 1000)) + result = await store.get_item(filesize=(Operation.GREATER_EQUAL, 1000)) assert result is not None assert result.info._id == "id1" # Test >= with less than - result = store.get_item(filesize=(Operation.GREATER_EQUAL, 500)) + result = await store.get_item(filesize=(Operation.GREATER_EQUAL, 500)) assert result is not None assert result.info._id == "id1" # Test >= with greater than - result = store.get_item(filesize=(Operation.GREATER_EQUAL, 1500)) + result = await store.get_item(filesize=(Operation.GREATER_EQUAL, 1500)) assert result is None await db.close() @@ -1009,15 +1009,15 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item1)) # Test <= with exact match - result = store.get_item(filesize=(Operation.LESS_EQUAL, 1000)) + result = await store.get_item(filesize=(Operation.LESS_EQUAL, 1000)) assert result is not None # Test <= with greater than - result = store.get_item(filesize=(Operation.LESS_EQUAL, 1500)) + result = await store.get_item(filesize=(Operation.LESS_EQUAL, 1500)) assert result is not None # Test <= with less than - result = store.get_item(filesize=(Operation.LESS_EQUAL, 500)) + result = await store.get_item(filesize=(Operation.LESS_EQUAL, 500)) assert result is None await db.close() @@ -1038,12 +1038,12 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item3)) # Mix of operation and default (any match returns true) - result = store.get_item(title=(Operation.CONTAIN, "Python"), folder="tutorials") + result = await store.get_item(title=(Operation.CONTAIN, "Python"), folder="tutorials") assert result is not None assert result.info._id == "id1" # Mix where first condition matches - result = store.get_item(title=(Operation.CONTAIN, "JavaScript"), folder="nonexistent") + result = await store.get_item(title=(Operation.CONTAIN, "JavaScript"), folder="nonexistent") assert result is not None assert result.info._id == "id3" await db.close() @@ -1060,15 +1060,15 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item1)) # CONTAIN with None field should return False - result = store.get_item(description=(Operation.CONTAIN, "test")) + result = await store.get_item(description=(Operation.CONTAIN, "test")) assert result is None # NOT_CONTAIN with None field should return True - result = store.get_item(description=(Operation.NOT_CONTAIN, "test")) + result = await store.get_item(description=(Operation.NOT_CONTAIN, "test")) assert result is not None # GREATER_THAN with None should return False - result = store.get_item(description=(Operation.GREATER_THAN, 100)) + result = await store.get_item(description=(Operation.GREATER_THAN, 100)) assert result is None await db.close() @@ -1083,7 +1083,7 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item1)) # Try to compare string with number using > (should return False/None) - result = store.get_item(title=(Operation.GREATER_THAN, 100)) + result = await store.get_item(title=(Operation.GREATER_THAN, 100)) assert result is None await db.close() @@ -1098,12 +1098,12 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item1)) # Using string operation value - result = store.get_item(title=("in", "Python")) + result = await store.get_item(title=("in", "Python")) assert result is not None assert result.info._id == "id1" # Using string for EQUAL - result = store.get_item(title=("==", "Python Tutorial")) + result = await store.get_item(title=("==", "Python Tutorial")) assert result is not None await db.close() @@ -1118,9 +1118,9 @@ class TestDataStoreOperations: await store.put(StubDownload(info=item1)) # Try to match on non-existent field - result = store.get_item(nonexistent_field=(Operation.EQUAL, "value")) + result = await store.get_item(nonexistent_field=(Operation.EQUAL, "value")) assert result is None - result = store.get_item(nonexistent_field=(Operation.CONTAIN, "value")) + result = await store.get_item(nonexistent_field=(Operation.CONTAIN, "value")) assert result is None await db.close() diff --git a/app/tests/test_sqlite_store.py b/app/tests/test_sqlite_store.py index d7be094b..fc0e36e9 100644 --- a/app/tests/test_sqlite_store.py +++ b/app/tests/test_sqlite_store.py @@ -3,6 +3,7 @@ from datetime import UTC, datetime, timedelta import pytest from app.library.ItemDTO import ItemDTO +from app.library.operations import Operation from app.library.sqlite_store import SqliteStore @@ -171,6 +172,47 @@ async def test_paginate_out_of_range_returns_last_page(): await store.close() +@pytest.mark.asyncio +async def test_get_item_returns_none_without_kwargs(): + store = await make_store() + await store.enqueue_upsert("queue", make_item(1)) + await store.flush() + + result = await store.get_item("queue") + assert result is None + await store.close() + + +@pytest.mark.asyncio +async def test_get_item_matches_conditions(): + store = await make_store() + first = make_item(1, status="finished") + second = make_item(2, status="pending") + + await store.enqueue_upsert("queue", first) + await store.enqueue_upsert("queue", second) + await store.flush() + + # equality match + match_equal = await store.get_item("queue", title=first.title) + assert match_equal is not None + assert match_equal._id == first._id + + # NOT_EQUAL should find the second item + match_not_equal = await store.get_item("queue", status=(Operation.NOT_EQUAL, "finished")) + assert match_not_equal is not None + assert match_not_equal._id == second._id + + # CONTAIN should find first created (first) + match_contain = await store.get_item("queue", title=(Operation.CONTAIN, "Video")) + assert match_contain is not None + assert match_contain._id == first._id + + # No match returns None + no_match = await store.get_item("queue", title=(Operation.CONTAIN, "does-not-exist")) + assert no_match is None + await store.close() + @pytest.mark.asyncio async def test_on_shutdown_closes_connection(): store = await make_store() @@ -181,3 +223,34 @@ async def test_on_shutdown_closes_connection(): assert store._conn is None +@pytest.mark.asyncio +async def test_exists_and_get_by_key_and_url(): + store = await make_store() + item = make_item(42) + await store.enqueue_upsert("queue", item) + await store.flush() + + assert await store.exists("queue", key=item._id) is True + assert await store.exists("queue", url=item.url) is True + + fetched_by_key = await store.get("queue", key=item._id) + assert fetched_by_key is not None + assert fetched_by_key._id == item._id + + fetched_by_url = await store.get("queue", url=item.url) + assert fetched_by_url is not None + assert fetched_by_url._id == item._id + + assert await store.exists("queue", key="missing") is False + assert await store.get("queue", key="missing") is None + await store.close() + + +@pytest.mark.asyncio +async def test_exists_and_get_raise_without_key_or_url(): + store = await make_store() + with pytest.raises(KeyError): + await store.exists("queue") + with pytest.raises(KeyError): + await store.get("queue") + await store.close()