feat(bridge): RecordGod WP bridge plugin (WowPlatter successor) + thumb on browse/release

Thin WordPress/Woo bridge — RecordGod owns catalog/stock/pricing/shipping; the plugin is a storefront
skin + checkout adapter over /shop:
- storefront: SSR /records (browse) + /release/{id} inside the theme, with title/meta/JSON-LD MusicAlbum
- on-the-fly Woo product: /?rg_add=<sku> mints a hidden WC_Product_Simple from /shop/item (the WowPlatter
  trick — 25k catalog, only sold items become Woo products)
- shipping: WC_Shipping_Method quoting /shop/shipping/quote by cart item count (AusPost flat rate)
- orders: order_status_completed -> POST /shop/woo-order (X-Bridge-Key), idempotent, marks SKUs sold
- settings page: base URL + bridge key + store id
Backend: added thumb to /shop/browse + /shop/release so storefront pages have cover art.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-24 01:43:30 +10:00
parent 8a3d833986
commit 5ad4de1d2c
8 changed files with 490 additions and 0 deletions

View File

@ -111,6 +111,7 @@ async def shop_browse(q: str = Query(""), genre: str = Query(""), style: str = Q
items = [dict(r) for r in (await db.execute(text(f"""
SELECT i.release_id, coalesce(min(i.title), dr.title) AS title, dr.artists_sort AS artist,
dr.year, dr.country, min(i.price)::float AS price, count(*) AS copies,
(SELECT thumb FROM disc_cache dc WHERE dc.release_id=i.release_id) AS thumb,
(SELECT artist_id FROM disc_release_artist ra WHERE ra.release_id=i.release_id ORDER BY ra.position LIMIT 1) AS artist_id,
(SELECT string_agg(DISTINCT genre_name, ', ') FROM disc_release_genre g WHERE g.release_id=i.release_id) AS genre
FROM inventory i JOIN disc_release dr ON dr.id = i.release_id
@ -142,6 +143,7 @@ async def shop_release(release_id: int, db=Depends(get_db)):
"""Public release detail — metadata + tracklist + in-stock copies (with the Woo buy link)."""
dr = (await db.execute(text("""
SELECT dr.id, dr.title, dr.artists_sort AS artist, dr.country, dr.year, dr.notes, dr.master_id,
(SELECT thumb FROM disc_cache dc WHERE dc.release_id=dr.id) AS thumb,
(SELECT artist_id FROM disc_release_artist ra WHERE ra.release_id=dr.id ORDER BY ra.position LIMIT 1) AS artist_id,
(SELECT string_agg(label_name || coalesce(' ('||catno||')',''), ', ') FROM disc_release_label WHERE release_id=dr.id) AS label,
(SELECT string_agg(name || coalesce(' '||descriptions,''), ', ') FROM disc_release_format WHERE release_id=dr.id) AS format,

26
wp-bridge/README.md Normal file
View File

@ -0,0 +1,26 @@
# RecordGod Bridge (WordPress plugin)
Thin successor to WowPlatter. **RecordGod owns the catalog, stock, pricing and shipping**; this plugin is a
storefront skin + checkout adapter over its `/shop` API. WordPress/WooCommerce is kept lean — Woo only ever
sees the records people actually buy.
## What it does
| Concern | How |
|---|---|
| **Storefront (SEO)** | `class-rg-storefront.php` — server-renders `/records` (browse) and `/release/{id}` inside the theme, with `<title>`, meta description, and JSON-LD `MusicAlbum`/`Offer`. Pulls `/shop/browse` + `/shop/release`. |
| **On-the-fly Woo product** | `class-rg-cart.php` — the WowPlatter trick. `/?rg_add=<sku>` looks the SKU up (`wc_get_product_id_by_sku`); if absent, mints a hidden `WC_Product_Simple` from `/shop/item` (price, image, stock=1) then adds it to the cart. 25k catalog, ~handful of Woo products. |
| **Shipping** | `class-rg-shipping.php` — a `WC_Shipping_Method` that quotes postage from `/shop/shipping/quote?units=<cart item count>` (AusPost Parcel/Express flat rate, 280g/record). Add it to a Woo shipping zone. |
| **Order webhook** | `class-rg-orders.php` — on `order_status_completed`, POSTs the order back to `/shop/woo-order` (with `X-Bridge-Key`). RecordGod marks the SKUs sold + logs the online sale. Idempotent. |
| **Settings** | Settings → RecordGod Bridge: base URL, bridge key (must match RecordGod's `bridge_key` secret), store id. |
## Install
1. Copy `wp-bridge/` to `wp-content/plugins/recordgod-bridge/` and activate (flushes rewrite rules).
2. Settings → RecordGod Bridge: set base URL (`https://recordgod.com`) + bridge key.
3. WooCommerce → Settings → Shipping → add **RecordGod Postage** to your AU zone.
4. Visit `/records`. (If pages 404, re-save Permalinks once.)
## Not in scope (stays where it is)
- Woo handles payment + transactional emails.
- RecordGod sends its own receipts (`mailer.py`) and holds Discogs/import auth — no OAuth in this plugin.

View File

@ -0,0 +1,39 @@
<?php
if (!defined('ABSPATH')) exit;
/** Thin HTTP client for the RecordGod /shop API — GET (transient-cached) + POST (bridge-key). */
class RG_API {
static function base() {
return rtrim(get_option('rg_base_url', 'https://recordgod.com'), '/');
}
/** GET /shop/<path>?args — decoded JSON or null. Cached in a transient for $ttl seconds (0 = no cache). */
static function get($path, $args = [], $ttl = 120) {
$url = self::base() . $path . ($args ? '?' . http_build_query($args) : '');
$key = 'rg_' . md5($url);
if ($ttl) {
$hit = get_transient($key);
if ($hit !== false) return $hit;
}
$r = wp_remote_get($url, ['timeout' => 12, 'headers' => ['Accept' => 'application/json']]);
if (is_wp_error($r) || wp_remote_retrieve_response_code($r) !== 200) return null;
$data = json_decode(wp_remote_retrieve_body($r), true);
if ($ttl && $data !== null) set_transient($key, $data, $ttl);
return $data;
}
/** POST JSON to /shop/<path> with the bridge key header. Returns [code, decoded-body]. */
static function post($path, $body) {
$r = wp_remote_post(self::base() . $path, [
'timeout' => 15,
'headers' => [
'Content-Type' => 'application/json',
'X-Bridge-Key' => get_option('rg_bridge_key', ''),
],
'body' => wp_json_encode($body),
]);
if (is_wp_error($r)) return [0, ['error' => $r->get_error_message()]];
return [wp_remote_retrieve_response_code($r), json_decode(wp_remote_retrieve_body($r), true)];
}
}

View File

@ -0,0 +1,63 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* On-the-fly Woo products the WowPlatter trick. RecordGod holds the 25k catalog; Woo only ever sees the
* ~handful of items someone actually buys. A Woo product is minted the moment a SKU is added to cart, looked
* up by SKU thereafter (never duplicated), and kept out of the Woo shop loop (catalog_visibility=hidden).
*/
class RG_Cart {
static function init() {
add_action('template_redirect', [__CLASS__, 'handle_add']);
}
/** Ensure a Woo product exists for $sku (creating it from /shop/item if absent). Returns product id or 0. */
static function ensure_product($sku) {
$id = wc_get_product_id_by_sku($sku);
if ($id) return $id;
$item = RG_API::get('/shop/item/' . rawurlencode($sku), [], 0);
if (!$item || empty($item['in_stock'])) return 0;
$p = new WC_Product_Simple();
$p->set_name(trim(($item['artist'] ? $item['artist'] . ' — ' : '') . $item['title']));
$p->set_sku($sku);
$p->set_regular_price((string) $item['price']);
$p->set_price((string) $item['price']);
$p->set_catalog_visibility('hidden'); // never floods the Woo shop loop
$p->set_manage_stock(true);
$p->set_stock_quantity(1); // single physical copy
$p->set_sold_individually(true);
$p->set_weight('0.28'); // 230g record + 50g packaging (Woo fallback; real quote is RG_Shipping)
if (!empty($item['release_id'])) $p->update_meta_data('_rg_release_id', $item['release_id']);
$id = $p->save();
if ($id && !empty($item['thumb'])) self::attach_thumb($id, $item['thumb']);
return $id;
}
/** GET ?rg_add=<sku> → mint product, add to cart, bounce to the cart page. */
static function handle_add() {
if (empty($_GET['rg_add'])) return;
$sku = sanitize_text_field(wp_unslash($_GET['rg_add']));
$id = self::ensure_product($sku);
if ($id) {
WC()->cart->add_to_cart($id, 1);
wp_safe_redirect(wc_get_cart_url());
} else {
wc_add_notice('Sorry, that record has just sold.', 'error');
wp_safe_redirect(home_url('/records'));
}
exit;
}
/** Sideload the Discogs thumb as the product image (once). */
static function attach_thumb($product_id, $url) {
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$att = media_sideload_image($url, $product_id, null, 'id');
if (!is_wp_error($att)) set_post_thumbnail($product_id, $att);
}
}

View File

@ -0,0 +1,45 @@
<?php
if (!defined('ABSPATH')) exit;
/** On order completion, post it back to RecordGod so it marks the SKUs sold + logs the online sale. */
class RG_Orders {
static function init() {
add_action('woocommerce_order_status_completed', [__CLASS__, 'push'], 10, 1);
}
static function push($order_id) {
$order = wc_get_order($order_id);
if (!$order || $order->get_meta('_rg_synced')) return; // idempotent (RecordGod also dedups on WC-<id>)
$items = [];
foreach ($order->get_items() as $line) {
$product = $line->get_product();
$sku = $product ? $product->get_sku() : '';
if (!$sku) continue;
$items[] = [
'sku' => $sku,
'name' => $line->get_name(),
'qty' => (int) $line->get_quantity(),
'price' => (float) $order->get_item_total($line, false),
];
}
if (!$items) return;
[$code, $body] = RG_API::post('/shop/woo-order', [
'order_number' => (string) $order->get_order_number(),
'email' => $order->get_billing_email(),
'name' => trim($order->get_formatted_billing_full_name()),
'total' => (float) $order->get_total(),
'items' => $items,
]);
if ($code === 200) {
$order->update_meta_data('_rg_synced', current_time('mysql'));
} else {
$order->update_meta_data('_rg_sync_error', "HTTP $code");
$order->add_order_note('RecordGod sync failed (HTTP ' . $code . ') — retry from order actions.');
}
$order->save();
}
}

View File

@ -0,0 +1,48 @@
<?php
if (!defined('ABSPATH')) exit;
/** Woo shipping method that quotes postage from RecordGod (record-count → AusPost flat rate). */
class RG_Shipping extends WC_Shipping_Method {
function __construct($instance_id = 0) {
$this->id = 'recordgod';
$this->instance_id = absint($instance_id);
$this->method_title = 'RecordGod Postage';
$this->method_description = 'Live AusPost flat-rate quote from RecordGod (Parcel / Express Post).';
$this->supports = ['shipping-zones', 'instance-settings', 'settings'];
$this->init();
}
function init() {
$this->init_form_fields();
$this->init_settings();
$this->enabled = $this->get_option('enabled', 'yes');
$this->title = $this->get_option('title', 'Postage');
add_action('woocommerce_update_options_shipping_' . $this->id, [$this, 'process_admin_options']);
}
function init_form_fields() {
$this->instance_form_fields = [
'enabled' => ['title' => 'Enable', 'type' => 'checkbox', 'default' => 'yes'],
'title' => ['title' => 'Title', 'type' => 'text', 'default' => 'Postage'],
];
}
function calculate_shipping($package = []) {
$units = 0;
foreach ($package['contents'] as $line) $units += (int) $line['quantity'];
if ($units < 1) return;
$q = RG_API::get('/shop/shipping/quote', ['units' => $units], 30);
if (!$q || empty($q['options'])) return;
foreach ($q['options'] as $opt) {
$this->add_rate([
'id' => $this->id . ':' . $opt['service'],
'label' => $opt['label'],
'cost' => (float) $opt['price'],
'calc_tax' => 'per_order',
]);
}
}
}

View File

@ -0,0 +1,189 @@
<?php
if (!defined('ABSPATH')) exit;
/**
* SEO storefront, server-rendered from RecordGod's /shop API inside the active theme's chrome.
* /records browse (release-grouped, ?q= ?genre= ?page=)
* /release/{id} release detail + in-stock copies + JSON-LD MusicAlbum/Product
* Add-to-cart points at /?rg_add=<sku>, which mints the Woo product on the fly (RG_Cart).
*/
class RG_Storefront {
static function init() {
add_action('init', [__CLASS__, 'rewrites']);
add_filter('query_vars', function ($v) {
return array_merge($v, ['rg_view', 'rg_id']);
});
add_action('template_redirect', [__CLASS__, 'render']);
}
static function rewrites() {
add_rewrite_rule('^records/?$', 'index.php?rg_view=records', 'top');
add_rewrite_rule('^release/([0-9]+)/?', 'index.php?rg_view=release&rg_id=$matches[1]', 'top');
}
static function render() {
$view = get_query_var('rg_view');
if (!$view) return;
if ($view === 'release') {
$id = (int) get_query_var('rg_id');
$data = RG_API::get('/shop/release/' . $id, [], 120);
if (!$data || empty($data['release'])) { self::not_found(); return; }
self::head_for_release($data);
get_header();
self::release_html($data);
get_footer();
exit;
}
// browse
$q = isset($_GET['q']) ? sanitize_text_field(wp_unslash($_GET['q'])) : '';
$genre = isset($_GET['genre']) ? sanitize_text_field(wp_unslash($_GET['genre'])) : '';
$page = max(1, (int) ($_GET['page'] ?? 1));
$args = array_filter(['q' => $q, 'genre' => $genre, 'page' => $page]);
$data = RG_API::get('/shop/browse', $args, 60);
self::head_for_browse($q, $genre);
get_header();
self::browse_html($data ?: ['items' => [], 'total' => 0, 'page' => 1, 'per' => 24], $q, $genre, $page);
get_footer();
exit;
}
// --- <head>: title / description / JSON-LD --------------------------------
static function head_for_browse($q, $genre) {
$title = trim(($q ?: $genre) ? ($q ?: $genre) . ' — Records' : 'Records') . ' · ' . get_bloginfo('name');
add_filter('pre_get_document_title', fn() => $title);
add_action('wp_head', function () {
echo '<meta name="description" content="Browse vinyl, CDs and cassettes in stock.">' . "\n";
});
}
static function head_for_release($data) {
$r = $data['release'];
$copies = $data['copies'];
$title = trim($r['artist'] . ' ' . $r['title']) . ' · ' . get_bloginfo('name');
add_filter('pre_get_document_title', fn() => $title);
add_action('wp_head', function () use ($r, $copies) {
$price = $copies ? min(array_column($copies, 'price')) : null;
$desc = trim(implode(' · ', array_filter([$r['artist'], $r['format'], $r['year'], $r['label']])));
echo '<meta name="description" content="' . esc_attr($desc) . '">' . "\n";
$ld = [
'@context' => 'https://schema.org',
'@type' => 'MusicAlbum',
'name' => $r['title'],
'byArtist' => ['@type' => 'MusicGroup', 'name' => $r['artist']],
];
if (!empty($r['thumb'])) $ld['image'] = $r['thumb'];
if (!empty($r['genre'])) $ld['genre'] = $r['genre'];
if ($price !== null) {
$ld['offers'] = [
'@type' => 'Offer', 'priceCurrency' => 'AUD', 'price' => $price,
'availability' => $copies ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
'url' => home_url('/release/' . $r['id']),
];
}
echo '<script type="application/ld+json">' . wp_json_encode($ld) . '</script>' . "\n";
});
}
// --- body -----------------------------------------------------------------
static function browse_html($d, $q, $genre, $page) {
$heading = $q ?: ($genre ?: 'Records');
echo '<main class="rg-shop" style="max-width:1100px;margin:2rem auto;padding:0 1rem">';
echo '<form method="get" action="' . esc_url(home_url('/records')) . '" style="margin-bottom:1.5rem">';
echo '<input type="search" name="q" value="' . esc_attr($q) . '" placeholder="Search artist or title"
style="padding:.6rem;width:60%;max-width:420px"> <button type="submit">Search</button></form>';
echo '<h1>' . esc_html($heading) . ' <small style="font-weight:400;color:#888">(' . (int) $d['total'] . ')</small></h1>';
if (!$d['items']) { echo '<p>Nothing in stock matching that.</p></main>'; return; }
echo '<div class="rg-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:1.2rem">';
foreach ($d['items'] as $it) {
$url = home_url('/release/' . (int) $it['release_id']);
echo '<a href="' . esc_url($url) . '" style="text-decoration:none;color:inherit">';
if (!empty($it['thumb']))
echo '<img src="' . esc_url($it['thumb']) . '" alt="' . esc_attr($it['title']) . '" loading="lazy"
style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px">';
echo '<div style="font-weight:600;margin-top:.4rem;font-size:.9rem">' . esc_html($it['title']) . '</div>';
echo '<div style="color:#888;font-size:.85rem">' . esc_html($it['artist']) . '</div>';
echo '<div style="margin-top:.2rem">$' . esc_html(number_format((float) $it['price'], 2));
if ((int) $it['copies'] > 1) echo ' <small style="color:#888">· ' . (int) $it['copies'] . ' copies</small>';
echo '</div></a>';
}
echo '</div>';
self::pager($d, $page, $q, $genre);
echo '</main>';
}
static function pager($d, $page, $q, $genre) {
$pages = (int) ceil($d['total'] / max(1, $d['per']));
if ($pages <= 1) return;
$link = fn($p) => esc_url(add_query_arg(array_filter(['q' => $q, 'genre' => $genre, 'page' => $p]), home_url('/records')));
echo '<div style="margin:2rem 0;text-align:center">';
if ($page > 1) echo '<a href="' . $link($page - 1) . '">← Prev</a> ';
echo '<span style="margin:0 1rem">Page ' . $page . ' of ' . $pages . '</span>';
if ($page < $pages) echo '<a href="' . $link($page + 1) . '">Next →</a>';
echo '</div>';
}
static function release_html($data) {
$r = $data['release'];
echo '<main class="rg-release" style="max-width:900px;margin:2rem auto;padding:0 1rem">';
echo '<p><a href="' . esc_url(home_url('/records')) . '">← Records</a></p>';
echo '<div style="display:flex;gap:2rem;flex-wrap:wrap">';
if (!empty($r['thumb']))
echo '<img src="' . esc_url($r['thumb']) . '" alt="' . esc_attr($r['title']) . '"
style="width:320px;max-width:100%;border-radius:8px">';
echo '<div style="flex:1;min-width:280px">';
echo '<h1 style="margin:0">' . esc_html($r['title']) . '</h1>';
echo '<h2 style="margin:.2rem 0 1rem;font-weight:400;color:#666">' . esc_html($r['artist']) . '</h2>';
foreach (['label' => 'Label', 'format' => 'Format', 'country' => 'Country',
'year' => 'Year', 'genre' => 'Genre', 'style' => 'Style'] as $k => $lbl)
if (!empty($r[$k]))
echo '<div><strong>' . $lbl . ':</strong> ' . esc_html($r[$k]) . '</div>';
echo '<h3 style="margin-top:1.5rem">In stock</h3>';
if (empty($data['copies'])) {
echo '<p>No copies in stock right now.</p>';
} else {
echo '<ul style="list-style:none;padding:0">';
foreach ($data['copies'] as $c) {
$cond = trim(implode(' / ', array_filter([$c['condition'], $c['sleeve_cond'] ?? null])));
echo '<li style="display:flex;justify-content:space-between;align-items:center;
border:1px solid #eee;border-radius:6px;padding:.6rem .8rem;margin-bottom:.5rem">';
echo '<span>$' . esc_html(number_format((float) $c['price'], 2));
if ($cond) echo ' <small style="color:#888">· ' . esc_html($cond) . '</small>';
echo '</span>';
echo '<a class="button" href="' . esc_url(home_url('/?rg_add=' . rawurlencode($c['sku']))) . '"
style="background:#111;color:#fff;padding:.4rem .9rem;border-radius:5px;text-decoration:none">Add to cart</a>';
echo '</li>';
}
echo '</ul>';
}
echo '</div></div>';
if (!empty($data['tracks'])) {
echo '<h3 style="margin-top:2rem">Tracklist</h3><ol style="columns:2;max-width:600px">';
foreach ($data['tracks'] as $t) {
echo '<li>' . esc_html($t['title']);
if (!empty($t['duration'])) echo ' <small style="color:#999">' . esc_html($t['duration']) . '</small>';
echo '</li>';
}
echo '</ol>';
}
echo '</main>';
}
static function not_found() {
status_header(404);
get_header();
echo '<main style="max-width:700px;margin:3rem auto;text-align:center"><h1>Record not found</h1>'
. '<p><a href="' . esc_url(home_url('/records')) . '">Browse the shop →</a></p></main>';
get_footer();
exit;
}
}

View File

@ -0,0 +1,78 @@
<?php
/**
* Plugin Name: RecordGod Bridge
* Description: Thin bridge to RecordGod RecordGod owns the catalog/stock/pricing/shipping; this plugin
* renders SEO storefront pages from its /shop API, mints Woo products on the fly at add-to-cart,
* injects postage, and posts completed orders back. Successor to WowPlatter.
* Version: 0.1.0
* Author: Monster Robot
* Requires Plugins: woocommerce
*/
if (!defined('ABSPATH')) exit;
define('RG_BRIDGE_VERSION', '0.1.0');
define('RG_BRIDGE_DIR', plugin_dir_path(__FILE__));
require_once RG_BRIDGE_DIR . 'includes/class-rg-api.php';
require_once RG_BRIDGE_DIR . 'includes/class-rg-cart.php';
require_once RG_BRIDGE_DIR . 'includes/class-rg-storefront.php';
require_once RG_BRIDGE_DIR . 'includes/class-rg-orders.php';
// Shipping method loads only once Woo's class exists.
add_action('woocommerce_shipping_init', function () {
require_once RG_BRIDGE_DIR . 'includes/class-rg-shipping.php';
});
add_filter('woocommerce_shipping_methods', function ($methods) {
$methods['recordgod'] = 'RG_Shipping';
return $methods;
});
// Wire the pieces.
add_action('plugins_loaded', function () {
RG_Cart::init();
RG_Storefront::init();
RG_Orders::init();
});
// --- Settings (Settings API): base URL + bridge key + store id ---------------
add_action('admin_menu', function () {
add_options_page('RecordGod Bridge', 'RecordGod Bridge', 'manage_options', 'rg-bridge', 'rg_bridge_settings_page');
});
add_action('admin_init', function () {
register_setting('rg_bridge', 'rg_base_url');
register_setting('rg_bridge', 'rg_bridge_key');
register_setting('rg_bridge', 'rg_store_id');
});
function rg_bridge_settings_page() {
?>
<div class="wrap">
<h1>RecordGod Bridge</h1>
<form method="post" action="options.php">
<?php settings_fields('rg_bridge'); ?>
<table class="form-table">
<tr><th>RecordGod base URL</th>
<td><input type="url" name="rg_base_url" class="regular-text"
value="<?php echo esc_attr(get_option('rg_base_url', 'https://recordgod.com')); ?>"
placeholder="https://recordgod.com"></td></tr>
<tr><th>Bridge key</th>
<td><input type="text" name="rg_bridge_key" class="regular-text"
value="<?php echo esc_attr(get_option('rg_bridge_key', '')); ?>">
<p class="description">Must match the <code>bridge_key</code> secret in RecordGod settings.</p></td></tr>
<tr><th>Store ID</th>
<td><input type="number" name="rg_store_id" class="small-text"
value="<?php echo esc_attr(get_option('rg_store_id', '1')); ?>"></td></tr>
</table>
<?php submit_button(); ?>
</form>
<p><a href="<?php echo esc_url(home_url('/records')); ?>">View storefront </a></p>
</div>
<?php
}
// Storefront rewrite rules need flushing on (de)activate.
register_activation_hook(__FILE__, function () {
RG_Storefront::rewrites();
flush_rewrite_rules();
});
register_deactivation_hook(__FILE__, 'flush_rewrite_rules');