Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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

Expand All @@ -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:<ASIN>&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:<ASIN>&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.

Expand Down
55 changes: 55 additions & 0 deletions alembic/versions/c4a1f2b3d4e5_rename_downloaded_to_fulfilled.py
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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'"
)
2 changes: 1 addition & 1 deletion app/internal/audible/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 4 additions & 4 deletions app/internal/audiobookshelf/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]

Expand All @@ -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))
Expand Down
8 changes: 4 additions & 4 deletions app/internal/chaptarr/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,22 +230,22 @@ 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",
"errorReason": e.message,
},
)
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"},
)
28 changes: 14 additions & 14 deletions app/internal/db_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

class WishlistCounts(BaseModel):
requests: int
downloaded: int
fulfilled: int
manual: int


Expand All @@ -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

Expand All @@ -54,25 +54,25 @@ def get_wishlist_counts(session: Session, user: User | None = None) -> WishlistC

return WishlistCounts(
requests=requests,
downloaded=downloaded,
fulfilled=fulfilled,
manual=manual,
)


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

Expand Down Expand Up @@ -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()
24 changes: 18 additions & 6 deletions app/internal/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
"""

Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading