#!/usr/bin/env python3
"""Feeds the eww now-playing widget one JSON object per MPRIS change.

Installed to /usr/local/bin/now-playing-json, consumed by eww's `deflisten`.

Python rather than shell: track titles are arbitrary user text and have to end up
inside a JSON string, which is exactly the thing shell quoting gets wrong. Python 3 is
already a hard dependency of the image (thinclient-agent runs on it), so this costs
nothing extra.

`playerctl --follow` rather than the polling loop in thinclient_agent/mpris_bridge.py:
that module is a system service that must survive the session bus disappearing, so it
polls. This runs inside the session and can afford to block on a subscription, which
makes the widget update on the beat instead of up to 2s late.
"""

from __future__ import annotations

import base64
import hashlib
import json
import os
import subprocess
import sys
import urllib.parse
import urllib.request

# Mirrors thinclient_agent.mpris_bridge.PLAYER_PRIORITY and the `playerctl -p` lists in
# configs/sway/config and configs/eww/eww.yuck — keep the four in step.
PLAYER_PRIORITY = "mpv,spotifyd,%any"

SEP = "\x1f"
FORMAT = SEP.join(
    ("{{status}}", "{{title}}", "{{artist}}", "{{album}}", "{{mpris:artUrl}}")
)

CACHE_DIR = os.path.join(
    os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"),
    "now-playing",
)

# eww's image widget logs an error and leaves a broken box when :path does not resolve,
# so there is always a real file to point at.
PLACEHOLDER_PNG = base64.b64decode(
    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)


def placeholder_path() -> str:
    path = os.path.join(CACHE_DIR, "placeholder.png")
    if not os.path.exists(path):
        with open(path, "wb") as handle:
            handle.write(PLACEHOLDER_PNG)
    return path


def cache_art(art_url: str) -> str:
    if not art_url:
        return ""

    parsed = urllib.parse.urlparse(art_url)
    if parsed.scheme == "file":
        local = urllib.parse.unquote(parsed.path)
        return local if os.path.isfile(local) else ""

    if parsed.scheme not in ("http", "https"):
        return ""

    # Cached by URL digest so a repeated track does not re-fetch, and so nothing from
    # the (remote, untrusted) URL ever reaches the filesystem as a path component.
    target = os.path.join(CACHE_DIR, hashlib.sha256(art_url.encode()).hexdigest() + ".img")
    if os.path.exists(target):
        return target

    try:
        with urllib.request.urlopen(art_url, timeout=5) as response:
            data = response.read(4 * 1024 * 1024)
    except Exception:
        return ""

    tmp = target + ".part"
    with open(tmp, "wb") as handle:
        handle.write(data)
    os.replace(tmp, target)
    return target


def emit(state: dict) -> None:
    sys.stdout.write(json.dumps(state) + "\n")
    sys.stdout.flush()


def blank() -> dict:
    return {
        "visible": False,
        "title": "",
        "artist": "",
        "album": "",
        "art": placeholder_path(),
        "controls": False,
    }


def parse(line: str) -> dict:
    fields = (line.split(SEP) + [""] * 5)[:5]
    status, title, artist, album, art_url = (f.strip() for f in fields)

    if status not in ("Playing", "Paused"):
        return blank()

    art = cache_art(art_url)
    return {
        "visible": True,
        "title": title or "Unknown track",
        "artist": artist,
        "album": album,
        # Sources that expose no art degrade to text-only rather than showing a
        # placeholder box; the widget hides the image when this is the 1x1 pixel.
        "art": art or placeholder_path(),
        # playerctl's own transport commands work for any MPRIS player that answers
        # `status`, so controls follow the same signal rather than a separate probe.
        "controls": True,
    }


def main() -> int:
    os.makedirs(CACHE_DIR, exist_ok=True)
    emit(blank())

    process = subprocess.Popen(
        ["playerctl", "-p", PLAYER_PRIORITY, "metadata", "--follow", "--format", FORMAT],
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        text=True,
    )

    assert process.stdout is not None
    for line in process.stdout:
        line = line.rstrip("\n")
        try:
            emit(parse(line) if line else blank())
        except Exception:
            emit(blank())

    # playerctl exits when the session bus goes away, i.e. when sway is going down.
    emit(blank())
    return process.wait()


if __name__ == "__main__":
    raise SystemExit(main())
