Emergent:Blu/Patches/sideman-bandcamp

From PickiPedia: A knowledge base of bluegrass, old time psychedelic jams, and other public domain music
Jump to navigationJump to search

Replaces Emergent:Blu/Patches/bandcamp (pickipedia#178): the Bandcamp reader folded into Sideman as a third reading in --album, not a tool of its own (Justin, 7 Nov, block 26,365,089). One commit, on top of Emergent:Blu/Patches/ensemble-pipeline (#177). sha256 of the file: 10e6bedba35a29058e407dd2286500b2bc5bcd67566f8b3183a2e7966c409187. Round-trip checked on production f44da0e + the Sideman fixes + #177: 79 tests pass. tools/ only, nothing to redeploy. For magent: git am onto #177's branch, and close #178.

From da23678c6a1242c63459e100520a43bc84504e83 Mon Sep 17 00:00:00 2001
From: blu <blu@cryptograss.live>
Date: Sat, 7 Nov 2026 19:33:26 +0000
Subject: [PATCH] Sideman --album --bandcamp: a record's Bandcamp page as a
 third reading

Folds the Bandcamp reader into Sideman instead of a tool of its own
(Justin: why a new tool?). Bandcamp is one more source answering "who is
on this record", so it is one more reading in --album.

Bandcamp's credits are prose, so the reading carries them word for word
(credits_text, about_text) and no people: splitting a line into a person
and an instrument is for the person who writes it up. Bandcamp challenges
searches, so the page is a URL a person hands in. Fetched with curl, since
Bandcamp challenges Python's urllib whatever its headers; an argument list,
no shell, one request every 1.5 s. main() moves to argparse for the new
flag. 79 tests.
---
 tools/recording_credits.py      | 142 ++++++++++++++++++++++++++++----
 tools/test_recording_credits.py |  74 ++++++++++++++++-
 2 files changed, 200 insertions(+), 16 deletions(-)

diff --git a/tools/recording_credits.py b/tools/recording_credits.py
index 9cc25c9..2c0358f 100644
--- a/tools/recording_credits.py
+++ b/tools/recording_credits.py
@@ -31,13 +31,27 @@ answer is printed under that source, every person with the roles and the
 scope that source gives them. Nobody is merged across sources; a reader
 sees two readings of the same record and decides. This is what Rambler
 reads, and what a person verifies.
+
+--bandcamp URL adds a third reading: the record's Bandcamp page, where small
+bands put the credits MusicBrainz and Discogs never got. Bandcamp's credits
+are prose ("Joe Biglin - keys", "Recorded at 25 Murray Studios") with no
+fields for who or what, so Sideman copies them word for word and parses
+nothing: splitting a line into a person and an instrument is the judgement
+of the person who writes it up, with the text in front of them. Bandcamp
+challenges searches, so the page is a URL a person hands in.
+
+    python3 tools/recording_credits.py --album "Milkweed" "Milkweed EP" \
+        --bandcamp https://milkweed.bandcamp.com/album/milkweed-ep
 """
 
+import argparse
+import html
 import json
 import re
+import subprocess
 import sys
 import time
-from urllib.parse import urlencode
+from urllib.parse import urlencode, urlparse
 from urllib.error import HTTPError
 from urllib.request import Request, urlopen
 
@@ -409,7 +423,76 @@ def discogs_find_releases(artist, title):
     return _one_per_group_earliest(hits, lambda r: r.get("master_id") or r["id"])
 
 
-def album(artist, title):
+# ---------------------------------------------------------------------------
+# Bandcamp: credits copied, never parsed
+# ---------------------------------------------------------------------------
+
+BANDCAMP_UA = "pickipedia-recording-credits/0.1 (https://pickipedia.xyz; Artificial Dumb)"
+BANDCAMP_INTERVAL = 1.5
+LD_JSON = re.compile(r'<script type="application/ld\+json">\s*(.*?)\s*</script>', re.S)
+
+
+class NotABandcampRecord(Exception):
+    pass
+
+
+def is_bandcamp(url):
+    host = urlparse(url).hostname or ""
+    return host == "bandcamp.com" or host.endswith(".bandcamp.com")
+
+
+def bandcamp_get(url):
+    """(final URL, page), fetched with curl, not urllib.
+
+    Bandcamp answers Python's own HTTPS client with a JavaScript challenge
+    page whatever its headers say, and answers curl with the album (tested
+    7 Nov 2026, same User-Agent both ways). Same honest User-Agent; an
+    argument list, no shell.
+    """
+    wait = BANDCAMP_INTERVAL - (time.monotonic() - _last_call.get("bandcamp", 0.0))
+    if wait > 0:
+        time.sleep(wait)
+    try:
+        out = subprocess.run(
+            ["curl", "-sSL", "--max-time", "30", "-A", BANDCAMP_UA,
+             "-w", "\n%{url_effective}", "--", url],
+            capture_output=True, text=True, check=True).stdout
+    finally:
+        _last_call["bandcamp"] = time.monotonic()
+    page, _, final = out.rpartition("\n")
+    return final, page
+
+
+def _bc_text(value):
+    """Bandcamp's prose as written: entities decoded, line ends made plain."""
+    return html.unescape(value).replace("\r\n", "\n").strip() if value else ""
+
+
+def bandcamp_reading(url, get=bandcamp_get):
+    """A Bandcamp album or track page, from the schema.org JSON-LD it carries:
+    the credits and about text verbatim, and nothing parsed out of them."""
+    final, page = get(url)
+    m = LD_JSON.search(page)
+    d = json.loads(m.group(1)) if m else {}
+    if d.get("@type") not in ("MusicAlbum", "MusicRecording"):
+        raise NotABandcampRecord(f"{url}: no album or track data on the page "
+                                 "(an artist page, or Bandcamp's challenge page)")
+    return {
+        "source": "Bandcamp",
+        "url": final,
+        "id": final,
+        "group": f"bandcamp-{final}",
+        "artist": _bc_text((d.get("byArtist") or {}).get("name")),
+        "title": _bc_text(d.get("name")),
+        "date": re.sub(r"\s+00:00:00 GMT$", "", d.get("datePublished") or ""),
+        "tracks": len((d.get("track") or {}).get("itemListElement", [])),
+        "people": {},
+        "credits_text": _bc_text(d.get("creditText")),
+        "about_text": _bc_text(d.get("description")),
+    }
+
+
+def album(artist, title, bandcamp=None, bandcamp_get=bandcamp_get):
     """What each source says about who is on a record, source by source.
 
     MusicBrainz and Discogs are searched independently, so a record one has
@@ -417,6 +500,7 @@ def album(artist, title):
     release, that Discogs release is read under the MusicBrainz entry rather
     than found again. Nothing is merged across sources: the same person
     appears once per source that names them, with what that source says.
+    A Bandcamp URL, if given, is read last; its credits stay text.
     """
     readings = []
     linked_discogs = set()
@@ -441,6 +525,8 @@ def album(artist, title):
     for hit in discogs_find_releases(artist, title):
         if hit["id"] not in linked_discogs:
             readings.append(_discogs_reading(discogs_get(f"releases/{hit['id']}"), hit["id"]))
+    if bandcamp:
+        readings.append(bandcamp_reading(bandcamp, get=bandcamp_get))
     return readings
 
 
@@ -464,6 +550,16 @@ def render_album(artist, title, readings):
     if not readings:
         lines.append("Neither MusicBrainz nor Discogs has a release with this title and artist.")
     for r in readings:
+        if r["source"] == "Bandcamp":
+            lines.append(f'Bandcamp: {r["artist"]}: {r["title"]} ({r["date"] or "undated"}), {r["tracks"]} tracks')
+            lines.append(f'  {r["url"]}')
+            lines.append("  Credits, word for word (not parsed; a person reads who played what):")
+            lines += [f"    {ln}" for ln in r["credits_text"].splitlines()] or ["    (none)"]
+            if r["about_text"]:
+                lines.append("  About, word for word:")
+                lines += [f"    {ln}" for ln in r["about_text"].splitlines()]
+            lines.append("")
+            continue
         linked = f', linked from MusicBrainz release {r["linked_to"]}' if r.get("linked_to") else ""
         lines.append(f'{r["source"]}: {r["artist"]}: {r["title"]} ({r["date"] or "undated"}), '
                      f'{r["tracks"]} tracks{linked}')
@@ -511,21 +607,37 @@ def _jsonable(o):
 
 
 def main(argv):
-    flags = {a for a in argv[1:] if a.startswith("--")}
-    args = [a for a in argv[1:] if not a.startswith("--")]
-    if len(args) != 2 or flags - {"--json", "--album"}:
-        print(__doc__.strip().splitlines()[0])
-        print('usage: recording_credits.py [--json] "ARTIST" "SONG TITLE"')
-        print('       recording_credits.py [--json] --album "ARTIST" "RECORD TITLE"')
+    ap = argparse.ArgumentParser(
+        description=__doc__.strip().splitlines()[0],
+        usage='%(prog)s [--json] "ARTIST" "SONG TITLE"\n'
+              '       %(prog)s [--json] --album "ARTIST" "RECORD TITLE" [--bandcamp URL]')
+    ap.add_argument("artist")
+    ap.add_argument("title")
+    ap.add_argument("--album", action="store_true", help="who is on a whole record, source by source")
+    ap.add_argument("--bandcamp", metavar="URL", help="with --album: the record's Bandcamp page, read too")
+    ap.add_argument("--json", action="store_true")
+    try:
+        args = ap.parse_args(argv[1:])
+    except SystemExit as e:
+        return e.code
+    if args.bandcamp and not args.album:
+        ap.print_usage()
+        print("--bandcamp goes with --album")
         return 2
-    artist, title = args
-    if "--album" in flags:
-        readings = album(artist, title)
-        print(json.dumps(_jsonable(readings), indent=2, ensure_ascii=False) if "--json" in flags
-              else render_album(artist, title, readings))
+    if args.bandcamp and not is_bandcamp(args.bandcamp):
+        print(f"not a bandcamp.com URL: {args.bandcamp}")
+        return 2
+    if args.album:
+        try:
+            readings = album(args.artist, args.title, bandcamp=args.bandcamp)
+        except NotABandcampRecord as e:
+            print(e, file=sys.stderr)
+            return 1
+        print(json.dumps(_jsonable(readings), indent=2, ensure_ascii=False) if args.json
+              else render_album(args.artist, args.title, readings))
     else:
-        versions = lookup(artist, title)
-        print(json.dumps(versions, indent=2) if "--json" in flags else render(artist, title, versions))
+        versions = lookup(args.artist, args.title)
+        print(json.dumps(versions, indent=2) if args.json else render(args.artist, args.title, versions))
     return 0
 
 
diff --git a/tools/test_recording_credits.py b/tools/test_recording_credits.py
index bfe6a7e..41a0c18 100644
--- a/tools/test_recording_credits.py
+++ b/tools/test_recording_credits.py
@@ -8,6 +8,8 @@ on canned documents shaped like the real MusicBrainz and Discogs responses,
 so no test touches the network.
 """
 
+import json
+
 import pytest
 
 import recording_credits as rc
@@ -336,7 +338,77 @@ def test_render_album_says_when_nobody_has_it():
 
 
 def test_main_album_flag(monkeypatch, capsys):
-    monkeypatch.setattr(rc, "album", lambda a, t: [])
+    monkeypatch.setattr(rc, "album", lambda a, t, bandcamp=None: [])
     assert rc.main(["x", "--album", "A", "T"]) == 0
     assert "Neither MusicBrainz nor Discogs" in capsys.readouterr().out
     assert rc.main(["x", "--bogus", "A", "T"]) == 2
+
+
+# ---------------------------------------------------------------------------
+# --album --bandcamp: credits copied, never parsed
+# ---------------------------------------------------------------------------
+
+def bc_page(obj):
+    return '<html><script type="application/ld+json">\n' + json.dumps(obj) + "\n</script></html>"
+
+
+BC_ALBUM = {
+    "@type": "MusicAlbum", "name": "Workin's Too Hard", "byArtist": {"name": "Rayna Gellert"},
+    "datePublished": "20 Jan 2017 00:00:00 GMT",
+    "creditText": "Musicians: Rayna Gellert, Kieran Kane\r\nJoe - keys & vocals",
+    "description": "Cut live in a room.",
+    "track": {"itemListElement": [{"position": 1, "item": {"name": "Starch"}}]},
+}
+
+
+def test_bandcamp_credits_are_copied_not_parsed():
+    r = rc.bandcamp_reading("https://x.bandcamp.com/album/w",
+                            get=lambda url: (url, bc_page(BC_ALBUM)))
+    assert r["source"] == "Bandcamp" and r["url"] == "https://x.bandcamp.com/album/w"
+    assert r["credits_text"] == "Musicians: Rayna Gellert, Kieran Kane\nJoe - keys & vocals"
+    assert r["title"] == "Workin's Too Hard" and r["date"] == "20 Jan 2017" and r["tracks"] == 1
+    assert r["people"] == {}  # nobody is extracted from prose
+
+
+def test_bandcamp_challenge_page_is_an_error():
+    with pytest.raises(rc.NotABandcampRecord):
+        rc.bandcamp_reading("https://x.bandcamp.com/album/w",
+                            get=lambda url: (url, "<title>Client Challenge</title>"))
+
+
+def test_album_reads_bandcamp_last_and_render_says_not_parsed(monkeypatch):
+    monkeypatch.setattr(rc, "find_releases", lambda a, t: [])
+    monkeypatch.setattr(rc, "discogs_find_releases", lambda a, t: [])
+    readings = rc.album("Rayna Gellert", "Workin's Too Hard", bandcamp="https://x.bandcamp.com/album/w",
+                        bandcamp_get=lambda url: (url, bc_page(BC_ALBUM)))
+    assert [r["source"] for r in readings] == ["Bandcamp"]
+    text = rc.render_album("Rayna Gellert", "Workin's Too Hard", readings)
+    assert "Credits, word for word (not parsed" in text
+    assert "    Joe - keys & vocals" in text
+    assert "  About, word for word:\n    Cut live in a room." in text
+
+
+def test_bandcamp_fetch_is_curl_with_an_argument_list(monkeypatch):
+    seen = {}
+
+    def run(cmd, **kw):
+        seen["cmd"] = cmd
+        return rc.subprocess.CompletedProcess(cmd, 0, stdout="<html/>\nhttps://x.bandcamp.com/album/w")
+    monkeypatch.setattr(rc.subprocess, "run", run)
+    monkeypatch.setattr(rc, "BANDCAMP_INTERVAL", 0)
+    assert rc.bandcamp_get("https://x.bandcamp.com/album/w") == ("https://x.bandcamp.com/album/w", "<html/>")
+    assert seen["cmd"][0] == "curl" and seen["cmd"][-2:] == ["--", "https://x.bandcamp.com/album/w"]
+
+
+@pytest.mark.parametrize("url,ok", [
+    ("https://milkweed.bandcamp.com/album/milkweed-ep", True),
+    ("https://evilbandcamp.com/album/x", False),
+    ("https://bandcamp.com.example.org/album/x", False),
+])
+def test_is_bandcamp(url, ok):
+    assert rc.is_bandcamp(url) is ok
+
+
+def test_main_bandcamp_needs_album_and_a_bandcamp_url():
+    assert rc.main(["x", "A", "T", "--bandcamp", "https://x.bandcamp.com/album/w"]) == 2
+    assert rc.main(["x", "--album", "A", "T", "--bandcamp", "https://example.org/album/w"]) == 2
-- 
2.53.0