diff --git a/.dockerignore b/.dockerignore index d7600d5..0029894 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,4 +2,4 @@ __pycache__ *.pyc .env -bellhop.db +melora.db diff --git a/.env.example b/.env.example index c75778a..d96e77c 100644 --- a/.env.example +++ b/.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 diff --git a/.gitignore b/.gitignore index c52f830..2738ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ __pycache__/ *.pyc .env -bellhop.db +melora.db *.sqlite *.sqlite3 diff --git a/README.md b/README.md index 9d8242a..0bcc7f5 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,30 @@ -# 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 ``` @@ -39,228 +37,104 @@ pip install -r requirements.txt uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` -Open `http://localhost:8000` in your browser. - ### 3. Run with Docker ```bash -docker build -t bellhop . +docker build -t melora . 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`) | +In each *arr instance, go to **Settings → Connect → Add → Webhook** and configure: -### *arr Services +- **URL**: `http://melora-host:8000/webhook/radarr` (or `/sonarr`, `/lidarr`) +- **Method**: `POST` +- **Events**: Enable **On Import** (and **On Upgrade** if desired) +- **Tags**: Leave blank to capture all imports +- **Headers**: Add `X-Arr-Webhook-Secret` with the same value as `WEBHOOK_SECRET` -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. +## Webhook Endpoints -| 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 | - -**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: - -```bash -curl -H "X-Api-Key: YOUR_KEY" https://radarr.example.com/api/v3/qualityprofile +``` +POST /webhook/radarr +POST /webhook/sonarr +POST /webhook/lidarr +GET /health ``` -### 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 -│ ├── 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 +│ ├── main.py # FastAPI app, lifespan, startup +│ ├── config.py # Environment variable loading +│ ├── 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 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..2aa36cb --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..c1ca389 --- /dev/null +++ b/app/database.py @@ -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() diff --git a/app/deletethis b/app/deletethis deleted file mode 100644 index 8b13789..0000000 --- a/app/deletethis +++ /dev/null @@ -1 +0,0 @@ - diff --git a/app/formatters.py b/app/formatters.py new file mode 100644 index 0000000..3e5cd00 --- /dev/null +++ b/app/formatters.py @@ -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 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e656fa4 --- /dev/null +++ b/app/main.py @@ -0,0 +1,46 @@ +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 +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() + 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"} diff --git a/app/matrix.py b/app/matrix.py new file mode 100644 index 0000000..a7b0a81 --- /dev/null +++ b/app/matrix.py @@ -0,0 +1,80 @@ +import logging + +from nio import AsyncClient, RoomSendResponse + +from app.config import config + +log = logging.getLogger(__name__) + +_client: AsyncClient | None = None + +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 + if _client is not None: + await _client.close() + _client = None + + +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 diff --git a/app/webhooks.py b/app/webhooks.py new file mode 100644 index 0000000..7da2c24 --- /dev/null +++ b/app/webhooks.py @@ -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) diff --git a/requirements.txt b/requirements.txt index 46ca906..48805c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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