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