Merge pull request #1 from prosolis/claude/media-arrival-webhook-Rabvg
Initial verison of Melora
This commit is contained in:
@@ -2,4 +2,4 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
bellhop.db
|
||||
melora.db
|
||||
|
||||
32
.env.example
32
.env.example
@@ -1,30 +1,16 @@
|
||||
# Matrix homeserver
|
||||
MATRIX_HOMESERVER_URL=https://matrix.example.com
|
||||
|
||||
# Matrix audit bot (pre-authenticated)
|
||||
MATRIX_AUDIT_ROOM_ID=!roomid:example.com
|
||||
MATRIX_BOT_USER_ID=@bot:example.com
|
||||
# Matrix bot (pre-authenticated)
|
||||
MATRIX_BOT_USER_ID=@melora-bot:example.com
|
||||
MATRIX_BOT_ACCESS_TOKEN=syt_bot_token_here
|
||||
|
||||
# Radarr
|
||||
RADARR_URL=https://radarr.example.com
|
||||
RADARR_API_KEY=your_radarr_api_key
|
||||
RADARR_QUALITY_PROFILE_ID=1
|
||||
RADARR_ROOT_FOLDER=/movies
|
||||
# Matrix room for arrival announcements (unencrypted)
|
||||
MATRIX_ARRIVALS_ROOM_ID=!roomid:example.com
|
||||
|
||||
# Sonarr
|
||||
SONARR_URL=https://sonarr.example.com
|
||||
SONARR_API_KEY=your_sonarr_api_key
|
||||
SONARR_QUALITY_PROFILE_ID=1
|
||||
SONARR_ROOT_FOLDER=/tv
|
||||
# Shared secret for *arr webhook authentication
|
||||
# Set the same value in each *arr instance under Settings → Connect → Webhook
|
||||
WEBHOOK_SECRET=your_shared_secret_here
|
||||
|
||||
# Lidarr
|
||||
LIDARR_URL=https://lidarr.example.com
|
||||
LIDARR_API_KEY=your_lidarr_api_key
|
||||
LIDARR_QUALITY_PROFILE_ID=1
|
||||
LIDARR_ROOT_FOLDER=/music
|
||||
|
||||
# App
|
||||
# SESSION_SECRET_KEY is auto-generated if omitted. A new key on every restart invalidates all sessions.
|
||||
# SESSION_SECRET_KEY=
|
||||
DATABASE_PATH=bellhop.db
|
||||
# SQLite database path
|
||||
DATABASE_PATH=melora.db
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
bellhop.db
|
||||
melora.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
373
README.md
373
README.md
@@ -1,266 +1,257 @@
|
||||
# Bellhop
|
||||
# Melora
|
||||
|
||||
A Matrix-authenticated web portal for submitting media requests to Radarr, Sonarr, and Lidarr. Users sign in with their Matrix homeserver credentials, search for movies, TV shows, or music, and submit requests — all through a clean single-page interface. Every request is logged to a Matrix room for auditing.
|
||||
A webhook receiver that listens for media import events from Radarr, Sonarr, and Lidarr and announces new arrivals to a Matrix room via a bot. Each media type posts into its own persistent thread, keeping the room tidy.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser ──► FastAPI app ──► Matrix homeserver (authentication)
|
||||
──► Radarr / Sonarr / Lidarr (search + add)
|
||||
──► Matrix room (audit log)
|
||||
──► SQLite (session storage)
|
||||
Radarr ─┐
|
||||
Sonarr ──┼─→ POST webhook → Melora (FastAPI) → Matrix room (threaded)
|
||||
Lidarr ─┘
|
||||
```
|
||||
|
||||
All *arr communication happens server-side. API keys and service URLs are never exposed to the browser.
|
||||
No polling. All three *arr instances push events to Melora via their built-in webhook/Connect system.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.12+
|
||||
- A Matrix homeserver (Synapse, Dendrite, Conduit, etc.)
|
||||
- At least one of: Radarr, Sonarr, or Lidarr accessible over HTTPS
|
||||
- (Optional) A Matrix bot account for audit logging
|
||||
- A Matrix homeserver with an unencrypted room and a bot account
|
||||
- Radarr, Sonarr, and/or Lidarr instances configured to send webhooks
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone and configure
|
||||
|
||||
```bash
|
||||
git clone https://github.com/prosolis/Bellhop.git
|
||||
cd Bellhop
|
||||
git clone https://github.com/prosolis/Melora.git
|
||||
cd Melora
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your actual values (see [Environment Variables](#environment-variables) below).
|
||||
|
||||
### 2. Run locally
|
||||
### 2. Verify your configuration
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python -m app --check
|
||||
```
|
||||
|
||||
This validates that all required environment variables are set, the Matrix homeserver is reachable, the bot token is valid, the bot has joined the announcements room, and the database path is writable. Fix any failing checks before starting the server.
|
||||
|
||||
### 3. Run locally
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Open `http://localhost:8000` in your browser.
|
||||
|
||||
### 3. Run with Docker
|
||||
### 4. Run with Docker
|
||||
|
||||
```bash
|
||||
docker build -t bellhop .
|
||||
docker build -t melora .
|
||||
|
||||
# Verify configuration first
|
||||
docker run --rm --env-file .env melora python -m app --check
|
||||
|
||||
# Start the service
|
||||
docker run -d \
|
||||
--name bellhop \
|
||||
--name melora \
|
||||
--env-file .env \
|
||||
-p 8000:8000 \
|
||||
-v bellhop-data:/app \
|
||||
bellhop
|
||||
-v melora-data:/app \
|
||||
melora
|
||||
```
|
||||
|
||||
The SQLite database file is created at the path specified by `DATABASE_PATH` (default: `bellhop.db` in the working directory). Mount a volume if you want persistence across container recreations.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create a `.env` file in the project root (or pass variables via Docker `--env-file`). See `.env.example` for a template.
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `MATRIX_HOMESERVER_URL` | Yes | — | Base URL of your Matrix homeserver |
|
||||
| `MATRIX_BOT_USER_ID` | Yes | — | Bot's Matrix user ID (e.g. `@melora-bot:example.com`) |
|
||||
| `MATRIX_BOT_ACCESS_TOKEN` | Yes | — | Pre-authenticated access token for the bot |
|
||||
| `MATRIX_ARRIVALS_ROOM_ID` | Yes | — | Room ID for arrival announcements (e.g. `!abc123:example.com`) |
|
||||
| `WEBHOOK_SECRET` | Yes | — | Shared secret for *arr webhook authentication |
|
||||
| `DATABASE_PATH` | No | `melora.db` | Path to the SQLite database file |
|
||||
|
||||
### Required
|
||||
## *arr Configuration
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `MATRIX_HOMESERVER_URL` | Base URL of your Matrix homeserver (e.g. `https://matrix.example.com`) |
|
||||
Melora receives push notifications from each *arr app via their built-in **Connect / Webhook** system. The setup is nearly identical across Radarr, Sonarr, and Lidarr — only the webhook URL path differs.
|
||||
|
||||
### *arr Services
|
||||
> **Prerequisite:** Before configuring any *arr instance, make sure Melora is running and reachable from the machine that hosts your *arr apps. You can verify by hitting the health endpoint:
|
||||
> ```bash
|
||||
> curl http://melora-host:8000/health
|
||||
> # Expected: {"status":"ok"}
|
||||
> ```
|
||||
|
||||
Configure one or more. If a service's URL or API key is left empty, that media type will return a "not configured" error when used.
|
||||
---
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `RADARR_URL` | _(empty)_ | Radarr instance URL (e.g. `https://radarr.example.com`) |
|
||||
| `RADARR_API_KEY` | _(empty)_ | Radarr API key (Settings > General in Radarr) |
|
||||
| `RADARR_QUALITY_PROFILE_ID` | `1` | Quality profile ID to assign to new movies |
|
||||
| `RADARR_ROOT_FOLDER` | `/movies` | Root folder path for movie storage |
|
||||
| `SONARR_URL` | _(empty)_ | Sonarr instance URL |
|
||||
| `SONARR_API_KEY` | _(empty)_ | Sonarr API key |
|
||||
| `SONARR_QUALITY_PROFILE_ID` | `1` | Quality profile ID for new series |
|
||||
| `SONARR_ROOT_FOLDER` | `/tv` | Root folder path for TV storage |
|
||||
| `LIDARR_URL` | _(empty)_ | Lidarr instance URL |
|
||||
| `LIDARR_API_KEY` | _(empty)_ | Lidarr API key |
|
||||
| `LIDARR_QUALITY_PROFILE_ID` | `1` | Quality profile ID for new artists |
|
||||
| `LIDARR_ROOT_FOLDER` | `/music` | Root folder path for music storage |
|
||||
### Radarr (Movies)
|
||||
|
||||
**Finding quality profile IDs:** Open your *arr instance, go to Settings > Profiles. The ID is visible in the URL when you click a profile, or query the API directly:
|
||||
1. Open Radarr → **Settings** → **Connect**
|
||||
2. Click **+** to add a new connection and select **Webhook**
|
||||
3. Fill in the following fields:
|
||||
|
||||
```bash
|
||||
curl -H "X-Api-Key: YOUR_KEY" https://radarr.example.com/api/v3/qualityprofile
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Name** | `Melora` (or any label you like) |
|
||||
| **On Grab** | Off |
|
||||
| **On Import** | **On** |
|
||||
| **On Upgrade** | **On** (if you want upgrade notifications) |
|
||||
| **On Rename** | Off |
|
||||
| **On Movie Added** | Off |
|
||||
| **On Movie Delete** | Off |
|
||||
| **On Movie File Delete** | Off |
|
||||
| **On Health Issue** | Off |
|
||||
| **Tags** | Leave blank (all movies) or choose specific tags |
|
||||
| **URL** | `http://melora-host:8000/webhook/radarr` |
|
||||
| **Method** | `POST` |
|
||||
| **Username** | _(leave blank)_ |
|
||||
| **Password** | _(leave blank)_ |
|
||||
|
||||
4. Under **Request Headers**, add a header:
|
||||
- **Key:** `X-Arr-Webhook-Secret`
|
||||
- **Value:** The same value you set for `WEBHOOK_SECRET` in your `.env`
|
||||
5. Click **Test** — you should see a green check. Then click **Save**.
|
||||
|
||||
---
|
||||
|
||||
### Sonarr (TV Shows)
|
||||
|
||||
1. Open Sonarr → **Settings** → **Connect**
|
||||
2. Click **+** to add a new connection and select **Webhook**
|
||||
3. Fill in the following fields:
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Name** | `Melora` |
|
||||
| **On Grab** | Off |
|
||||
| **On Import** | **On** |
|
||||
| **On Upgrade** | **On** (if you want upgrade notifications) |
|
||||
| **On Rename** | Off |
|
||||
| **On Series Add** | Off |
|
||||
| **On Series Delete** | Off |
|
||||
| **On Episode File Delete** | Off |
|
||||
| **On Health Issue** | Off |
|
||||
| **Tags** | Leave blank (all series) or choose specific tags |
|
||||
| **URL** | `http://melora-host:8000/webhook/sonarr` |
|
||||
| **Method** | `POST` |
|
||||
| **Username** | _(leave blank)_ |
|
||||
| **Password** | _(leave blank)_ |
|
||||
|
||||
4. Under **Request Headers**, add a header:
|
||||
- **Key:** `X-Arr-Webhook-Secret`
|
||||
- **Value:** The same value you set for `WEBHOOK_SECRET` in your `.env`
|
||||
5. Click **Test** — you should see a green check. Then click **Save**.
|
||||
|
||||
---
|
||||
|
||||
### Lidarr (Music)
|
||||
|
||||
1. Open Lidarr → **Settings** → **Connect**
|
||||
2. Click **+** to add a new connection and select **Webhook**
|
||||
3. Fill in the following fields:
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Name** | `Melora` |
|
||||
| **On Grab** | Off |
|
||||
| **On Import** | **On** |
|
||||
| **On Upgrade** | **On** (if you want upgrade notifications) |
|
||||
| **On Rename** | Off |
|
||||
| **On Album Delete** | Off |
|
||||
| **On Artist Delete** | Off |
|
||||
| **On Health Issue** | Off |
|
||||
| **Tags** | Leave blank (all artists) or choose specific tags |
|
||||
| **URL** | `http://melora-host:8000/webhook/lidarr` |
|
||||
| **Method** | `POST` |
|
||||
| **Username** | _(leave blank)_ |
|
||||
| **Password** | _(leave blank)_ |
|
||||
|
||||
4. Under **Request Headers**, add a header:
|
||||
- **Key:** `X-Arr-Webhook-Secret`
|
||||
- **Value:** The same value you set for `WEBHOOK_SECRET` in your `.env`
|
||||
5. Click **Test** — you should see a green check. Then click **Save**.
|
||||
|
||||
---
|
||||
|
||||
### Troubleshooting *arr Webhooks
|
||||
|
||||
| Symptom | Likely Cause | Fix |
|
||||
|---------|-------------|-----|
|
||||
| Test button shows red X | Melora is unreachable from the *arr host | Verify the URL, port, and any firewall rules between the hosts |
|
||||
| Test passes but no Matrix messages appear | The Test event type is `Test`, not `Download` — Melora ignores it by design | Import a real file or trigger a manual import to generate a `Download` event |
|
||||
| 401 Unauthorized in Melora logs | Secret mismatch | Ensure `X-Arr-Webhook-Secret` header value exactly matches the `WEBHOOK_SECRET` in Melora's `.env` |
|
||||
| Duplicate notifications | Same media re-imported | Melora deduplicates by media ID — duplicates are silently ignored. If you see duplicates, check if the media has a different internal ID |
|
||||
|
||||
> **Note:** Melora only processes events with `eventType: "Download"`. This is the event type that Radarr/Sonarr/Lidarr send when a file is actually imported (not when it is grabbed from an indexer). All other event types are acknowledged with a `200 OK` but silently ignored.
|
||||
|
||||
## Webhook Endpoints
|
||||
|
||||
```
|
||||
POST /webhook/radarr — receives Radarr movie import events
|
||||
POST /webhook/sonarr — receives Sonarr episode import events
|
||||
POST /webhook/lidarr — receives Lidarr album import events
|
||||
GET /health — returns {"status": "ok"} when the service is running
|
||||
```
|
||||
|
||||
### Audit Bot (optional)
|
||||
Each webhook endpoint validates the `X-Arr-Webhook-Secret` header, processes only `Download` events, and posts to the appropriate Matrix thread.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `MATRIX_AUDIT_ROOM_ID` | _(empty)_ | Room ID for audit messages (e.g. `!abc123:example.com`) |
|
||||
| `MATRIX_BOT_USER_ID` | _(empty)_ | Bot's Matrix user ID (e.g. `@bellhop-bot:example.com`) |
|
||||
| `MATRIX_BOT_ACCESS_TOKEN` | _(empty)_ | Pre-authenticated access token for the bot |
|
||||
## Matrix Room Structure
|
||||
|
||||
If any of these are left empty, audit logging is silently disabled. The room must be **unencrypted** and the bot must already be joined to it.
|
||||
On first startup, Melora creates three thread root messages in the configured room. All subsequent announcements reply into the appropriate thread.
|
||||
|
||||
**Getting a bot access token:**
|
||||
|
||||
```bash
|
||||
curl -X POST https://matrix.example.com/_matrix/client/v3/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"@bellhop-bot:example.com"},"password":"bot-password"}'
|
||||
```
|
||||
#new-arrivals:your.domain
|
||||
├── 🎬 Movies ← Radarr imports
|
||||
├── 📺 Shows ← Sonarr imports
|
||||
└── 🎵 Music ← Lidarr imports
|
||||
```
|
||||
|
||||
Copy the `access_token` from the response.
|
||||
Thread root `event_id` values are stored in SQLite, so threads persist across restarts.
|
||||
|
||||
### Other
|
||||
## Message Format
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SESSION_SECRET_KEY` | _(auto-generated)_ | Secret for signing session cookies. Auto-generated at startup if not set. A new key is generated on every restart, which invalidates all existing sessions. |
|
||||
| `DATABASE_PATH` | `bellhop.db` | Path to the SQLite database file |
|
||||
Messages include both plain text and HTML (Matrix-flavored Markdown). New additions and quality upgrades are distinguished:
|
||||
|
||||
## API Reference
|
||||
|
||||
### Authentication
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `POST` | `/auth/login` | Authenticate with Matrix credentials. Rate-limited to 5 requests/minute per IP. |
|
||||
| `POST` | `/auth/logout` | Destroy the current session. |
|
||||
| `GET` | `/auth/me` | Return the current user's Matrix ID, or 401 if not authenticated. |
|
||||
|
||||
**Login request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "@user:example.com",
|
||||
"password": "your-password"
|
||||
}
|
||||
**New movie:**
|
||||
```
|
||||
🎬 The Substance (2024)
|
||||
✅ New addition
|
||||
🎞️ Quality: Bluray-1080p
|
||||
```
|
||||
|
||||
The username can be a full Matrix ID (`@user:example.com`) or a localpart (`user`) — the homeserver resolves it.
|
||||
|
||||
**Login response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "@user:example.com"
|
||||
}
|
||||
**Quality upgrade:**
|
||||
```
|
||||
|
||||
A `bellhop_session` cookie is set automatically.
|
||||
|
||||
### Search
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/search/movie?term=...` | Search Radarr for movies |
|
||||
| `GET` | `/search/tv?term=...` | Search Sonarr for TV shows |
|
||||
| `GET` | `/search/music?term=...` | Search Lidarr for artists |
|
||||
|
||||
All search endpoints require an active session (cookie). Results are capped at 25 items. Response fields are sanitized — only safe metadata (title, year, poster URL, IDs) is returned.
|
||||
|
||||
### Request
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `POST` | `/request/movie` | Add a movie to Radarr |
|
||||
| `POST` | `/request/tv` | Add a series to Sonarr |
|
||||
| `POST` | `/request/music` | Add an artist to Lidarr |
|
||||
|
||||
**Movie request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Movie Title",
|
||||
"tmdbId": 12345,
|
||||
"year": 2024
|
||||
}
|
||||
🎬 The Substance (2024)
|
||||
⬆️ Quality upgrade
|
||||
🎞️ Quality: Bluray-2160p
|
||||
```
|
||||
|
||||
**TV request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Show Title",
|
||||
"tvdbId": 67890,
|
||||
"year": 2024
|
||||
}
|
||||
```
|
||||
|
||||
**Music request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"artistName": "Artist Name",
|
||||
"foreignArtistId": "mbid-uuid-here"
|
||||
}
|
||||
```
|
||||
|
||||
All items are added as monitored with "search on add" enabled. Quality profile and root folder are set from the corresponding environment variables.
|
||||
|
||||
**Success response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"message": "Movie added successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `GET` | `/` | Serves the single-page Alpine.js frontend |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Bellhop/
|
||||
Melora/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI app, lifespan, rate limiter, route mounting
|
||||
│ ├── __main__.py # CLI entry point (--check flag)
|
||||
│ ├── main.py # FastAPI app, lifespan, startup
|
||||
│ ├── check.py # Configuration and connectivity checker
|
||||
│ ├── config.py # Environment variable loading
|
||||
│ ├── database.py # Async SQLite session CRUD
|
||||
│ ├── auth.py # /auth/* routes, session cookie management
|
||||
│ ├── arr.py # /search/* and /request/* routes, *arr API proxying
|
||||
│ ├── audit.py # Fire-and-forget Matrix room messaging
|
||||
│ ├── static/ # Static assets (served at /static)
|
||||
│ └── templates/
|
||||
│ └── index.html # Alpine.js single-page frontend
|
||||
│ ├── database.py # Async SQLite for thread roots and dedup
|
||||
│ ├── matrix.py # matrix-nio posting and thread management
|
||||
│ ├── formatters.py # Message formatting for each media type
|
||||
│ └── webhooks.py # Webhook route handlers
|
||||
├── Dockerfile
|
||||
├── requirements.txt
|
||||
├── .env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Security
|
||||
## Error Handling
|
||||
|
||||
- **Session cookies** are set with `httponly`, `samesite=strict`, and `secure` flags. The `secure` flag means cookies are only sent over HTTPS — use a reverse proxy with TLS in production.
|
||||
- **Login rate limiting** — 5 attempts per minute per IP address via slowapi.
|
||||
- **Token validation** — every protected route verifies the Matrix access token against the homeserver's `/_matrix/client/v3/account/whoami` endpoint. If the token has been revoked, the session is deleted immediately. If the homeserver is unreachable, the local session is trusted as a fallback.
|
||||
- **No credential leakage** — *arr API keys, URLs, and internal IDs are never included in any response to the browser. Search results are mapped to a safe subset of fields before returning.
|
||||
- **Sessions expire** after 7 days (cookie `max_age`).
|
||||
|
||||
### Production Recommendations
|
||||
|
||||
- Run behind a reverse proxy (nginx, Caddy, Traefik) with TLS termination so the `secure` cookie flag works.
|
||||
- If you want sessions to persist across restarts, set `SESSION_SECRET_KEY` explicitly. Otherwise, all users are logged out on restart.
|
||||
- Restrict network access to your *arr instances — only the Bellhop container needs to reach them.
|
||||
- Use a dedicated Matrix bot account for audit logging rather than a personal account.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **User signs in** — the frontend POSTs Matrix credentials to `/auth/login`. The backend authenticates against the Matrix homeserver's Client-Server API (`m.login.password`), stores the resulting access token in SQLite, and returns a session cookie.
|
||||
|
||||
2. **User searches** — the frontend sends a search query to `/search/{type}`. The backend proxies the request to the appropriate *arr instance, strips internal fields, and returns sanitized results with poster URLs.
|
||||
|
||||
3. **User requests** — clicking "Request" on a result POSTs it to `/request/{type}`. The backend sends the add command to the *arr API with preconfigured quality profile and root folder. On success, an audit message is fired asynchronously to the configured Matrix room.
|
||||
|
||||
4. **Audit trail** — every successful request posts a message like `[REQUEST] @user:example.com → [Movie] "Title" (2024)` to the Matrix audit room. This is fire-and-forget — failures are logged but never block the user's request.
|
||||
|
||||
## Lidarr Notes
|
||||
|
||||
Lidarr uses MusicBrainz IDs (`foreignArtistId`) rather than TMDB/TVDB IDs. The lookup response includes this field and it is passed through directly to the add call. No independent MBID resolution is needed.
|
||||
- Unknown or malformed payloads return 200 (prevents *arr retry storms)
|
||||
- Parsing and Matrix posting errors are logged but don't crash the service
|
||||
- Missing thread roots on startup halt with a clear error
|
||||
|
||||
## License
|
||||
|
||||
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
35
app/__main__.py
Normal file
35
app/__main__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""CLI entry point for Melora.
|
||||
|
||||
Usage:
|
||||
python -m app --check Verify configuration and connectivity
|
||||
python -m app Start the server
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="melora",
|
||||
description="Melora — *arr media arrival webhook bridge for Matrix",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="verify configuration and connectivity, then exit",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.check:
|
||||
from app.check import run_checks
|
||||
|
||||
raise SystemExit(run_checks())
|
||||
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
178
app/check.py
Normal file
178
app/check.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Melora configuration checker.
|
||||
|
||||
Validates environment variables, Matrix connectivity, and database access.
|
||||
Run with: python -m app --check
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
_PASS = "\033[32m✓\033[0m"
|
||||
_FAIL = "\033[31m✗\033[0m"
|
||||
_failures = 0
|
||||
|
||||
|
||||
def _ok(msg: str) -> None:
|
||||
print(f" [{_PASS}] {msg}")
|
||||
|
||||
|
||||
def _fail(msg: str) -> None:
|
||||
global _failures
|
||||
_failures += 1
|
||||
print(f" [{_FAIL}] {msg}")
|
||||
|
||||
|
||||
def _hint(msg: str) -> None:
|
||||
print(f" {msg}")
|
||||
|
||||
|
||||
def _section(title: str) -> None:
|
||||
print(f"\n{title}")
|
||||
print("─" * len(title))
|
||||
|
||||
|
||||
# ── Environment variables ────────────────────────────────────────────
|
||||
|
||||
def _check_env() -> dict[str, str]:
|
||||
"""Validate required env vars and return their values."""
|
||||
_section("Environment variables")
|
||||
|
||||
required = [
|
||||
"MATRIX_HOMESERVER_URL",
|
||||
"MATRIX_BOT_USER_ID",
|
||||
"MATRIX_BOT_ACCESS_TOKEN",
|
||||
"MATRIX_ARRIVALS_ROOM_ID",
|
||||
"WEBHOOK_SECRET",
|
||||
]
|
||||
vals: dict[str, str] = {}
|
||||
for var in required:
|
||||
raw = os.environ.get(var, "").strip()
|
||||
if raw:
|
||||
if "TOKEN" in var or "SECRET" in var:
|
||||
shown = raw[:4] + "····" + raw[-4:] if len(raw) > 12 else "····"
|
||||
else:
|
||||
shown = raw
|
||||
_ok(f"{var} = {shown}")
|
||||
vals[var] = raw
|
||||
else:
|
||||
_fail(f"{var} is missing or empty")
|
||||
|
||||
db = os.environ.get("DATABASE_PATH", "").strip()
|
||||
if db:
|
||||
_ok(f"DATABASE_PATH = {db}")
|
||||
vals["DATABASE_PATH"] = db
|
||||
else:
|
||||
_ok("DATABASE_PATH = melora.db (default)")
|
||||
vals["DATABASE_PATH"] = "melora.db"
|
||||
|
||||
return vals
|
||||
|
||||
|
||||
# ── Matrix connectivity ──────────────────────────────────────────────
|
||||
|
||||
async def _check_matrix(vals: dict[str, str]) -> None:
|
||||
"""Verify homeserver reachability, token validity, and room membership."""
|
||||
_section("Matrix connectivity")
|
||||
|
||||
hs = vals.get("MATRIX_HOMESERVER_URL")
|
||||
uid = vals.get("MATRIX_BOT_USER_ID")
|
||||
token = vals.get("MATRIX_BOT_ACCESS_TOKEN")
|
||||
room = vals.get("MATRIX_ARRIVALS_ROOM_ID")
|
||||
|
||||
if not all([hs, uid, token, room]):
|
||||
_fail("Skipping — required environment variables are missing")
|
||||
return
|
||||
|
||||
from nio import AsyncClient, AsyncClientConfig, JoinedRoomsResponse
|
||||
|
||||
cfg = AsyncClientConfig(max_timeouts=0, request_timeout=10)
|
||||
client = AsyncClient(hs, uid, config=cfg)
|
||||
client.access_token = token
|
||||
|
||||
try:
|
||||
resp = await client.joined_rooms()
|
||||
except Exception as exc:
|
||||
_fail(f"Cannot reach homeserver at {hs}")
|
||||
_hint(f"Error: {exc}")
|
||||
_hint("Check the URL, port, and that the server is running.")
|
||||
return
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
if isinstance(resp, JoinedRoomsResponse):
|
||||
_ok(f"Homeserver is reachable ({hs})")
|
||||
_ok(f"Bot access token is valid ({uid})")
|
||||
if room in resp.rooms:
|
||||
_ok(f"Bot is a member of room {room}")
|
||||
else:
|
||||
_fail(f"Bot is NOT a member of room {room}")
|
||||
_hint("Invite the bot to the room, or verify MATRIX_ARRIVALS_ROOM_ID.")
|
||||
else:
|
||||
status = getattr(resp, "status_code", None)
|
||||
message = getattr(resp, "message", str(resp))
|
||||
_ok(f"Homeserver is reachable ({hs})")
|
||||
if str(status) in ("401", "M_UNKNOWN_TOKEN"):
|
||||
_fail("Bot access token is invalid (401 Unauthorized)")
|
||||
_hint("Generate a new token or check MATRIX_BOT_ACCESS_TOKEN.")
|
||||
else:
|
||||
_fail(f"Matrix API error — {message}")
|
||||
|
||||
|
||||
# ── Database ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _check_database(vals: dict[str, str]) -> None:
|
||||
"""Verify the database path is usable."""
|
||||
_section("Database")
|
||||
|
||||
db_path = Path(vals.get("DATABASE_PATH", "melora.db"))
|
||||
parent = db_path.parent.resolve()
|
||||
|
||||
if db_path.exists():
|
||||
import aiosqlite
|
||||
|
||||
try:
|
||||
async with aiosqlite.connect(str(db_path)) as conn:
|
||||
cur = await conn.execute("PRAGMA integrity_check")
|
||||
row = await cur.fetchone()
|
||||
if row and row[0] == "ok":
|
||||
_ok(f"Database exists and passes integrity check ({db_path})")
|
||||
else:
|
||||
_fail(f"Database integrity check failed ({db_path})")
|
||||
except Exception as exc:
|
||||
_fail(f"Cannot open database at {db_path} — {exc}")
|
||||
elif parent.exists() and os.access(str(parent), os.W_OK):
|
||||
_ok(f"Database will be created at {db_path} (directory is writable)")
|
||||
else:
|
||||
_fail(f"Cannot write to database directory ({parent})")
|
||||
|
||||
|
||||
# ── Entry point ──────────────────────────────────────────────────────
|
||||
|
||||
async def _run_async(vals: dict[str, str]) -> None:
|
||||
await _check_matrix(vals)
|
||||
await _check_database(vals)
|
||||
|
||||
|
||||
def run_checks() -> int:
|
||||
"""Run all configuration checks. Returns 0 on success, 1 on failure."""
|
||||
global _failures
|
||||
_failures = 0
|
||||
|
||||
print("\nMelora configuration check")
|
||||
print("══════════════════════════")
|
||||
|
||||
load_dotenv()
|
||||
vals = _check_env()
|
||||
asyncio.run(_run_async(vals))
|
||||
|
||||
print()
|
||||
if _failures:
|
||||
print(f" {_failures} check(s) failed.\n")
|
||||
return 1
|
||||
print(" All checks passed!\n")
|
||||
return 0
|
||||
18
app/config.py
Normal file
18
app/config.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Config:
|
||||
MATRIX_HOMESERVER_URL: str = os.environ["MATRIX_HOMESERVER_URL"]
|
||||
MATRIX_BOT_USER_ID: str = os.environ["MATRIX_BOT_USER_ID"]
|
||||
MATRIX_BOT_ACCESS_TOKEN: str = os.environ["MATRIX_BOT_ACCESS_TOKEN"]
|
||||
MATRIX_ARRIVALS_ROOM_ID: str = os.environ["MATRIX_ARRIVALS_ROOM_ID"]
|
||||
|
||||
WEBHOOK_SECRET: str = os.environ["WEBHOOK_SECRET"]
|
||||
|
||||
DATABASE_PATH: str = os.getenv("DATABASE_PATH", "melora.db")
|
||||
|
||||
|
||||
config = Config()
|
||||
85
app/database.py
Normal file
85
app/database.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import aiosqlite
|
||||
|
||||
from app.config import config
|
||||
|
||||
_db: aiosqlite.Connection | None = None
|
||||
|
||||
|
||||
async def get_db() -> aiosqlite.Connection:
|
||||
global _db
|
||||
if _db is None:
|
||||
raise RuntimeError("Database not initialised — call init_db() first")
|
||||
return _db
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
global _db
|
||||
_db = await aiosqlite.connect(config.DATABASE_PATH)
|
||||
_db.row_factory = aiosqlite.Row
|
||||
await _db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS thread_roots (
|
||||
media_type TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
await _db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS posted_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
is_upgrade BOOLEAN NOT NULL,
|
||||
posted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
await _db.commit()
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
global _db
|
||||
if _db is not None:
|
||||
await _db.close()
|
||||
_db = None
|
||||
|
||||
|
||||
async def get_thread_root(media_type: str) -> str | None:
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT event_id FROM thread_roots WHERE media_type = ?",
|
||||
(media_type,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["event_id"] if row else None
|
||||
|
||||
|
||||
async def set_thread_root(media_type: str, event_id: str) -> None:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"INSERT OR REPLACE INTO thread_roots (media_type, event_id) VALUES (?, ?)",
|
||||
(media_type, event_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def is_event_posted(event_key: str) -> bool:
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT 1 FROM posted_events WHERE id = ?",
|
||||
(event_key,),
|
||||
)
|
||||
return await cursor.fetchone() is not None
|
||||
|
||||
|
||||
async def record_posted_event(
|
||||
event_key: str, title: str, media_type: str, is_upgrade: bool
|
||||
) -> None:
|
||||
db = await get_db()
|
||||
await db.execute(
|
||||
"INSERT OR IGNORE INTO posted_events (id, title, media_type, is_upgrade) VALUES (?, ?, ?, ?)",
|
||||
(event_key, title, media_type, is_upgrade),
|
||||
)
|
||||
await db.commit()
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
120
app/formatters.py
Normal file
120
app/formatters.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import markdown as md
|
||||
|
||||
|
||||
def _render_html(text: str) -> str:
|
||||
return md.markdown(text, extensions=["extra"])
|
||||
|
||||
|
||||
def _strip_markdown(text: str) -> str:
|
||||
return text.replace("**", "").replace("*", "").replace("> ", "")
|
||||
|
||||
|
||||
def format_radarr(payload: dict) -> tuple[str, str]:
|
||||
movie = payload.get("movie", {})
|
||||
title = movie.get("title", "Unknown")
|
||||
year = movie.get("year", "")
|
||||
is_upgrade = payload.get("isUpgrade", False)
|
||||
|
||||
quality = (
|
||||
payload.get("movieFile", {})
|
||||
.get("quality", {})
|
||||
.get("quality", {})
|
||||
.get("name", "Unknown")
|
||||
)
|
||||
|
||||
if is_upgrade:
|
||||
lines = [
|
||||
f"\U0001f3ac **{title}** ({year})",
|
||||
f"> \u2b06\ufe0f *Quality upgrade*",
|
||||
f"> \U0001f39e\ufe0f Quality: {quality}",
|
||||
]
|
||||
else:
|
||||
lines = [
|
||||
f"\U0001f3ac **{title}** ({year})",
|
||||
f"> \u2705 *New addition*",
|
||||
f"> \U0001f39e\ufe0f Quality: {quality}",
|
||||
]
|
||||
|
||||
body_md = "\n".join(lines)
|
||||
plain = _strip_markdown(body_md)
|
||||
html = _render_html(body_md)
|
||||
return plain, html
|
||||
|
||||
|
||||
def format_sonarr(payload: dict) -> tuple[str, str]:
|
||||
series = payload.get("series", {})
|
||||
series_title = series.get("title", "Unknown")
|
||||
is_upgrade = payload.get("isUpgrade", False)
|
||||
|
||||
episodes = payload.get("episodes", [{}])
|
||||
ep = episodes[0] if episodes else {}
|
||||
season = ep.get("seasonNumber", 0)
|
||||
episode = ep.get("episodeNumber", 0)
|
||||
ep_title = ep.get("title", "")
|
||||
|
||||
quality = (
|
||||
payload.get("episodeFile", {})
|
||||
.get("quality", {})
|
||||
.get("quality", {})
|
||||
.get("name", "Unknown")
|
||||
)
|
||||
|
||||
header = f"\U0001f4fa **{series_title}** \u2014 S{season:02d}E{episode:02d}"
|
||||
if ep_title:
|
||||
header += f" \u2014 *{ep_title}*"
|
||||
|
||||
if is_upgrade:
|
||||
lines = [
|
||||
header,
|
||||
f"> \u2b06\ufe0f *Quality upgrade*",
|
||||
f"> \U0001f39e\ufe0f Quality: {quality}",
|
||||
]
|
||||
else:
|
||||
lines = [
|
||||
header,
|
||||
f"> \u2705 *New addition*",
|
||||
f"> \U0001f39e\ufe0f Quality: {quality}",
|
||||
]
|
||||
|
||||
body_md = "\n".join(lines)
|
||||
plain = _strip_markdown(body_md)
|
||||
html = _render_html(body_md)
|
||||
return plain, html
|
||||
|
||||
|
||||
def format_lidarr(payload: dict) -> tuple[str, str]:
|
||||
artist = payload.get("artist", {})
|
||||
artist_name = artist.get("name", "Unknown")
|
||||
album = payload.get("album", {})
|
||||
album_title = album.get("title", "")
|
||||
is_upgrade = payload.get("isUpgrade", False)
|
||||
|
||||
track_files = payload.get("trackFiles", [{}])
|
||||
tf = track_files[0] if track_files else {}
|
||||
quality = (
|
||||
tf.get("quality", {})
|
||||
.get("quality", {})
|
||||
.get("name", "Unknown")
|
||||
)
|
||||
|
||||
header = f"\U0001f3b5 **{artist_name}**"
|
||||
if album_title:
|
||||
header += f" \u2014 *{album_title}*"
|
||||
|
||||
if is_upgrade:
|
||||
lines = [
|
||||
header,
|
||||
f"> \u2b06\ufe0f *Quality upgrade*",
|
||||
f"> \U0001f3b5 Format: {quality}",
|
||||
]
|
||||
else:
|
||||
lines = [
|
||||
header,
|
||||
f"> \u2705 *New addition*",
|
||||
f"> \U0001f3b5 Format: {quality}",
|
||||
]
|
||||
|
||||
body_md = "\n".join(lines)
|
||||
plain = _strip_markdown(body_md)
|
||||
html = _render_html(body_md)
|
||||
return plain, html
|
||||
47
app/main.py
Normal file
47
app/main.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.database import close_db, get_thread_root, init_db, set_thread_root
|
||||
from app.matrix import THREAD_ROOT_MESSAGES, close_client, send_thread_root, start_presence
|
||||
from app.webhooks import router as webhook_router
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
log.info("Starting Melora")
|
||||
await init_db()
|
||||
await _ensure_thread_roots()
|
||||
await start_presence()
|
||||
log.info("Melora ready")
|
||||
yield
|
||||
log.info("Shutting down Melora")
|
||||
await close_client()
|
||||
await close_db()
|
||||
|
||||
|
||||
async def _ensure_thread_roots() -> None:
|
||||
for media_type in THREAD_ROOT_MESSAGES:
|
||||
existing = await get_thread_root(media_type)
|
||||
if existing:
|
||||
log.info("Thread root for %s: %s", media_type, existing)
|
||||
continue
|
||||
log.info("Creating thread root for %s", media_type)
|
||||
event_id = await send_thread_root(media_type)
|
||||
await set_thread_root(media_type, event_id)
|
||||
|
||||
|
||||
app = FastAPI(title="Melora", lifespan=lifespan)
|
||||
app.include_router(webhook_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
113
app/matrix.py
Normal file
113
app/matrix.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from nio import AsyncClient, RoomSendResponse
|
||||
|
||||
from app.config import config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_client: AsyncClient | None = None
|
||||
_presence_task: asyncio.Task | None = None
|
||||
|
||||
PRESENCE_INTERVAL_SECONDS = 60
|
||||
|
||||
THREAD_ROOT_MESSAGES: dict[str, str] = {
|
||||
"movies": "\U0001f3ac Movies",
|
||||
"shows": "\U0001f4fa Shows",
|
||||
"music": "\U0001f3b5 Music",
|
||||
}
|
||||
|
||||
|
||||
async def get_client() -> AsyncClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = AsyncClient(config.MATRIX_HOMESERVER_URL)
|
||||
_client.user_id = config.MATRIX_BOT_USER_ID
|
||||
_client.access_token = config.MATRIX_BOT_ACCESS_TOKEN
|
||||
return _client
|
||||
|
||||
|
||||
async def close_client() -> None:
|
||||
global _client, _presence_task
|
||||
if _presence_task is not None:
|
||||
_presence_task.cancel()
|
||||
try:
|
||||
await _presence_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_presence_task = None
|
||||
if _client is not None:
|
||||
await _set_presence("offline")
|
||||
await _client.close()
|
||||
_client = None
|
||||
|
||||
|
||||
async def _set_presence(state: str) -> None:
|
||||
client = await get_client()
|
||||
try:
|
||||
await client.set_presence(state)
|
||||
except Exception:
|
||||
log.debug("Presence update failed", exc_info=True)
|
||||
|
||||
|
||||
async def _presence_loop() -> None:
|
||||
while True:
|
||||
await _set_presence("online")
|
||||
await asyncio.sleep(PRESENCE_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
async def start_presence() -> None:
|
||||
global _presence_task
|
||||
await _set_presence("online")
|
||||
_presence_task = asyncio.create_task(_presence_loop())
|
||||
log.info("Presence heartbeat started (every %ds)", PRESENCE_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
async def send_thread_root(media_type: str) -> str:
|
||||
client = await get_client()
|
||||
label = THREAD_ROOT_MESSAGES[media_type]
|
||||
content = {
|
||||
"msgtype": "m.text",
|
||||
"body": label,
|
||||
}
|
||||
resp = await client.room_send(
|
||||
config.MATRIX_ARRIVALS_ROOM_ID,
|
||||
"m.room.message",
|
||||
content,
|
||||
)
|
||||
if isinstance(resp, RoomSendResponse):
|
||||
log.info("Created thread root for %s: %s", media_type, resp.event_id)
|
||||
return resp.event_id
|
||||
raise RuntimeError(f"Failed to create thread root for {media_type}: {resp}")
|
||||
|
||||
|
||||
async def post_to_thread(
|
||||
thread_root_event_id: str,
|
||||
plain_body: str,
|
||||
html_body: str,
|
||||
) -> str | None:
|
||||
client = await get_client()
|
||||
content = {
|
||||
"msgtype": "m.text",
|
||||
"format": "org.matrix.custom.html",
|
||||
"body": plain_body,
|
||||
"formatted_body": html_body,
|
||||
"m.relates_to": {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": thread_root_event_id,
|
||||
},
|
||||
}
|
||||
try:
|
||||
resp = await client.room_send(
|
||||
config.MATRIX_ARRIVALS_ROOM_ID,
|
||||
"m.room.message",
|
||||
content,
|
||||
)
|
||||
if isinstance(resp, RoomSendResponse):
|
||||
return resp.event_id
|
||||
log.error("Matrix send failed: %s", resp)
|
||||
return None
|
||||
except Exception:
|
||||
log.exception("Matrix posting error")
|
||||
return None
|
||||
130
app/webhooks.py
Normal file
130
app/webhooks.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Header, Request, Response
|
||||
|
||||
from app.config import config
|
||||
from app.database import get_thread_root, is_event_posted, record_posted_event
|
||||
from app.formatters import format_lidarr, format_radarr, format_sonarr
|
||||
from app.matrix import post_to_thread
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/webhook")
|
||||
|
||||
SOURCE_MAP = {
|
||||
"radarr": {"media_type": "movies", "formatter": format_radarr, "id_field": "movie"},
|
||||
"sonarr": {"media_type": "shows", "formatter": format_sonarr, "id_field": "series"},
|
||||
"lidarr": {"media_type": "music", "formatter": format_lidarr, "id_field": "artist"},
|
||||
}
|
||||
|
||||
|
||||
def _validate_secret(header_value: str | None) -> bool:
|
||||
return header_value == config.WEBHOOK_SECRET
|
||||
|
||||
|
||||
def _extract_event_key(source: str, payload: dict) -> str | None:
|
||||
if source == "radarr":
|
||||
movie = payload.get("movie", {})
|
||||
movie_id = movie.get("id")
|
||||
if movie_id is not None:
|
||||
return f"radarr-{movie_id}"
|
||||
elif source == "sonarr":
|
||||
episodes = payload.get("episodes", [])
|
||||
if episodes:
|
||||
ep_id = episodes[0].get("id")
|
||||
if ep_id is not None:
|
||||
return f"sonarr-{ep_id}"
|
||||
elif source == "lidarr":
|
||||
album = payload.get("album", {})
|
||||
album_id = album.get("id")
|
||||
if album_id is not None:
|
||||
return f"lidarr-{album_id}"
|
||||
return None
|
||||
|
||||
|
||||
def _extract_title(source: str, payload: dict) -> str:
|
||||
if source == "radarr":
|
||||
return payload.get("movie", {}).get("title", "Unknown")
|
||||
elif source == "sonarr":
|
||||
return payload.get("series", {}).get("title", "Unknown")
|
||||
elif source == "lidarr":
|
||||
return payload.get("artist", {}).get("name", "Unknown")
|
||||
return "Unknown"
|
||||
|
||||
|
||||
async def _handle_webhook(source: str, payload: dict, secret: str | None) -> Response:
|
||||
if not _validate_secret(secret):
|
||||
return Response(status_code=401, content="Unauthorized")
|
||||
|
||||
event_type = payload.get("eventType", "")
|
||||
if event_type != "Download":
|
||||
log.debug("Ignoring %s event from %s", event_type, source)
|
||||
return Response(status_code=200, content="OK")
|
||||
|
||||
info = SOURCE_MAP[source]
|
||||
media_type = info["media_type"]
|
||||
formatter = info["formatter"]
|
||||
is_upgrade = payload.get("isUpgrade", False)
|
||||
|
||||
event_key = _extract_event_key(source, payload)
|
||||
if event_key:
|
||||
if await is_event_posted(event_key):
|
||||
log.debug("Duplicate event %s, skipping", event_key)
|
||||
return Response(status_code=200, content="OK")
|
||||
|
||||
try:
|
||||
plain, html = formatter(payload)
|
||||
except Exception:
|
||||
log.exception("Failed to format %s payload", source)
|
||||
return Response(status_code=200, content="OK")
|
||||
|
||||
thread_root_id = await get_thread_root(media_type)
|
||||
if not thread_root_id:
|
||||
log.error("No thread root for %s — cannot post", media_type)
|
||||
return Response(status_code=200, content="OK")
|
||||
|
||||
event_id = await post_to_thread(thread_root_id, plain, html)
|
||||
if event_id and event_key:
|
||||
title = _extract_title(source, payload)
|
||||
await record_posted_event(event_key, title, media_type, is_upgrade)
|
||||
|
||||
return Response(status_code=200, content="OK")
|
||||
|
||||
|
||||
@router.post("/radarr")
|
||||
async def webhook_radarr(
|
||||
request: Request,
|
||||
x_arr_webhook_secret: str | None = Header(None),
|
||||
):
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
log.exception("Failed to parse Radarr payload")
|
||||
return Response(status_code=200, content="OK")
|
||||
return await _handle_webhook("radarr", payload, x_arr_webhook_secret)
|
||||
|
||||
|
||||
@router.post("/sonarr")
|
||||
async def webhook_sonarr(
|
||||
request: Request,
|
||||
x_arr_webhook_secret: str | None = Header(None),
|
||||
):
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
log.exception("Failed to parse Sonarr payload")
|
||||
return Response(status_code=200, content="OK")
|
||||
return await _handle_webhook("sonarr", payload, x_arr_webhook_secret)
|
||||
|
||||
|
||||
@router.post("/lidarr")
|
||||
async def webhook_lidarr(
|
||||
request: Request,
|
||||
x_arr_webhook_secret: str | None = Header(None),
|
||||
):
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
log.exception("Failed to parse Lidarr payload")
|
||||
return Response(status_code=200, content="OK")
|
||||
return await _handle_webhook("lidarr", payload, x_arr_webhook_secret)
|
||||
@@ -1,9 +1,6 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
httpx==0.28.1
|
||||
aiosqlite==0.20.0
|
||||
python-dotenv==1.0.1
|
||||
matrix-nio==0.24.0
|
||||
slowapi==0.1.9
|
||||
jinja2==3.1.5
|
||||
itsdangerous==2.2.0
|
||||
markdown==3.7
|
||||
|
||||
Reference in New Issue
Block a user