Emergent:Blu/Patches/ensemble-pipeline
From PickiPedia: A knowledge base of bluegrass, old time psychedelic jams, and other public domain music
Jump to navigationJump to search
Sideman --album and Discogs seeds for Rambler, so a record is read source by source (Justin, 7 Nov, block 26,363,997). One commit, on top of the Sideman-fixes commit already in pickipedia#172 (b2b8e5c there; the same change cherry-picked onto production as 49ed047 for the round trip). sha256 of the file: 1bb59cca3534792a9d3c9c42ad1419796c365b3318695e974ca0083754e6e75e. Round-trip checked on production f44da0e + the Sideman fixes: 71 tests pass. tools/ only, nothing to redeploy. For magent: cherry-pick the Sideman-fixes commit from #172 (or wait for #172), then git am this.
From 9e407a79083e2b86a137ba5054f68faaa4c9af46 Mon Sep 17 00:00:00 2001
From: blu <blu@cryptograss.live>
Date: Sat, 7 Nov 2026 15:59:29 +0000
Subject: [PATCH] Sideman --album and Discogs seeds for Rambler: a record read
source by source
People bring records, not songs. Sideman --album takes an artist and a
record title and asks MusicBrainz and Discogs separately, so a record that
only Discogs has (most of a small band's catalogue) still comes back. Each
source's answer is printed under that source, with roles and scope; nobody
is merged across sources.
Rambler takes a Discogs release as a seed, looking it up on MusicBrainz by
URL first. Every person now carries by_source: what each source said on
its own, beside the union the walk uses. That is what a person verifying
an ensemble needs to see, and what the verification UI will read.
mb_people and discogs_people move from Rambler into Sideman, which is
where reading a release belongs; Rambler keeps the names.
---
tools/rambler.py | 153 +++++++++++++++----------
tools/recording_credits.py | 192 +++++++++++++++++++++++++++++++-
tools/test_rambler.py | 62 +++++++++++
tools/test_recording_credits.py | 77 +++++++++++++
4 files changed, 423 insertions(+), 61 deletions(-)
diff --git a/tools/rambler.py b/tools/rambler.py
index 3965674..cee8527 100644
--- a/tools/rambler.py
+++ b/tools/rambler.py
@@ -5,6 +5,7 @@ Rambler: who was on a record with whom, walked outward from one record.
python3 tools/rambler.py 750499ef-cec3-4c3c-9e8d-2caacff30e0e
python3 tools/rambler.py --hops 2 --json <release-mbid>
python3 tools/rambler.py --wikitext <release-mbid>
+ python3 tools/rambler.py https://www.discogs.com/release/24433085
An Artificial Dumb crawler for the connectome, built on Sideman
(recording_credits.py) and its MusicBrainz and Discogs helpers. Starting at
@@ -31,6 +32,13 @@ What makes it dumb, deliberately:
are kept as edges but go no further.
- Compilations are skipped: a track on one does not put its players on a
record together.
+ - Each source's reading is kept apart (by_source) beside the union the
+ walk uses, so a person verifying sees what MusicBrainz said and what
+ Discogs said, not a blend.
+
+A seed can be a Discogs release too, for a record MusicBrainz lacks. It is
+looked up on MusicBrainz by URL first; unlinked, it is read from Discogs
+alone, and since Discogs names carry no MusicBrainz id, it goes no further.
Records that come back with nobody credited are listed as the worklist:
they need liner notes, or someone who was there.
@@ -58,50 +66,10 @@ DEFAULT_MAX_RECORDS = 40
# One record's credits
# ---------------------------------------------------------------------------
-def mb_people(release):
- """Performance credits on a MusicBrainz release, release- and track-level.
-
- Returns {name: {"mbid", "roles", "scopes"}}. Track-level credits count:
- a person credited on any track is on the record.
- """
- people = {}
-
- def add(rel, scope):
- if rel.get("type") not in sideman.MB_PERFORMANCE_TYPES or "artist" not in rel:
- return
- a = rel["artist"]
- p = people.setdefault(a["name"], {"mbid": a.get("id"), "roles": set(), "scopes": set()})
- p["roles"].add(", ".join(rel.get("attributes") or []) or rel["type"])
- p["scopes"].add(scope)
-
- for rel in release.get("relations", []):
- add(rel, "release")
- for medium in release.get("media", []):
- for track in medium.get("tracks", []):
- for rel in track.get("recording", {}).get("relations", []):
- add(rel, "track")
- return people
-
-
-def discogs_people(release):
- """Performance credits on a Discogs release, release- and track-level."""
- people = {}
-
- def add(entry, scope):
- roles = sideman.performance_roles(entry.get("role", ""))
- if not roles:
- return
- name = sideman.strip_discogs_suffix(entry["name"])
- p = people.setdefault(name, {"mbid": None, "roles": set(), "scopes": set()})
- p["roles"].update(roles)
- p["scopes"].add("track" if (entry.get("tracks") or "").strip() else scope)
-
- for e in release.get("extraartists", []):
- add(e, "release")
- for track in release.get("tracklist", []):
- for e in track.get("extraartists", []):
- add(e, "track")
- return people
+# Reading a release's credits is Sideman's job; these names stay so a
+# reader of Rambler sees where its people come from.
+mb_people = sideman.mb_people
+discogs_people = sideman.discogs_people
def is_compilation(release):
@@ -109,8 +77,34 @@ def is_compilation(release):
return "Compilation" in (group.get("secondary-types") or [])
+def add_people(record, source, people):
+ """Fold one source's reading of a record into it. The union (roles,
+ scopes, sources) is what the walk uses; by_source keeps what each source
+ said on its own, which is what a person verifying needs to see."""
+ for name, p in people.items():
+ q = record["people"].setdefault(
+ name, {"mbid": None, "roles": set(), "scopes": set(), "sources": set(), "by_source": {}})
+ q["mbid"] = q["mbid"] or p.get("mbid")
+ q["roles"] |= p["roles"]
+ q["scopes"] |= p["scopes"]
+ q["sources"].add(source)
+ q["by_source"][source] = {"roles": set(p["roles"]), "scopes": set(p["scopes"])}
+
+
+DISCOGS_SEED = re.compile(r"(?:discogs\.com/release/|^discogs:)(\d+)")
+
+
def read_record(release_id):
- """Fetch one record and everyone credited on it, from both sources."""
+ """Fetch one record and everyone credited on it, from both sources.
+
+ release_id is a MusicBrainz release id, or "discogs:N" for a record that
+ is only on Discogs (or whose MusicBrainz entry is unknown). A Discogs seed
+ is looked up on MusicBrainz by its URL first: if MusicBrainz links it,
+ the record is read from both, and can be walked onward.
+ """
+ m = DISCOGS_SEED.search(release_id)
+ if m:
+ return read_discogs_record(m.group(1))
release = sideman.mb_get(
f"release/{release_id}",
inc="artist-credits+release-groups+recordings+artist-rels+recording-level-rels+url-rels")
@@ -124,16 +118,48 @@ def read_record(release_id):
"sources": {"MusicBrainz": f"https://musicbrainz.org/release/{release_id}"},
"people": {},
}
- for name, p in mb_people(release).items():
- record["people"][name] = {**p, "sources": {"MusicBrainz"}}
+ add_people(record, "MusicBrainz", mb_people(release))
did = sideman.discogs_id(release)
if did:
record["sources"]["Discogs"] = f"https://www.discogs.com/release/{did}"
- for name, p in discogs_people(sideman.discogs_get(f"releases/{did}")).items():
- q = record["people"].setdefault(name, {"mbid": None, "roles": set(), "scopes": set(), "sources": set()})
- q["roles"] |= p["roles"]
- q["scopes"] |= p["scopes"]
- q["sources"].add("Discogs")
+ add_people(record, "Discogs", discogs_people(sideman.discogs_get(f"releases/{did}")))
+ return record
+
+
+def musicbrainz_release_for_discogs(did):
+ """The MusicBrainz release linked to a Discogs release, if any."""
+ try:
+ url = sideman.mb_get("url", resource=f"https://www.discogs.com/release/{did}", inc="release-rels")
+ except sideman.HTTPError as e:
+ if e.code == 404:
+ return None
+ raise
+ for rel in url.get("relations", []):
+ if rel.get("target-type") == "release" and "release" in rel:
+ return rel["release"]["id"]
+ return None
+
+
+def read_discogs_record(did):
+ mbid = musicbrainz_release_for_discogs(did)
+ if mbid:
+ record = read_record(mbid)
+ if "Discogs" not in record["sources"]:
+ record["sources"]["Discogs"] = f"https://www.discogs.com/release/{did}"
+ add_people(record, "Discogs", discogs_people(sideman.discogs_get(f"releases/{did}")))
+ return record
+ release = sideman.discogs_get(f"releases/{did}")
+ record = {
+ "id": f"discogs:{did}",
+ "group": f"discogs-master-{release.get('master_id') or did}",
+ "title": release.get("title", ""),
+ "date": str(release.get("year") or ""),
+ "artist": ", ".join(sideman.strip_discogs_suffix(a["name"]) for a in release.get("artists", [])),
+ "compilation": "Compilation" in (release.get("formats", [{}])[0].get("descriptions") or []),
+ "sources": {"Discogs": f"https://www.discogs.com/release/{did}"},
+ "people": {},
+ }
+ add_people(record, "Discogs", discogs_people(release))
return record
@@ -253,6 +279,18 @@ def render(graph):
if len(gids) > 1:
out.append(f" {name}: {len(gids)}")
out.append("")
+ out.append("Each record, as each source reads it:")
+ for gid in sorted(recs, key=lambda g: (recs[g]["date"] or "9999", g)):
+ rec = recs[gid]
+ out.append(f" {label(rec)}")
+ for src, url in sorted(rec["sources"].items()):
+ out.append(f" {src}: {url}")
+ for name, p in sorted(rec["people"].items()):
+ says = "; ".join(
+ f'{src} says {", ".join(sorted(r["roles"]))} [{"+".join(sorted(r["scopes"]))}]'
+ for src, r in sorted(p.get("by_source", {}).items()))
+ out.append(f" {name}: {says or ', '.join(sorted(p['roles']))}")
+ out.append("")
out.append("Record pairs:")
for p in graph["pairs"]:
a, b = (recs[g] for g in p["records"])
@@ -302,7 +340,8 @@ MBID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}
def main(argv):
ap = argparse.ArgumentParser(description="Rambler: record-to-record walk from one MusicBrainz release.")
- ap.add_argument("seed", help="MusicBrainz release id, or a musicbrainz.org/release/... URL")
+ ap.add_argument("seed", help="MusicBrainz release id or musicbrainz.org/release/... URL; "
+ "or a discogs.com/release/N URL (or discogs:N)")
ap.add_argument("--hops", type=int, default=1, help="record-to-record steps (default 1)")
ap.add_argument("--per-person", type=int, default=DEFAULT_PER_PERSON)
ap.add_argument("--max-records", type=int, default=DEFAULT_MAX_RECORDS)
@@ -311,9 +350,11 @@ def main(argv):
out.add_argument("--wikitext", action="store_true")
args = ap.parse_args(argv[1:])
m = MBID.search(args.seed)
- if not m:
- ap.error("seed must contain a MusicBrainz release id")
- graph = ramble(m.group(0), args.hops, args.per_person, args.max_records,
+ d = DISCOGS_SEED.search(args.seed)
+ if not m and not d:
+ ap.error("seed must contain a MusicBrainz release id or a Discogs release URL")
+ seed = m.group(0) if m else f"discogs:{d.group(1)}"
+ graph = ramble(seed, args.hops, args.per_person, args.max_records,
log=lambda msg: print(msg, file=sys.stderr))
if args.json:
print(json.dumps(jsonable(graph), indent=2, ensure_ascii=False))
diff --git a/tools/recording_credits.py b/tools/recording_credits.py
index af19618..9cc25c9 100644
--- a/tools/recording_credits.py
+++ b/tools/recording_credits.py
@@ -3,6 +3,7 @@
Sideman: who plays on a recording, an Artificial Dumb lookup.
python3 tools/recording_credits.py "Tony Rice" "House of the Rising Sun"
+ python3 tools/recording_credits.py --album "Tony Rice" "Manzanita"
Asks MusicBrainz for every recording of the title whose artist credit
mentions the artist, then reads each release's credits from MusicBrainz and,
@@ -22,6 +23,14 @@ promoting them.
Reissues are folded together: one release per MusicBrainz release group,
the earliest dated one.
+
+--album asks the other question, "who is on this record?", and asks it of
+each source separately: MusicBrainz, then Discogs on its own, because a
+record MusicBrainz has never heard of is often on Discogs. Each source's
+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.
"""
import json
@@ -312,6 +321,162 @@ def lookup(artist, title):
return versions
+# ---------------------------------------------------------------------------
+# A whole record: --album
+# ---------------------------------------------------------------------------
+
+def mb_people(release):
+ """Everyone credited as performing on a MusicBrainz release.
+
+ Returns {name: {"mbid", "roles", "scopes"}}. A person credited on any
+ track is on the record, so track credits count, marked "track"; credits
+ on the release itself are marked "release".
+ """
+ people = {}
+
+ def add(rel, scope):
+ if rel.get("type") not in MB_PERFORMANCE_TYPES or "artist" not in rel:
+ return
+ a = rel["artist"]
+ p = people.setdefault(a["name"], {"mbid": a.get("id"), "roles": set(), "scopes": set()})
+ p["roles"].add(", ".join(rel.get("attributes") or []) or rel["type"])
+ p["scopes"].add(scope)
+
+ for rel in release.get("relations", []):
+ add(rel, "release")
+ for medium in release.get("media", []):
+ for track in medium.get("tracks", []):
+ for rel in track.get("recording", {}).get("relations", []):
+ add(rel, "track")
+ return people
+
+
+def discogs_people(release):
+ """Everyone credited as performing on a Discogs release, same shape as
+ mb_people. A release-level credit naming tracks is a track credit."""
+ people = {}
+
+ def add(entry, scope):
+ roles = performance_roles(entry.get("role", ""))
+ if not roles:
+ return
+ name = strip_discogs_suffix(entry["name"])
+ p = people.setdefault(name, {"mbid": None, "roles": set(), "scopes": set()})
+ p["roles"].update(roles)
+ p["scopes"].add("track" if (entry.get("tracks") or "").strip() else scope)
+
+ for e in release.get("extraartists", []):
+ add(e, "release")
+ for track in discogs_tracks(release):
+ for e in track.get("extraartists", []):
+ add(e, "track")
+ return people
+
+
+def _one_per_group_earliest(hits, group_of):
+ groups = {}
+ for hit in hits:
+ g = group_of(hit)
+ key = (hit.get("date") or hit.get("year") or "9999", hit["id"])
+ if g not in groups or key < groups[g][0]:
+ groups[g] = (key, hit)
+ return [hit for _, hit in sorted(groups.values(), key=lambda kv: kv[0])]
+
+
+def find_releases(artist, title):
+ """MusicBrainz releases titled title whose artist credit mentions artist:
+ one per release group, the earliest dated."""
+ want = normalize_title(title)
+ data = mb_get("release/", query=f'release:"{title}" AND artist:"{artist}"', limit=25)
+ hits = [r for r in data.get("releases", [])
+ if normalize_title(r.get("title", "")) == want
+ and artist.lower() in credit_line(r.get("artist-credit", [])).lower()]
+ return _one_per_group_earliest(hits, lambda r: (r.get("release-group") or {}).get("id", r["id"]))
+
+
+def discogs_find_releases(artist, title):
+ """Discogs releases of title by artist, searched directly: a record that
+ MusicBrainz has never heard of is often here. One per master, earliest."""
+ want_title, want_artist = normalize_title(title), normalize_title(artist)
+ data = discogs_get("database/search?" + urlencode(
+ {"artist": artist, "release_title": title, "type": "release", "per_page": 25}))
+ hits = []
+ for r in data.get("results", []):
+ # Discogs search titles read "Artist - Title".
+ got_artist, _, got_title = r.get("title", "").partition(" - ")
+ if normalize_title(got_title) == want_title and want_artist in normalize_title(strip_discogs_suffix(got_artist)):
+ hits.append({**r, "id": str(r["id"])})
+ return _one_per_group_earliest(hits, lambda r: r.get("master_id") or r["id"])
+
+
+def album(artist, title):
+ """What each source says about who is on a record, source by source.
+
+ MusicBrainz and Discogs are searched independently, so a record one has
+ and the other lacks still comes back. Where MusicBrainz links a Discogs
+ 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.
+ """
+ readings = []
+ linked_discogs = set()
+ for hit in find_releases(artist, title):
+ release = mb_get(f"release/{hit['id']}",
+ inc="artist-credits+release-groups+recordings+artist-rels+recording-level-rels+url-rels")
+ readings.append({
+ "source": "MusicBrainz",
+ "url": f"https://musicbrainz.org/release/{hit['id']}",
+ "id": hit["id"],
+ "group": (release.get("release-group") or {}).get("id", hit["id"]),
+ "artist": credit_line(release.get("artist-credit", [])),
+ "title": release.get("title", ""),
+ "date": release.get("date", ""),
+ "tracks": sum(len(m.get("tracks", [])) for m in release.get("media", [])),
+ "people": mb_people(release),
+ })
+ did = discogs_id(release)
+ if did:
+ linked_discogs.add(did)
+ readings.append(_discogs_reading(discogs_get(f"releases/{did}"), did, linked_to=hit["id"]))
+ 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"]))
+ return readings
+
+
+def _discogs_reading(release, did, linked_to=None):
+ return {
+ "source": "Discogs",
+ "url": f"https://www.discogs.com/release/{did}",
+ "id": str(did),
+ "group": f"discogs-master-{release.get('master_id') or did}",
+ "linked_to": linked_to,
+ "artist": ", ".join(strip_discogs_suffix(a["name"]) for a in release.get("artists", [])),
+ "title": release.get("title", ""),
+ "date": str(release.get("year") or ""),
+ "tracks": len(release.get("tracklist", [])),
+ "people": discogs_people(release),
+ }
+
+
+def render_album(artist, title, readings):
+ lines = [f'Who is on {artist}: "{title}"', ""]
+ if not readings:
+ lines.append("Neither MusicBrainz nor Discogs has a release with this title and artist.")
+ for r in readings:
+ 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}')
+ lines.append(f' {r["url"]}')
+ if not r["people"]:
+ lines.append(" Nobody credited as performing. Liner notes, or someone who was there.")
+ for name, p in sorted(r["people"].items()):
+ scope = "track" if p["scopes"] == {"track"} else "release" if p["scopes"] == {"release"} else "track+release"
+ lines.append(f' {name}: {", ".join(sorted(p["roles"]))} [{scope}]')
+ lines.append("")
+ return "\n".join(lines)
+
+
def render(artist, title, versions):
lines = [f'Who plays on {artist}: "{title}"', ""]
if not versions:
@@ -335,15 +500,32 @@ def render(artist, title, versions):
return "\n".join(lines)
+def _jsonable(o):
+ if isinstance(o, set):
+ return sorted(o)
+ if isinstance(o, dict):
+ return {k: _jsonable(v) for k, v in o.items()}
+ if isinstance(o, list):
+ return [_jsonable(v) for v in o]
+ return o
+
+
def main(argv):
- args = [a for a in argv[1:] if a != "--json"]
- if len(args) != 2:
+ 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" "TITLE"')
+ print('usage: recording_credits.py [--json] "ARTIST" "SONG TITLE"')
+ print(' recording_credits.py [--json] --album "ARTIST" "RECORD TITLE"')
return 2
artist, title = args
- versions = lookup(artist, title)
- print(json.dumps(versions, indent=2) if "--json" in argv else render(artist, title, versions))
+ 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))
+ else:
+ versions = lookup(artist, title)
+ print(json.dumps(versions, indent=2) if "--json" in flags else render(artist, title, versions))
return 0
diff --git a/tools/test_rambler.py b/tools/test_rambler.py
index 2db3dd1..d25fdc4 100644
--- a/tools/test_rambler.py
+++ b/tools/test_rambler.py
@@ -162,3 +162,65 @@ def test_wikitext_one_call_per_person_with_a_source():
assert "== Guitar" not in text # nobody credited, nothing to write
# Discogs-only credit on a record whose Discogs link is unknown: no source, not a crash
assert "{{Record personnel|Rickie Simpkins|instrument=guitar}}" in text
+
+
+# ---------------------------------------------------------------------------
+# Discogs seeds, and what each source said
+# ---------------------------------------------------------------------------
+
+DISCOGS_RELEASE = {
+ "title": "The First TV Dinner", "year": 2012, "master_id": 0,
+ "artists": [{"name": "The Turpentine Ray"}],
+ "formats": [{"descriptions": ["Album"]}],
+ "extraartists": [{"name": "Nick Jennings", "role": "5-String Banjo, Producer", "tracks": ""}],
+ "tracklist": [],
+}
+
+
+def test_discogs_seed_is_read_alone_when_musicbrainz_lacks_it(monkeypatch):
+ def mb_get(path, **params):
+ raise rambler.sideman.HTTPError("u", 404, "Not Found", {}, None)
+ monkeypatch.setattr(rambler.sideman, "mb_get", mb_get)
+ monkeypatch.setattr(rambler.sideman, "discogs_get", lambda path: DISCOGS_RELEASE)
+ rec = rambler.read_record("discogs:24433085")
+ assert rec["id"] == "discogs:24433085" and rec["artist"] == "The Turpentine Ray"
+ assert rec["sources"] == {"Discogs": "https://www.discogs.com/release/24433085"}
+ assert rec["people"]["Nick Jennings"]["by_source"] == {"Discogs": {"roles": {"5-String Banjo"}, "scopes": {"release"}}}
+ assert rec["people"]["Nick Jennings"]["mbid"] is None # goes no further
+
+
+def test_discogs_seed_linked_on_musicbrainz_is_read_from_both(monkeypatch):
+ def mb_get(path, **params):
+ if path == "url":
+ return {"relations": [{"target-type": "release", "release": {"id": "mb-1"}}]}
+ return {"title": "The First TV Dinner", "date": "2012", "release-group": {"id": "g"},
+ "artist-credit": [{"name": "The Turpentine Ray"}],
+ "relations": [{"type": "instrument", "attributes": ["banjo"],
+ "artist": {"id": "n", "name": "Nick Jennings"}}], "media": []}
+ monkeypatch.setattr(rambler.sideman, "mb_get", mb_get)
+ monkeypatch.setattr(rambler.sideman, "discogs_get", lambda path: DISCOGS_RELEASE)
+ rec = rambler.read_record("https://www.discogs.com/release/24433085")
+ assert rec["id"] == "mb-1"
+ assert set(rec["sources"]) == {"MusicBrainz", "Discogs"}
+ nick = rec["people"]["Nick Jennings"]
+ assert nick["mbid"] == "n" # walked onward, since MusicBrainz knows him
+ assert nick["roles"] == {"banjo", "5-String Banjo"}
+ assert nick["by_source"]["MusicBrainz"]["roles"] == {"banjo"}
+ assert nick["by_source"]["Discogs"]["roles"] == {"5-String Banjo"}
+
+
+def test_render_says_what_each_source_said():
+ rec = record("r", "Ways of the World", {"Paul Brown": {
+ "mbid": "p", "roles": {"banjo", "Banjo"}, "scopes": {"track"}, "sources": {"MusicBrainz", "Discogs"},
+ "by_source": {"MusicBrainz": {"roles": {"banjo"}, "scopes": {"track"}},
+ "Discogs": {"roles": {"Banjo"}, "scopes": {"track", "release"}}}}})
+ rec["sources"]["Discogs"] = "https://www.discogs.com/release/1"
+ text = rambler.render(rambler.summarize({"g-r": rec}, {}))
+ assert " Paul Brown: Discogs says Banjo [release+track]; MusicBrainz says banjo [track]" in text
+ assert " Discogs: https://www.discogs.com/release/1" in text
+
+
+def test_seed_forms():
+ assert rambler.DISCOGS_SEED.search("https://www.discogs.com/release/24433085-The-Turpentine-Ray").group(1) == "24433085"
+ assert rambler.DISCOGS_SEED.search("discogs:5").group(1) == "5"
+ assert rambler.DISCOGS_SEED.search("750499ef-cec3-4c3c-9e8d-2caacff30e0e") is None
diff --git a/tools/test_recording_credits.py b/tools/test_recording_credits.py
index 871e2b6..bfe6a7e 100644
--- a/tools/test_recording_credits.py
+++ b/tools/test_recording_credits.py
@@ -263,3 +263,80 @@ def test_literal_duplicates_across_groups_are_folded():
{"id": "d", "title": "1991-12-08 Warfield Theater, SF, CA, USA", "date": "1991-12-08",
"release-group": {"id": "g4"}}]}]
assert rc.pick_releases(recordings) == [("r1", "c"), ("r1", "d"), ("r1", "a")]
+
+
+# ---------------------------------------------------------------------------
+# --album: a whole record, source by source
+# ---------------------------------------------------------------------------
+
+def test_find_releases_filters_title_and_artist_and_folds_groups(monkeypatch):
+ monkeypatch.setattr(rc, "mb_get", lambda path, **params: {"releases": [
+ {"id": "a", "title": "Manzanita", "date": "1979", "release-group": {"id": "g1"},
+ "artist-credit": [{"name": "Tony Rice"}]},
+ {"id": "b", "title": "Manzanita", "date": "1998", "release-group": {"id": "g1"},
+ "artist-credit": [{"name": "Tony Rice"}]},
+ {"id": "c", "title": "Manzanita Blues", "date": "1979", "release-group": {"id": "g2"},
+ "artist-credit": [{"name": "Tony Rice"}]},
+ {"id": "d", "title": "Manzanita", "date": "1979", "release-group": {"id": "g3"},
+ "artist-credit": [{"name": "Larry Rice"}]},
+ ]})
+ assert [r["id"] for r in rc.find_releases("Tony Rice", "Manzanita")] == ["a"]
+
+
+def test_discogs_find_releases_reads_artist_dash_title(monkeypatch):
+ monkeypatch.setattr(rc, "discogs_get", lambda path: {"results": [
+ {"id": 1, "title": "The Turpentine Ray - The First TV Dinner", "year": "2012", "master_id": 0},
+ {"id": 2, "title": "The Turpentine Ray - The First TV Dinner", "year": "2020", "master_id": 0},
+ {"id": 3, "title": "Kevin Coyne - Matching Head And Feet", "year": "1975", "master_id": 9},
+ ]})
+ hits = rc.discogs_find_releases("Turpentine Ray", "The First TV Dinner")
+ assert [h["id"] for h in hits] == ["1", "2"] # no master: each release stands alone, earliest first
+
+
+def test_album_reads_a_linked_discogs_release_once(monkeypatch):
+ mb = mb_release(release_relations=[("instrument", ["guitar"], "Tony Rice")],
+ url="https://www.discogs.com/release/77")
+ mb["release-group"] = {"id": "g1"}
+ calls = []
+ monkeypatch.setattr(rc, "find_releases", lambda a, t: [{"id": "mb-1"}])
+ monkeypatch.setattr(rc, "mb_get", lambda path, **params: mb)
+ monkeypatch.setattr(rc, "discogs_find_releases", lambda a, t: [{"id": "77"}, {"id": "78"}])
+
+ def discogs_get(path):
+ calls.append(path)
+ return {"title": "Unit of Measure", "year": 2000, "artists": [{"name": "Tony Rice Unit (2)"}],
+ "extraartists": [{"name": "Tony Rice", "role": "Guitar", "tracks": ""}], "tracklist": []}
+ monkeypatch.setattr(rc, "discogs_get", discogs_get)
+
+ readings = rc.album("Tony Rice", "Unit of Measure")
+ assert [(r["source"], r["id"]) for r in readings] == [("MusicBrainz", "mb-1"), ("Discogs", "77"), ("Discogs", "78")]
+ assert calls == ["releases/77", "releases/78"]
+ assert readings[1]["linked_to"] == "mb-1" and readings[2]["linked_to"] is None
+ assert readings[2]["artist"] == "Tony Rice Unit"
+ # Nothing merged: each reading carries its own people.
+ assert readings[0]["people"]["Tony Rice"]["scopes"] == {"release"}
+
+
+def test_render_album_keeps_sources_apart_and_marks_scope():
+ readings = [
+ {"source": "MusicBrainz", "url": "https://musicbrainz.org/release/x", "artist": "A", "title": "T",
+ "date": "2000", "tracks": 2, "people": {"Tony Rice": {"roles": {"guitar"}, "scopes": {"track"}}}},
+ {"source": "Discogs", "url": "https://www.discogs.com/release/1", "artist": "A", "title": "T",
+ "date": "", "tracks": 2, "linked_to": "x", "people": {}},
+ ]
+ text = rc.render_album("A", "T", readings)
+ assert "MusicBrainz: A: T (2000), 2 tracks\n" in text
+ assert " Tony Rice: guitar [track]" in text
+ assert "Discogs: A: T (undated), 2 tracks, linked from MusicBrainz release x" in text
+ assert "Nobody credited as performing" in text
+
+
+def test_render_album_says_when_nobody_has_it():
+ assert "Neither MusicBrainz nor Discogs" in rc.render_album("A", "T", [])
+
+
+def test_main_album_flag(monkeypatch, capsys):
+ monkeypatch.setattr(rc, "album", lambda a, t: [])
+ 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
--
2.53.0