feat(disc): mirror WowPlatter Discogs metadata into recordgod (disc_sync.py) + locator matches on collection genre/style ids

- disc_sync.py: MariaDB clone -> recordgod disc_* (30.8k releases, genre/style/label/format/identifier/artist/track/master), keeping WowPlatter's own genre/style ids
- locator reads disc_release.genre_ids/style_ids + parses collection 'ID,0' id format
- images stay with DealGod; masters sparse (backfill from Ultra later)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-22 21:19:47 +10:00
parent a1b4538954
commit 10af3464ac
2 changed files with 197 additions and 4 deletions

View File

@ -23,6 +23,17 @@ def _arr(v):
return []
def _ids(v):
"""Collection genre_ids/style_ids are stored as "ID,0" strings (WowPlatter's own ids) — take the lead int."""
out = []
for x in _arr(v):
try:
out.append(int(str(x).split(",")[0]))
except Exception:
pass
return out
class CollectionIn(BaseModel):
id: int | None = None
name: str
@ -146,12 +157,12 @@ async def locate(release_id: int | None = Query(None), sku: str | None = Query(N
# fallback: match the record against active collections by priority. genre/style needs the disc_cache
# enrichment (added but backfilled separately) — until then a style-ruled collection won't match.
meta = (await db.execute(text(
"SELECT year, genre_ids, style_ids FROM disc_cache WHERE release_id=:r"
"SELECT year, genre_ids, style_ids FROM disc_release WHERE id=:r"
), {"r": inv.get("release_id")})).mappings().first() if inv.get("release_id") else None
price = float(inv["price"]) if inv.get("price") is not None else None
year = meta["year"] if meta else None
rg = set(meta["genre_ids"] or []) if meta and "genre_ids" in meta.keys() else set()
rs = set(meta["style_ids"] or []) if meta and "style_ids" in meta.keys() else set()
rg = set(meta["genre_ids"] or []) if meta else set() # the record's genre ids (WowPlatter id space)
rs = set(meta["style_ids"] or []) if meta else set()
suggestion = None
cols = (await db.execute(text("""
@ -169,7 +180,7 @@ async def locate(release_id: int | None = Query(None), sku: str | None = Query(N
continue
if c["year_max"] is not None and (year is None or year > c["year_max"]):
continue
cgen, csty = set(_arr(c["genre_ids"])), set(_arr(c["style_ids"]))
cgen, csty = set(_ids(c["genre_ids"])), set(_ids(c["style_ids"]))
if cgen and not (rg & cgen):
continue
if csty and not (rs & csty):

182
disc_sync.py Normal file
View File

@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""disc_sync.py — mirror the WowPlatter Discogs metadata clone into RecordGod (Postgres).
Reads the MariaDB clone (the ~30.8k releases the shop stocks) and emits a Postgres load
(schema + COPY data) on stdout. Pipe it into recordgod-db:
python disc_sync.py | gzip | ssh root@<vps> 'gunzip | docker exec -i recordgod-db psql -U recordgod -d recordgod -q'
Convention (per John, 2026-06-22):
genre/style ids are WowPlatter's OWN incremental ids — Discogs has no genre/style tables.
They're copied FAITHFULLY because the collections (filing rules) reference them. Names ride along
for display only. (Collections store them as "ID,0" strings the LOCATOR parses that, not this.)
release_id / artist_id / label_id / master_id ARE real Discogs ids the stable join keys.
Augmented WowPlatter columns (beatport/apple/wiki/etc.) are dropped here; we keep what RecordGod
needs for locator + navigator + display + barcode. Images come from DealGod (not stored here).
Masters are barely populated in WowPlatter (72) copied as-is; backfill from the Ultra later.
"""
import pathlib
import re
import sys
import pymysql
WP_CONFIG = "/opt/homebrew/var/www/monsterrobot.localsite/wp-config.php"
SOCK = "/opt/homebrew/var/mysql/mysql.sock"
SCHEMA = """
SET synchronous_commit = off;
DROP TABLE IF EXISTS disc_release, disc_release_genre, disc_release_style, disc_release_label,
disc_release_format, disc_release_identifier, disc_release_artist, disc_release_track,
disc_genre, disc_style, disc_label, disc_artist, disc_master, disc_master_genre, disc_master_style CASCADE;
CREATE TABLE disc_genre (id int PRIMARY KEY, name text);
CREATE TABLE disc_style (id int PRIMARY KEY, parent_genre_id int, name text);
CREATE TABLE disc_label (id bigint PRIMARY KEY, name text, profile text);
CREATE TABLE disc_artist (id bigint PRIMARY KEY, name text, realname text);
CREATE TABLE disc_master (id bigint PRIMARY KEY, title text, year int, main_release bigint);
CREATE TABLE disc_release (
id bigint PRIMARY KEY, title text, artists_sort text, released text, country text, year int,
notes text, format_quantity int, description text, apple_id text, estimated_weight numeric,
master_id bigint, thumb text, rating_count int, rating_average numeric, search_text text,
genre_ids int[], style_ids int[]);
CREATE TABLE disc_release_genre (release_id bigint, genre_id int, genre_name text);
CREATE TABLE disc_release_style (release_id bigint, style_id int, style_name text);
CREATE TABLE disc_release_label (release_id bigint, label_id bigint, label_name text, catno text);
CREATE TABLE disc_release_format (release_id bigint, name text, qty int, descriptions text);
CREATE TABLE disc_release_identifier (release_id bigint, type text, value text);
CREATE TABLE disc_release_artist (release_id bigint, artist_id bigint, artist_name text, role text, join_string text, position text);
CREATE TABLE disc_master_genre (master_id bigint, genre_id int, genre_name text);
CREATE TABLE disc_master_style (master_id bigint, style_id int, style_name text);
CREATE TABLE disc_release_track (release_id bigint, sequence int, position text, title text, duration text, track_id text, apple_id text, bpm numeric, musical_key text);
"""
INDEXES = """
CREATE INDEX ON disc_release USING gin (genre_ids);
CREATE INDEX ON disc_release USING gin (style_ids);
CREATE INDEX ON disc_release (master_id);
CREATE INDEX ON disc_release_genre (release_id);
CREATE INDEX ON disc_release_style (release_id);
CREATE INDEX ON disc_release_label (release_id);
CREATE INDEX ON disc_release_identifier (value);
CREATE INDEX ON disc_release_artist (release_id);
CREATE INDEX ON disc_release_artist (artist_id);
CREATE INDEX ON disc_release_track (release_id);
ANALYZE disc_release;
"""
def creds():
t = pathlib.Path(WP_CONFIG).read_text()
g = lambda k: re.search(rf"'{k}',\s*'([^']*)'", t).group(1)
return g("DB_NAME"), g("DB_USER"), g("DB_PASSWORD")
def cp(v):
"""Format one value for COPY text format."""
if v is None or v == "":
return r"\N"
return (str(v).replace("\\", "\\\\").replace("\t", "\\t")
.replace("\n", "\\n").replace("\r", "\\r"))
def iarr(ids):
"""int[] COPY literal — {2,6} — or NULL. Skips any null ids in the list."""
vals = [str(int(i)) for i in (ids or []) if i is not None]
return "{" + ",".join(vals) + "}" if vals else r"\N"
def copy_block(out, table, cols, rows, fmt):
out.write(f"COPY {table} ({','.join(cols)}) FROM stdin;\n")
for r in rows:
out.write("\t".join(fmt(r)) + "\n")
out.write("\\.\n\n")
print(f" {table:26} {len(rows):>7,}", file=sys.stderr)
def main():
name, user, pw = creds()
my = pymysql.connect(unix_socket=SOCK, user=user, password=pw, database=name,
cursorclass=pymysql.cursors.DictCursor)
c = my.cursor()
out = sys.stdout
out.write(SCHEMA)
P = "wp_rmp_disc_"
print("emitting:", file=sys.stderr)
# genre / style lookups (WowPlatter's own ids)
c.execute(f"SELECT id, name FROM {P}genre")
copy_block(out, "disc_genre", ["id", "name"], c.fetchall(), lambda r: [cp(r["id"]), cp(r["name"])])
c.execute(f"SELECT id, parent_genre_id, name FROM {P}style")
copy_block(out, "disc_style", ["id", "parent_genre_id", "name"], c.fetchall(),
lambda r: [cp(r["id"]), cp(r["parent_genre_id"]), cp(r["name"])])
# per-release genre/style id aggregation (for the fast-match arrays on disc_release)
rg, rs = {}, {}
c.execute(f"SELECT release_id, genre_id FROM {P}release_genre")
for r in c.fetchall():
rg.setdefault(r["release_id"], []).append(r["genre_id"])
c.execute(f"SELECT release_id, style_id FROM {P}release_style")
for r in c.fetchall():
rs.setdefault(r["release_id"], []).append(r["style_id"])
# releases
c.execute(f"""SELECT id, title, artists_sort, released, country, year, notes, format_quantity,
description, apple_id, estimated_weight, master_id, thumb, rating_count,
rating_average, search_text FROM {P}release""")
cols = ["id", "title", "artists_sort", "released", "country", "year", "notes", "format_quantity",
"description", "apple_id", "estimated_weight", "master_id", "thumb", "rating_count",
"rating_average", "search_text", "genre_ids", "style_ids"]
copy_block(out, "disc_release", cols, c.fetchall(),
lambda r: [cp(r[k]) for k in cols[:-2]] + [iarr(rg.get(r["id"])), iarr(rs.get(r["id"]))])
# join + lookup tables
c.execute(f"SELECT release_id, genre_id, genre_name FROM {P}release_genre")
copy_block(out, "disc_release_genre", ["release_id", "genre_id", "genre_name"], c.fetchall(),
lambda r: [cp(r["release_id"]), cp(r["genre_id"]), cp(r["genre_name"])])
c.execute(f"SELECT release_id, style_id, style_name FROM {P}release_style")
copy_block(out, "disc_release_style", ["release_id", "style_id", "style_name"], c.fetchall(),
lambda r: [cp(r["release_id"]), cp(r["style_id"]), cp(r["style_name"])])
c.execute(f"SELECT release_id, label_id, label_name, catno FROM {P}release_label")
copy_block(out, "disc_release_label", ["release_id", "label_id", "label_name", "catno"], c.fetchall(),
lambda r: [cp(r["release_id"]), cp(r["label_id"]), cp(r["label_name"]), cp(r["catno"])])
c.execute(f"SELECT id, name, profile FROM {P}label")
copy_block(out, "disc_label", ["id", "name", "profile"], c.fetchall(),
lambda r: [cp(r["id"]), cp(r["name"]), cp(r["profile"])])
c.execute(f"SELECT release_id, name, qty, descriptions FROM {P}release_format")
copy_block(out, "disc_release_format", ["release_id", "name", "qty", "descriptions"], c.fetchall(),
lambda r: [cp(r["release_id"]), cp(r["name"]), cp(r["qty"]), cp(r["descriptions"])])
c.execute(f"SELECT release_id, type, value FROM {P}release_identifier")
copy_block(out, "disc_release_identifier", ["release_id", "type", "value"], c.fetchall(),
lambda r: [cp(r["release_id"]), cp(r["type"]), cp(r["value"])])
c.execute(f"SELECT release_id, artist_id, artist_name, role, join_string, position FROM {P}release_artist")
copy_block(out, "disc_release_artist",
["release_id", "artist_id", "artist_name", "role", "join_string", "position"], c.fetchall(),
lambda r: [cp(r["release_id"]), cp(r["artist_id"]), cp(r["artist_name"]),
cp(r["role"]), cp(r["join_string"]), cp(r["position"])])
# only artists referenced by our releases (the full artist table is 226k)
c.execute(f"SELECT id, name, realname FROM {P}artist WHERE id IN (SELECT DISTINCT artist_id FROM {P}release_artist)")
copy_block(out, "disc_artist", ["id", "name", "realname"], c.fetchall(),
lambda r: [cp(r["id"]), cp(r["name"]), cp(r["realname"])])
c.execute(f"SELECT release_id, sequence, position, title, duration, track_id, apple_id, bpm, musical_key FROM {P}release_track")
copy_block(out, "disc_release_track",
["release_id", "sequence", "position", "title", "duration", "track_id", "apple_id", "bpm", "musical_key"],
c.fetchall(),
lambda r: [cp(r["release_id"]), cp(r["sequence"]), cp(r["position"]), cp(r["title"]),
cp(r["duration"]), cp(r["track_id"]), cp(r["apple_id"]), cp(r["bpm"]), cp(r["musical_key"])])
# masters (sparse — copy what exists)
c.execute(f"SELECT id, title, year, main_release FROM {P}master")
copy_block(out, "disc_master", ["id", "title", "year", "main_release"], c.fetchall(),
lambda r: [cp(r["id"]), cp(r["title"]), cp(r["year"]), cp(r["main_release"])])
c.execute(f"SELECT master_id, genre_id, genre_name FROM {P}master_genre")
copy_block(out, "disc_master_genre", ["master_id", "genre_id", "genre_name"], c.fetchall(),
lambda r: [cp(r["master_id"]), cp(r["genre_id"]), cp(r["genre_name"])])
c.execute(f"SELECT master_id, style_id, style_name FROM {P}master_style")
copy_block(out, "disc_master_style", ["master_id", "style_id", "style_name"], c.fetchall(),
lambda r: [cp(r["master_id"]), cp(r["style_id"]), cp(r["style_name"])])
out.write(INDEXES)
print("done.", file=sys.stderr)
if __name__ == "__main__":
main()