prettyalbums.py

#!/usr/bin/env python3
# vim: set ts=4 sw=4 expandtab syntax=python :

import os
import os.path
import html
import json
import hashlib
import textwrap
import urllib.parse as up
import urllib.request as urq

from datetime import datetime, timezone
from pathlib import Path


CONFIG = None
SERVER_TYPE = None


def loadConfig(cfgfile = None):
    if cfgfile is None or cfgfile == '':
        cfgdir = Path(os.path.expanduser(os.getenv('XDG_CONFIG_DIR', '~/.config')))
        cfgfile = cfgdir / 'iris-subsonic.json'

    cfgfile = Path(cfgfile)
    if not cfgfile.is_file():
        raise RuntimeError(f"Configuration file {cfgfile!r} does not exist")

    with open(cfgfile, 'r') as fh:
        return json.loads(fh.read())


def hashPassword(password):
    salt = hashlib.md5(os.urandom(24)).hexdigest()
    hashed = hashlib.md5((password + salt).encode('utf-8')).hexdigest()
    return (salt, hashed)


def apiGet(slug, params = {}):
    if not 'u' in params:
        params['u'] = CONFIG['user']
    if not 'p' in params:
        params['p'] = CONFIG['pass']
    if 'p' in params:
        s, t = hashPassword(params['p'])
        params['s'] = s
        params['t'] = t
        del params['p']

    params['f'] = 'json'
    params['c'] = 'iris-subsonic/0.1'
    params['v'] = '1.16.1'

    url = CONFIG['host'] + slug + '?' + up.urlencode(params)
    with urq.urlopen(url) as resp:
        res = json.loads(resp.read())

    if 'subsonic-response' in res and 'status' in res['subsonic-response']:
        if res['subsonic-response']['status'] == 'ok':
            return res['subsonic-response']

    status = res['subsonic-response']['status']
    raise RuntimeError(f"Subsonic API returned non-ok status for {slug!r}: {status!r}")


def apiPing(params = {}):
    return apiGet('/rest/ping', params)


def apiAlbumList(params = {}):
    if not 'type' in params:
        params['type'] = 'alphabeticalByArtist'
    if not 'size' in params:
        params['size'] = 500

    albums, coffset = ([], 0)
    while True:
        params['offset'] = coffset
        res = apiGet('/rest/getAlbumList2', params)
        if 'album' not in res['albumList2'] or len(res['albumList2']['album']) == 0:
            break

        albums += res['albumList2']['album']
        coffset += len(res['albumList2']['album'])

    return albums


def apiAlbumInfo(params = {}):
    # XXX: this is not implemented in Navidrome
    if SERVER_TYPE in ['navidrome']:
        raise RuntimeError(f"/rest/getAlbumInfo2 not implemented on server type {SERVER_TYPE!r}")

    res = apiGet('/rest/getAlbumInfo2', params)
    if not 'albumInfo' in res:
        return None
    return res['albumInfo']


def renderAlbums():
    albumelements = []
    albums = apiAlbumList({'type': 'alphabeticalByArtist', 'size': 500})
    for entity in albums:
        album_info = {}
        try:
            album_info = apiAlbumInfo({'id': entity['id']})
        except:
            album_info = {}

        bracketed = []
        n_album = html.escape(entity['album'])
        n_artist = html.escape(entity['artist'])

        # if we have a MusicBrainz release ID, include a link to it
        if 'musicBrainzId' in album_info and album_info['musicBrainzId'] is not None:
            mbuuid = album_info['musicBrainzId']
            mblink = html.escape(f"https://musicbrainz.org/release/{mbuuid}")
            bracketed.append(f"<a href=\"{mblink}\" target=\"_blank\">musicbrainz</a>")

        bracketed = ", ".join(bracketed)
        if len(bracketed.strip()) > 0:
            bracketed = f" ({bracketed})"

        albumelements.append(f"<li><strong>{n_album}</strong> - {n_artist}{bracketed}</li>")

    return albumelements


def main():
    global CONFIG
    CONFIG = loadConfig(os.getenv('IRIS_SUBSONIC_CONFIG', None))

    global SERVER_TYPE
    pingres = apiPing()
    SERVER_TYPE = pingres['type']
 
    # template variables!
    host = html.escape(CONFIG['host'])
    ts = html.escape(datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S %Z"))
    albumelements = renderAlbums()
    albumcount = len(albumelements)
    albumelements = textwrap.indent("\n".join(albumelements), '    ' * 3).strip()

    # template
    output = textwrap.dedent(f"""\
    <!DOCTYPE html>
    <html prefix="og: https://ogp.me/ns#">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="initial-scale=1, width=device-width">
        <meta property="og:title" content="Album list for {host}">
        <meta property="og:description" content="As of {ts}, this server has {albumcount} albums.">
        <title>Album list for {host}</title>
        <style>
            *, *::before, *::after {{ box-sizing: border-box; }}
            html, body {{ padding: 0; margin: 0; font-family: sans-serif; }}
            body {{ padding: 1rem; }}
        </style>
    </head>
    <body>
        <h1>
            Album list for
            <a href="{host}" target="_blank">{host}</a>
        </h1>
        <p>
            There is a total of
            <code>{albumcount}</code>
            albums on this server.
        </p>
        <hr>
        <ul class="album-list">
            {albumelements}
        </ul>
        <hr>
        <p>
            List generated at {ts} by
            <a href="https://irys.cc/misc/prettyalbums.py.html" target="_blank">prettyalbums.py</a> :)
        </p>
    </body>
    </html>
    """)

    # and print to stdout :)
    print(output.strip())
    return 0


if __name__ == "__main__":
    exit(main())