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:
parent
59b8b99076
commit
e042b325b5
4 changed files with 182 additions and 4 deletions
|
|
@ -47,6 +47,17 @@ radarr:
|
|||
url: http://192.168.1.80: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://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:
|
||||
url: http://192.168.1.120:8096/emby
|
||||
api_key: CHANGEME
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
144
media_bot/bot.py
144
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 <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"],
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue