v0.5.0: Lidarr music search + add

- LidarrClient (api/v1) with lookup, profile/folder discovery, and
  add_artist
- !media music <query> — MusicBrainz lookup via Lidarr, numbered list
  cached per (room, sender)
- !media music add <query|N> — POST /api/v1/artist with profile/folder
  defaults (auto-picks first if not set in config). Carries through
  images/genres/links from the lookup result and kicks off a search
  for missing albums by default.
- New config block 'lidarr' with optional quality_profile_id /
  metadata_profile_id / root_folder_path overrides.
This commit is contained in:
Maddox 2026-04-29 07:03:13 -04:00
parent f7537894d0
commit b2c6b86546
4 changed files with 182 additions and 4 deletions

View file

@ -47,6 +47,17 @@ radarr:
url: http://arr-host:7878
api_key: CHANGEME
# Lidarr — music. API v1 (different from Sonarr/Radarr v3).
# Profile/folder defaults: leave null to auto-pick the first one Lidarr returns.
lidarr:
url: http://arr-host:8686
api_key: CHANGEME
quality_profile_id: null # e.g. 3 for "Standard"
metadata_profile_id: null # e.g. 1 for "Standard"
root_folder_path: null # e.g. "/media/Music"
monitor: "all" # all|future|missing|existing|first|latest|none
search_on_add: true # kick off a search for missing albums right after add
emby:
url: http://emby-host:8096/emby
api_key: CHANGEME

View file

@ -1,6 +1,6 @@
maubot: 0.3.1
id: com.3ddbrewery.media
version: 0.4.3
version: 0.5.0
license: MIT
modules:
- media_bot

View file

@ -32,7 +32,7 @@ from mautrix.types import (
from mautrix.util.async_db import UpgradeTable
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
from .clients.arr import ArrError, RadarrClient, SonarrClient
from .clients.arr import ArrError, LidarrClient, RadarrClient, SonarrClient
from .clients.downloads import DownloadError, NzbgetClient, QbtClient
from .clients.emby import EmbyClient, EmbyError
from .clients.seerr import SeerrClient, SeerrError
@ -107,6 +107,7 @@ class Config(BaseProxyConfig):
helper.copy("seerr")
helper.copy("sonarr")
helper.copy("radarr")
helper.copy("lidarr")
helper.copy("emby")
helper.copy("nzbget")
helper.copy("qbittorrent")
@ -129,6 +130,7 @@ class MediaBot(Plugin):
seerr: SeerrClient
sonarr: SonarrClient
radarr: RadarrClient
lidarr: LidarrClient
emby: EmbyClient
nzbget: NzbgetClient
qbt: QbtClient
@ -154,13 +156,15 @@ class MediaBot(Plugin):
timeout = aiohttp.ClientTimeout(total=self.config["http_timeout"])
self.session = aiohttp.ClientSession(timeout=timeout)
s, so, r, e, n, q = (
s, so, r, l, e, n, q = (
self.config["seerr"], self.config["sonarr"], self.config["radarr"],
self.config["emby"], self.config["nzbget"], self.config["qbittorrent"],
self.config["lidarr"], self.config["emby"],
self.config["nzbget"], self.config["qbittorrent"],
)
self.seerr = SeerrClient(self.session, s["url"], s["api_key"])
self.sonarr = SonarrClient(self.session, so["url"], so["api_key"])
self.radarr = RadarrClient(self.session, r["url"], r["api_key"])
self.lidarr = LidarrClient(self.session, l["url"], l["api_key"])
self.emby = EmbyClient(self.session, e["url"], e["api_key"])
self.nzbget = NzbgetClient(self.session, n["url"], n["username"], n["password"])
self.qbt = QbtClient(self.session, q["url"], q["username"], q["password"])
@ -269,6 +273,9 @@ class MediaBot(Plugin):
"- `!media upcoming` — Sonarr calendar (next 7 days)\n"
"- `!media missing` — Sonarr wanted/missing\n"
"- `!media health` — active warnings on either arr\n\n"
"*Lidarr (music)*\n"
"- `!media music <q>` — search MusicBrainz (numbered)\n"
"- `!media music add <q|N>` — add the artist to Lidarr\n\n"
"*Downloads (NZBGet + qBt)*\n"
"- `!media activity` — current downloads\n"
"- `!media completed` — finished in the last 24h\n"
@ -1036,6 +1043,137 @@ class MediaBot(Plugin):
)
return text, html
# ---------- Lidarr music ----------
# Cache of last music lookup per (room, sender) so `!media music add <N>` works.
_music_cache: dict[tuple[str, str], list[dict]] = {}
@media.subcommand("music",
help="Search MusicBrainz via Lidarr. `add <q>` or `add <N>` to add.")
@command.argument("query", pass_raw=True, required=True)
async def cmd_music(self, evt: MessageEvent, query: str) -> None:
await evt.mark_read()
if await self._reject_unmapped(evt):
return
parts = query.strip().split(None, 1)
if parts and parts[0].lower() == "add":
arg = parts[1] if len(parts) > 1 else ""
await self._music_add(evt, arg)
return
await self._music_search(evt, query.strip())
async def _music_search(self, evt: MessageEvent, term: str) -> None:
if not term:
await self._say(evt, "Need a search query, e.g. `!media music radiohead`.")
return
try:
results = await self.lidarr.lookup(term)
except ArrError as ex:
await self._say(evt, f"Lidarr lookup failed: {ex}")
return
if not results:
await self._say(evt, f"No artists found for **{term}**.")
return
n = self._result_count(evt.sender)
shown = results[:n]
self._music_cache[(evt.room_id, evt.sender)] = list(shown)
if len(self._music_cache) > 200:
del self._music_cache[next(iter(self._music_cache))]
lines = [f"**Top {len(shown)} for '{term}':**"]
for i, a in enumerate(shown, 1):
name = a.get("artistName") or "?"
disambig = a.get("disambiguation") or ""
albums = a.get("statistics", {}).get("albumCount") or len(a.get("remoteAlbums") or [])
extra = []
if disambig:
extra.append(disambig)
if albums:
extra.append(f"{albums} albums")
tail = f" ({', '.join(extra)})" if extra else ""
lines.append(f"{i}. *{name}*{tail}")
lines.append("")
lines.append("_Add with `!media music add <N>`._")
await self._say(evt, "\n".join(lines))
async def _music_add(self, evt: MessageEvent, arg: str) -> None:
arg = arg.strip()
if not arg:
await self._say(evt, "`!media music add <query>` or `!media music add <N>`.")
return
# Numbered selection
if arg.isdigit():
cached = self._music_cache.get((evt.room_id, evt.sender)) or []
idx = int(arg) - 1
if not cached:
await self._say(evt, "No recent music search to pick from. Run `!media music <query>` first.")
return
if idx < 0 or idx >= len(cached):
await self._say(evt, f"Pick a number 1-{len(cached)}.")
return
artist = cached[idx]
else:
try:
results = await self.lidarr.lookup(arg)
except ArrError as ex:
await self._say(evt, f"Lidarr lookup failed: {ex}")
return
if not results:
await self._say(evt, f"No artists found for **{arg}**.")
return
artist = results[0]
# Resolve profile/folder defaults if not set in config
cfg = self.config["lidarr"] or {}
try:
qpid = cfg.get("quality_profile_id")
mpid = cfg.get("metadata_profile_id")
root = cfg.get("root_folder_path")
if not qpid:
qps = await self.lidarr.quality_profiles()
qpid = qps[0]["id"] if qps else None
if not mpid:
mps = await self.lidarr.metadata_profiles()
mpid = mps[0]["id"] if mps else None
if not root:
rfs = await self.lidarr.root_folders()
root = rfs[0]["path"] if rfs else None
except ArrError as ex:
await self._say(evt, f"Lidarr defaults lookup failed: {ex}")
return
if not (qpid and mpid and root):
await self._say(evt, "Lidarr is missing a quality/metadata profile or root folder — set up in Lidarr first.")
return
payload = {
"artistName": artist.get("artistName"),
"foreignArtistId": artist.get("foreignArtistId"),
"qualityProfileId": qpid,
"metadataProfileId": mpid,
"rootFolderPath": root,
"monitored": True,
"monitorNewItems": "all",
"addOptions": {
"monitor": cfg.get("monitor", "all"),
"searchForMissingAlbums": bool(cfg.get("search_on_add", True)),
},
}
# Lidarr's add endpoint also expects images + a few other passthroughs from lookup
for k in ("images", "links", "genres", "tags", "ratings", "overview",
"disambiguation", "artistType", "status", "remotePoster"):
if k in artist:
payload[k] = artist[k]
try:
added = await self.lidarr.add_artist(payload)
except ArrError as ex:
await self._say(evt, f"Add failed: {ex}")
return
name = added.get("artistName") or payload["artistName"]
await self._say(
evt,
f"Added **{name}** to Lidarr. Quality: profile {qpid}, root: `{root}`. "
f"{'Search kicked off.' if cfg.get('search_on_add', True) else 'No search triggered.'}"
)
# ---------- Subscriptions ----------
@media.subcommand("subscribe", aliases=["sub"],

View file

@ -21,6 +21,12 @@ class ArrClient:
raise ArrError(f"GET {path}{r.status}: {(await r.text())[:200]}")
return await r.json()
async def _post(self, path: str, body: dict) -> dict | list:
async with self.session.post(f"{self.base}{path}", headers=self.headers, json=body) as r:
if r.status >= 400:
raise ArrError(f"POST {path}{r.status}: {(await r.text())[:200]}")
return await r.json()
async def health(self) -> list[dict]:
"""Returns active health-check warnings (empty list = healthy)."""
data = await self._get("/api/v3/health")
@ -66,3 +72,26 @@ class SonarrClient(ArrClient):
class RadarrClient(ArrClient):
pass
class LidarrClient(ArrClient):
"""Lidarr API v1 — note the version difference from Sonarr/Radarr."""
async def lookup(self, term: str) -> list[dict]:
data = await self._get("/api/v1/artist/lookup", params={"term": term})
return data if isinstance(data, list) else (data or [])
async def quality_profiles(self) -> list[dict]:
data = await self._get("/api/v1/qualityprofile")
return data if isinstance(data, list) else []
async def metadata_profiles(self) -> list[dict]:
data = await self._get("/api/v1/metadataprofile")
return data if isinstance(data, list) else []
async def root_folders(self) -> list[dict]:
data = await self._get("/api/v1/rootfolder")
return data if isinstance(data, list) else []
async def add_artist(self, payload: dict) -> dict:
return await self._post("/api/v1/artist", payload) # type: ignore[return-value]