diff --git a/base-config.yaml b/base-config.yaml index 7616311..a5c930d 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -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 diff --git a/maubot.yaml b/maubot.yaml index ca6270e..136bc81 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -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 diff --git a/media_bot/bot.py b/media_bot/bot.py index 6c50e41..cbcc356 100644 --- a/media_bot/bot.py +++ b/media_bot/bot.py @@ -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 ` — search MusicBrainz (numbered)\n" + "- `!media music add ` — 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 ` works. + _music_cache: dict[tuple[str, str], list[dict]] = {} + + @media.subcommand("music", + help="Search MusicBrainz via Lidarr. `add ` or `add ` 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 `._") + 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 ` or `!media music add `.") + 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 ` 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"], diff --git a/media_bot/clients/arr.py b/media_bot/clients/arr.py index 91f6132..6e3c384 100644 --- a/media_bot/clients/arr.py +++ b/media_bot/clients/arr.py @@ -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]