Merge pull request #1 from prosolis/claude/media-arrival-webhook-Rabvg

Initial verison of Melora
This commit is contained in:
prosolis
2026-02-28 00:08:18 -08:00
committed by GitHub
15 changed files with 921 additions and 222 deletions

View File

@@ -2,4 +2,4 @@
__pycache__ __pycache__
*.pyc *.pyc
.env .env
bellhop.db melora.db

View File

@@ -1,30 +1,16 @@
# Matrix homeserver # Matrix homeserver
MATRIX_HOMESERVER_URL=https://matrix.example.com MATRIX_HOMESERVER_URL=https://matrix.example.com
# Matrix audit bot (pre-authenticated) # Matrix bot (pre-authenticated)
MATRIX_AUDIT_ROOM_ID=!roomid:example.com MATRIX_BOT_USER_ID=@melora-bot:example.com
MATRIX_BOT_USER_ID=@bot:example.com
MATRIX_BOT_ACCESS_TOKEN=syt_bot_token_here MATRIX_BOT_ACCESS_TOKEN=syt_bot_token_here
# Radarr # Matrix room for arrival announcements (unencrypted)
RADARR_URL=https://radarr.example.com MATRIX_ARRIVALS_ROOM_ID=!roomid:example.com
RADARR_API_KEY=your_radarr_api_key
RADARR_QUALITY_PROFILE_ID=1
RADARR_ROOT_FOLDER=/movies
# Sonarr # Shared secret for *arr webhook authentication
SONARR_URL=https://sonarr.example.com # Set the same value in each *arr instance under Settings → Connect → Webhook
SONARR_API_KEY=your_sonarr_api_key WEBHOOK_SECRET=your_shared_secret_here
SONARR_QUALITY_PROFILE_ID=1
SONARR_ROOT_FOLDER=/tv
# Lidarr # SQLite database path
LIDARR_URL=https://lidarr.example.com DATABASE_PATH=melora.db
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

2
.gitignore vendored
View File

@@ -1,6 +1,6 @@
__pycache__/ __pycache__/
*.pyc *.pyc
.env .env
bellhop.db melora.db
*.sqlite *.sqlite
*.sqlite3 *.sqlite3

375
README.md
View File

@@ -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 ## Architecture
``` ```
Browser ──► FastAPI app ──► Matrix homeserver (authentication) Radarr ─┐
──► Radarr / Sonarr / Lidarr (search + add) Sonarr ──┼─→ POST webhook → Melora (FastAPI) → Matrix room (threaded)
──► Matrix room (audit log) Lidarr ─┘
──► SQLite (session storage)
``` ```
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 ## Requirements
- Python 3.12+ - Python 3.12+
- A Matrix homeserver (Synapse, Dendrite, Conduit, etc.) - A Matrix homeserver with an unencrypted room and a bot account
- At least one of: Radarr, Sonarr, or Lidarr accessible over HTTPS - Radarr, Sonarr, and/or Lidarr instances configured to send webhooks
- (Optional) A Matrix bot account for audit logging
## Quick Start ## Quick Start
### 1. Clone and configure ### 1. Clone and configure
```bash ```bash
git clone https://github.com/prosolis/Bellhop.git git clone https://github.com/prosolis/Melora.git
cd Bellhop cd Melora
cp .env.example .env cp .env.example .env
``` ```
Edit `.env` with your actual values (see [Environment Variables](#environment-variables) below). Edit `.env` with your actual values (see [Environment Variables](#environment-variables) below).
### 2. Run locally ### 2. Verify your configuration
```bash ```bash
pip install -r requirements.txt 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 uvicorn app.main:app --host 0.0.0.0 --port 8000
``` ```
Open `http://localhost:8000` in your browser. ### 4. Run with Docker
### 3. Run with Docker
```bash ```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 \ docker run -d \
--name bellhop \ --name melora \
--env-file .env \ --env-file .env \
-p 8000:8000 \ -p 8000:8000 \
-v bellhop-data:/app \ -v melora-data:/app \
bellhop 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 ## 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 | 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.
|---|---|
| `MATRIX_HOMESERVER_URL` | Base URL of your Matrix homeserver (e.g. `https://matrix.example.com`) |
### *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 (Movies)
|---|---|---|
| `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 |
**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 | Field | Value |
curl -H "X-Api-Key: YOUR_KEY" https://radarr.example.com/api/v3/qualityprofile |-------|-------|
| **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 Room Structure
|---|---|---|
| `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 |
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:** ```
#new-arrivals:your.domain
```bash ├── 🎬 Movies ← Radarr imports
curl -X POST https://matrix.example.com/_matrix/client/v3/login \ ├── 📺 Shows ← Sonarr imports
-H "Content-Type: application/json" \ └── 🎵 Music ← Lidarr imports
-d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"@bellhop-bot:example.com"},"password":"bot-password"}'
``` ```
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 | Messages include both plain text and HTML (Matrix-flavored Markdown). New additions and quality upgrades are distinguished:
|---|---|---|
| `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 |
## API Reference **New movie:**
```
### Authentication 🎬 The Substance (2024)
✅ New addition
| Method | Path | Description | 🎞️ Quality: Bluray-1080p
|---|---|---|
| `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"
}
``` ```
The username can be a full Matrix ID (`@user:example.com`) or a localpart (`user`) — the homeserver resolves it. **Quality upgrade:**
**Login response (200):**
```json
{
"user_id": "@user:example.com"
}
``` ```
🎬 The Substance (2024)
A `bellhop_session` cookie is set automatically. ⬆️ Quality upgrade
🎞️ Quality: Bluray-2160p
### 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
}
``` ```
**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 ## Project Structure
``` ```
Bellhop/ Melora/
├── app/ ├── app/
│ ├── __init__.py │ ├── __init__.py
│ ├── main.py # FastAPI app, lifespan, rate limiter, route mounting │ ├── __main__.py # CLI entry point (--check flag)
│ ├── config.py # Environment variable loading │ ├── main.py # FastAPI app, lifespan, startup
│ ├── database.py # Async SQLite session CRUD │ ├── check.py # Configuration and connectivity checker
│ ├── auth.py # /auth/* routes, session cookie management │ ├── config.py # Environment variable loading
│ ├── arr.py # /search/* and /request/* routes, *arr API proxying │ ├── database.py # Async SQLite for thread roots and dedup
│ ├── audit.py # Fire-and-forget Matrix room messaging │ ├── matrix.py # matrix-nio posting and thread management
│ ├── static/ # Static assets (served at /static) │ ├── formatters.py # Message formatting for each media type
│ └── templates/ │ └── webhooks.py # Webhook route handlers
│ └── index.html # Alpine.js single-page frontend
├── Dockerfile ├── Dockerfile
├── requirements.txt ├── requirements.txt
├── .env.example ├── .env.example
└── README.md └── 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. - Unknown or malformed payloads return 200 (prevents *arr retry storms)
- **Login rate limiting** — 5 attempts per minute per IP address via slowapi. - Parsing and Matrix posting errors are logged but don't crash the service
- **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. - Missing thread roots on startup halt with a clear error
- **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.
## License ## License

0
app/__init__.py Normal file
View File

35
app/__main__.py Normal file
View 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
View 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
View 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
View 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()

View File

@@ -1 +0,0 @@

120
app/formatters.py Normal file
View 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
View 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
View 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
View 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)

View File

@@ -1,9 +1,6 @@
fastapi==0.115.6 fastapi==0.115.6
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
httpx==0.28.1
aiosqlite==0.20.0 aiosqlite==0.20.0
python-dotenv==1.0.1 python-dotenv==1.0.1
matrix-nio==0.24.0 matrix-nio==0.24.0
slowapi==0.1.9 markdown==3.7
jinja2==3.1.5
itsdangerous==2.2.0