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 59b8b99076
commit e042b325b5
4 changed files with 182 additions and 4 deletions

View file

@ -47,6 +47,17 @@ radarr:
url: http://192.168.1.80:7878 url: http://192.168.1.80:7878
api_key: CHANGEME 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://192.168.1.80: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: emby:
url: http://192.168.1.120:8096/emby url: http://192.168.1.120:8096/emby
api_key: CHANGEME api_key: CHANGEME

View file

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

View file

@ -32,7 +32,7 @@ from mautrix.types import (
from mautrix.util.async_db import UpgradeTable from mautrix.util.async_db import UpgradeTable
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper 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.downloads import DownloadError, NzbgetClient, QbtClient
from .clients.emby import EmbyClient, EmbyError from .clients.emby import EmbyClient, EmbyError
from .clients.seerr import SeerrClient, SeerrError from .clients.seerr import SeerrClient, SeerrError
@ -107,6 +107,7 @@ class Config(BaseProxyConfig):
helper.copy("seerr") helper.copy("seerr")
helper.copy("sonarr") helper.copy("sonarr")
helper.copy("radarr") helper.copy("radarr")
helper.copy("lidarr")
helper.copy("emby") helper.copy("emby")
helper.copy("nzbget") helper.copy("nzbget")
helper.copy("qbittorrent") helper.copy("qbittorrent")
@ -129,6 +130,7 @@ class MediaBot(Plugin):
seerr: SeerrClient seerr: SeerrClient
sonarr: SonarrClient sonarr: SonarrClient
radarr: RadarrClient radarr: RadarrClient
lidarr: LidarrClient
emby: EmbyClient emby: EmbyClient
nzbget: NzbgetClient nzbget: NzbgetClient
qbt: QbtClient qbt: QbtClient
@ -154,13 +156,15 @@ class MediaBot(Plugin):
timeout = aiohttp.ClientTimeout(total=self.config["http_timeout"]) timeout = aiohttp.ClientTimeout(total=self.config["http_timeout"])
self.session = aiohttp.ClientSession(timeout=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["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.seerr = SeerrClient(self.session, s["url"], s["api_key"])
self.sonarr = SonarrClient(self.session, so["url"], so["api_key"]) self.sonarr = SonarrClient(self.session, so["url"], so["api_key"])
self.radarr = RadarrClient(self.session, r["url"], r["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.emby = EmbyClient(self.session, e["url"], e["api_key"])
self.nzbget = NzbgetClient(self.session, n["url"], n["username"], n["password"]) self.nzbget = NzbgetClient(self.session, n["url"], n["username"], n["password"])
self.qbt = QbtClient(self.session, q["url"], q["username"], q["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 upcoming` — Sonarr calendar (next 7 days)\n"
"- `!media missing` — Sonarr wanted/missing\n" "- `!media missing` — Sonarr wanted/missing\n"
"- `!media health` — active warnings on either arr\n\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" "*Downloads (NZBGet + qBt)*\n"
"- `!media activity` — current downloads\n" "- `!media activity` — current downloads\n"
"- `!media completed` — finished in the last 24h\n" "- `!media completed` — finished in the last 24h\n"
@ -1036,6 +1043,137 @@ class MediaBot(Plugin):
) )
return text, html 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 ---------- # ---------- Subscriptions ----------
@media.subcommand("subscribe", aliases=["sub"], @media.subcommand("subscribe", aliases=["sub"],

View file

@ -21,6 +21,12 @@ class ArrClient:
raise ArrError(f"GET {path}{r.status}: {(await r.text())[:200]}") raise ArrError(f"GET {path}{r.status}: {(await r.text())[:200]}")
return await r.json() 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]: async def health(self) -> list[dict]:
"""Returns active health-check warnings (empty list = healthy).""" """Returns active health-check warnings (empty list = healthy)."""
data = await self._get("/api/v3/health") data = await self._get("/api/v3/health")
@ -66,3 +72,26 @@ class SonarrClient(ArrClient):
class RadarrClient(ArrClient): class RadarrClient(ArrClient):
pass 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]