15 Commits

Author SHA1 Message Date
prosolis
3e938efe3e Merge pull request #7 from prosolis/claude/fix-sonarr-webhook-error-CM6zH
Add webhook security guide to README (TLS + IP allowlisting)
2026-02-28 15:46:09 -08:00
Claude
90c7567e16 Add webhook security guide to README (TLS + IP allowlisting)
Document how to put Melora behind a Caddy reverse proxy for
automatic TLS and how to restrict access by source IP, both at
the proxy layer and with iptables.

https://claude.ai/code/session_0188gygwJLdYhxbKvEz2jFjr
2026-02-28 17:12:07 +00:00
prosolis
348a86495b Merge pull request #6 from prosolis/claude/fix-sonarr-webhook-error-CM6zH
Fix same quality field crash in radarr and lidarr formatters
2026-02-28 02:11:44 -08:00
Claude
de0de68ba5 Fix same quality field crash in radarr and lidarr formatters
Apply the same isinstance guard from the sonarr fix to radarr
(movieFile.quality) and lidarr (trackFiles[].quality) so a plain
string value no longer triggers an AttributeError.

https://claude.ai/code/session_0188gygwJLdYhxbKvEz2jFjr
2026-02-28 10:10:53 +00:00
prosolis
bddb5de803 Merge pull request #5 from prosolis/claude/fix-sonarr-webhook-error-CM6zH
Fix sonarr formatter crash when episodeFile.quality is a string
2026-02-28 01:55:12 -08:00
Claude
693ab9f217 Fix sonarr formatter crash when episodeFile.quality is a string
Sonarr can send quality as a plain string instead of the nested
{"quality": {"name": ...}} object. Guard with an isinstance check
and fall back to using the string value directly.

https://claude.ai/code/session_0188gygwJLdYhxbKvEz2jFjr
2026-02-28 09:54:09 +00:00
prosolis
e1de6a0794 Merge pull request #4 from prosolis/claude/fix-bot-online-issue-dWyHe
Claude/fix bot online issue d wy he
2026-02-28 01:38:28 -08:00
Claude
e4c696d142 Add venv setup instructions to README
https://claude.ai/code/session_019ANRdyL2jfi7ysWhqN4PfD
2026-02-28 09:31:39 +00:00
Claude
7cc5fcb514 Revert to lifespan handler, use venv for correct FastAPI version
https://claude.ai/code/session_019ANRdyL2jfi7ysWhqN4PfD
2026-02-28 09:27:40 +00:00
Claude
dbaa7b0d8b Replace lifespan with on_event handlers for older FastAPI compat
The lifespan constructor parameter was added in FastAPI 0.93. When
running outside Docker with an older system-packaged FastAPI, the
parameter is silently ignored — the app starts and serves routes but
init_db() and start_presence() never run, so the bot never goes online
and webhooks crash with "Database not initialised".

on_event("startup")/on_event("shutdown") work across all FastAPI
versions.

https://claude.ai/code/session_019ANRdyL2jfi7ysWhqN4PfD
2026-02-28 09:24:02 +00:00
prosolis
950e60b0de Merge pull request #3 from prosolis/claude/fix-bot-online-issue-dWyHe
Check set_presence return value for errors
2026-02-28 01:18:45 -08:00
Claude
d22fb6ae10 Check set_presence return value for errors
matrix-nio's set_presence() doesn't raise on failure — it returns a
PresenceSetError object. The code was ignoring the return value, so the
bot would silently fail to go online even with valid credentials.

https://claude.ai/code/session_019ANRdyL2jfi7ysWhqN4PfD
2026-02-28 09:14:06 +00:00
prosolis
03c0d72272 Merge pull request #2 from prosolis/claude/fix-bot-online-issue-dWyHe
Fix bot not going online: stop silently swallowing presence errors
2026-02-28 01:13:20 -08:00
Claude
8d7a66c431 Fix bot not going online: stop silently swallowing presence errors
_set_presence was catching all exceptions and logging them at DEBUG
level, which meant connection failures (bad token, unreachable
homeserver, etc.) were completely hidden at the default INFO log level.
The bot would appear to start normally but never actually go online.

Now the initial presence call in start_presence() propagates errors so
startup fails visibly if the Matrix connection doesn't work. The
background heartbeat loop still catches errors but logs them at WARNING.

https://claude.ai/code/session_019ANRdyL2jfi7ysWhqN4PfD
2026-02-28 09:04:28 +00:00
prosolis
6812758af0 Merge pull request #1 from prosolis/claude/media-arrival-webhook-Rabvg
Initial verison of Melora
2026-02-28 00:08:18 -08:00
3 changed files with 135 additions and 27 deletions

111
README.md
View File

@@ -30,22 +30,30 @@ 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. Verify your configuration ### 2. Set up a virtual environment
```bash ```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
```
### 3. Verify your configuration
```bash
python -m app --check 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. 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 ### 4. Run locally
```bash ```bash
source .venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8000 uvicorn app.main:app --host 0.0.0.0 --port 8000
``` ```
### 4. Run with Docker ### 5. Run with Docker
```bash ```bash
docker build -t melora . docker build -t melora .
@@ -253,6 +261,103 @@ Melora/
- Parsing and Matrix posting errors are logged but don't crash the service - Parsing and Matrix posting errors are logged but don't crash the service
- Missing thread roots on startup halt with a clear error - Missing thread roots on startup halt with a clear error
## Securing Webhooks
By default, webhook traffic (including the `X-Arr-Webhook-Secret` header) is sent over plain HTTP. Anyone who can observe network traffic between your \*arr instances and Melora can read the secret. The two recommended mitigations are a TLS reverse proxy and IP allowlisting — ideally both.
### 1. TLS via Caddy reverse proxy
[Caddy](https://caddyserver.com/) is the simplest option because it provisions and renews Let's Encrypt certificates automatically. Any reverse proxy that terminates TLS will work (nginx, Traefik, etc.) — Caddy just requires the least configuration.
#### Install Caddy
```bash
# Debian / Ubuntu
sudo apt install -y caddy
# Or with Docker
docker pull caddy:latest
```
#### Configure
Create (or edit) `/etc/caddy/Caddyfile`:
```
melora.example.com {
reverse_proxy localhost:8000
}
```
Replace `melora.example.com` with your actual domain. Caddy will automatically obtain a TLS certificate and redirect HTTP to HTTPS.
```bash
sudo systemctl restart caddy
```
#### Update your \*arr webhook URLs
In each \*arr instance, change the webhook URL from:
```
http://melora-host:8000/webhook/radarr
```
to:
```
https://melora.example.com/webhook/radarr
```
The `X-Arr-Webhook-Secret` header is now encrypted in transit.
### 2. IP allowlisting
Restrict the webhook endpoints so only your \*arr server(s) can reach them. This works regardless of whether you use TLS, and is a good defense-in-depth layer.
#### Option A: At the reverse proxy (Caddy)
Add a `remote_ip` matcher to your Caddyfile:
```
melora.example.com {
@allowed remote_ip 192.168.1.50 192.168.1.51
handle @allowed {
reverse_proxy localhost:8000
}
respond 403
}
```
Replace the IPs with the actual addresses of your Radarr/Sonarr/Lidarr hosts. If everything runs on the same machine, use `127.0.0.1`.
#### Option B: With a firewall (iptables / nftables)
If you aren't using a reverse proxy, restrict at the OS level:
```bash
# Allow only 192.168.1.50 to reach Melora on port 8000
sudo iptables -A INPUT -p tcp --dport 8000 -s 192.168.1.50 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 8000 -j DROP
```
To persist across reboots:
```bash
sudo apt install -y iptables-persistent
sudo netfilter-persistent save
```
### Recommended setup
| Layer | What it does |
|-------|-------------|
| **Caddy (TLS)** | Encrypts all traffic, including the webhook secret header |
| **IP allowlist** | Ensures only your \*arr hosts can reach the endpoint at all |
| **Webhook secret** | Authenticates requests in case the IP filter is misconfigured |
All three layers together give defense in depth — any single layer failing still leaves the other two in place.
## License ## License
See repository for license details. See repository for license details.

View File

@@ -15,12 +15,11 @@ def format_radarr(payload: dict) -> tuple[str, str]:
year = movie.get("year", "") year = movie.get("year", "")
is_upgrade = payload.get("isUpgrade", False) is_upgrade = payload.get("isUpgrade", False)
quality = ( quality_field = payload.get("movieFile", {}).get("quality", {})
payload.get("movieFile", {}) if isinstance(quality_field, dict):
.get("quality", {}) quality = quality_field.get("quality", {}).get("name", "Unknown")
.get("quality", {}) else:
.get("name", "Unknown") quality = str(quality_field) if quality_field else "Unknown"
)
if is_upgrade: if is_upgrade:
lines = [ lines = [
@@ -52,12 +51,11 @@ def format_sonarr(payload: dict) -> tuple[str, str]:
episode = ep.get("episodeNumber", 0) episode = ep.get("episodeNumber", 0)
ep_title = ep.get("title", "") ep_title = ep.get("title", "")
quality = ( quality_field = payload.get("episodeFile", {}).get("quality", {})
payload.get("episodeFile", {}) if isinstance(quality_field, dict):
.get("quality", {}) quality = quality_field.get("quality", {}).get("name", "Unknown")
.get("quality", {}) else:
.get("name", "Unknown") quality = str(quality_field) if quality_field else "Unknown"
)
header = f"\U0001f4fa **{series_title}** \u2014 S{season:02d}E{episode:02d}" header = f"\U0001f4fa **{series_title}** \u2014 S{season:02d}E{episode:02d}"
if ep_title: if ep_title:
@@ -91,11 +89,11 @@ def format_lidarr(payload: dict) -> tuple[str, str]:
track_files = payload.get("trackFiles", [{}]) track_files = payload.get("trackFiles", [{}])
tf = track_files[0] if track_files else {} tf = track_files[0] if track_files else {}
quality = ( quality_field = tf.get("quality", {})
tf.get("quality", {}) if isinstance(quality_field, dict):
.get("quality", {}) quality = quality_field.get("quality", {}).get("name", "Unknown")
.get("name", "Unknown") else:
) quality = str(quality_field) if quality_field else "Unknown"
header = f"\U0001f3b5 **{artist_name}**" header = f"\U0001f3b5 **{artist_name}**"
if album_title: if album_title:

View File

@@ -1,7 +1,7 @@
import asyncio import asyncio
import logging import logging
from nio import AsyncClient, RoomSendResponse from nio import AsyncClient, PresenceSetError, RoomSendResponse
from app.config import config from app.config import config
@@ -38,23 +38,28 @@ async def close_client() -> None:
pass pass
_presence_task = None _presence_task = None
if _client is not None: if _client is not None:
await _set_presence("offline") try:
await _set_presence("offline")
except Exception:
log.warning("Failed to set offline presence during shutdown", exc_info=True)
await _client.close() await _client.close()
_client = None _client = None
async def _set_presence(state: str) -> None: async def _set_presence(state: str) -> None:
client = await get_client() client = await get_client()
try: resp = await client.set_presence(state)
await client.set_presence(state) if isinstance(resp, PresenceSetError):
except Exception: raise RuntimeError(f"Presence update to '{state}' failed: {resp.message}")
log.debug("Presence update failed", exc_info=True)
async def _presence_loop() -> None: async def _presence_loop() -> None:
while True: while True:
await _set_presence("online")
await asyncio.sleep(PRESENCE_INTERVAL_SECONDS) await asyncio.sleep(PRESENCE_INTERVAL_SECONDS)
try:
await _set_presence("online")
except Exception:
log.warning("Presence heartbeat failed", exc_info=True)
async def start_presence() -> None: async def start_presence() -> None: