7 Commits

Author SHA1 Message Date
Claude
f8bef94e7f Fix quality extraction crashing on string-typed quality fields
Sonarr (and potentially Radarr/Lidarr) can send the quality field as
a plain string instead of the expected nested dict. Added a helper that
handles both formats gracefully.

https://claude.ai/code/session_019ANRdyL2jfi7ysWhqN4PfD
2026-02-28 09:52:26 +00: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
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
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 41 additions and 25 deletions

View File

@@ -30,22 +30,30 @@ cp .env.example .env
Edit `.env` with your actual values (see [Environment Variables](#environment-variables) below).
### 2. Verify your configuration
### 2. Set up a virtual environment
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
### 3. Verify your configuration
```bash
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
### 4. Run locally
```bash
source .venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8000
```
### 4. Run with Docker
### 5. Run with Docker
```bash
docker build -t melora .

View File

@@ -9,17 +9,27 @@ def _strip_markdown(text: str) -> str:
return text.replace("**", "").replace("*", "").replace("> ", "")
def _extract_quality(quality_obj) -> str:
"""Extract quality name from *arr quality field, which may be a string or nested dict."""
if isinstance(quality_obj, str):
return quality_obj
if isinstance(quality_obj, dict):
inner = quality_obj.get("quality", quality_obj)
if isinstance(inner, str):
return inner
if isinstance(inner, dict):
return inner.get("name", "Unknown")
return "Unknown"
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")
quality = _extract_quality(
payload.get("movieFile", {}).get("quality", {})
)
if is_upgrade:
@@ -52,11 +62,8 @@ def format_sonarr(payload: dict) -> tuple[str, str]:
episode = ep.get("episodeNumber", 0)
ep_title = ep.get("title", "")
quality = (
payload.get("episodeFile", {})
.get("quality", {})
.get("quality", {})
.get("name", "Unknown")
quality = _extract_quality(
payload.get("episodeFile", {}).get("quality", {})
)
header = f"\U0001f4fa **{series_title}** \u2014 S{season:02d}E{episode:02d}"
@@ -91,11 +98,7 @@ def format_lidarr(payload: dict) -> tuple[str, str]:
track_files = payload.get("trackFiles", [{}])
tf = track_files[0] if track_files else {}
quality = (
tf.get("quality", {})
.get("quality", {})
.get("name", "Unknown")
)
quality = _extract_quality(tf.get("quality", {}))
header = f"\U0001f3b5 **{artist_name}**"
if album_title:

View File

@@ -1,7 +1,7 @@
import asyncio
import logging
from nio import AsyncClient, RoomSendResponse
from nio import AsyncClient, PresenceSetError, RoomSendResponse
from app.config import config
@@ -38,23 +38,28 @@ async def close_client() -> None:
pass
_presence_task = 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()
_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)
resp = await client.set_presence(state)
if isinstance(resp, PresenceSetError):
raise RuntimeError(f"Presence update to '{state}' failed: {resp.message}")
async def _presence_loop() -> None:
while True:
await _set_presence("online")
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: