qrate/bot.py

208 lines
7.1 KiB
Python

"""
Signal-Qobuz bot — main entry point.
Usage:
python bot.py
"""
import asyncio
import logging
import config
import qobuz_client
from qobuz_client import AlbumItem
from session import PendingArtist, ResultEntry, State, UserSession
from signal_client import SignalClient
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
_sessions: dict[str, UserSession] = {}
def _get_session(number: str) -> UserSession:
if number not in _sessions:
_sessions[number] = UserSession()
return _sessions[number]
def _results_message(entries: list[ResultEntry]) -> str:
lines = ["Here's what I found:\n"]
for i, e in enumerate(entries, 1):
lines.append(f"[{i}] {e.display}")
lines.append("\nReply with a number to download, or send a new search.")
return "\n".join(lines)
def _artist_options_message(name: str, total: int) -> str:
return (
f"{name}{total} album(s) on Qobuz.\n\n"
f"[1] Full discography ({total} albums)\n"
f"[2] Top {config.MAX_ARTIST_ALBUMS} most recent albums\n\n"
"Reply with 1 or 2, or send a new search."
)
async def handle_message(signal: SignalClient, sender: str, text: str) -> None:
session = _get_session(sender)
text = text.strip()
if session.state == State.DOWNLOADING:
await signal.send_message(sender, "A download is already in progress. Please wait.")
return
# ── ARTIST OPTIONS: waiting for "1" or "2" ──────────────────────────────
if session.state == State.ARTIST_OPTIONS and session.pending_artist:
if text == "1":
pending = session.pending_artist
session.state = State.DOWNLOADING
await signal.send_message(
sender, f"Downloading full discography: {pending.name}"
)
await _download_album_list(signal, sender, session, pending.albums)
return
if text == "2":
pending = session.pending_artist
albums = pending.albums[:config.MAX_ARTIST_ALBUMS]
session.state = State.DOWNLOADING
await signal.send_message(
sender,
f"Downloading {len(albums)} most recent album(s) by {pending.name}",
)
await _download_album_list(signal, sender, session, albums)
return
# Anything else → treat as a new search
# ── RESULTS: waiting for a number ────────────────────────────────────────
if session.state == State.RESULTS and text.isdigit():
index = int(text) - 1
if not (0 <= index < len(session.entries)):
await signal.send_message(
sender,
f"Please pick a number between 1 and {len(session.entries)}.",
)
return
entry = session.entries[index]
if entry.kind == "album":
session.state = State.DOWNLOADING
await signal.send_message(sender, f"Downloading: {entry.display}")
try:
await qobuz_client.download_url(entry.item.url)
await signal.send_message(sender, "Download complete.")
except Exception as exc:
logger.error("Album download error: %s", exc)
await signal.send_message(sender, f"Download failed: {exc}")
session.reset()
return
if entry.kind == "artist":
await signal.send_message(
sender, f"Fetching discography for {entry.display}"
)
try:
albums = await qobuz_client.get_artist_albums(entry.item.url)
except Exception as exc:
logger.error("Artist albums error: %s", exc)
await signal.send_message(sender, f"Error fetching albums: {exc}")
session.reset()
return
if not albums:
await signal.send_message(sender, "No albums found for this artist.")
session.reset()
return
# Extract artist name from the display text (format: "Name - (N releases)")
artist_name = entry.display.split(" - (")[0].strip()
session.state = State.ARTIST_OPTIONS
session.pending_artist = PendingArtist(name=artist_name, albums=albums)
await signal.send_message(
sender, _artist_options_message(artist_name, len(albums))
)
return
# ── NEW SEARCH (IDLE or fallthrough) ─────────────────────────────────────
session.reset()
await signal.send_message(sender, f'Searching Qobuz for "{text}"')
try:
results = await qobuz_client.search(text)
except Exception as exc:
logger.error("Search error: %s", exc)
await signal.send_message(sender, f"Search failed: {exc}")
return
entries: list[ResultEntry] = []
for item in results.artists:
entries.append(ResultEntry(display=item.text, kind="artist", item=item))
for item in results.albums:
entries.append(ResultEntry(display=item.text, kind="album", item=item))
if not entries:
await signal.send_message(sender, "No results found. Try a different query.")
return
session.entries = entries
session.state = State.RESULTS
await signal.send_message(sender, _results_message(entries))
async def _download_album_list(
signal: SignalClient,
sender: str,
session: UserSession,
albums: list[AlbumItem],
) -> None:
errors = []
for album in albums:
try:
await qobuz_client.download_album_id(album.id)
await signal.send_message(sender, f"{album.title}")
except Exception as exc:
logger.error("Download error for album %s: %s", album.id, exc)
errors.append(album.title)
await signal.send_message(sender, f"{album.title} (failed)")
ok = len(albums) - len(errors)
if errors:
await signal.send_message(sender, f"Done — {ok}/{len(albums)} succeeded.")
else:
await signal.send_message(sender, "All downloads complete.")
session.reset()
async def main() -> None:
signal = SignalClient()
config.DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
logger.info(
"Bot starting — signal-cli at %s:%s, downloads → %s",
config.SIGNAL_HOST,
config.SIGNAL_PORT,
config.DOWNLOAD_DIR,
)
async def dispatch(sender: str, text: str) -> None:
try:
await handle_message(signal, sender, text)
except Exception as exc:
logger.exception("Unhandled error for %s: %s", sender, exc)
try:
await signal.send_message(sender, "An unexpected error occurred. Please try again.")
except Exception:
pass
await signal.listen(dispatch)
if __name__ == "__main__":
asyncio.run(main())