#!/usr/bin/env python3
"""
Memory Lane FM — Discord Rich Presence companion app
------------------------------------------------------
Run this on your desktop (where Discord is installed and logged in).
It polls your Memory Lane FM listening status — which reflects ANY
device's activity on the account, including mobile/iOS — and sets
that account's Discord Rich Presence to match.

Works for anyone on the station: a regular listener, Grey, DJ Asuka,
DJ Static, or DJ Nightmare. There's no role to pick — just paste your
token below and the server tells this script who you are.

Setup:
  1. pip install pypresence requests
  2. Create a Discord Application at https://discord.com/developers/applications
     - Name it whatever you want (e.g. "Memory Lane FM")
     - Copy the "Application ID" -> DISCORD_CLIENT_ID below
     - Optional: under "Rich Presence" -> "Art Assets", upload a station logo
       and name the asset key the same as LARGE_IMAGE_KEY below
  3. Get your token from the site — click "Copy My API Token" wherever it
     appears for your account (listener profile, admin dashboard, or your
     DJ panel — same button, same flow, no matter who you are).
  4. Fill in TOKEN and DISCORD_CLIENT_ID below (or set as environment
     variables MLFM_TOKEN / MLFM_DISCORD_CLIENT_ID).
  5. Run: python3 mlfm_discord_presence.py
  6. (Optional) set it up to autostart — see the systemd unit example
     at the bottom of this file for Arch/Linux.

Notes:
  - Discord's Rich Presence only works while your local Discord desktop
    client is running and you're logged into it — that's a Discord/OS
    constraint, not something this script can work around. No app can
    set your Discord status without something running on your own
    machine; that's true for Spotify, games, and this alike.
  - This script doesn't care which device is actually playing audio.
    As long as it's running on your desktop and the account has an
    active listening heartbeat (from ANY device), the Discord status
    will update.
  - Multiple people can run this at once, no conflicts — it's tied to
    your own token and your own local Discord login, same as anyone
    else running their own copy with their own token.
"""

import os
import sys
import time
import logging

try:
    import requests
except ImportError:
    sys.exit("Missing dependency: pip install requests")

try:
    from pypresence import Presence, DiscordNotFound, PipeClosed
    from pypresence.types import ActivityType
except ImportError:
    sys.exit("Missing dependency: pip install pypresence")

# ── Config — fill these in, or set as environment variables ────────────────
SERVER_URL          = os.environ.get("MLFM_SERVER_URL", "https://memorylane.un-nerv.org/")
TOKEN                = os.environ.get("MLFM_TOKEN", "")
DISCORD_CLIENT_ID   = os.environ.get("MLFM_DISCORD_CLIENT_ID", "")

LARGE_IMAGE_KEY     = "memorylane_logo_1024"   # must match an uploaded Rich Presence art asset name
POLL_INTERVAL_SECS  = 15
RECONNECT_DELAY     = 10

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    datefmt="%H:%M:%S",
)
log = logging.getLogger("mlfm-presence")


def fetch_presence_status() -> dict | None:
    """Hit the Memory Lane FM presence endpoint with a single generic
    token — the server figures out who you are (listener, Grey, DJ Asuka,
    DJ Static, or DJ Nightmare) and tells us via is_admin/is_asuka/
    is_static/is_nightmare in the response. No role to pick up front."""
    try:
        resp = requests.get(
            f"{SERVER_URL.rstrip('/')}/api/user/presence",
            headers={"X-Token": TOKEN},
            timeout=10,
        )
        if resp.status_code == 401:
            log.error("Server rejected the token (401). Copy a fresh one from "
                       "the site and update TOKEN.")
            return None
        resp.raise_for_status()
        return resp.json()
    except requests.RequestException as e:
        log.warning(f"Couldn't reach server: {e}")
        return None


def connect_rpc() -> Presence:
    rpc = Presence(DISCORD_CLIENT_ID)
    rpc.connect()
    log.info("Connected to Discord.")
    return rpc


def _safe_rp_string(value: str, fallback: str) -> str:
    """Discord Rich Presence requires details/state to be 2-128 chars.
    Truncate long titles and fall back on anything too short/empty so a
    single weird track name can't blow up rpc.update()."""
    value = (value or "").strip()
    if len(value) < 2:
        return fallback
    if len(value) > 128:
        return value[:125] + "..."
    return value


def _state_for(status: dict, default: str) -> str:
    """Pick the Rich Presence 'state' line based on who the server says
    this token belongs to — no locally-configured role needed."""
    if status.get("is_admin"):
        return "Hosting Memory Lane FM"
    if status.get("is_asuka") and status.get("asuka_live"):
        return "Live on air"
    if status.get("is_static") and status.get("static_live"):
        return "Live on air"
    if status.get("is_nightmare") and status.get("nightmare_live"):
        return "Live on air"
    return default


def main():
    if not TOKEN:
        sys.exit("Set MLFM_TOKEN (env var) or fill in TOKEN in the script.")
    if not DISCORD_CLIENT_ID:
        sys.exit("Set MLFM_DISCORD_CLIENT_ID (env var) or fill in DISCORD_CLIENT_ID in the script.")

    rpc = None
    last_key = None      # (track, show) of what we last actually sent, or None if cleared
    start_ts = None

    while True:
        # Reconnect to Discord if needed
        if rpc is None:
            try:
                rpc = connect_rpc()
            except (DiscordNotFound, Exception) as e:
                log.warning(f"Discord not reachable yet ({e}). Retrying in {RECONNECT_DELAY}s.")
                time.sleep(RECONNECT_DELAY)
                continue

        status = fetch_presence_status()

        if status is None:
            # Network/auth hiccup — leave existing presence alone, just retry later
            time.sleep(POLL_INTERVAL_SECS)
            continue

        if status.get("listening"):
            track = _safe_rp_string(status.get("now_playing"), "Memory Lane FM")
            show  = _state_for(status, _safe_rp_string(status.get("show"), "Memory Lane FM"))
            key = (track, show)
            # Only push an update when something actually changed — resending
            # an identical update every poll would reset Discord's shown
            # elapsed-time clock back to zero every cycle instead of letting
            # it count up properly.
            #
            # EXPERIMENT: back to activity_type=LISTENING, this time with a
            # party field included. A plain Listening-type activity (no
            # party) was confirmed to drop the large_image art asset —
            # Discord's client likely treats a party-less Listening activity
            # as needing Spotify-specific handling to render assets. Adding
            # a party (even a fake single-person one — nobody can actually
            # join) is a common trick other custom Rich Presence tools use
            # to get Discord to render it as a normal activity instead.
            # If this still drops the art, revert activity_type back to
            # None/Playing — the art asset mattering more than the wording.
            if key != last_key:
                if last_key is None:
                    start_ts = int(time.time())
                try:
                    rpc.update(
                        details=track,
                        state=show,
                        activity_type=ActivityType.LISTENING,
                        large_image=LARGE_IMAGE_KEY,
                        large_text="Memory Lane FM",
                        start=start_ts,
                        party_id="mlfm-listening",
                        party_size=[1, 1],
                    )
                    log.info(f"Presence updated: {track} — {show}")
                    last_key = key
                except PipeClosed as e:
                    # The pipe to Discord is actually gone — reconnect next loop.
                    log.warning(f"Lost connection to Discord ({e}). Reconnecting.")
                    rpc = None
                    last_key = None
                except Exception as e:
                    # Some other error (e.g. a malformed payload) — connection to
                    # Discord is still alive, so don't tear it down or reset
                    # last_key. Just log it and try again on the next poll
                    # with whatever the next track brings.
                    log.warning(f"Presence update failed, will retry next poll ({e}).")
        else:
            if last_key is not None:
                try:
                    rpc.clear()
                    log.info("Cleared presence (not listening).")
                except PipeClosed as e:
                    log.warning(f"Lost connection to Discord while clearing ({e}).")
                    rpc = None
                except Exception as e:
                    log.warning(f"Clearing presence failed, will retry next poll ({e}).")
                last_key = None
                start_ts = None

        time.sleep(POLL_INTERVAL_SECS)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print()
        log.info("Stopped.")


# ── Optional: autostart on Arch Linux via systemd user service ─────────────
# Save as ~/.config/systemd/user/mlfm-presence.service:
#
#   [Unit]
#   Description=Memory Lane FM Discord Rich Presence
#   After=graphical-session.target
#
#   [Service]
#   ExecStart=/usr/bin/python3 /path/to/mlfm_discord_presence.py
#   Environment=MLFM_SERVER_URL=https://your-domain-or-ip
#   Environment=MLFM_TOKEN=your_token_here
#   Environment=MLFM_DISCORD_CLIENT_ID=your_app_id_here
#   Restart=on-failure
#   RestartSec=10
#
#   [Install]
#   WantedBy=default.target
#
# Then:
#   systemctl --user daemon-reload
#   systemctl --user enable --now mlfm-presence.service
