"""Using MBXHub from Python -- a worked example.

How to drive a MusicBee library from Python through MBXHub, written for the most
common reason to want to: building the plugin or bridge that lets another service
-- a playlist generator, an analyzer, a home-automation system -- treat MusicBee as
a library it can read from and write back to. It depends on nothing but the Python
standard library and runs standalone; copy it into your project and build on it.

WHAT IT IS: a complete, tested client for the operations such a bridge needs. Every
call below was run end to end against a live hub -- enumerate the library, stream a
track, create a playlist, read it back, append to it, delete it.

WHAT IT IS NOT: a finished plugin for any particular service. Each service has its
own plugin interface -- the function names and return shapes it expects, a
connection check, setup fields. Wrap these methods in whatever yours requires.

MusicBee is a desktop player, not a server: one process, one machine, no network
interface. MBXHub is a MusicBee plugin that gives the running instance an HTTP API
(REST + a WebSocket event stream), by default on port 8080. That is what makes any
of this possible -- without MBXHub there is nothing for another service to call.

Full endpoint reference, including every response field and both authentication
gates: https://mbxhub.com/api.html  (a running hub also serves /docs and /llms.txt,
generated from the same source as the implementation).

The operations, and the MBXHub call behind each:

    list tracks       GET    /library/files            paged; response carries total
    fetch audio       GET    /stream/{quoted path}     Range supported
    create playlist   POST   /playlists                name + ordered files, one call
    append tracks     POST   /playlists/{id}/files
    delete playlist   DELETE /playlists/{id}

Three things to design around, all different from a headless server:

1. **MusicBee has to be running.** The API exists only while the application is
   open. Treat a refused connection as "host asleep", not as a permanent error.
2. **The track identifier is a file path** -- a Windows path, not a ``file://``
   URI. Stable while the library is, orphaned if the user reorganizes folders.
3. **Streaming can be switched off** by the host, and closes to non-local callers
   while a MusicBee party session runs. A 404 from /stream means "not available
   right now", not "file missing".

Usage:

    from mbxhub_client import MBXHubClient

    client = MBXHubClient("http://<ipaddress>:8080", api_key="mbx_...")
    client.test_connection()
    for track in client.iter_tracks():
        ...
    playlist_id = client.create_playlist("My mix", [t["id"] for t in picks])

Self-test against a live hub (enumerates, streams one track, creates a playlist,
reads it back, then deletes it):

    python mbxhub_client.py http://127.0.0.1:8080

License: MIT. Do what you like with it.
"""

import json
import logging
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

log = logging.getLogger(__name__)

REQUESTS_TIMEOUT = 30
PAGE_SIZE = 1000          # hub maximum is 10000; 1000 keeps responses manageable
MAX_RETRIES = 3


class MBXHubError(RuntimeError):
    """An MBXHub call failed in a way the caller should see."""


class MBXHubClient(object):
    """Minimal MBXHub client: library, audio and playlists.

    Authentication is optional and off by default on the hub. Pass whichever the
    host has enabled -- an API key (named, revocable, the right choice for an
    integration) or the shared Basic password. Both travel in the clear over plain
    HTTP; there is no HTTPS by default on a LAN hub.

    An API key must carry Read-write to create playlists; Read is enough to
    enumerate and stream.
    """

    def __init__(self, base_url, api_key=None, password=None, timeout=REQUESTS_TIMEOUT):
        self.base_url = base_url.rstrip("/")
        self.api_key = api_key
        self.password = password
        self.timeout = timeout

    # ---------------------------------------------------------------- plumbing

    def _headers(self):
        headers = {"Accept": "application/json"}
        if self.api_key:
            # Header only. A key in the query string is never accepted, on any
            # route -- the hub's log prints request URLs.
            headers["X-Api-Key"] = self.api_key
        elif self.password:
            import base64
            # The username is ignored; only the password is checked.
            raw = ("x:%s" % self.password).encode("utf-8")
            headers["Authorization"] = "Basic " + base64.b64encode(raw).decode("ascii")
        return headers

    def _request(self, method, path, body=None, raw=False):
        """Issue one request. Returns the unwrapped ``data`` object, or a response
        object when ``raw`` is set (used for audio, which must not be buffered)."""
        url = self.base_url + path
        data = None
        headers = self._headers()
        if body is not None:
            data = json.dumps(body).encode("utf-8")
            headers["Content-Type"] = "application/json"

        request = urllib.request.Request(url, data=data, headers=headers, method=method)

        for attempt in range(1, MAX_RETRIES + 1):
            try:
                log.debug("%s %s (attempt %d)", method, path, attempt)
                response = urllib.request.urlopen(request, timeout=self.timeout)
                if raw:
                    return response
                payload = json.loads(response.read().decode("utf-8"))
                if not payload.get("success"):
                    raise MBXHubError("%s %s: %r" % (method, path, payload))
                return payload.get("data")

            except urllib.error.HTTPError as exc:
                # The hub sheds load rather than queueing: an exhausted stream-slot
                # pool answers 503 with Retry-After. Worth honoring in a bulk pass.
                if exc.code == 503 and attempt < MAX_RETRIES:
                    delay = int(exc.headers.get("Retry-After") or 5)
                    log.warning("hub busy on %s, retrying in %ds", path, delay)
                    time.sleep(delay)
                    continue
                detail = ""
                try:
                    detail = exc.read().decode("utf-8", "replace")[:300]
                except Exception:
                    pass
                if exc.code == 401:
                    raise MBXHubError(
                        "401 on %s -- the hub requires a credential. Supply api_key "
                        "(X-Api-Key) or password. %s" % (path, detail))
                if exc.code == 403:
                    raise MBXHubError(
                        "403 on %s -- refused. A Read-only API key cannot write; "
                        "a track outside the library cannot be streamed. %s"
                        % (path, detail))
                raise MBXHubError("HTTP %d on %s %s: %s"
                                    % (exc.code, method, path, detail))

            except urllib.error.URLError as exc:
                # MusicBee is a desktop app. Connection refused usually means it is
                # simply not running, which is a normal state, not a failure.
                raise MBXHubError(
                    "cannot reach MBXHub at %s (%s). MusicBee must be running with "
                    "the MBXHub plugin enabled." % (self.base_url, exc.reason))

        raise MBXHubError("gave up on %s %s after %d attempts"
                            % (method, path, MAX_RETRIES))

    @staticmethod
    def _quote(track_id):
        """Percent-encode a track or playlist identifier for a path segment.

        The identifier is a Windows file path, so every separator and space has to
        be escaped -- ``safe=''`` leaves nothing unencoded.
        """
        return urllib.parse.quote(track_id, safe="")

    # ----------------------------------------------------------- probe / setup

    def test_connection(self):
        """Verify the hub answers and is serving its REST module."""
        data = self._request("GET", "/system/status")
        status = data.get("status")
        log.info("connected to MBXHub at %s (status: %s)", self.base_url, status)
        return status == "running"

    def list_libraries(self):
        """MusicBee serves one library per running instance.

        There is no library-selection concept to expose, so this reports a single
        entry with the track count, which is what a setup wizard wants to show.

        Asks for 2 rows rather than 1 deliberately. Hubs built before the fix for
        issue I014 (2026-10-04) answer ``limit=1`` from a shortcut that skips CUE
        processing, so its ``total`` counts physical files instead of playable
        tracks (measured: 7544 vs 7661 on the same library). Newer hubs give the
        same total at every page size; asking for 2 is correct on both.
        """
        data = self._request("GET", "/library/files?limit=2")
        return [{"id": "default", "name": "MusicBee library",
                 "track_count": data.get("total", 0)}]

    # ------------------------------------------------------------- the library

    def iter_tracks(self, page_size=PAGE_SIZE):
        """Yield every track in the library, one page at a time.

        ``/library/files`` is paged and the response carries ``total``, so the loop
        terminates on count rather than on an empty page.
        """
        offset = 0
        total = None
        while total is None or offset < total:
            data = self._request(
                "GET", "/library/files?offset=%d&limit=%d" % (offset, page_size))
            total = data.get("total", 0)
            tracks = data.get("tracks") or []
            if not tracks:
                break
            for track in tracks:
                yield self._map_track(track)
            offset += len(tracks)
            log.debug("enumerated %d/%d tracks", offset, total)

    @staticmethod
    def _map_track(row):
        """Map an MBXHub track row onto plain, common field names (a guess at what a
        consumer wants -- rename to suit the tool you are wiring it into).

        ``url`` is the identifier -- pass it back verbatim to stream the track or
        to put it in a playlist. ``duration`` is milliseconds on the wire; seconds
        is the more common currency in analysis code, so both are kept.
        """
        duration_ms = row.get("duration") or 0
        return {
            "id": row.get("url"),
            "title": row.get("title") or "",
            "artist": row.get("artist") or "",
            "album": row.get("album") or "",
            "album_artist": row.get("albumArtist") or "",
            "genre": row.get("genre") or "",
            "year": row.get("year") or "",
            "track_number": row.get("trackNo") or "",
            "duration_ms": duration_ms,
            "duration": duration_ms / 1000.0,
            "play_count": row.get("playCount") or 0,
            "rating": row.get("rating") or "",
            "format": row.get("kind") or "",
        }

    def open_track_audio(self, track_id):
        """Open a track's bytes for reading. Supports Range; caller closes it.

        The hub refuses a path that is not in the library (403) and one that is not
        audio or video (400). A 404 can mean the host disabled streaming or a party
        session is running -- not that the file is missing.
        """
        return self._request(
            "GET", "/stream/" + self._quote(track_id), raw=True)

    def download_track(self, track_id, destination):
        """Stream a track to a local path, without buffering it in memory."""
        response = self.open_track_audio(track_id)
        try:
            with open(destination, "wb") as handle:
                while True:
                    chunk = response.read(64 * 1024)
                    if not chunk:
                        break
                    handle.write(chunk)
        finally:
            response.close()
        log.info("downloaded %s", destination)
        return destination

    # ------------------------------------------------------------- playlists

    def get_playlists(self):
        data = self._request("GET", "/playlists")
        return [{"id": p.get("url"), "name": p.get("name"),
                 "track_count": p.get("trackCount", 0)}
                for p in (data.get("playlists") or [])]

    def create_playlist(self, name, track_ids, folder=""):
        """Create a playlist and fill it in ONE call, order preserved.

        No create-then-add dance and no per-track loop: pass the whole ordered
        list. Needs a Read-write API key when keys are enabled.

        Returns the playlist identifier, used by append and delete.
        """
        body = {"name": name, "files": list(track_ids)}
        if folder:
            body["folder"] = folder
        data = self._request("POST", "/playlists", body=body)
        log.info("created playlist %r with %d tracks",
                 name, data.get("trackCount", 0))
        return data.get("url")

    def add_to_playlist(self, playlist_id, track_ids):
        """Append tracks to an existing playlist."""
        data = self._request(
            "POST", "/playlists/" + self._quote(playlist_id) + "/files",
            body={"files": list(track_ids)})
        return data.get("added", 0)

    def delete_playlist(self, playlist_id):
        self._request("DELETE", "/playlists/" + self._quote(playlist_id))
        log.info("deleted playlist %s", playlist_id)
        return True


# --------------------------------------------------------------------- self-test

def _self_test(base_url, api_key=None, password=None):
    """Exercise every operation against a live hub, then undo the one write.

    Creates a playlist named below and deletes it again. Nothing else is written.
    """
    probe_name = "MBXHub integration sample (safe to delete)"
    client = MBXHubClient(base_url, api_key=api_key, password=password)

    print("1. connection")
    print("   running: %s" % client.test_connection())

    print("2. library")
    for library in client.list_libraries():
        print("   %s: %d tracks" % (library["name"], library["track_count"]))

    print("3. enumerate (first 5 of the paged walk)")
    sample = []
    for track in client.iter_tracks(page_size=50):
        sample.append(track)
        if len(sample) >= 5:
            break
    for track in sample:
        print("   %-28s %-24s %6.1fs" % (track["title"][:28],
                                         track["artist"][:24],
                                         track["duration"]))

    print("4. audio")
    response = client.open_track_audio(sample[0]["id"])
    try:
        head = response.read(8)
        print("   %s -> read %d bytes: %r"
              % (sample[0]["title"][:40], len(head), head))
    finally:
        response.close()

    print("5. create playlist")
    playlist_id = client.create_playlist(probe_name, [t["id"] for t in sample])
    print("   id: %s" % playlist_id)

    print("6. read it back")
    found = [p for p in client.get_playlists() if p["id"] == playlist_id]
    print("   %s" % (found[0] if found else "NOT FOUND"))

    print("7. append one track")
    print("   added: %d" % client.add_to_playlist(playlist_id, [sample[0]["id"]]))

    print("8. delete it again")
    client.delete_playlist(playlist_id)
    still_there = [p for p in client.get_playlists() if p["id"] == playlist_id]
    print("   gone: %s" % (not still_there))

    print("\nall operations succeeded")


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO,
                        format="%(levelname)-7s %(name)s: %(message)s")
    if len(sys.argv) < 2:
        print(__doc__)
        print("usage: python mbxhub_client.py <base-url> [api-key]")
        sys.exit(1)
    _self_test(sys.argv[1], api_key=(sys.argv[2] if len(sys.argv) > 2 else None))
