Emby: - !media find <q> — search the existing library - !media resume — continue-watching list (with progress %) - !media random [movie|tv] — random unwatched pick Sonarr/Radarr: - !media health — active warnings on either arr Downloads: - !media speed — aggregate down/up across NZBGet + qBt - !media completed — finished in the last 24h - !media pause / !media unpause — global pause/resume QoL: - Numbered selection: !media search dune then !media request 2 - Optional per-user defaults: default_media_type, result_count
804 lines
34 KiB
Python
804 lines
34 KiB
Python
"""Media bot — Matrix companion for the homelab media stack.
|
|
|
|
Wraps Seerr (search/request), Emby (library/playback), Sonarr/Radarr (queue/calendar),
|
|
NZBGet + qBittorrent (active downloads).
|
|
|
|
Each Matrix sender is mapped to per-service user IDs via plugin config; unmapped
|
|
senders are rejected. All replies are prefixed with the sender's MXID so notifications
|
|
in shared rooms stay readable (matches the Books Bot convention).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from typing import Any, Optional, Type
|
|
|
|
import aiohttp
|
|
|
|
from maubot import MessageEvent, Plugin
|
|
from maubot.handlers import command
|
|
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
|
|
|
|
from .clients.arr import ArrError, RadarrClient, SonarrClient
|
|
from .clients.downloads import DownloadError, NzbgetClient, QbtClient
|
|
from .clients.emby import EmbyClient, EmbyError
|
|
from .clients.seerr import SeerrClient, SeerrError
|
|
|
|
|
|
# --- Seerr availability codes ----------------------------------------------
|
|
# https://api-docs.overseerr.dev — `Status` enum
|
|
SEERR_AVAIL = {
|
|
1: "unknown",
|
|
2: "pending",
|
|
3: "processing",
|
|
4: "partial",
|
|
5: "available",
|
|
}
|
|
|
|
|
|
def _human_bytes(n: float) -> str:
|
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
if abs(n) < 1024:
|
|
return f"{n:.1f} {unit}"
|
|
n /= 1024
|
|
return f"{n:.1f} PB"
|
|
|
|
|
|
def _human_eta(seconds: int | float | None) -> str:
|
|
if not seconds or seconds < 0 or seconds > 8640000:
|
|
return "?"
|
|
s = int(seconds)
|
|
h, rem = divmod(s, 3600)
|
|
m, s = divmod(rem, 60)
|
|
if h:
|
|
return f"{h}h{m}m"
|
|
if m:
|
|
return f"{m}m{s}s"
|
|
return f"{s}s"
|
|
|
|
|
|
class Config(BaseProxyConfig):
|
|
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
|
helper.copy("http_timeout")
|
|
helper.copy("default_results")
|
|
helper.copy("seerr")
|
|
helper.copy("sonarr")
|
|
helper.copy("radarr")
|
|
helper.copy("emby")
|
|
helper.copy("nzbget")
|
|
helper.copy("qbittorrent")
|
|
helper.copy("user_map")
|
|
|
|
|
|
class MediaBot(Plugin):
|
|
config: Config
|
|
session: aiohttp.ClientSession
|
|
|
|
seerr: SeerrClient
|
|
sonarr: SonarrClient
|
|
radarr: RadarrClient
|
|
emby: EmbyClient
|
|
nzbget: NzbgetClient
|
|
qbt: QbtClient
|
|
|
|
# Per-(room, sender) cache of last search/trending results — used by
|
|
# `!media request <N>` to skip re-typing the query. In-memory only; a bot
|
|
# restart clears it (acceptable, results are cheap to rebuild).
|
|
_search_cache: dict[tuple[str, str], list[dict]]
|
|
|
|
async def start(self) -> None:
|
|
self.config.load_and_update()
|
|
timeout = aiohttp.ClientTimeout(total=self.config["http_timeout"])
|
|
self.session = aiohttp.ClientSession(timeout=timeout)
|
|
|
|
s, so, r, e, n, q = (
|
|
self.config["seerr"], self.config["sonarr"], self.config["radarr"],
|
|
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.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"])
|
|
|
|
self._search_cache = {}
|
|
self.log.info("Media bot started — users=%d", len(self.config["user_map"] or {}))
|
|
|
|
async def stop(self) -> None:
|
|
if hasattr(self, "session"):
|
|
await self.session.close()
|
|
|
|
@classmethod
|
|
def get_config_class(cls) -> Type[BaseProxyConfig]:
|
|
return Config
|
|
|
|
# ---------- helpers ----------
|
|
|
|
def _resolve_user(self, sender: str) -> Optional[dict]:
|
|
return (self.config["user_map"] or {}).get(sender)
|
|
|
|
async def _say(self, evt: MessageEvent, message: str) -> None:
|
|
await evt.respond(f"{evt.sender}\n\n{message}")
|
|
|
|
async def _reject_unmapped(self, evt: MessageEvent) -> bool:
|
|
if not self._resolve_user(evt.sender):
|
|
self.log.warning("Unauthorized sender: %s", evt.sender)
|
|
await self._say(evt, "Sorry, your Matrix user isn't authorized to use the media bot.")
|
|
return True
|
|
return False
|
|
|
|
def _user_pref(self, sender: str, key: str, default: Any) -> Any:
|
|
cfg = self._resolve_user(sender) or {}
|
|
val = cfg.get(key)
|
|
return val if val is not None else default
|
|
|
|
def _result_count(self, sender: str) -> int:
|
|
return int(self._user_pref(sender, "result_count", self.config["default_results"]))
|
|
|
|
def _stash_search(self, evt: MessageEvent, results: list[dict]) -> None:
|
|
"""Cache search/trending results so the user can `!media request <N>`."""
|
|
self._search_cache[(evt.room_id, evt.sender)] = list(results)
|
|
|
|
# ---------- top-level command ----------
|
|
|
|
@command.new("media", aliases=["m"], help="Media stack bot — try `!media help`",
|
|
require_subcommand=True)
|
|
async def media(self) -> None:
|
|
pass
|
|
|
|
@media.subcommand("help", help="Show available commands")
|
|
async def cmd_help(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
msg = (
|
|
"**Media bot commands**\n\n"
|
|
"*Search & request (Seerr)*\n"
|
|
"- `!media search <query>` — top results (numbered)\n"
|
|
"- `!media request <query> [--tv|--movie]` — request top hit\n"
|
|
"- `!media request <N>` — pick item N from your last search/trending\n"
|
|
"- `!media requests` — your pending/processing requests\n"
|
|
"- `!media trending` — what's trending (numbered)\n\n"
|
|
"*Library (Emby)*\n"
|
|
"- `!media nowplaying` — active sessions\n"
|
|
"- `!media recent [movies|tv]` — recently added\n"
|
|
"- `!media watched` — what you recently finished\n"
|
|
"- `!media find <query>` — search the existing library\n"
|
|
"- `!media resume` — your continue-watching list\n"
|
|
"- `!media random [movie|tv]` — random unwatched pick\n\n"
|
|
"*Sonarr / Radarr*\n"
|
|
"- `!media queue` — combined queue\n"
|
|
"- `!media upcoming` — Sonarr calendar (next 7 days)\n"
|
|
"- `!media missing` — Sonarr wanted/missing\n"
|
|
"- `!media health` — active warnings on either arr\n\n"
|
|
"*Downloads (NZBGet + qBt)*\n"
|
|
"- `!media activity` — current downloads\n"
|
|
"- `!media completed` — finished in the last 24h\n"
|
|
"- `!media speed` — aggregate down/up speed\n"
|
|
"- `!media pause` / `!media unpause` — global pause/resume\n"
|
|
)
|
|
await self._say(evt, msg)
|
|
|
|
# ---------- Seerr ----------
|
|
|
|
@media.subcommand("search", help="Search Seerr for a movie or show")
|
|
@command.argument("query", pass_raw=True, required=True)
|
|
async def cmd_search(self, evt: MessageEvent, query: str) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
try:
|
|
results = await self.seerr.search(query)
|
|
except SeerrError as ex:
|
|
await self._say(evt, f"Search failed: {ex}")
|
|
return
|
|
results = [r for r in results if r.get("mediaType") in ("movie", "tv")]
|
|
if not results:
|
|
await self._say(evt, f"No Seerr results for **{query}**.")
|
|
return
|
|
n = self._result_count(evt.sender)
|
|
shown = results[:n]
|
|
self._stash_search(evt, shown)
|
|
lines = [f"**Top {len(shown)} for '{query}':**"]
|
|
for i, r in enumerate(shown, 1):
|
|
lines.append(f"{i}. " + self._fmt_seerr(r, leading_dash=False))
|
|
lines.append("")
|
|
lines.append("_Pick one with `!media request <N>`._")
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
@media.subcommand("request",
|
|
help="Request a movie/show. Pass `<query>` or a number from a recent search.")
|
|
@command.argument("query", pass_raw=True, required=True)
|
|
async def cmd_request(self, evt: MessageEvent, query: str) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
user_cfg = self._resolve_user(evt.sender) or {}
|
|
seerr_uid = user_cfg.get("seerr_user_id")
|
|
if not seerr_uid:
|
|
await self._say(evt, "Your Matrix user has no Seerr user_id mapped — ask maddox.")
|
|
return
|
|
|
|
# Numbered selection: !media request 2 → 2nd item from last search/trending
|
|
stripped = query.strip()
|
|
if stripped.isdigit():
|
|
cached = self._search_cache.get((evt.room_id, evt.sender)) or []
|
|
idx = int(stripped) - 1
|
|
if not cached:
|
|
await self._say(evt, "No recent search results to pick from. Run `!media search <query>` first.")
|
|
return
|
|
if idx < 0 or idx >= len(cached):
|
|
await self._say(evt, f"Pick a number 1-{len(cached)}.")
|
|
return
|
|
top = cached[idx]
|
|
await self._do_request(evt, seerr_uid, top)
|
|
return
|
|
|
|
# Free-text path: optional --tv/--movie flag, else fall back to user default
|
|
forced_type: Optional[str] = None
|
|
cleaned: list[str] = []
|
|
for w in stripped.split():
|
|
if w == "--tv":
|
|
forced_type = "tv"
|
|
elif w == "--movie":
|
|
forced_type = "movie"
|
|
else:
|
|
cleaned.append(w)
|
|
if forced_type is None:
|
|
forced_type = user_cfg.get("default_media_type")
|
|
q = " ".join(cleaned).strip()
|
|
if not q:
|
|
await self._say(evt, "Need a search query, e.g. `!media request dune --movie`.")
|
|
return
|
|
try:
|
|
results = await self.seerr.search(q)
|
|
except SeerrError as ex:
|
|
await self._say(evt, f"Search failed: {ex}")
|
|
return
|
|
candidates = [r for r in results if r.get("mediaType") in ("movie", "tv")]
|
|
if forced_type:
|
|
candidates = [r for r in candidates if r.get("mediaType") == forced_type]
|
|
if not candidates:
|
|
await self._say(evt, f"No matching results for **{q}**.")
|
|
return
|
|
await self._do_request(evt, seerr_uid, candidates[0])
|
|
|
|
async def _do_request(self, evt: MessageEvent, seerr_uid: int, item: dict) -> None:
|
|
media_type = item.get("mediaType")
|
|
tmdb_id = item.get("id")
|
|
title = item.get("title") or item.get("name") or "?"
|
|
if not (media_type and tmdb_id):
|
|
await self._say(evt, f"**{title}** is missing a media type or TMDB id — can't request.")
|
|
return
|
|
try:
|
|
req = await self.seerr.request(media_type, tmdb_id, seerr_uid)
|
|
except SeerrError as ex:
|
|
await self._say(evt, f"Request failed: {ex}")
|
|
return
|
|
status_id = (req or {}).get("status")
|
|
status = {1: "pending approval", 2: "approved", 3: "declined"}.get(status_id, str(status_id))
|
|
await self._say(evt, f"Requested **{title}** ({media_type}) — status: *{status}*.")
|
|
|
|
@media.subcommand("requests", help="Show your pending/processing Seerr requests")
|
|
async def cmd_requests(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
user_cfg = self._resolve_user(evt.sender)
|
|
seerr_uid = (user_cfg or {}).get("seerr_user_id")
|
|
if not seerr_uid:
|
|
await self._say(evt, "Your Matrix user has no Seerr user_id mapped — ask maddox.")
|
|
return
|
|
try:
|
|
reqs = await self.seerr.user_requests(seerr_uid, take=10)
|
|
except SeerrError as ex:
|
|
await self._say(evt, f"Lookup failed: {ex}")
|
|
return
|
|
if not reqs:
|
|
await self._say(evt, "No pending or processing requests.")
|
|
return
|
|
lines = [f"**Your requests ({len(reqs)}):**"]
|
|
for r in reqs:
|
|
mi = r.get("media") or {}
|
|
t = mi.get("title") or mi.get("name") or f"tmdb:{mi.get('tmdbId')}"
|
|
mt = mi.get("mediaType") or "?"
|
|
avail = SEERR_AVAIL.get(mi.get("status", 0), "?")
|
|
lines.append(f"- *{t}* ({mt}) — {avail}")
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
@media.subcommand("trending", help="Show what's trending in Seerr")
|
|
async def cmd_trending(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
try:
|
|
results = await self.seerr.trending()
|
|
except SeerrError as ex:
|
|
await self._say(evt, f"Trending fetch failed: {ex}")
|
|
return
|
|
n = self._result_count(evt.sender)
|
|
results = [r for r in results if r.get("mediaType") in ("movie", "tv")][:n]
|
|
if not results:
|
|
await self._say(evt, "Nothing trending right now.")
|
|
return
|
|
self._stash_search(evt, results)
|
|
lines = [f"**Trending ({len(results)}):**"]
|
|
for i, r in enumerate(results, 1):
|
|
lines.append(f"{i}. " + self._fmt_seerr(r, leading_dash=False))
|
|
lines.append("")
|
|
lines.append("_Pick one with `!media request <N>`._")
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
def _fmt_seerr(self, r: dict, leading_dash: bool = True) -> str:
|
|
title = r.get("title") or r.get("name") or "?"
|
|
mt = r.get("mediaType") or "?"
|
|
date_s = r.get("releaseDate") or r.get("firstAirDate") or ""
|
|
year = date_s[:4] if date_s else ""
|
|
info = (r.get("mediaInfo") or {}).get("status")
|
|
avail = SEERR_AVAIL.get(info, None) if info else None
|
|
bits = [f"*{title}*"]
|
|
if year:
|
|
bits.append(f"({year})")
|
|
bits.append(f"— {mt}")
|
|
if avail:
|
|
bits.append(f"[{avail}]")
|
|
prefix = "- " if leading_dash else ""
|
|
return prefix + " ".join(bits)
|
|
|
|
# ---------- Emby ----------
|
|
|
|
@media.subcommand("nowplaying", aliases=["np"], help="Active Emby sessions")
|
|
async def cmd_nowplaying(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
try:
|
|
sessions = await self.emby.sessions()
|
|
except EmbyError as ex:
|
|
await self._say(evt, f"Emby fetch failed: {ex}")
|
|
return
|
|
active = [s for s in sessions if s.get("NowPlayingItem")]
|
|
if not active:
|
|
await self._say(evt, "Nothing playing on Emby right now.")
|
|
return
|
|
lines = [f"**Now playing on Emby ({len(active)}):**"]
|
|
for s in active:
|
|
item = s.get("NowPlayingItem") or {}
|
|
user = s.get("UserName") or "?"
|
|
client = s.get("Client") or ""
|
|
t = self._fmt_emby_item(item)
|
|
ps = s.get("PlayState") or {}
|
|
pos = ps.get("PositionTicks") or 0
|
|
run = item.get("RunTimeTicks") or 0
|
|
pct = (pos / run * 100) if run else 0
|
|
paused = " (paused)" if ps.get("IsPaused") else ""
|
|
lines.append(f"- **{user}** — {t} · {pct:.0f}%{paused} [{client}]")
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
@media.subcommand("recent", help="Recently added — `!media recent [movies|tv]`")
|
|
@command.argument("kind", required=False)
|
|
async def cmd_recent(self, evt: MessageEvent, kind: str = "") -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
# Use the sender's emby_user_id if set, else fall back to the first mapped user's id
|
|
user_cfg = self._resolve_user(evt.sender) or {}
|
|
emby_uid = user_cfg.get("emby_user_id")
|
|
if not emby_uid or emby_uid == "TBD":
|
|
for v in (self.config["user_map"] or {}).values():
|
|
if v and v.get("emby_user_id") and v["emby_user_id"] != "TBD":
|
|
emby_uid = v["emby_user_id"]
|
|
break
|
|
if not emby_uid:
|
|
await self._say(evt, "No usable Emby user_id in config.")
|
|
return
|
|
item_types = None
|
|
if kind.lower() in ("movie", "movies"):
|
|
item_types = "Movie"
|
|
elif kind.lower() in ("tv", "series", "shows"):
|
|
item_types = "Series,Episode"
|
|
try:
|
|
items = await self.emby.recently_added(emby_uid, limit=10, item_types=item_types)
|
|
except EmbyError as ex:
|
|
await self._say(evt, f"Emby fetch failed: {ex}")
|
|
return
|
|
if not items:
|
|
await self._say(evt, "Nothing recently added.")
|
|
return
|
|
label = f" ({kind})" if kind else ""
|
|
lines = [f"**Recently added{label} ({len(items)}):**"]
|
|
for it in items:
|
|
lines.append("- " + self._fmt_emby_item(it))
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
@media.subcommand("watched", help="Your recently watched items on Emby")
|
|
async def cmd_watched(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
user_cfg = self._resolve_user(evt.sender) or {}
|
|
emby_uid = user_cfg.get("emby_user_id")
|
|
if not emby_uid or emby_uid == "TBD":
|
|
await self._say(evt, "Your Matrix user has no Emby user_id mapped — ask maddox.")
|
|
return
|
|
try:
|
|
items = await self.emby.user_played(emby_uid, limit=10)
|
|
except EmbyError as ex:
|
|
await self._say(evt, f"Emby fetch failed: {ex}")
|
|
return
|
|
if not items:
|
|
await self._say(evt, "No recently watched items.")
|
|
return
|
|
lines = [f"**Recently watched ({len(items)}):**"]
|
|
for it in items:
|
|
lines.append("- " + self._fmt_emby_item(it))
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
@media.subcommand("find", help="Search the existing Emby library")
|
|
@command.argument("query", pass_raw=True, required=True)
|
|
async def cmd_find(self, evt: MessageEvent, query: str) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
emby_uid = self._any_emby_uid(evt.sender)
|
|
if not emby_uid:
|
|
await self._say(evt, "No usable Emby user_id in config.")
|
|
return
|
|
try:
|
|
items = await self.emby.find(emby_uid, query, limit=10)
|
|
except EmbyError as ex:
|
|
await self._say(evt, f"Emby fetch failed: {ex}")
|
|
return
|
|
if not items:
|
|
await self._say(evt, f"Nothing in the library matching **{query}**.")
|
|
return
|
|
lines = [f"**Found in library ({len(items)}):**"]
|
|
for it in items:
|
|
lines.append("- " + self._fmt_emby_item(it))
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
@media.subcommand("resume", help="Your continue-watching list")
|
|
async def cmd_resume(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
user_cfg = self._resolve_user(evt.sender) or {}
|
|
emby_uid = user_cfg.get("emby_user_id")
|
|
if not emby_uid or emby_uid == "TBD":
|
|
await self._say(evt, "Your Matrix user has no Emby user_id mapped — ask maddox.")
|
|
return
|
|
try:
|
|
items = await self.emby.resume(emby_uid, limit=10)
|
|
except EmbyError as ex:
|
|
await self._say(evt, f"Emby fetch failed: {ex}")
|
|
return
|
|
if not items:
|
|
await self._say(evt, "Nothing to resume — you've finished everything you started.")
|
|
return
|
|
lines = [f"**Continue watching ({len(items)}):**"]
|
|
for it in items:
|
|
pct = ""
|
|
ud = it.get("UserData") or {}
|
|
played_pct = ud.get("PlayedPercentage")
|
|
if played_pct:
|
|
pct = f" · {played_pct:.0f}%"
|
|
lines.append("- " + self._fmt_emby_item(it) + pct)
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
@media.subcommand("random", help="Random unwatched pick — `!media random [movie|tv]`")
|
|
@command.argument("kind", required=False)
|
|
async def cmd_random(self, evt: MessageEvent, kind: str = "") -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
user_cfg = self._resolve_user(evt.sender) or {}
|
|
emby_uid = user_cfg.get("emby_user_id")
|
|
if not emby_uid or emby_uid == "TBD":
|
|
emby_uid = self._any_emby_uid(evt.sender)
|
|
if not emby_uid:
|
|
await self._say(evt, "No usable Emby user_id in config.")
|
|
return
|
|
item_type = "Movie"
|
|
if kind.lower() in ("tv", "series", "show", "shows"):
|
|
item_type = "Series"
|
|
try:
|
|
item = await self.emby.random_unplayed(emby_uid, item_type=item_type)
|
|
except EmbyError as ex:
|
|
await self._say(evt, f"Emby fetch failed: {ex}")
|
|
return
|
|
if not item:
|
|
await self._say(evt, f"No unwatched {item_type.lower()}s found — go you.")
|
|
return
|
|
line = self._fmt_emby_item(item)
|
|
overview = (item.get("Overview") or "").strip()
|
|
if len(overview) > 240:
|
|
overview = overview[:240].rsplit(" ", 1)[0] + "…"
|
|
msg = f"**Random pick:** {line}"
|
|
if overview:
|
|
msg += f"\n\n{overview}"
|
|
await self._say(evt, msg)
|
|
|
|
def _any_emby_uid(self, sender: str) -> Optional[str]:
|
|
"""Sender's emby_user_id, falling back to any other mapped user's id."""
|
|
cfg = self._resolve_user(sender) or {}
|
|
uid = cfg.get("emby_user_id")
|
|
if uid and uid != "TBD":
|
|
return uid
|
|
for v in (self.config["user_map"] or {}).values():
|
|
if v and v.get("emby_user_id") and v["emby_user_id"] != "TBD":
|
|
return v["emby_user_id"]
|
|
return None
|
|
|
|
def _fmt_emby_item(self, it: dict) -> str:
|
|
t = it.get("Type")
|
|
name = it.get("Name") or "?"
|
|
year = it.get("ProductionYear")
|
|
if t == "Episode":
|
|
series = it.get("SeriesName") or "?"
|
|
sn = it.get("ParentIndexNumber")
|
|
ep = it.get("IndexNumber")
|
|
tag = f"S{sn:02d}E{ep:02d}" if sn and ep else ""
|
|
return f"*{series}* {tag} — {name}".strip()
|
|
if year:
|
|
return f"*{name}* ({year})"
|
|
return f"*{name}*"
|
|
|
|
# ---------- Sonarr / Radarr ----------
|
|
|
|
@media.subcommand("queue", help="Combined Sonarr + Radarr queue")
|
|
async def cmd_queue(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
try:
|
|
sonarr_q, radarr_q = await asyncio.gather(
|
|
self.sonarr.queue(), self.radarr.queue(),
|
|
return_exceptions=True,
|
|
)
|
|
except Exception as ex:
|
|
await self._say(evt, f"Queue fetch failed: {ex}")
|
|
return
|
|
lines: list[str] = []
|
|
if isinstance(sonarr_q, Exception):
|
|
lines.append(f"**Sonarr:** unreachable ({sonarr_q})")
|
|
else:
|
|
lines.append(f"**Sonarr queue ({len(sonarr_q)}):**")
|
|
for q in sonarr_q[:10]:
|
|
series = (q.get("series") or {}).get("title") or "?"
|
|
ep = q.get("episode") or {}
|
|
tag = ""
|
|
if ep.get("seasonNumber") is not None and ep.get("episodeNumber") is not None:
|
|
tag = f" S{ep['seasonNumber']:02d}E{ep['episodeNumber']:02d}"
|
|
status = q.get("status") or "?"
|
|
pct = self._arr_pct(q)
|
|
lines.append(f"- *{series}*{tag} — {status} {pct}")
|
|
lines.append("")
|
|
if isinstance(radarr_q, Exception):
|
|
lines.append(f"**Radarr:** unreachable ({radarr_q})")
|
|
else:
|
|
lines.append(f"**Radarr queue ({len(radarr_q)}):**")
|
|
for q in radarr_q[:10]:
|
|
title = (q.get("movie") or {}).get("title") or q.get("title") or "?"
|
|
status = q.get("status") or "?"
|
|
pct = self._arr_pct(q)
|
|
lines.append(f"- *{title}* — {status} {pct}")
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
@media.subcommand("upcoming", help="Sonarr calendar — episodes airing in next 7 days")
|
|
async def cmd_upcoming(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
today = date.today()
|
|
end = today + timedelta(days=7)
|
|
try:
|
|
cal = await self.sonarr.calendar(today.isoformat(), end.isoformat())
|
|
except ArrError as ex:
|
|
await self._say(evt, f"Calendar fetch failed: {ex}")
|
|
return
|
|
if not cal:
|
|
await self._say(evt, "Nothing airing in the next 7 days.")
|
|
return
|
|
lines = [f"**Upcoming ({len(cal)}):**"]
|
|
for ep in cal[:15]:
|
|
series = (ep.get("series") or {}).get("title") or "?"
|
|
sn = ep.get("seasonNumber")
|
|
en = ep.get("episodeNumber")
|
|
tag = f"S{sn:02d}E{en:02d}" if sn is not None and en is not None else ""
|
|
t = ep.get("title") or ""
|
|
air = (ep.get("airDateUtc") or ep.get("airDate") or "")[:10]
|
|
lines.append(f"- {air}: *{series}* {tag} — {t}")
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
@media.subcommand("missing", help="Sonarr wanted/missing list")
|
|
async def cmd_missing(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
try:
|
|
items = await self.sonarr.missing(page_size=10)
|
|
except ArrError as ex:
|
|
await self._say(evt, f"Missing fetch failed: {ex}")
|
|
return
|
|
if not items:
|
|
await self._say(evt, "No missing episodes — Sonarr is happy.")
|
|
return
|
|
lines = [f"**Wanted/Missing ({len(items)}):**"]
|
|
for ep in items:
|
|
series = (ep.get("series") or {}).get("title") or "?"
|
|
sn = ep.get("seasonNumber")
|
|
en = ep.get("episodeNumber")
|
|
tag = f"S{sn:02d}E{en:02d}" if sn is not None and en is not None else ""
|
|
t = ep.get("title") or ""
|
|
air = (ep.get("airDateUtc") or ep.get("airDate") or "")[:10]
|
|
lines.append(f"- *{series}* {tag} — {t} (aired {air})")
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
@media.subcommand("health", help="Sonarr + Radarr active health warnings")
|
|
async def cmd_health(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
sonarr_h, radarr_h = await asyncio.gather(
|
|
self.sonarr.health(), self.radarr.health(),
|
|
return_exceptions=True,
|
|
)
|
|
lines: list[str] = []
|
|
for label, data in (("Sonarr", sonarr_h), ("Radarr", radarr_h)):
|
|
if isinstance(data, Exception):
|
|
lines.append(f"**{label}:** unreachable ({data})")
|
|
continue
|
|
if not data:
|
|
lines.append(f"**{label}:** ✅ healthy")
|
|
continue
|
|
lines.append(f"**{label} ({len(data)} warnings):**")
|
|
for h in data:
|
|
src = h.get("source") or "?"
|
|
msg = h.get("message") or "?"
|
|
lines.append(f"- [{src}] {msg}")
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
def _arr_pct(self, q: dict) -> str:
|
|
size = q.get("size") or 0
|
|
left = q.get("sizeleft") or 0
|
|
if size <= 0:
|
|
return ""
|
|
pct = (1 - left / size) * 100
|
|
return f"{pct:.0f}%"
|
|
|
|
# ---------- Downloads ----------
|
|
|
|
@media.subcommand("activity", aliases=["dl"], help="NZBGet + qBittorrent active downloads")
|
|
async def cmd_activity(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
nzb_res, qbt_res = await asyncio.gather(
|
|
self.nzbget.listgroups(),
|
|
self.qbt.downloading(),
|
|
return_exceptions=True,
|
|
)
|
|
lines: list[str] = []
|
|
# NZBGet
|
|
if isinstance(nzb_res, Exception):
|
|
lines.append(f"**NZBGet:** unreachable ({nzb_res})")
|
|
else:
|
|
active = [g for g in nzb_res if (g.get("RemainingSizeMB") or 0) > 0]
|
|
lines.append(f"**NZBGet ({len(active)} active):**" if active else "**NZBGet:** idle")
|
|
for g in active[:8]:
|
|
name = g.get("NZBNicename") or g.get("NZBName") or "?"
|
|
rate_mbps = (g.get("DownloadRate") or 0) / 1024 / 1024 # NZBGet reports B/s
|
|
size_mb = g.get("FileSizeMB") or 0
|
|
left_mb = g.get("RemainingSizeMB") or 0
|
|
pct = (1 - left_mb / size_mb) * 100 if size_mb else 0
|
|
lines.append(f"- *{name}* — {pct:.0f}% · {rate_mbps:.1f} MB/s")
|
|
lines.append("")
|
|
# qBittorrent
|
|
if isinstance(qbt_res, Exception):
|
|
lines.append(f"**qBittorrent:** unreachable ({qbt_res})")
|
|
else:
|
|
lines.append(f"**qBittorrent ({len(qbt_res)} active):**" if qbt_res else "**qBittorrent:** idle")
|
|
for t in qbt_res[:8]:
|
|
name = t.get("name") or "?"
|
|
pct = (t.get("progress") or 0) * 100
|
|
speed = _human_bytes(t.get("dlspeed") or 0) + "/s"
|
|
eta = _human_eta(t.get("eta"))
|
|
lines.append(f"- *{name}* — {pct:.0f}% · {speed} · ETA {eta}")
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
@media.subcommand("speed", help="Aggregate down/up speeds across NZBGet + qBt")
|
|
async def cmd_speed(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
nzb_status, qbt_xfer = await asyncio.gather(
|
|
self.nzbget.status(), self.qbt.transfer_info(),
|
|
return_exceptions=True,
|
|
)
|
|
lines: list[str] = []
|
|
if isinstance(nzb_status, Exception):
|
|
lines.append(f"**NZBGet:** unreachable ({nzb_status})")
|
|
else:
|
|
dl = nzb_status.get("DownloadRate") or 0 # bytes/s
|
|
paused = nzb_status.get("DownloadPaused") or nzb_status.get("ServerPaused")
|
|
tag = " (paused)" if paused else ""
|
|
lines.append(f"**NZBGet:** ↓ {_human_bytes(dl)}/s{tag}")
|
|
if isinstance(qbt_xfer, Exception):
|
|
lines.append(f"**qBittorrent:** unreachable ({qbt_xfer})")
|
|
else:
|
|
dl = qbt_xfer.get("dl_info_speed") or 0
|
|
ul = qbt_xfer.get("up_info_speed") or 0
|
|
state = qbt_xfer.get("connection_status") or "?"
|
|
lines.append(f"**qBittorrent:** ↓ {_human_bytes(dl)}/s · ↑ {_human_bytes(ul)}/s [{state}]")
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
@media.subcommand("completed", help="Downloads finished in the last 24h")
|
|
async def cmd_completed(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
cutoff = datetime.now(timezone.utc).timestamp() - 86400
|
|
nzb_hist, qbt_all = await asyncio.gather(
|
|
self.nzbget.history(), self.qbt.all_torrents(),
|
|
return_exceptions=True,
|
|
)
|
|
lines: list[str] = []
|
|
# NZBGet history items have HistoryTime (epoch) and FileSizeMB
|
|
if isinstance(nzb_hist, Exception):
|
|
lines.append(f"**NZBGet:** unreachable ({nzb_hist})")
|
|
else:
|
|
recent = [h for h in nzb_hist if (h.get("HistoryTime") or 0) >= cutoff]
|
|
lines.append(f"**NZBGet — completed last 24h ({len(recent)}):**")
|
|
for h in recent[:10]:
|
|
name = h.get("Name") or h.get("NZBName") or "?"
|
|
size_mb = h.get("FileSizeMB") or 0
|
|
status = h.get("Status") or "?"
|
|
lines.append(f"- *{name}* — {size_mb / 1024:.1f} GB [{status}]")
|
|
lines.append("")
|
|
# qBt: completion_on (epoch); finished torrents have it set
|
|
if isinstance(qbt_all, Exception):
|
|
lines.append(f"**qBittorrent:** unreachable ({qbt_all})")
|
|
else:
|
|
recent = [t for t in qbt_all
|
|
if (t.get("completion_on") or 0) >= cutoff
|
|
and (t.get("completion_on") or 0) > 0]
|
|
lines.append(f"**qBittorrent — completed last 24h ({len(recent)}):**")
|
|
for t in recent[:10]:
|
|
name = t.get("name") or "?"
|
|
size = t.get("size") or 0
|
|
lines.append(f"- *{name}* — {_human_bytes(size)}")
|
|
await self._say(evt, "\n".join(lines))
|
|
|
|
@media.subcommand("pause", help="Pause all downloads (NZBGet + qBittorrent)")
|
|
async def cmd_pause(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
results = await asyncio.gather(
|
|
self.nzbget.pause(), self.qbt.pause_all(),
|
|
return_exceptions=True,
|
|
)
|
|
nzb_ok = not isinstance(results[0], Exception)
|
|
qbt_ok = not isinstance(results[1], Exception)
|
|
msg = (
|
|
f"NZBGet: {'paused ⏸' if nzb_ok else f'failed ({results[0]})'}\n"
|
|
f"qBittorrent: {'paused ⏸' if qbt_ok else f'failed ({results[1]})'}"
|
|
)
|
|
await self._say(evt, msg)
|
|
|
|
@media.subcommand("unpause", help="Resume all downloads (NZBGet + qBittorrent)")
|
|
async def cmd_unpause(self, evt: MessageEvent) -> None:
|
|
await evt.mark_read()
|
|
if await self._reject_unmapped(evt):
|
|
return
|
|
results = await asyncio.gather(
|
|
self.nzbget.unpause(), self.qbt.resume_all(),
|
|
return_exceptions=True,
|
|
)
|
|
nzb_ok = not isinstance(results[0], Exception)
|
|
qbt_ok = not isinstance(results[1], Exception)
|
|
msg = (
|
|
f"NZBGet: {'resumed ▶' if nzb_ok else f'failed ({results[0]})'}\n"
|
|
f"qBittorrent: {'resumed ▶' if qbt_ok else f'failed ({results[1]})'}"
|
|
)
|
|
await self._say(evt, msg)
|