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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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.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"],
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue