From 3f2f5e0aa99a8900af009cf4d17415f237b9e695 Mon Sep 17 00:00:00 2001 From: Mark <20665331+markbeep@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:08:54 +0200 Subject: [PATCH] refactor!: rename terminology of request "downloaded" to "fulfilled" for more clarity chore: deprecate `/api/requests/{asin}/downloaded`, use `/api/requests/{asin}/fulfilled` chore: deprecate `/api/requests/manual/{id}/downloaded`, use `/api/requests/manual/{id}/fulfilled` chore: deprecate `/api/requests?filter=downloaded|not_downloaded`, use `/api/requests?filter=fulfilled|not_fulfilled` chore: deprecate `exclude_downloaded` on `/api/recommendations/*`, use `exclude_fulfilled` chore: keep `downloaded` in API responses as a deprecated alias for `fulfilled` BREAKING CHANGE: /wishlist/downloaded page has been updated to /wishlist/fulfilled BREAKING CHANGE: notification event values are now `onFulfilled` and `onFailedFulfillment` (API responses and `{eventType}` expansion; before: `onSuccessfulDownload`/`onFailedDownload`) --- README.md | 6 +- ...f2b3d4e5_rename_downloaded_to_fulfilled.py | 55 +++++++++ ...name_notification_events_to_fulfillment.py | 92 +++++++++++++++ app/internal/audible/search.py | 2 +- app/internal/audiobookshelf/client.py | 8 +- app/internal/chaptarr/client.py | 8 +- app/internal/db_queries.py | 28 ++--- app/internal/models.py | 24 +++- app/internal/prowlarr/prowlarr.py | 14 +-- app/internal/query.py | 4 +- app/internal/recommendations/local.py | 18 +-- .../recommendations/user_recommendations.py | 4 +- app/routers/api/recommendations.py | 66 +++++++++-- app/routers/api/requests.py | 111 +++++++++++++++--- app/routers/api/search.py | 2 +- app/routers/api/settings/notifications.py | 31 +++-- app/routers/pages/index.py | 16 +-- app/routers/pages/wishlist/__init__.py | 18 +-- .../wishlist/{downloaded.py => fulfilled.py} | 16 +-- app/routers/pages/wishlist/manual.py | 6 +- templates/components/BookCard.jinja | 6 +- .../pages/Settings/Notifications/Index.jinja | 2 +- .../{Downloaded.jinja => Fulfilled.jinja} | 4 +- templates/pages/Wishlist/ManualWishlist.jinja | 8 +- templates/pages/Wishlist/Tablist.jinja | 8 +- templates/pages/Wishlist/Wishlist.jinja | 16 +-- tests/conftest.py | 14 +-- tests/constants.py | 2 +- tests/test_api_endpoints.py | 86 ++++++++++++-- tests/test_chaptarr.py | 24 ++-- tests/test_download_failures.py | 2 +- tests/test_index_fragments.py | 4 +- tests/test_known_issues.py | 11 +- tests/test_page_endpoints.py | 4 +- 34 files changed, 543 insertions(+), 177 deletions(-) create mode 100644 alembic/versions/c4a1f2b3d4e5_rename_downloaded_to_fulfilled.py create mode 100644 alembic/versions/d5b2e3f4a6c7_rename_notification_events_to_fulfillment.py rename app/routers/pages/wishlist/{downloaded.py => fulfilled.py} (80%) rename templates/pages/Wishlist/{Downloaded.jinja => Fulfilled.jinja} (64%) diff --git a/README.md b/README.md index d291a797..eac6034f 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Auto-downloading enables requests by `Trusted` and `Admin` users to directly sta Audiobookshelf (ABS) integration lets ABR: - Check if a book already exists in your ABS library and mark it as downloaded in search results to avoid duplicate requests. -- Trigger a library scan in ABS when a request is marked as downloaded in ABR (manual or automatic), so the new item appears quickly. +- Trigger a library scan in ABS when a request is marked as fulfilled in ABR (manual or automatic), so the new item appears quickly. Setup steps: @@ -113,7 +113,7 @@ Setup steps: Notes: - ABR searches ABS by ASIN and by “title + first author” to detect existing books; this is a best-effort match and may not catch every case depending on your metadata. -- ABS is automatically asked to scan after successful downloads are marked in ABR. ABS typically auto-detects updates, but this helps pick up changes sooner. +- ABS is automatically asked to scan after requests are fulfilled in ABR. ABS typically auto-detects updates, but this helps pick up changes sooner. ### Chaptarr Integration @@ -129,7 +129,7 @@ Notes: - ABR uses only the selected backend. Selecting `Chaptarr` hides the Prowlarr settings, and Prowlarr is then not used by ABR. - The torrent sources view is Prowlarr-only, so it is hidden while `Chaptarr` is selected. Manual requests can still be created, but they need the Prowlarr backend to look up and download sources. -- ABR looks the book up with `GET /api/v1/book/lookup?term=az:&mediaType=audiobook`, adds it with an automatic search enabled, and marks the request as downloaded once Chaptarr accepts the add. +- ABR looks the book up with `GET /api/v1/book/lookup?term=az:&mediaType=audiobook`, adds it with an automatic search enabled, and marks the request as fulfilled once Chaptarr accepts the add. - The audiobook root folder and quality/metadata profiles are taken from Chaptarr's effective audiobook defaults, so ABR needs no further configuration. - Only Audible ASIN requests are handed off to Chaptarr. diff --git a/alembic/versions/c4a1f2b3d4e5_rename_downloaded_to_fulfilled.py b/alembic/versions/c4a1f2b3d4e5_rename_downloaded_to_fulfilled.py new file mode 100644 index 00000000..edff2507 --- /dev/null +++ b/alembic/versions/c4a1f2b3d4e5_rename_downloaded_to_fulfilled.py @@ -0,0 +1,55 @@ +"""rename downloaded to fulfilled + +Revision ID: c4a1f2b3d4e5 +Revises: babaede58935 +Create Date: 2026-10-22 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c4a1f2b3d4e5" +down_revision: str | None = "babaede58935" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.batch_alter_table("audiobook", schema=None) as batch_op: + batch_op.alter_column( + "downloaded", + new_column_name="fulfilled", + existing_type=sa.Boolean(), + existing_nullable=False, + ) + + with op.batch_alter_table("manualbookrequest", schema=None) as batch_op: + batch_op.alter_column( + "downloaded", + new_column_name="fulfilled", + existing_type=sa.Boolean(), + existing_nullable=False, + ) + + +def downgrade() -> None: + with op.batch_alter_table("manualbookrequest", schema=None) as batch_op: + batch_op.alter_column( + "fulfilled", + new_column_name="downloaded", + existing_type=sa.Boolean(), + existing_nullable=False, + ) + + with op.batch_alter_table("audiobook", schema=None) as batch_op: + batch_op.alter_column( + "fulfilled", + new_column_name="downloaded", + existing_type=sa.Boolean(), + existing_nullable=False, + ) diff --git a/alembic/versions/d5b2e3f4a6c7_rename_notification_events_to_fulfillment.py b/alembic/versions/d5b2e3f4a6c7_rename_notification_events_to_fulfillment.py new file mode 100644 index 00000000..9334a837 --- /dev/null +++ b/alembic/versions/d5b2e3f4a6c7_rename_notification_events_to_fulfillment.py @@ -0,0 +1,92 @@ +"""rename notification events to fulfillment + +Revision ID: d5b2e3f4a6c7 +Revises: c4a1f2b3d4e5 +Create Date: 2026-10-22 00:00:01.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d5b2e3f4a6c7" +down_revision: str | None = "c4a1f2b3d4e5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + if op.get_bind().dialect.name == "postgresql": + op.execute( + "ALTER TYPE eventenum RENAME VALUE " + + "'on_successful_download' TO 'on_fulfilled'" + ) + op.execute( + "ALTER TYPE eventenum RENAME VALUE " + + "'on_failed_download' TO 'on_failed_fulfillment'" + ) + else: + op.execute( + "UPDATE notification SET event = 'on_fulfilled' " + + "WHERE event = 'on_successful_download'" + ) + op.execute( + "UPDATE notification SET event = 'on_failed_fulfillment' " + + "WHERE event = 'on_failed_download'" + ) + # "on_fulfilled" shortens the longest label, so narrow the column to + # match the enum inferred from the models. + with op.batch_alter_table("notification", schema=None) as batch_op: + batch_op.alter_column( + "event", + existing_type=sa.VARCHAR(length=22), + type_=sa.Enum( + "on_new_request", + "on_fulfilled", + "on_failed_fulfillment", + name="eventenum", + ), + existing_nullable=False, + ) + + +def downgrade() -> None: + if op.get_bind().dialect.name == "postgresql": + op.execute( + "ALTER TYPE eventenum RENAME VALUE " + + "'on_fulfilled' TO 'on_successful_download'" + ) + op.execute( + "ALTER TYPE eventenum RENAME VALUE " + + "'on_failed_fulfillment' TO 'on_failed_download'" + ) + else: + with op.batch_alter_table("notification", schema=None) as batch_op: + batch_op.alter_column( + "event", + existing_type=sa.Enum( + "on_new_request", + "on_fulfilled", + "on_failed_fulfillment", + name="eventenum", + ), + type_=sa.Enum( + "on_new_request", + "on_successful_download", + "on_failed_download", + name="eventenum", + ), + existing_nullable=False, + ) + op.execute( + "UPDATE notification SET event = 'on_successful_download' " + + "WHERE event = 'on_fulfilled'" + ) + op.execute( + "UPDATE notification SET event = 'on_failed_download' " + + "WHERE event = 'on_failed_fulfillment'" + ) diff --git a/app/internal/audible/search.py b/app/internal/audible/search.py index df327c2a..0370b9f4 100644 --- a/app/internal/audible/search.py +++ b/app/internal/audible/search.py @@ -23,7 +23,7 @@ def clear_old_book_caches(session: Session): delete_query = delete(Audiobook).where( col(Audiobook.updated_at) < datetime.fromtimestamp(time.time() - REFETCH_TTL), col(Audiobook.asin).not_in(select(col(AudiobookRequest.asin)).distinct()), - not_(Audiobook.downloaded), + not_(Audiobook.fulfilled), ) result = cast(CursorResult[Audiobook], session.execute(delete_query)) session.commit() diff --git a/app/internal/audiobookshelf/client.py b/app/internal/audiobookshelf/client.py index 230a660b..17e6c994 100644 --- a/app/internal/audiobookshelf/client.py +++ b/app/internal/audiobookshelf/client.py @@ -186,7 +186,7 @@ async def abs_list_library_items( cover_image=cover_image, release_date=release_date, runtime_length_min=runtime_length_min, - downloaded=True, + fulfilled=True, ) books.append(book) except Exception as e: @@ -294,8 +294,8 @@ async def abs_mark_downloaded_flags( ) -> None: if not abs_config.get_check_downloaded(session): return - # Only check books not already marked downloaded - to_check = [b for b in books if not b.downloaded] + # Only check books not already marked fulfilled + to_check = [b for b in books if not b.fulfilled] # Limit to avoid flooding ABS to_check = to_check[:25] @@ -304,7 +304,7 @@ async def _check_and_mark(b: Audiobook): exists = await abs_book_exists(session, client_session, b) logger.debug("ABS: exist check", asin=b.asin, exists=exists) if exists: - b.downloaded = True + b.fulfilled = True session.add(b) except Exception as e: logger.debug("ABS: failed exist check", asin=b.asin, error=str(e)) diff --git a/app/internal/chaptarr/client.py b/app/internal/chaptarr/client.py index 37a9af12..a633b77c 100644 --- a/app/internal/chaptarr/client.py +++ b/app/internal/chaptarr/client.py @@ -230,7 +230,7 @@ async def start_chaptarr_download( logger.error("Chaptarr: failed to add book", asin=book.asin, error=e.message) try: await send_all_notifications( - EventEnum.on_failed_download, + EventEnum.on_failed_fulfillment, book.asin, { "errorStatus": str(e.status) if e.status else "chaptarr", @@ -238,14 +238,14 @@ async def start_chaptarr_download( }, ) except Exception: - logger.error("Chaptarr: failed to send download failure notification") + logger.error("Chaptarr: failed to send fulfillment failure notification") raise e from exc - book.downloaded = True + book.fulfilled = True session.add(book) session.commit() await send_all_notifications( - EventEnum.on_successful_download, + EventEnum.on_fulfilled, book.asin, {"indexerName": "Chaptarr"}, ) diff --git a/app/internal/db_queries.py b/app/internal/db_queries.py index 87463867..e62df78b 100644 --- a/app/internal/db_queries.py +++ b/app/internal/db_queries.py @@ -17,7 +17,7 @@ class WishlistCounts(BaseModel): requests: int - downloaded: int + fulfilled: int manual: int @@ -29,17 +29,17 @@ def get_wishlist_counts(session: Session, user: User | None = None) -> WishlistC username = None if user is None or user.is_admin() else user.username rows = session.exec( - select(Audiobook.downloaded, func.count("*")) + select(Audiobook.fulfilled, func.count("*")) .where(not username or AudiobookRequest.user_username == username) .select_from(Audiobook) .join(AudiobookRequest) - .group_by(col(Audiobook.downloaded)) + .group_by(col(Audiobook.fulfilled)) ).all() requests = 0 - downloaded = 0 - for downloaded_status, count in rows: - if downloaded_status: - downloaded = count + fulfilled = 0 + for fulfilled_status, count in rows: + if fulfilled_status: + fulfilled = count else: requests = count @@ -54,7 +54,7 @@ def get_wishlist_counts(session: Session, user: User | None = None) -> WishlistC return WishlistCounts( requests=requests, - downloaded=downloaded, + fulfilled=fulfilled, manual=manual, ) @@ -62,17 +62,17 @@ def get_wishlist_counts(session: Session, user: User | None = None) -> WishlistC def get_wishlist_results( session: Session, username: str | None = None, - response_type: Literal["all", "downloaded", "not_downloaded"] = "all", + response_type: Literal["all", "fulfilled", "not_fulfilled"] = "all", ) -> list[AudiobookWishlistResult]: """ Gets the books that have been requested. If a username is given only the books requested by that user are returned. If no username is given, all book requests are returned. """ match response_type: - case "downloaded": - clause = Audiobook.downloaded - case "not_downloaded": - clause = not_(Audiobook.downloaded) + case "fulfilled": + clause = Audiobook.fulfilled + case "not_fulfilled": + clause = not_(Audiobook.fulfilled) case _: clause = True @@ -114,5 +114,5 @@ def get_all_manual_requests( user.is_admin() or ManualBookRequest.user_username == user.username, col(ManualBookRequest.user_username).is_not(None), ) - .order_by(asc(ManualBookRequest.downloaded)) + .order_by(asc(ManualBookRequest.fulfilled)) ).all() diff --git a/app/internal/models.py b/app/internal/models.py index 7a403b83..f2ea34d2 100644 --- a/app/internal/models.py +++ b/app/internal/models.py @@ -4,7 +4,7 @@ from enum import Enum from typing import Annotated, Literal, cast -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, computed_field from sqlmodel import JSON, Column, DateTime, Field, SQLModel, func from sqlmodel._compat import SQLModelConfig from sqlmodel.main import Relationship @@ -37,7 +37,7 @@ class User(BaseSQLModel, table=True): """ untrusted: Requests need to be manually reviewed - trusted: Requests are automatically downloaded if possible + trusted: Requests are automatically fulfilled if possible admin: Can approve or deny requests, change settings, etc. """ @@ -79,7 +79,13 @@ class Audiobook(BaseSQLModel, table=True): nullable=False, ), ) - downloaded: bool = False + fulfilled: bool = False + + @computed_field + @property + def downloaded(self) -> bool: + """Deprecated API alias for `fulfilled`.""" + return self.fulfilled requests: list[AudiobookRequest] = Relationship(back_populates="audiobook") # pyright: ignore[reportAny] @@ -165,7 +171,13 @@ class ManualBookRequest(BaseSQLModel, table=True): nullable=False, ), ) - downloaded: bool = False + fulfilled: bool = False + + @computed_field + @property + def downloaded(self) -> bool: + """Deprecated API alias for `fulfilled`.""" + return self.fulfilled model_config: SQLModelConfig = cast( SQLModelConfig, cast(object, ConfigDict(arbitrary_types_allowed=True)) @@ -231,8 +243,8 @@ class Config(BaseSQLModel, table=True): class EventEnum(str, Enum): on_new_request = "onNewRequest" - on_successful_download = "onSuccessfulDownload" - on_failed_download = "onFailedDownload" + on_fulfilled = "onFulfilled" + on_failed_fulfillment = "onFailedFulfillment" class NotificationBodyTypeEnum(str, Enum): diff --git a/app/internal/prowlarr/prowlarr.py b/app/internal/prowlarr/prowlarr.py index 287467b1..e4e834d1 100644 --- a/app/internal/prowlarr/prowlarr.py +++ b/app/internal/prowlarr/prowlarr.py @@ -147,19 +147,19 @@ async def start_download( try: if manual_book_request: await send_all_manual_notifications( - EventEnum.on_failed_download, + EventEnum.on_failed_fulfillment, manual_book_request, replacements, ) else: await send_all_notifications( - EventEnum.on_failed_download, + EventEnum.on_failed_fulfillment, asin_or_uuid, replacements, ) except Exception as e: logger.error( - "Failed to send download failure notification", + "Failed to send fulfillment failure notification", guid=guid, error=str(e), ) @@ -192,11 +192,11 @@ async def start_download( logger.debug("Download successfully started", guid=guid) if manual_book_request: - manual_book_request.downloaded = True + manual_book_request.fulfilled = True session.add(manual_book_request) session.commit() await send_all_manual_notifications( - EventEnum.on_successful_download, + EventEnum.on_fulfilled, manual_book_request, additional_replacements, ) @@ -205,12 +205,12 @@ async def start_download( select(Audiobook).where(Audiobook.asin == asin_or_uuid) ).all() for b in same_books: - b.downloaded = True + b.fulfilled = True session.add(b) session.commit() await send_all_notifications( - EventEnum.on_successful_download, + EventEnum.on_fulfilled, asin_or_uuid, additional_replacements, ) diff --git a/app/internal/query.py b/app/internal/query.py index b5868bf2..2da13fd7 100644 --- a/app/internal/query.py +++ b/app/internal/query.py @@ -79,7 +79,7 @@ async def query_sources( # Chaptarr owns download, import and file organization when selected. if ( start_auto_download - and not book.downloaded + and not book.fulfilled and isinstance(book, Audiobook) and get_download_backend(session) == "chaptarr" ): @@ -125,7 +125,7 @@ async def query_sources( ranked = await rank_sources(session, client_session, sources, book, is_manual) # start download if requested - if start_auto_download and not book.downloaded and len(ranked) > 0: + if start_auto_download and not book.fulfilled and len(ranked) > 0: try: await start_download( session=session, diff --git a/app/internal/recommendations/local.py b/app/internal/recommendations/local.py index 1424b041..2799ba6e 100644 --- a/app/internal/recommendations/local.py +++ b/app/internal/recommendations/local.py @@ -28,7 +28,7 @@ def get_popular_books( session: Session, limit: int = 10, min_requests: int = 1, # Lower default minimum - exclude_downloaded: bool = True, + exclude_fulfilled: bool = True, exclude_requested_username: str | None = None, ) -> list[AudiobookPopularity]: """Get the most popular books based on how many users have requested them.""" @@ -53,8 +53,8 @@ def get_popular_books( .limit(limit) ) - if exclude_downloaded: - query = query.where(~col(Audiobook.downloaded)) + if exclude_fulfilled: + query = query.where(~col(Audiobook.fulfilled)) if exclude_requested_username: # removes all books requested by the excluded user query = query.where( col(Audiobook.asin).not_in( @@ -89,7 +89,7 @@ def get_recently_requested_books( session: Session, limit: int = 10, days_back: int = 30, - exclude_downloaded: bool = True, + exclude_fulfilled: bool = True, exclude_requested_username: str | None = None, ) -> Sequence[AudiobookWithRequests]: """Get recently requested books within the specified time frame.""" @@ -119,8 +119,8 @@ def get_recently_requested_books( ) ) - if exclude_downloaded: - query = query.where(~col(Audiobook.downloaded)) + if exclude_fulfilled: + query = query.where(~col(Audiobook.fulfilled)) results = session.exec(query).all() logger.debug(f"Recently requested books query returned {len(results)} results") @@ -143,7 +143,7 @@ class AuthorNarrators(BaseModel): def get_most_popular_authors( session: Session, limit: int = 10, - exclude_downloaded: bool = True, + exclude_fulfilled: bool = True, username: str | None = None, ) -> AuthorNarrators: """Get the most popular authors based on how many users have requested their books.""" @@ -154,8 +154,8 @@ def get_most_popular_authors( AudiobookRequest.user_username == username ) query = select(Audiobook).where(col(Audiobook.asin).in_(requested_asins)) - if exclude_downloaded: - query = query.where(~col(Audiobook.downloaded)) + if exclude_fulfilled: + query = query.where(~col(Audiobook.fulfilled)) audiobooks = session.exec(query).all() author_counter = Counter[str]() narrator_counter = Counter[str]() diff --git a/app/internal/recommendations/user_recommendations.py b/app/internal/recommendations/user_recommendations.py index 5285b392..5e659718 100644 --- a/app/internal/recommendations/user_recommendations.py +++ b/app/internal/recommendations/user_recommendations.py @@ -55,7 +55,7 @@ async def get_user_sims_recommendations( Filters: - Exclude already requested by the user - - Exclude already downloaded/owned (if detectable) + - Exclude already fulfilled/owned (if detectable) - Exclude duplicates """ @@ -142,7 +142,7 @@ def _recent_component(b: Audiobook) -> float: # Build candidate score list candidate_scores: list[_BookScore] = [] for sim in similar_books: - if sim.book.downloaded: + if sim.book.fulfilled: continue avg_pos = sum(positions[sim.book.asin]) / max(1, len(positions[sim.book.asin])) diff --git a/app/routers/api/recommendations.py b/app/routers/api/recommendations.py index fd8fb549..0800a6d5 100644 --- a/app/routers/api/recommendations.py +++ b/app/routers/api/recommendations.py @@ -28,6 +28,12 @@ router = APIRouter(prefix="/recommendations", tags=["Recommendations"]) +def _resolve_exclude_fulfilled( + exclude_fulfilled: bool, exclude_downloaded: bool | None +) -> bool: + return exclude_fulfilled if exclude_downloaded is None else exclude_downloaded + + def parse_seed_asins(seed_asins: list[str] | None) -> list[str] | None: """Accept both repeated `seed_asins` params and one comma-separated value.""" if seed_asins is None: @@ -74,13 +80,24 @@ async def get_popular_recommendations( user: Annotated[DetailedUser, Security(AnyAuth())], min_requests: int = 1, limit: int = 10, - exclude_downloaded: bool = True, + exclude_fulfilled: Annotated[ + bool, Query(description="Exclude requests that are already fulfilled.") + ] = True, + exclude_downloaded: Annotated[ + bool | None, + Query( + description="Deprecated: use `exclude_fulfilled`.", + deprecated=True, + ), + ] = None, ) -> list[AudiobookPopularity]: return get_popular_books( session=session, limit=limit, min_requests=min_requests, - exclude_downloaded=exclude_downloaded, + exclude_fulfilled=_resolve_exclude_fulfilled( + exclude_fulfilled, exclude_downloaded + ), exclude_requested_username=user.username, ) @@ -91,13 +108,24 @@ async def get_recently_requested_recommendations( user: Annotated[DetailedUser, Security(AnyAuth())], limit: int = 10, days_back: int = 30, - exclude_downloaded: bool = True, + exclude_fulfilled: Annotated[ + bool, Query(description="Exclude requests that are already fulfilled.") + ] = True, + exclude_downloaded: Annotated[ + bool | None, + Query( + description="Deprecated: use `exclude_fulfilled`.", + deprecated=True, + ), + ] = None, ) -> Sequence[AudiobookWithRequests]: return get_recently_requested_books( session=session, limit=limit, days_back=days_back, - exclude_downloaded=exclude_downloaded, + exclude_fulfilled=_resolve_exclude_fulfilled( + exclude_fulfilled, exclude_downloaded + ), exclude_requested_username=user.username, ) @@ -161,7 +189,16 @@ async def get_popular_authors_recommendations( client_session: Annotated[ClientSession, Depends(get_connection)], user: Annotated[DetailedUser, Security(AnyAuth())], limit: int = 10, - exclude_downloaded: bool = True, + exclude_fulfilled: Annotated[ + bool, Query(description="Exclude requests that are already fulfilled.") + ] = True, + exclude_downloaded: Annotated[ + bool | None, + Query( + description="Deprecated: use `exclude_fulfilled`.", + deprecated=True, + ), + ] = None, audible_region: audible_region_type | None = None, personal_favorites: bool = True, ) -> list[AudiobookWithRequests]: @@ -171,7 +208,9 @@ async def get_popular_authors_recommendations( author_narrators = get_most_popular_authors( session=session, limit=limit, - exclude_downloaded=exclude_downloaded, + exclude_fulfilled=_resolve_exclude_fulfilled( + exclude_fulfilled, exclude_downloaded + ), username=user.username if personal_favorites else None, ) return await list_combined_audible_books( @@ -189,7 +228,16 @@ async def get_popular_narrators_recommendations( client_session: Annotated[ClientSession, Depends(get_connection)], user: Annotated[DetailedUser, Security(AnyAuth())], limit: int = 10, - exclude_downloaded: bool = True, + exclude_fulfilled: Annotated[ + bool, Query(description="Exclude requests that are already fulfilled.") + ] = True, + exclude_downloaded: Annotated[ + bool | None, + Query( + description="Deprecated: use `exclude_fulfilled`.", + deprecated=True, + ), + ] = None, audible_region: audible_region_type | None = None, personal_favorites: bool = True, ) -> list[AudiobookWithRequests]: @@ -199,7 +247,9 @@ async def get_popular_narrators_recommendations( author_narrators = get_most_popular_authors( session=session, limit=limit, - exclude_downloaded=exclude_downloaded, + exclude_fulfilled=_resolve_exclude_fulfilled( + exclude_fulfilled, exclude_downloaded + ), username=user.username if personal_favorites else None, ) return await list_combined_audible_books( diff --git a/app/routers/api/requests.py b/app/routers/api/requests.py index be275217..05f19c43 100644 --- a/app/routers/api/requests.py +++ b/app/routers/api/requests.py @@ -49,6 +49,26 @@ router = APIRouter(prefix="/requests", tags=["Requests"]) +WishlistFilter = Literal[ + "all", "fulfilled", "not_fulfilled", "downloaded", "not_downloaded" +] + + +def _resolve_filter( + filter: WishlistFilter, +) -> Literal["all", "fulfilled", "not_fulfilled"]: + match filter: + case "downloaded": + return "fulfilled" + case "not_downloaded": + return "not_fulfilled" + case "fulfilled": + return "fulfilled" + case "not_fulfilled": + return "not_fulfilled" + case _: + return "all" + class DownloadSourceBody(BaseModel): guid: str @@ -179,12 +199,17 @@ async def list_requests( session: Annotated[Session, Depends(get_session)], user: Annotated[DetailedUser, Security(AnyAuth())], filter: Annotated[ - Literal["all", "downloaded", "not_downloaded"], - Query(description="Filter by download status."), + WishlistFilter, + Query( + description=( + "Filter by request state. `downloaded` and `not_downloaded` are " + "deprecated aliases for `fulfilled` and `not_fulfilled`." + ) + ), ] = "all", ): username = None if user.is_admin() else user.username - results = get_wishlist_results(session, username, filter) + results = get_wishlist_results(session, username, _resolve_filter(filter)) return results @@ -209,28 +234,53 @@ async def delete_request( return Response(status_code=204) -@router.patch("/{asin_or_uuid}/downloaded", status_code=204) -async def mark_downloaded( +async def _mark_fulfilled( asin_or_uuid: str, - session: Annotated[Session, Depends(get_session)], + session: Session, background_task: BackgroundTasks, - _: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], -): +) -> Response: book = session.exec(select(Audiobook).where(Audiobook.asin == asin_or_uuid)).first() if book: - book.downloaded = True + book.fulfilled = True session.add(book) session.commit() background_task.add_task( send_all_notifications, - event_type=EventEnum.on_successful_download, + event_type=EventEnum.on_fulfilled, book_asin=asin_or_uuid, ) return Response(status_code=204) raise HTTPException(status_code=404, detail="Book not found") +@router.patch("/{asin_or_uuid}/fulfilled", status_code=204) +async def mark_fulfilled( + asin_or_uuid: str, + session: Annotated[Session, Depends(get_session)], + background_task: BackgroundTasks, + _: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], +): + return await _mark_fulfilled(asin_or_uuid, session, background_task) + + +@router.patch( + "/{asin_or_uuid}/downloaded", + status_code=204, + deprecated=True, + summary="Deprecated: use /{asin_or_uuid}/fulfilled", +) +async def mark_downloaded( + asin_or_uuid: str, + session: Annotated[Session, Depends(get_session)], + background_task: BackgroundTasks, + _: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], +): + response = await _mark_fulfilled(asin_or_uuid, session, background_task) + response.headers["Deprecation"] = "true" + return response + + @router.get("/manual", response_model=list[ManualBookRequest]) async def list_manual_requests( session: Annotated[Session, Depends(get_session)], @@ -242,7 +292,7 @@ async def list_manual_requests( user.is_admin() or ManualBookRequest.user_username == user.username, col(ManualBookRequest.user_username).is_not(None), ) - .order_by(asc(ManualBookRequest.downloaded)) + .order_by(asc(ManualBookRequest.fulfilled)) ).all() @@ -272,28 +322,53 @@ async def update_manual_request( return Response(status_code=204) -@router.patch("/manual/{id}/downloaded", status_code=204) -async def mark_manual_downloaded( +async def _mark_manual_fulfilled( id: uuid.UUID, - session: Annotated[Session, Depends(get_session)], + session: Session, background_task: BackgroundTasks, - _: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], -): +) -> Response: book_request = session.get(ManualBookRequest, id) if book_request: - book_request.downloaded = True + book_request.fulfilled = True session.add(book_request) session.commit() background_task.add_task( send_all_manual_notifications, - event_type=EventEnum.on_successful_download, + event_type=EventEnum.on_fulfilled, book_request=ManualBookRequest.model_validate(book_request), ) return Response(status_code=204) raise HTTPException(status_code=404, detail="Request not found") +@router.patch("/manual/{id}/fulfilled", status_code=204) +async def mark_manual_fulfilled( + id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], + background_task: BackgroundTasks, + _: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], +): + return await _mark_manual_fulfilled(id, session, background_task) + + +@router.patch( + "/manual/{id}/downloaded", + status_code=204, + deprecated=True, + summary="Deprecated: use /manual/{id}/fulfilled", +) +async def mark_manual_downloaded( + id: uuid.UUID, + session: Annotated[Session, Depends(get_session)], + background_task: BackgroundTasks, + _: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], +): + response = await _mark_manual_fulfilled(id, session, background_task) + response.headers["Deprecation"] = "true" + return response + + @router.delete("/manual/{id}", status_code=204) async def delete_manual_request( id: uuid.UUID, diff --git a/app/routers/api/search.py b/app/routers/api/search.py index a126febc..2e40d31f 100644 --- a/app/routers/api/search.py +++ b/app/routers/api/search.py @@ -55,7 +55,7 @@ async def search_books( for res in results: merged.append(session.merge(res)) - # Check ABS for existing books and mark as downloaded if found + # Check ABS for existing books and mark as fulfilled if found await abs_mark_downloaded_flags(session, client_session, merged) return [ diff --git a/app/routers/api/settings/notifications.py b/app/routers/api/settings/notifications.py index a104dc31..27ed4750 100644 --- a/app/routers/api/settings/notifications.py +++ b/app/routers/api/settings/notifications.py @@ -24,7 +24,11 @@ class NotificationRequest(BaseModel): name: str = Field(min_length=1) url: str = Field(min_length=1) event_type: str = Field( - json_schema_extra={"enum": [event.value for event in EventEnum]} + json_schema_extra={"enum": [event.value for event in EventEnum]}, + description=( + "Legacy values `onSuccessfulDownload` and `onFailedDownload` are " + "accepted but deprecated." + ), ) body: str body_type: NotificationBodyTypeEnum @@ -39,6 +43,21 @@ def list_notifications( return session.exec(select(Notification)).all() +_LEGACY_EVENT_TYPES = { + "onSuccessfulDownload": EventEnum.on_fulfilled, + "onFailedDownload": EventEnum.on_failed_fulfillment, +} + + +def _parse_event_type(event_type: str) -> EventEnum: + if legacy := _LEGACY_EVENT_TYPES.get(event_type): + return legacy + try: + return EventEnum(event_type) + except ValueError: + raise HTTPException(400, "Invalid event type") + + def _validate_headers(headers: str) -> dict[str, str]: try: headers_json = json.loads(headers or "{}") # pyright: ignore[reportAny] @@ -73,10 +92,7 @@ def _upsert_notification( except json.JSONDecodeError, ValueError: raise HTTPException(422, "Body is invalid JSON") - try: - event_enum = EventEnum(event_type) - except ValueError: - raise HTTPException(400, "Invalid event type") + event_enum = _parse_event_type(event_type) try: body_enum = NotificationBodyTypeEnum(body_type) @@ -186,10 +202,7 @@ async def test_notification( _: Annotated[DetailedUser, Security(AnyAuth(GroupEnum.admin))], ): headers_json = _validate_headers(body.headers) - try: - event_enum = EventEnum(body.event_type) - except ValueError: - raise HTTPException(400, "Invalid event type") + event_enum = _parse_event_type(body.event_type) try: await send_notification( session, diff --git a/app/routers/pages/index.py b/app/routers/pages/index.py index a9207eb3..04e0ac7a 100644 --- a/app/routers/pages/index.py +++ b/app/routers/pages/index.py @@ -95,14 +95,14 @@ async def get_popular_recommendations( user: Annotated[DetailedUser, Security(ABRAuth())], min_requests: int = 1, limit: int = 10, - exclude_downloaded: bool = True, + exclude_fulfilled: bool = True, ): result = await api_get_popular_recommendations( session=session, user=user, min_requests=min_requests, limit=limit, - exclude_downloaded=exclude_downloaded, + exclude_fulfilled=exclude_fulfilled, ) return catalog_response( @@ -161,14 +161,14 @@ async def get_recently_requested_recommendations( user: Annotated[DetailedUser, Security(ABRAuth())], limit: int = 10, days_back: int = 30, - exclude_downloaded: bool = True, + exclude_fulfilled: bool = True, ): result = await api_get_recently_requested_recommendations( session=session, user=user, limit=limit, days_back=days_back, - exclude_downloaded=exclude_downloaded, + exclude_fulfilled=exclude_fulfilled, ) reasons = [ @@ -231,7 +231,7 @@ async def get_popular_authors_recommendations( client_session: Annotated[ClientSession, Depends(get_connection)], user: Annotated[DetailedUser, Security(ABRAuth())], limit: int = 10, - exclude_downloaded: bool = True, + exclude_fulfilled: bool = True, audible_region: audible_region_type | None = None, personal_favorites: bool = True, ): @@ -240,7 +240,7 @@ async def get_popular_authors_recommendations( client_session=client_session, user=user, limit=limit, - exclude_downloaded=exclude_downloaded, + exclude_fulfilled=exclude_fulfilled, audible_region=audible_region, personal_favorites=personal_favorites, ) @@ -269,7 +269,7 @@ async def get_popular_narrators_recommendations( client_session: Annotated[ClientSession, Depends(get_connection)], user: Annotated[DetailedUser, Security(ABRAuth())], limit: int = 10, - exclude_downloaded: bool = True, + exclude_fulfilled: bool = True, audible_region: audible_region_type | None = None, personal_favorites: bool = True, ): @@ -278,7 +278,7 @@ async def get_popular_narrators_recommendations( client_session=client_session, user=user, limit=limit, - exclude_downloaded=exclude_downloaded, + exclude_fulfilled=exclude_fulfilled, audible_region=audible_region, personal_favorites=personal_favorites, ) diff --git a/app/routers/pages/wishlist/__init__.py b/app/routers/pages/wishlist/__init__.py index c7701793..f03e0888 100644 --- a/app/routers/pages/wishlist/__init__.py +++ b/app/routers/pages/wishlist/__init__.py @@ -14,11 +14,11 @@ from app.util.db import get_session from app.util.templates import catalog_response -from . import downloaded, manual, sources +from . import fulfilled, manual, sources router = APIRouter(prefix="/wishlist") -router.include_router(downloaded.router) +router.include_router(fulfilled.router) router.include_router(manual.router) router.include_router(sources.router) @@ -29,7 +29,7 @@ async def wishlist( user: Annotated[DetailedUser, Security(ABRAuth())], ): username = None if user.is_admin() else user.username - results = get_wishlist_results(session, username, "not_downloaded") + results = get_wishlist_results(session, username, "not_fulfilled") counts = get_wishlist_counts(session, user) return catalog_response( "Wishlist.Index", @@ -49,7 +49,7 @@ async def start_auto_download( ): await start_auto_download_endpoint(asin, session, client_session, user) username = None if user.is_admin() else user.username - results = get_wishlist_results(session, username, "not_downloaded") + results = get_wishlist_results(session, username, "not_fulfilled") counts = get_wishlist_counts(session, user) return catalog_response( @@ -68,23 +68,23 @@ async def delete_request( asin: str, session: Annotated[Session, Depends(get_session)], user: Annotated[DetailedUser, Security(ABRAuth())], - downloaded: bool | None = None, + fulfilled: bool | None = None, ): await api_delete_request(asin, session, user) counts = get_wishlist_counts(session, user) - if downloaded: # download page + if fulfilled: # fulfilled page results = get_wishlist_results( session, None if user.is_admin() else user.username, - "downloaded", + "fulfilled", ) return catalog_response( "Wishlist.Wishlist", user=user, results=results, - page="downloaded", + page="fulfilled", counts=counts, show_sources=get_download_backend(session) == "prowlarr", update_tablist=True, @@ -93,7 +93,7 @@ async def delete_request( results = get_wishlist_results( session, None if user.is_admin() else user.username, - "not_downloaded", + "not_fulfilled", ) return catalog_response( "Wishlist.Wishlist", diff --git a/app/routers/pages/wishlist/downloaded.py b/app/routers/pages/wishlist/fulfilled.py similarity index 80% rename from app/routers/pages/wishlist/downloaded.py rename to app/routers/pages/wishlist/fulfilled.py index f0250fea..6cc0a81e 100644 --- a/app/routers/pages/wishlist/downloaded.py +++ b/app/routers/pages/wishlist/fulfilled.py @@ -9,23 +9,23 @@ from app.internal.db_queries import get_wishlist_counts, get_wishlist_results from app.internal.download_backend import get_download_backend from app.internal.models import GroupEnum -from app.routers.api.requests import mark_downloaded as api_mark_downloaded +from app.routers.api.requests import mark_fulfilled as api_mark_fulfilled from app.util.db import get_session from app.util.templates import catalog_response -router = APIRouter(prefix="/downloaded") +router = APIRouter(prefix="/fulfilled") @router.get("") -async def downloaded( +async def fulfilled( session: Annotated[Session, Depends(get_session)], user: Annotated[DetailedUser, Security(ABRAuth())], ): username = None if user.is_admin() else user.username - results = get_wishlist_results(session, username, "downloaded") + results = get_wishlist_results(session, username, "fulfilled") counts = get_wishlist_counts(session, user) return catalog_response( - "Wishlist.Downloaded", + "Wishlist.Fulfilled", user=user, results=results, counts=counts, @@ -34,16 +34,16 @@ async def downloaded( @router.patch("/hx-add/{asin}") -async def update_downloaded( +async def update_fulfilled( asin: str, session: Annotated[Session, Depends(get_session)], background_task: BackgroundTasks, admin_user: Annotated[DetailedUser, Security(ABRAuth(GroupEnum.admin))], ): - await api_mark_downloaded(asin, session, background_task, admin_user) + await api_mark_fulfilled(asin, session, background_task, admin_user) username = None if admin_user.is_admin() else admin_user.username - results = get_wishlist_results(session, username, "not_downloaded") + results = get_wishlist_results(session, username, "not_fulfilled") counts = get_wishlist_counts(session, admin_user) if abs_config.is_valid(session): diff --git a/app/routers/pages/wishlist/manual.py b/app/routers/pages/wishlist/manual.py index 1ba6e47d..3d8176fc 100644 --- a/app/routers/pages/wishlist/manual.py +++ b/app/routers/pages/wishlist/manual.py @@ -10,7 +10,7 @@ from app.internal.db_queries import get_all_manual_requests, get_wishlist_counts from app.internal.download_backend import get_download_backend from app.internal.models import GroupEnum -from app.routers.api.requests import delete_manual_request, mark_manual_downloaded +from app.routers.api.requests import delete_manual_request, mark_manual_fulfilled from app.util.db import get_session from app.util.templates import catalog_response @@ -34,13 +34,13 @@ async def manual( @router.patch("/hx-add/{id}") -async def downloaded_manual( +async def fulfilled_manual( id: uuid.UUID, session: Annotated[Session, Depends(get_session)], background_task: BackgroundTasks, admin_user: Annotated[DetailedUser, Security(ABRAuth(GroupEnum.admin))], ): - await mark_manual_downloaded(id, session, background_task, admin_user) + await mark_manual_fulfilled(id, session, background_task, admin_user) results = get_all_manual_requests(session, admin_user) counts = get_wishlist_counts(session, admin_user) diff --git a/templates/components/BookCard.jinja b/templates/components/BookCard.jinja index acb4a2fa..bfa1dbb1 100644 --- a/templates/components/BookCard.jinja +++ b/templates/components/BookCard.jinja @@ -30,15 +30,15 @@ {% endif %} - - {% if book.downloaded %} + {% if book.fulfilled %} {% else %} - {% if book.downloaded %} + {% if book.fulfilled %} {% else %}