|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Sync a Discogs collection + wantlist into the vault as Album notes. |
| 3 | +
|
| 4 | +The vault's convention (see the Albums memory / README): one note per album, |
| 5 | +``categories: [[Albums]]``, an ``lp:`` date meaning "owned on vinyl" — empty for |
| 6 | +a wishlist record. So: |
| 7 | + * every release in the Discogs collection -> Album note, lp = date added; |
| 8 | + * every release in the wantlist not owned -> Album note, lp empty (wishlist). |
| 9 | +
|
| 10 | +Dedup is by the ``discogs:`` release URL, which every existing album carries. |
| 11 | +A wishlist note whose record you later buy gets its blank ``lp:`` filled in |
| 12 | +(the only mutation); nothing else about a hand-curated note is touched. |
| 13 | +""" |
| 14 | + |
| 15 | +import argparse |
| 16 | +import json |
| 17 | +import os |
| 18 | +import re |
| 19 | +import sys |
| 20 | +import time |
| 21 | +import urllib.request |
| 22 | +from urllib.parse import urljoin |
| 23 | + |
| 24 | +import vaultlib |
| 25 | + |
| 26 | +BASE_URL = "https://api.discogs.com" |
| 27 | +UA = "vault-sync robot" |
| 28 | + |
| 29 | + |
| 30 | +def api_get(url, token): |
| 31 | + headers = {"User-Agent": UA, "Accept": "application/json"} |
| 32 | + if token: |
| 33 | + headers["Authorization"] = f"Discogs token={token}" |
| 34 | + while True: |
| 35 | + req = urllib.request.Request(method="GET", url=url, headers=headers) |
| 36 | + try: |
| 37 | + resp = urllib.request.urlopen(req) |
| 38 | + except urllib.error.HTTPError as e: |
| 39 | + if e.code == 429: # rate limited |
| 40 | + time.sleep(int(e.headers.get("Retry-After", 2))) |
| 41 | + continue |
| 42 | + raise |
| 43 | + return json.loads(resp.read()) |
| 44 | + |
| 45 | + |
| 46 | +def paginate(first_url, token, key): |
| 47 | + url = first_url |
| 48 | + while url: |
| 49 | + page = api_get(url, token) |
| 50 | + for item in page.get(key, []): |
| 51 | + yield item |
| 52 | + url = page.get("pagination", {}).get("urls", {}).get("next") |
| 53 | + time.sleep(1) # stay under Discogs' rate limit |
| 54 | + |
| 55 | + |
| 56 | +_ARTIST_SUFFIX = re.compile(r"\s*\(\d+\)$") # Discogs disambiguator, e.g. "Nirvana (2)" |
| 57 | + |
| 58 | + |
| 59 | +def clean_artist(name): |
| 60 | + return _ARTIST_SUFFIX.sub("", name).strip() |
| 61 | + |
| 62 | + |
| 63 | +def release_url(info): |
| 64 | + return f"https://www.discogs.com/release/{info['id']}" |
| 65 | + |
| 66 | + |
| 67 | +def album_note(title, year, artists, cover_file, url, lp_date): |
| 68 | + artist_block = "artist:\n" + "".join(f' - "[[{a}]]"\n' for a in artists) |
| 69 | + cover_line = f'cover: "[[{cover_file}]]"\n' if cover_file else "cover:\n" |
| 70 | + lp_line = f'lp: "[[{lp_date}]]"\n' if lp_date else "lp:\n" |
| 71 | + return ( |
| 72 | + "---\n" |
| 73 | + "categories:\n" |
| 74 | + ' - "[[Albums]]"\n' |
| 75 | + f"{artist_block}" |
| 76 | + f"{cover_line}" |
| 77 | + f"discogs: {url}\n" |
| 78 | + f"{lp_line}" |
| 79 | + f"year: {year}\n" |
| 80 | + "---\n" |
| 81 | + ) |
| 82 | + |
| 83 | + |
| 84 | +def sync_release(item, references, attachments, token, lp_date, index): |
| 85 | + """Create or update a single album note. ``lp_date`` is the owned-on date |
| 86 | + (collection) or None (wantlist). ``index`` is the {discogs-url: path} dedup |
| 87 | + map, updated in place when a note is created. Returns 'new', 'updated', or |
| 88 | + None.""" |
| 89 | + info = item["basic_information"] |
| 90 | + url = release_url(info) |
| 91 | + title = info["title"] |
| 92 | + year = info.get("year") or "" |
| 93 | + artists = [clean_artist(a["name"]) for a in info.get("artists", []) if a.get("name")] |
| 94 | + |
| 95 | + existing = index.get(url.rstrip("/")) |
| 96 | + if existing: |
| 97 | + if lp_date and vaultlib.set_scalar_if_empty(existing, "lp", f'"[[{lp_date}]]"'): |
| 98 | + print(f" + owned {lp_date}: {os.path.basename(existing)}") |
| 99 | + return "updated" |
| 100 | + return None |
| 101 | + |
| 102 | + path, base = vaultlib.unique_note_path(references, title, year) |
| 103 | + |
| 104 | + cover_file = None |
| 105 | + cover = info.get("cover_image") |
| 106 | + if cover and "spacer" not in os.path.basename(cover): |
| 107 | + ext = os.path.splitext(cover.split("?")[0])[1] or ".jpeg" |
| 108 | + cover_file = f"{base}{ext}" |
| 109 | + try: |
| 110 | + vaultlib.download(cover, os.path.join(attachments, cover_file), |
| 111 | + {"User-Agent": UA, "Authorization": f"Discogs token={token}"}) |
| 112 | + except Exception as e: |
| 113 | + print(f" war: cover download failed for {title}: {e}", file=sys.stderr) |
| 114 | + cover_file = None |
| 115 | + |
| 116 | + for a in artists: |
| 117 | + vaultlib.ensure_person_note(references, a, "Artists") |
| 118 | + |
| 119 | + vaultlib.write_note(path, album_note(title, year, artists, cover_file, url, lp_date)) |
| 120 | + index[url.rstrip("/")] = path |
| 121 | + kind = "owned" if lp_date else "wishlist" |
| 122 | + print(f" new album ({kind}): {os.path.basename(path)} ({', '.join(artists)})") |
| 123 | + return "new" |
| 124 | + |
| 125 | + |
| 126 | +def main(username, token, vault, include_wantlist): |
| 127 | + references = os.path.join(vault, "References") |
| 128 | + attachments = os.path.join(vault, "Attachments") |
| 129 | + os.makedirs(references, exist_ok=True) |
| 130 | + os.makedirs(attachments, exist_ok=True) |
| 131 | + |
| 132 | + index = vaultlib.index_by_field(references, "discogs") |
| 133 | + created = updated = 0 |
| 134 | + |
| 135 | + collection = urljoin(BASE_URL, f"/users/{username}/collection/folders/0/releases?sort=artist") |
| 136 | + for item in paginate(collection, token, "releases"): |
| 137 | + date_added = (item.get("date_added") or "")[:10] # ISO ts -> YYYY-MM-DD |
| 138 | + result = sync_release(item, references, attachments, token, date_added or None, index) |
| 139 | + created += result == "new" |
| 140 | + updated += result == "updated" |
| 141 | + |
| 142 | + if include_wantlist: |
| 143 | + wantlist = urljoin(BASE_URL, f"/users/{username}/wants") |
| 144 | + for item in paginate(wantlist, token, "wants"): |
| 145 | + result = sync_release(item, references, attachments, token, None, index) |
| 146 | + created += result == "new" |
| 147 | + |
| 148 | + print(f"discogs: {created} new, {updated} updated") |
| 149 | + |
| 150 | + |
| 151 | +if __name__ == "__main__": |
| 152 | + p = argparse.ArgumentParser(description="Sync Discogs collection + wantlist into vault Album notes.") |
| 153 | + p.add_argument("-u", "--username", default=os.environ.get("DISCOGS_USERNAME", "ngalaiko")) |
| 154 | + p.add_argument("-t", "--token", default=os.environ.get("DISCOGS_TOKEN")) |
| 155 | + p.add_argument("--vault", default=os.environ.get("OBSIDIAN_VAULT_DIR", "/var/lib/assistant/Vault")) |
| 156 | + p.add_argument("--no-wantlist", dest="wantlist", action="store_false", help="skip the wantlist (owned records only)") |
| 157 | + args = p.parse_args() |
| 158 | + if not args.token: |
| 159 | + sys.exit("discogs: no token (set DISCOGS_TOKEN or pass --token)") |
| 160 | + main(args.username, args.token, args.vault, args.wantlist) |
0 commit comments