mirror of
https://github.com/markbeep/AudioBookRequest.git
synced 2025-12-21 12:59:29 -06:00
add gotify support
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@
|
||||
__pycache__
|
||||
static/globals.css
|
||||
config
|
||||
data
|
||||
node_modules
|
||||
result
|
||||
.ruff_cache
|
||||
|
||||
25
README.md
25
README.md
@@ -17,6 +17,9 @@ If you've heard of Overseer, Ombi, or Jellyseer; this is in the similar vein, <i
|
||||
- [Usage](#usage)
|
||||
- [Auto download](#auto-download)
|
||||
- [Notifications](#notifications)
|
||||
- [Apprise](#apprise)
|
||||
- [Gotify](#gotify)
|
||||
- [ABR](#abr)
|
||||
- [OpenID Connect](#openid-connect)
|
||||
- [Getting locked out](#getting-locked-out)
|
||||
- [Alternative Deployments](#alternative-deployments)
|
||||
@@ -62,13 +65,25 @@ Auto-downloading enables requests by `Trusted` and `Admin` users to directly sta
|
||||
|
||||
### Notifications
|
||||
|
||||
Notifications depend on [Apprise](https://github.com/caronc/apprise).
|
||||
Notifications depend on [Apprise](https://github.com/caronc/apprise) or [Gotify](https://gotify.net/).
|
||||
|
||||
#### Apprise
|
||||
|
||||
1. Ensure you have a working Apprise instance.
|
||||
2. On Apprise, create a new configuration. For example paste your Discord webhook link (`https://discord.com/api/webhooks/<channel>/<id>`) into the configuration.
|
||||
3. On Apprise, copy the notification url along the format of `https://apprise.example.com/notify/<id>`.
|
||||
4. On AudioBookRequest, head to `Settings>Notifications` and add the URL.
|
||||
5. Configure the remaining settings. **The event variables are case sensitive**.
|
||||
2. Create a new configuration. For example paste your Discord webhook link (`https://discord.com/api/webhooks/<channel>/<id>`) into the configuration.
|
||||
3. Copy the notification url along the format of `https://apprise.example.com/notify/<id>`.
|
||||
|
||||
#### Gotify
|
||||
|
||||
1. Create an application.
|
||||
2. Copy the token.
|
||||
|
||||
#### ABR
|
||||
|
||||
1. On AudioBookRequest, head to `Settings>Notifications`.
|
||||
2. Add the Apprise URL or the path to your gotify instance with `/message` appended, i.e.: `http://gotify:8080/message`.
|
||||
3. For gofity, add the API key as a header: `{"Authentication": "Bearer <your token>"}`.
|
||||
4. Configure the remaining settings. **The event variables are case sensitive**.
|
||||
|
||||
### OpenID Connect
|
||||
|
||||
|
||||
50
alembic/versions/0fa71b2e5d30_add gotify support.py
Normal file
50
alembic/versions/0fa71b2e5d30_add gotify support.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""add gotify support
|
||||
|
||||
Revision ID: 0fa71b2e5d30
|
||||
Revises: bc237f8b139d
|
||||
Create Date: 2025-04-24 14:27:10.412771
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0fa71b2e5d30"
|
||||
down_revision: Union[str, None] = "bc237f8b139d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("notification", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"service",
|
||||
sa.Enum("apprise", "gotify", name="notificationserviceenum"),
|
||||
nullable=False,
|
||||
server_default="apprise",
|
||||
)
|
||||
)
|
||||
batch_op.alter_column("apprise_url", new_column_name="url")
|
||||
|
||||
with op.batch_alter_table("notification", schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"service",
|
||||
server_default=None,
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("notification", schema=None) as batch_op:
|
||||
batch_op.alter_column("url", new_column_name="apprise_url")
|
||||
batch_op.drop_column("service")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -198,12 +198,18 @@ class EventEnum(str, Enum):
|
||||
on_failed_download = "onFailedDownload"
|
||||
|
||||
|
||||
class NotificationServiceEnum(str, Enum):
|
||||
apprise = "apprise"
|
||||
gotify = "gotify"
|
||||
|
||||
|
||||
class Notification(BaseModel, table=True):
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
name: str
|
||||
apprise_url: str
|
||||
url: str
|
||||
headers: dict[str, str] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||
event: EventEnum
|
||||
service: NotificationServiceEnum
|
||||
title_template: str
|
||||
body_template: str
|
||||
enabled: bool
|
||||
|
||||
@@ -4,7 +4,13 @@ from typing import Optional
|
||||
from aiohttp import ClientSession
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.internal.models import BookRequest, EventEnum, ManualBookRequest, Notification
|
||||
from app.internal.models import (
|
||||
BookRequest,
|
||||
EventEnum,
|
||||
ManualBookRequest,
|
||||
Notification,
|
||||
NotificationServiceEnum,
|
||||
)
|
||||
from app.util.db import open_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,6 +52,30 @@ def replace_variables(
|
||||
return title, body
|
||||
|
||||
|
||||
async def _send(
|
||||
title: str,
|
||||
body: str,
|
||||
notification: Notification,
|
||||
client_session: ClientSession,
|
||||
):
|
||||
match notification.service:
|
||||
case NotificationServiceEnum.gotify:
|
||||
body_key = "message"
|
||||
case NotificationServiceEnum.apprise:
|
||||
body_key = "body"
|
||||
|
||||
async with client_session.post(
|
||||
notification.url,
|
||||
json={
|
||||
"title": title,
|
||||
body_key: body,
|
||||
},
|
||||
headers=notification.headers,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
|
||||
|
||||
async def send_notification(
|
||||
session: Session,
|
||||
notification: Notification,
|
||||
@@ -53,44 +83,35 @@ async def send_notification(
|
||||
book_asin: Optional[str] = None,
|
||||
other_replacements: dict[str, str] = {},
|
||||
):
|
||||
book_title = None
|
||||
book_authors = None
|
||||
book_narrators = None
|
||||
if book_asin:
|
||||
book = session.exec(
|
||||
select(BookRequest).where(BookRequest.asin == book_asin)
|
||||
).first()
|
||||
if book:
|
||||
book_title = book.title
|
||||
book_authors = ",".join(book.authors)
|
||||
book_narrators = ",".join(book.narrators)
|
||||
|
||||
title, body = replace_variables(
|
||||
notification.title_template,
|
||||
notification.body_template,
|
||||
requester_username,
|
||||
book_title,
|
||||
book_authors,
|
||||
book_narrators,
|
||||
notification.event.value,
|
||||
other_replacements,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Sending notification to {notification.url} with title: '{title}', event type: {notification.event.value}"
|
||||
)
|
||||
|
||||
async with ClientSession() as client_session:
|
||||
book_title = None
|
||||
book_authors = None
|
||||
book_narrators = None
|
||||
if book_asin:
|
||||
book = session.exec(
|
||||
select(BookRequest).where(BookRequest.asin == book_asin)
|
||||
).first()
|
||||
if book:
|
||||
book_title = book.title
|
||||
book_authors = ",".join(book.authors)
|
||||
book_narrators = ",".join(book.narrators)
|
||||
|
||||
title, body = replace_variables(
|
||||
notification.title_template,
|
||||
notification.body_template,
|
||||
requester_username,
|
||||
book_title,
|
||||
book_authors,
|
||||
book_narrators,
|
||||
notification.event.value,
|
||||
other_replacements,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Sending notification to {notification.apprise_url} with title: '{title}', event type: {notification.event.value}"
|
||||
)
|
||||
|
||||
async with client_session.post(
|
||||
notification.apprise_url,
|
||||
json={
|
||||
"title": title,
|
||||
"body": body,
|
||||
},
|
||||
headers=notification.headers,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
return await _send(title, body, notification, client_session)
|
||||
|
||||
|
||||
async def send_all_notifications(
|
||||
@@ -123,32 +144,24 @@ async def send_manual_notification(
|
||||
):
|
||||
"""Send a notification for manual book requests"""
|
||||
try:
|
||||
title, body = replace_variables(
|
||||
notification.title_template,
|
||||
notification.body_template,
|
||||
requester_username,
|
||||
book.title,
|
||||
",".join(book.authors),
|
||||
",".join(book.narrators),
|
||||
notification.event.value,
|
||||
other_replacements,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Sending manual notification to {notification.url} with title: '{title}', event type: {notification.event.value}"
|
||||
)
|
||||
|
||||
async with ClientSession() as client_session:
|
||||
title, body = replace_variables(
|
||||
notification.title_template,
|
||||
notification.body_template,
|
||||
requester_username,
|
||||
book.title,
|
||||
",".join(book.authors),
|
||||
",".join(book.narrators),
|
||||
notification.event.value,
|
||||
other_replacements,
|
||||
)
|
||||
await _send(title, body, notification, client_session)
|
||||
|
||||
logger.info(
|
||||
f"Sending manual notification to {notification.apprise_url} with title: '{title}', event type: {notification.event.value}"
|
||||
)
|
||||
|
||||
async with client_session.post(
|
||||
notification.apprise_url,
|
||||
json={
|
||||
"title": title,
|
||||
"body": body,
|
||||
},
|
||||
headers=notification.headers,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
except Exception as e:
|
||||
logger.error("Failed to send notification", e)
|
||||
return None
|
||||
|
||||
@@ -92,8 +92,8 @@ class ProwlarrConfig(StringConfigCache[ProwlarrConfigKey]):
|
||||
|
||||
|
||||
prowlarr_config = ProwlarrConfig()
|
||||
prowlarr_source_cache = SimpleCache[list[ProwlarrSource]]()
|
||||
prowlarr_indexer_cache = SimpleCache[Indexer]()
|
||||
prowlarr_source_cache = SimpleCache[list[ProwlarrSource], str]()
|
||||
prowlarr_indexer_cache = SimpleCache[Indexer, str]()
|
||||
|
||||
|
||||
def flush_prowlarr_cache():
|
||||
|
||||
@@ -20,7 +20,13 @@ from app.internal.env_settings import Settings
|
||||
from app.internal.indexers.abstract import SessionContainer
|
||||
from app.internal.indexers.configuration import indexer_configuration_cache
|
||||
from app.internal.indexers.indexer_util import IndexerContext, get_indexer_contexts
|
||||
from app.internal.models import EventEnum, GroupEnum, Notification, User
|
||||
from app.internal.models import (
|
||||
EventEnum,
|
||||
GroupEnum,
|
||||
Notification,
|
||||
NotificationServiceEnum,
|
||||
User,
|
||||
)
|
||||
from app.internal.notifications import send_notification
|
||||
from app.internal.prowlarr.indexer_categories import indexer_categories
|
||||
from app.internal.prowlarr.prowlarr import (
|
||||
@@ -489,6 +495,7 @@ def read_notifications(
|
||||
):
|
||||
notifications = session.exec(select(Notification)).all()
|
||||
event_types = [e.value for e in EventEnum]
|
||||
service_types = [e.value for e in NotificationServiceEnum]
|
||||
return template_response(
|
||||
"settings_page/notifications.html",
|
||||
request,
|
||||
@@ -497,6 +504,7 @@ def read_notifications(
|
||||
"page": "notifications",
|
||||
"notifications": notifications,
|
||||
"event_types": event_types,
|
||||
"service_types": service_types,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -521,11 +529,13 @@ def _list_notifications(request: Request, session: Session, admin_user: Detailed
|
||||
|
||||
def _upsert_notification(
|
||||
request: Request,
|
||||
*,
|
||||
name: str,
|
||||
apprise_url: str,
|
||||
url: str,
|
||||
title_template: str,
|
||||
body_template: str,
|
||||
event_type: str,
|
||||
service_type: str,
|
||||
headers: str,
|
||||
admin_user: DetailedUser,
|
||||
session: Session,
|
||||
@@ -548,13 +558,19 @@ def _upsert_notification(
|
||||
except ValueError:
|
||||
raise ToastException("Invalid event type", "error")
|
||||
|
||||
try:
|
||||
service_enum = NotificationServiceEnum(service_type)
|
||||
except ValueError:
|
||||
raise ToastException("Invalid notification service type", "error")
|
||||
|
||||
if notification_id:
|
||||
notification = session.get(Notification, notification_id)
|
||||
if not notification:
|
||||
raise ToastException("Notification not found", "error")
|
||||
notification.name = name
|
||||
notification.apprise_url = apprise_url
|
||||
notification.url = url
|
||||
notification.event = event_enum
|
||||
notification.service = service_enum
|
||||
notification.title_template = title_template
|
||||
notification.body_template = body_template
|
||||
notification.headers = headers_json
|
||||
@@ -562,8 +578,9 @@ def _upsert_notification(
|
||||
else:
|
||||
notification = Notification(
|
||||
name=name,
|
||||
apprise_url=apprise_url,
|
||||
url=url,
|
||||
event=event_enum,
|
||||
service=service_enum,
|
||||
title_template=title_template,
|
||||
body_template=body_template,
|
||||
headers=headers_json,
|
||||
@@ -579,10 +596,11 @@ def _upsert_notification(
|
||||
def add_notification(
|
||||
request: Request,
|
||||
name: Annotated[str, Form()],
|
||||
apprise_url: Annotated[str, Form()],
|
||||
url: Annotated[str, Form()],
|
||||
title_template: Annotated[str, Form()],
|
||||
body_template: Annotated[str, Form()],
|
||||
event_type: Annotated[str, Form()],
|
||||
service_type: Annotated[str, Form()],
|
||||
headers: Annotated[str, Form()],
|
||||
admin_user: Annotated[
|
||||
DetailedUser, Depends(get_authenticated_user(GroupEnum.admin))
|
||||
@@ -592,10 +610,11 @@ def add_notification(
|
||||
return _upsert_notification(
|
||||
request=request,
|
||||
name=name,
|
||||
apprise_url=apprise_url,
|
||||
url=url,
|
||||
title_template=title_template,
|
||||
body_template=body_template,
|
||||
event_type=event_type,
|
||||
service_type=service_type,
|
||||
headers=headers,
|
||||
admin_user=admin_user,
|
||||
session=session,
|
||||
@@ -607,10 +626,11 @@ def update_notification(
|
||||
request: Request,
|
||||
notification_id: uuid.UUID,
|
||||
name: Annotated[str, Form()],
|
||||
apprise_url: Annotated[str, Form()],
|
||||
url: Annotated[str, Form()],
|
||||
title_template: Annotated[str, Form()],
|
||||
body_template: Annotated[str, Form()],
|
||||
event_type: Annotated[str, Form()],
|
||||
service_type: Annotated[str, Form()],
|
||||
headers: Annotated[str, Form()],
|
||||
admin_user: Annotated[
|
||||
DetailedUser, Depends(get_authenticated_user(GroupEnum.admin))
|
||||
@@ -620,10 +640,11 @@ def update_notification(
|
||||
return _upsert_notification(
|
||||
request=request,
|
||||
name=name,
|
||||
apprise_url=apprise_url,
|
||||
url=url,
|
||||
title_template=title_template,
|
||||
body_template=body_template,
|
||||
event_type=event_type,
|
||||
service_type=service_type,
|
||||
headers=headers,
|
||||
admin_user=admin_user,
|
||||
session=session,
|
||||
|
||||
@@ -7,10 +7,10 @@ from sqlmodel import Session, select
|
||||
from app.internal.models import Config
|
||||
|
||||
|
||||
class SimpleCache[T]:
|
||||
_cache: dict[tuple[str, ...], tuple[int, T]] = {}
|
||||
class SimpleCache[VT, *KTs]:
|
||||
_cache: dict[tuple[*KTs], tuple[int, VT]] = {}
|
||||
|
||||
def get(self, source_ttl: int, *query: str) -> Optional[T]:
|
||||
def get(self, source_ttl: int, *query: *KTs) -> Optional[VT]:
|
||||
hit = self._cache.get(query)
|
||||
if not hit:
|
||||
return None
|
||||
@@ -19,7 +19,7 @@ class SimpleCache[T]:
|
||||
return None
|
||||
return sources
|
||||
|
||||
def get_all(self, source_ttl: int) -> dict[tuple[str, ...], T]:
|
||||
def get_all(self, source_ttl: int) -> dict[tuple[*KTs], VT]:
|
||||
now = int(time.time())
|
||||
|
||||
return {
|
||||
@@ -28,7 +28,7 @@ class SimpleCache[T]:
|
||||
if cached_at + source_ttl > now
|
||||
}
|
||||
|
||||
def set(self, sources: T, *query: str):
|
||||
def set(self, sources: VT, *query: *KTs):
|
||||
self._cache[query] = (int(time.time()), sources)
|
||||
|
||||
def flush(self):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
services:
|
||||
web:
|
||||
# start with `docker-compose --profile local up`
|
||||
profiles: [local]
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
@@ -8,3 +10,10 @@ services:
|
||||
- ./config:/config
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
gotify:
|
||||
image: gotify/server
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./data/gotify/data:/app/data
|
||||
|
||||
@@ -27,12 +27,21 @@
|
||||
<select id="event" name="event_type" class="select w-full" required>
|
||||
{% for e in event_types %}<option value="{{ e }}">{{ e }}</option>{% endfor %}
|
||||
</select>
|
||||
<label for="apprise_url">
|
||||
Apprise Notify URL
|
||||
<span class="text-xs font-mono">(http://.../notify/c2h3fg...)</span><span class="text-error">*</span>
|
||||
<label for="service">
|
||||
Notification Service<span class="text-error">*</span>
|
||||
</label>
|
||||
<input id="apprise_url"
|
||||
name="apprise_url"
|
||||
<select id="service" name="service_type" class="select w-full" required>
|
||||
{% for e in service_types %}
|
||||
<option value="{{ e }}" {% if loop.index.__eq__(0) %}selected{% endif %}>{{ e }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label for="url" class="flex gap-0 flex-col">
|
||||
<span>Apprise Notify URL / Gotify Message URL<span class="text-error">*</span></span>
|
||||
<br />
|
||||
<span class="text-xs font-mono">(http://.../notify/c2h3fg...)</span>
|
||||
</label>
|
||||
<input id="url"
|
||||
name="url"
|
||||
minlength="1"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
@@ -98,7 +107,7 @@
|
||||
<th>{{ loop.index }}</th>
|
||||
<td>{{ n.name }}</td>
|
||||
<td>{{ n.event.value }}</td>
|
||||
<td>{{ n.apprise_url }}</td>
|
||||
<td>{{ n.url }}</td>
|
||||
<td class="grid grid-cols-2 min-w-[8rem] gap-1">
|
||||
<button title="Test"
|
||||
class="btn btn-square"
|
||||
@@ -137,7 +146,7 @@
|
||||
</div>
|
||||
{% for n in notifications %}
|
||||
<template x-if="edit === '{{ n.id }}'">
|
||||
<form x-data="{ name: {{ n.name|toJSstring }}, event: {{ n.event.value|toJSstring }}, apprise_url: {{ n.apprise_url|toJSstring }}, headers: {{ n.serialized_headers|toJSstring }}, title_template: {{ n.title_template|toJSstring }}, body_template: {{ n.body_template|toJSstring }} }"
|
||||
<form x-data="{ name: {{ n.name|toJSstring }}, event: {{ n.event.value|toJSstring }}, service: {{ n.service.value|toJSstring }}, url: {{ n.url|toJSstring }}, headers: {{ n.serialized_headers|toJSstring }}, title_template: {{ n.title_template|toJSstring }}, body_template: {{ n.body_template|toJSstring }} }"
|
||||
class="flex flex-col gap-2"
|
||||
hx-put="{{ base_url }}/settings/notification/{{ n.id }}"
|
||||
hx-target="#notification-list"
|
||||
@@ -164,17 +173,28 @@
|
||||
x-model="event">
|
||||
{% for e in event_types %}<option value="{{ e }}">{{ e }}</option>{% endfor %}
|
||||
</select>
|
||||
<label for="apprise_url">
|
||||
Apprise Notify URL
|
||||
<span class="text-xs font-mono">(http://.../notify/c2h3fg...)</span><span class="text-error">*</span>
|
||||
<label for="service">
|
||||
Notification Service<span class="text-error">*</span>
|
||||
</label>
|
||||
<input id="apprise_url"
|
||||
name="apprise_url"
|
||||
<select id="service"
|
||||
name="service_type"
|
||||
class="select w-full"
|
||||
required
|
||||
x-model="service">
|
||||
{% for e in service_types %}<option value="{{ e }}">{{ e }}</option>{% endfor %}
|
||||
</select>
|
||||
<label for="url">
|
||||
Apprise Notify URL / Gotify Message URL<span class="text-error">*</span>
|
||||
<br />
|
||||
<span class="text-xs font-mono">(http://.../notify/c2h3fg...)</span>
|
||||
</label>
|
||||
<input id="url"
|
||||
name="url"
|
||||
minlength="1"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
required
|
||||
x-model="apprise_url" />
|
||||
x-model="url" />
|
||||
<label for="headers">
|
||||
Headers
|
||||
<span class="astext-xs font-mono">(JSON format, optional)</span>
|
||||
|
||||
Reference in New Issue
Block a user