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 %}
-