#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-only
"""Melody panel plugin (ADR-0291): the playing artist and album, from public
data.

MusicBrainz finds the album playing and who made it -- the artist by name
where the album is not found -- and their Wikidata entries name the
Wikipedia articles, whose summaries the panel shows: the artist's with its
photo, the album's with its date, kind and label. Links go to both, and to
where else the album is: Discogs, Bandcamp, Rate Your Music, AllMusic.
Nothing is asked of anyone's own server and no account is needed. What is
found is kept a week in the cache folder, so the same artist or album is not
looked up every track; MusicBrainz is asked at most once a second, as it
asks -- by every run together, the panel being run anew for each track --
and a 503, its answer to being asked too often, is asked again after the
wait it names.

Where the files carry their MusicBrainz ids -- Melody tells a panel every
tag (ADR-0301) -- they are used, and nothing is searched for by name.

Beneath them, the artist's missing albums: their albums and EPs on
MusicBrainz -- not compilations, live albums or the like -- that are not
among the artist's albums in the library, which Melody tells the panel
(ADR-0301), matched by release group, else by title. For an artist
MusicBrainz does not know, Discogs's, with a Discogs token. What MusicBrainz
and Discogs list is kept a day; the library is told anew every time.

Badges above the missing albums show or hide the albums and the EPs, the
choice kept in the state folder. Albums are drawn as Melody draws a track's
row (ADR-0304), with their covers: the Cover Art Archive's for a release
group -- else, with a Discogs token, the thumbnail of the Discogs master
MusicBrainz links that release group to, for the archive's images are kept
on archive.org, which at times does not answer at all. Covers are fetched side by side for a few
seconds at most and kept a week -- and so is knowing there is none; one
that could not be fetched is tried again after an hour.
"""
import fcntl
import hashlib
import json
import os
import queue
import re
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request

AGENT = "Melody-about/1 (https://github.com/carnager/melody-next)"
KEPT = 7 * 24 * 3600
LISTED = 24 * 3600
# What is chosen to be shown -- the missing albums' filters -- kept apart
# from the cache, which may be cleared.
STATE = os.path.join(os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state"),
                     "melody", "plugins", "about")
# The kinds the missing albums are filtered by, and their badges.
KINDS = (("album", "Albums"), ("ep", "EPs"))
CACHE = os.path.join(os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"),
                     "melody", "plugins", "about")
TRIES = 4
# How long a run waits for covers it has not kept: those not there by then
# are shown the next time it is asked.
COVERS_WAIT = 6.0
# How long a cover that could not be fetched is left before trying again.
RETRY = 3600
COVERS_UNTIL = None

# Where else an album is, by the host of a MusicBrainz link -- and, where it
# has none, a search there.
SITES = (
    ("Discogs", "discogs.com",
     lambda artist, album: "https://www.discogs.com/search/?type=master&q="
     + urllib.parse.quote_plus("%s %s" % (artist, album))),
    ("Bandcamp", "bandcamp.com",
     lambda artist, album: "https://bandcamp.com/search?item_type=a&q="
     + urllib.parse.quote_plus("%s %s" % (artist, album))),
    ("Rate Your Music", "rateyourmusic.com",
     lambda artist, album: "https://rateyourmusic.com/search?searchtype=l&searchterm="
     + urllib.parse.quote_plus("%s %s" % (artist, album))),
    ("AllMusic", "allmusic.com",
     lambda artist, album: "https://www.allmusic.com/search/albums/"
     + urllib.parse.quote("%s %s" % (artist, album))),
)


def fail(message):
    print(message, file=sys.stderr)
    sys.exit(1)


def musicbrainz_turn():
    """Waits until a second has passed since any run last asked MusicBrainz.

    The time is kept in a file every run locks while it waits, so runs that
    overlap -- one track after another, a refresh -- take turns.
    """
    os.makedirs(CACHE, exist_ok=True)
    with open(os.path.join(CACHE, "musicbrainz.turn"), "a+", encoding="utf-8") as turn:
        fcntl.flock(turn, fcntl.LOCK_EX)
        turn.seek(0)
        try:
            last = float(turn.read().strip() or 0)
        except ValueError:
            last = 0.0
        wait = 1.1 - (time.time() - last)
        if 0 < wait <= 1.1:
            time.sleep(wait)
        turn.seek(0)
        turn.truncate()
        turn.write("%.3f" % time.time())
        turn.flush()


def fetch(url, binary=False, headers=None):
    host = urllib.parse.urlsplit(url).netloc
    for attempt in range(TRIES):
        if host == "musicbrainz.org":
            musicbrainz_turn()
        request = urllib.request.Request(url, headers={"User-Agent": AGENT, **(headers or {})})
        try:
            with urllib.request.urlopen(request, timeout=20) as response:
                body = response.read()
            return body if binary else json.loads(body)
        except urllib.error.HTTPError as error:
            if error.code == 404:
                return None
            # Asked too often, or busy: again after the wait it names.
            if error.code in (429, 503) and attempt + 1 < TRIES:
                try:
                    wait = float(error.headers.get("Retry-After") or 0)
                except ValueError:
                    wait = 0
                time.sleep(min(max(wait, 1.0 + attempt), 8.0))
                continue
            fail("%s answered %s%s" % (host, error.code,
                                       ", too often asked; try again in a while"
                                       if error.code in (429, 503) else ""))
        except Exception as error:
            fail("%s: %s" % (host, error))
    return None


def cached(name, kept_for=KEPT):
    path = os.path.join(CACHE, name)
    try:
        if time.time() - os.path.getmtime(path) < kept_for:
            with open(path, encoding="utf-8") as kept:
                return json.load(kept)
    except (OSError, ValueError):
        pass
    return None


def keep(name, value):
    os.makedirs(CACHE, exist_ok=True)
    path = os.path.join(CACHE, name)
    with open(path + ".partial", "w", encoding="utf-8") as kept:
        json.dump(value, kept)
    os.replace(path + ".partial", path)


def shown_kinds():
    """The kinds of missing album chosen to be shown: every one, until a
    badge is pressed."""
    try:
        with open(os.path.join(STATE, "filters.json"), encoding="utf-8") as kept:
            hidden = set(json.load(kept).get("hidden") or [])
    except (OSError, ValueError, AttributeError):
        hidden = set()
    return {kind for kind, _ in KINDS if kind not in hidden}


def keep_shown(shown):
    os.makedirs(STATE, exist_ok=True)
    path = os.path.join(STATE, "filters.json")
    with open(path + ".partial", "w", encoding="utf-8") as kept:
        json.dump({"hidden": [kind for kind, _ in KINDS if kind not in shown]}, kept)
    os.replace(path + ".partial", path)


def kind_of(album):
    # Discogs's masters say no kind: albums.
    return "ep" if (album.get("kind") or "").casefold() == "ep" else "album"


def cover_file(url):
    """The cover at `url`, kept as a file, or None: fetched at most once a
    week, a missing one too. Never fails the run -- a cover is not needed.

    A fetch that failed, or was still on its way when its run ended, is not
    tried again for an hour: a server that hangs -- archive.org, at times --
    costs one wait, not one every track."""
    name = hashlib.sha1(url.encode("utf-8")).hexdigest()
    folder = os.path.join(CACHE, "covers")
    path = os.path.join(folder, name + ".jpg")
    none = os.path.join(folder, name + ".none")
    tried = os.path.join(folder, name + ".tried")
    for there, answer, kept_for in ((path, path, KEPT), (none, None, KEPT), (tried, None, RETRY)):
        try:
            if time.time() - os.path.getmtime(there) < kept_for:
                return answer
        except OSError:
            pass
    os.makedirs(folder, exist_ok=True)
    # Said before asking: a run that ends while this one waits leaves it.
    open(tried, "w").close()
    request = urllib.request.Request(url, headers={"User-Agent": AGENT})
    try:
        with urllib.request.urlopen(request, timeout=COVERS_WAIT + 4) as response:
            body = response.read()
    except urllib.error.HTTPError as error:
        if error.code == 404:
            open(none, "w").close()
        return None
    except Exception:
        return None
    if not body:
        return None
    partial = "%s.%d.partial" % (path, os.getpid())
    with open(partial, "wb") as kept:
        kept.write(body)
    os.replace(partial, path)
    try:
        os.remove(tried)
    except OSError:
        pass
    return path


def covers_of(urls):
    """The covers of `urls`, by URL, those that came within COVERS_WAIT --
    fetched side by side, a few threads for each server, so that one that
    hangs -- archive.org, at times -- holds up none of another's; one still
    on its way when the time is up is tried again in an hour (cover_file)."""
    urls = list(dict.fromkeys(u for u in urls if u))
    found = {}
    by_server = {}
    for url in urls:
        by_server.setdefault(urllib.parse.urlsplit(url).netloc, queue.Queue()).put(url)
    done = threading.Semaphore(0)

    def work(waiting):
        while True:
            try:
                url = waiting.get_nowait()
            except queue.Empty:
                return
            found[url] = cover_file(url)
            done.release()

    for waiting in by_server.values():
        for _ in range(min(4, waiting.qsize())):
            threading.Thread(target=work, args=(waiting,), daemon=True).start()
    # One wait for the run, however many times it asks: the album's cover
    # and the missing albums' together.
    global COVERS_UNTIL
    if COVERS_UNTIL is None:
        COVERS_UNTIL = time.monotonic() + COVERS_WAIT
    until = COVERS_UNTIL
    for _ in urls:
        if not done.acquire(timeout=max(0.0, until - time.monotonic())):
            break
    return {url: path for url, path in dict(found).items() if path}


def master_of(url):
    """The Discogs master a link names, or "": MusicBrainz links an album's
    release group to it."""
    found = re.search(r"discogs\.com/(?:[a-z]{2}/)?master/(\d+)", url or "")
    return found.group(1) if found else ""


def master_thumb(master, token):
    """The thumbnail of a Discogs master's first image -- from Discogs's own
    servers, for when the Cover Art Archive's, kept on archive.org, does not
    come. Kept a week, none too; "" without a token, or when Discogs does not
    answer (asked again next time)."""
    if not master or not token:
        return ""
    key = "master-" + master
    known = cached(key)
    if known is not None:
        return known.get("thumb") or ""
    try:
        found = fetch("https://api.discogs.com/masters/" + master,
                      headers={"Authorization": "Discogs token=" + token}) or {}
    except SystemExit:
        return ""
    images = found.get("images") or []
    primary = next((i for i in images if i.get("type") == "primary"), images[0] if images else {})
    thumb = primary.get("uri150") or primary.get("uri") or ""
    keep(key, {"thumb": thumb})
    return thumb


def master_thumbs(masters, token):
    """The thumbnails of `masters`, asked side by side for a few seconds at
    most; those not answered by then are asked again next time."""
    masters = [m for m in dict.fromkeys(masters) if m]
    found = {}
    if not masters or not token:
        return found

    def ask(master):
        found[master] = master_thumb(master, token)

    threads = [threading.Thread(target=ask, args=(m,), daemon=True) for m in masters[:12]]
    for thread in threads:
        thread.start()
    until = time.monotonic() + COVERS_WAIT / 2
    for thread in threads:
        thread.join(max(0.0, until - time.monotonic()))
    return dict(found)


def cover_choices(group_id, thumb):
    """Where an album's cover may be had, best first: the Cover Art
    Archive's for its release group, else a Discogs thumbnail."""
    return [url for url in (group_cover(group_id) if group_id else "", thumb or "") if url]


def first_cover(choices, covers):
    return next((covers[url] for url in choices if covers.get(url)), "")


def group_cover(group_id):
    return "https://coverartarchive.org/release-group/%s/front-250" % group_id


def quoted(text):
    return '"%s"' % text.replace("\\", "\\\\").replace('"', '\\"')


def first_tag(tags, name):
    values = tags.get(name) or []
    return str(values[0]).strip() if values else ""


def identify(artist, album, tags):
    """The MusicBrainz album playing and its artist: by the ids the files
    carry, else searched for -- the artist by name where the album is not
    found."""
    named = bool(first_tag(tags, "albumartist"))
    known_artist = first_tag(tags, "musicbrainzalbumartistid" if named else "musicbrainzartistid")
    known_album = first_tag(tags, "musicbrainzreleasegroupid")
    if known_artist and (known_album or not album):
        return known_artist, known_album or None
    found_artist, found_album = search(artist, album)
    return known_artist or found_artist, known_album or found_album


def search(artist, album):
    key = "id-" + hashlib.sha1(("%s\x1f%s" % (artist.casefold(), album.casefold()))
                               .encode("utf-8")).hexdigest()
    known = cached(key)
    if known is not None:
        return known.get("artist"), known.get("album")
    found_artist = found_album = None
    if album:
        query = "releasegroup:%s AND artist:%s" % (quoted(album), quoted(artist))
        groups = (fetch("https://musicbrainz.org/ws/2/release-group/?fmt=json&limit=5&query="
                        + urllib.parse.quote(query)) or {}).get("release-groups") or []
        for group in groups:
            credits = group.get("artist-credit") or []
            if group.get("score", 0) >= 90 and credits:
                found_artist = credits[0]["artist"]["id"]
                found_album = group["id"]
                break
    if found_artist is None:
        artists = (fetch("https://musicbrainz.org/ws/2/artist/?fmt=json&limit=3&query="
                         + urllib.parse.quote("artist:" + quoted(artist))) or {}).get("artists") or []
        exact = [a for a in artists if a.get("score", 0) >= 95
                 and a.get("name", "").casefold() == artist.casefold()]
        found_artist = exact[0]["id"] if exact else None
    keep(key, {"artist": found_artist, "album": found_album})
    return found_artist, found_album


def wikipedia(wikidata, language):
    """The summary of the Wikipedia article a Wikidata entry names, in the
    language asked for, else in English."""
    if not wikidata:
        return None
    entity = (fetch("https://www.wikidata.org/wiki/Special:EntityData/%s.json" % wikidata)
              or {}).get("entities", {}).get(wikidata, {})
    sitelinks = entity.get("sitelinks") or {}
    for wiki in dict.fromkeys([language, "en"]):
        title = (sitelinks.get(wiki + "wiki") or {}).get("title")
        if title:
            return fetch("https://%s.wikipedia.org/api/rest_v1/page/summary/%s"
                         % (wiki, urllib.parse.quote(title.replace(" ", "_"))))
    return None


def page_of(summary):
    return ((summary or {}).get("content_urls") or {}).get("desktop", {}).get("page", "")


def wikidata_of(relations):
    for relation in relations or []:
        if relation.get("type") == "wikidata":
            url = (relation.get("url") or {}).get("resource") or ""
            return url.rstrip("/").rsplit("/", 1)[-1]
    return None


def about_artist(mbid, language):
    """What is said of the artist, gathered once a week."""
    key = "artist-%s-%s" % (mbid, language)
    known = cached(key)
    if known is not None:
        return known
    artist = fetch("https://musicbrainz.org/ws/2/artist/%s?inc=url-rels&fmt=json" % mbid) or {}
    links = {}
    for relation in artist.get("relations") or []:
        url = (relation.get("url") or {}).get("resource") or ""
        if relation.get("type") == "official homepage" and url.startswith("http"):
            links.setdefault("Official site", url)
    links["MusicBrainz"] = "https://musicbrainz.org/artist/" + mbid
    summary = wikipedia(wikidata_of(artist.get("relations")), language)
    image = None
    if summary:
        if page_of(summary):
            links = {"Wikipedia": page_of(summary), **links}
        thumbnail = (summary.get("thumbnail") or {}).get("source")
        if thumbnail:
            picture = fetch(thumbnail, binary=True)
            if picture:
                os.makedirs(CACHE, exist_ok=True)
                image = os.path.join(CACHE, "%s.jpg" % mbid)
                with open(image + ".partial", "wb") as kept:
                    kept.write(picture)
                os.replace(image + ".partial", image)
    span = artist.get("life-span") or {}
    begun = (span.get("begin") or "")[:4]
    ended = (span.get("end") or "")[:4]
    # Where it began and where it is -- once, when they are the same.
    where = ", ".join(dict.fromkeys(n for n in ((artist.get("begin-area") or {}).get("name"),
                                                (artist.get("area") or {}).get("name")) if n))
    told = {
        "name": artist.get("name") or "",
        "kind": artist.get("type") or "",
        "where": where,
        "when": "%s – %s" % (begun, ended) if ended else ("since %s" % begun if begun else ""),
        "summary": (summary or {}).get("extract") or "",
        "page": page_of(summary),
        "image": image or "",
        "links": links,
    }
    keep(key, told)
    return told


def about_album(mbid, language):
    """What is said of the album, gathered once a week."""
    key = "album-%s-%s" % (mbid, language)
    known = cached(key)
    if known is not None:
        return known
    group = fetch("https://musicbrainz.org/ws/2/release-group/%s?inc=url-rels+artist-credits"
                  "&fmt=json" % mbid) or {}
    # The label of its first release that names one.
    releases = (fetch("https://musicbrainz.org/ws/2/release?release-group=%s&inc=labels"
                      "&fmt=json&limit=25" % mbid) or {}).get("releases") or []
    label = ""
    for release in sorted(releases, key=lambda r: r.get("date") or "9999"):
        names = [(info.get("label") or {}).get("name") for info in release.get("label-info") or []]
        names = [n for n in dict.fromkeys(names) if n and n != "[no label]"]
        if names:
            label = ", ".join(names)
            break
    links = {}
    summary = wikipedia(wikidata_of(group.get("relations")), language)
    if page_of(summary):
        links["Wikipedia"] = page_of(summary)
    links["MusicBrainz"] = "https://musicbrainz.org/release-group/" + mbid
    for relation in group.get("relations") or []:
        url = (relation.get("url") or {}).get("resource") or ""
        host = urllib.parse.urlsplit(url).netloc
        for name, site, _ in SITES:
            if host == site or host.endswith("." + site):
                links.setdefault(name, url)
    kinds = [group.get("primary-type")] + list(group.get("secondary-types") or [])
    told = {
        "title": group.get("title") or "",
        "artist": "".join(credit.get("name", "") + credit.get("joinphrase", "")
                          for credit in group.get("artist-credit") or []),
        "kind": " · ".join(k for k in kinds if k),
        "date": group.get("first-release-date") or "",
        "label": label,
        "summary": (summary or {}).get("extract") or "",
        "links": links,
    }
    keep(key, told)
    return told


def folded(title):
    """A title as compared: case, punctuation and spacing aside."""
    return " ".join(re.sub(r"[^\w\s]", " ", title.casefold()).split())


def musicbrainz_albums(artist_id):
    """The artist's albums and EPs on MusicBrainz -- none of a secondary
    kind, compilations and live albums -- kept a day."""
    # "albums2": kept since each album carries its Discogs master.
    key = "albums2-" + artist_id
    known = cached(key, LISTED)
    if known is not None:
        return known
    groups, offset = [], 0
    while offset < 500:
        page = fetch("https://musicbrainz.org/ws/2/release-group?artist=%s&type=album|ep"
                     "&inc=url-rels&fmt=json&limit=100&offset=%d" % (artist_id, offset)) or {}
        listed = page.get("release-groups") or []
        groups += listed
        offset += len(listed)
        if not listed or offset >= page.get("release-group-count", 0):
            break
    albums = [{"id": group["id"], "title": group.get("title") or "",
               "date": group.get("first-release-date") or "",
               "kind": group.get("primary-type") or "",
               "url": "https://musicbrainz.org/release-group/" + group["id"],
               "master": next((master_of((r.get("url") or {}).get("resource"))
                               for r in group.get("relations") or []
                               if master_of((r.get("url") or {}).get("resource"))), "")}
              for group in groups if not group.get("secondary-types")]
    keep(key, albums)
    return albums


def discogs_albums(artist, token):
    """The artist's albums on Discogs -- the masters of their own releases
    -- for an artist MusicBrainz does not know; kept a day."""
    # "discogs3": kept since the listing has each album's thumbnail, and
    # every page of the artist's releases.
    key = "discogs3-" + hashlib.sha1(artist.casefold().encode("utf-8")).hexdigest()
    known = cached(key, LISTED)
    if known is not None:
        return known
    headers = {"Authorization": "Discogs token=" + token}
    found = (fetch("https://api.discogs.com/database/search?type=artist&per_page=5&q="
                   + urllib.parse.quote(artist), headers=headers) or {}).get("results") or []
    same = [r for r in found if folded(re.sub(r" \(\d+\)$", "", r.get("title") or ""))
            == folded(artist)]
    albums = []
    if same:
        # Every page: an artist of many singles and promos has its later
        # albums far past the first hundred releases. Ten at most.
        releases, number, pages = [], 1, 1
        while number <= min(pages, 10):
            page = fetch("https://api.discogs.com/artists/%s/releases?sort=year&per_page=100"
                         "&page=%d" % (same[0]["id"], number), headers=headers) or {}
            releases += page.get("releases") or []
            pages = (page.get("pagination") or {}).get("pages") or 1
            number += 1
        albums = [{"id": "", "title": r.get("title") or "", "date": str(r.get("year") or ""),
                   "kind": "", "url": "https://www.discogs.com/master/%s" % r["id"],
                   "thumb": r.get("thumb") or ""}
                  for r in releases
                  if r.get("type") == "master" and r.get("role") == "Main"]
    keep(key, albums)
    return albums


def missing_albums(artist_id, artist, owned, settings):
    """The parts for the artist's albums not in the library -- `owned`, the
    albums Melody says the library has of them -- beneath a heading."""
    token = str(settings.get("discogs_token") or "").strip()
    if artist_id:
        listed = musicbrainz_albums(artist_id)
    elif token:
        listed = discogs_albums(artist, token)
    else:
        return [{"heading": "Missing albums"},
                {"paragraph": "MusicBrainz knows no artist of that name for certain; a Discogs "
                              "token in About's settings would ask Discogs."}]
    groups = set()
    titles = set()
    for album in owned:
        groups.update((album.get("tags") or {}).get("musicbrainzreleasegroupid") or [])
        titles.add(folded(album.get("album") or ""))
    missing = sorted((a for a in listed
                      if a["id"] not in groups and folded(a["title"]) not in titles),
                     key=lambda a: a["date"] or "9999")
    if not missing:
        return [{"heading": "Missing albums"},
                {"paragraph": "Every album and EP %s lists is in the library."
                              % ("MusicBrainz" if artist_id else "Discogs")}]
    # Badges for the kinds there are, each with how many; pressed, the panel
    # is asked again (ADR-0303) and the choice kept.
    shown = shown_kinds()
    counts = {kind: sum(1 for a in missing if kind_of(a) == kind) for kind, _ in KINDS}
    parts = [{"heading": "Missing albums (%d)" % len(missing)}]
    if sum(1 for count in counts.values() if count) > 1:
        parts.append({"filters": [
            {"text": "%s %d" % (label, counts[kind]), "action": "filter", "value": kind,
             "on": kind in shown}
            for kind, label in KINDS if counts[kind]]})
    else:
        shown = set(counts)
    missing = [a for a in missing if kind_of(a) in shown]
    if not missing:
        return parts + [{"paragraph": "Hidden by the filters above."}]
    # Each cover from the Cover Art Archive, else from Discogs -- asked for
    # side by side, the first there taken.
    thumbs = master_thumbs((a.get("master") for a in missing), token)
    choices = [cover_choices(a["id"], a.get("thumb") or thumbs.get(a.get("master") or ""))
               for a in missing]
    covers = covers_of(url for each in choices for url in each)
    rows = []
    for a, each in zip(missing, choices):
        row = {"track": a["title"] or "?", "url": a["url"]}
        detail = " · ".join(p for p in (a["date"][:4], a["kind"]) if p)
        if detail:
            row["detail"] = detail
        cover = first_cover(each, covers)
        if cover:
            row["cover"] = cover
        rows.append(row)
    return parts + rows


def main():
    given = json.load(sys.stdin)
    # A filter's badge pressed: that kind shown or hidden from now on.
    action = given.get("action") or {}
    if action.get("id") == "filter" and action.get("value") in dict(KINDS):
        shown = shown_kinds()
        shown ^= {action["value"]}
        keep_shown(shown)
    playing = given.get("playing") or {}
    settings = given.get("settings") or {}
    artist = (playing.get("album_artist") or playing.get("artist") or "").strip()
    album = (playing.get("album") or "").strip()
    language = str(settings.get("language") or "en").strip().lower() or "en"
    show_artist = settings.get("artist", True) is not False
    show_album = settings.get("album", True) is not False and bool(album)
    tags = playing.get("tags") or {}
    owned = given.get("artist_albums")
    # Told no albums of the library -- a Melody before ADR-0301 -- it cannot
    # say which are missing.
    show_missing = settings.get("missing", True) is not False and isinstance(owned, list)
    if not artist:
        print(json.dumps({"title": album, "parts": [
            {"paragraph": "The track names no artist."}]}))
        return
    artist_id, album_id = identify(artist, album, tags)
    parts = []
    title = artist
    if show_artist:
        if artist_id:
            told = about_artist(artist_id, language)
            title = told["name"] or artist
            if told["image"] and os.path.exists(told["image"]):
                parts.append({"image": told["image"],
                              **({"url": told["page"]} if told["page"] else {})})
            line = " from ".join(p for p in (told["kind"], told["where"]) if p)
            if line or told["when"]:
                parts.append({"text": line or told["name"],
                              **({"detail": told["when"]} if told["when"] else {})})
            if told["summary"]:
                parts.append({"paragraph": told["summary"]})
            # Its links beneath it, side by side.
            parts += [{"link": name, "detail": title, "url": url}
                      for name, url in told["links"].items()]
        else:
            parts.append({"paragraph": "MusicBrainz knows no artist of that name for certain."})
    if show_album:
        if album_id:
            told = about_album(album_id, language)
            name = told["title"] or album
            album_artist = told["artist"] or artist
            found = dict(told["links"])
        else:
            name, album_artist, found = album, artist, {}
        if show_artist:
            parts.append({"heading": "Album"})
        else:
            title = name
        # The album as Melody draws what plays: its cover, its title over
        # its kind, year and label.
        row = {"track": name, "playing": True}
        if album_id:
            row["artist"] = " · ".join(p for p in (told["kind"], told["date"][:4]) if p)
            if told["label"]:
                row["detail"] = told["label"]
            if told.get("links", {}).get("MusicBrainz"):
                row["url"] = told["links"]["MusicBrainz"]
        elif album_artist:
            row["artist"] = album_artist
        # Its cover from the Cover Art Archive, else from the Discogs master
        # MusicBrainz links it to.
        master = master_of(found.get("Discogs"))
        token = str(settings.get("discogs_token") or "").strip()
        choices = cover_choices(album_id, master_thumbs([master], token).get(master))
        cover = first_cover(choices, covers_of(choices))
        if cover:
            row["cover"] = cover
        parts.append(row)
        if album_id:
            if told["summary"]:
                parts.append({"paragraph": told["summary"]})
        else:
            parts.append({"paragraph": "MusicBrainz knows no album of that name for certain."})
        # Where it has no link to a site, a search there; the sites always
        # in the same order.
        for site, _, search in SITES:
            found[site] = found.get(site) or search(album_artist, name)
        order = ["Wikipedia", "MusicBrainz"] + [site for site, _, _ in SITES]
        parts += [{"link": site, "detail": name, "url": found[site]}
                  for site in order if site in found]
    if show_missing:
        parts += missing_albums(artist_id, artist, owned, settings)
    print(json.dumps({"title": title, "parts": parts}))


if __name__ == "__main__":
    main()
