Skill: its-clawdia/musicbrainz-importer (version 1.2.1)

--- name: musicbrainz_importer description: "Look up and add music metadata on MusicBrainz. Use when asked to check if an artist, album, or release exists on MusicBrainz, find MusicBrainz entries linked to Spotify URLs, or add/edit releases, artists, or other entities on MusicBrainz. Triggers on mentions of MusicBrainz, MB, music database, adding releases, music metadata, or linking Spotify to MusicBrainz." homepage: https://github.com/its-clawdia/musicbrainz-importer metadata: { "openclaw": { "emoji": "🎵", "requires": { "bins": ["curl", "jq", "node"] }, "install": [ { "id": "playwright", "kind": "npm", "package": "playwright", "bins": [], "label": "Install Playwright (npm)", }, ], }, } --- # MusicBrainz ## Browser Setup All browser work uses **Playwright** via a Node.js script. Install once per session: ```bash cd /tmp && npm ls playwright 2>/dev/null || npm install playwright ``` Launch pattern (use in every script): ```javascript import { chromium } from 'playwright'; import { execSync } from 'child_process'; // Detect Playwright's bundled Chromium path dynamically const chromiumPath = process.env.PLAYWRIGHT_CHROMIUM_PATH || execSync('find ~/.cache/ms-playwright -name chrome -path "*/chrome-linux*/chrome" 2>/dev/null | head -1', { encoding: 'utf-8' }).trim() || execSync('find ~/.cache/ms-playwright -name Chromium -path "*/Chromium.app/Contents/MacOS/Chromium" 2>/dev/null | head -1', { encoding: 'utf-8' }).trim(); if (!chromiumPath) throw new Error('Chromium not found. Run: npx playwright install chromium'); const browser = await chromium.launch({ executablePath: chromiumPath, headless: true, args: ['--no-sandbox'] }); const page = await browser.newPage(); ``` ## Architecture: Isolated Steps There are **four operations**. Each is a self-contained Playwright script. Never combine them into one script. Run them sequentially: 1. **Login** — authenticate the browser session 2. **Add Artist** — create a new artist entity 3. **Add Release** — create a release with tracks, metadata, and Spotify link 4. **Upload Cover Art** — attach album artwork to an existing release Between steps, **close the browser** (`await browser.close()`). Each script launches fresh, logs in if needed, does its one job, and exits. This prevents state pollution between operations. --- ## Preflight — Credentials Check **Run before any write operation.** Skip for read-only lookups. ```bash bash scripts/preflight.sh ``` Credentials live in `.credentials.json` in the skill directory. - **`MISSING_CREDENTIALS`**: Ask user for MusicBrainz username + password. Save: ```bash cat > ~/.openclaw/skills/musicbrainz/.credentials.json << 'EOF' {"username": "USER", "password": "PASS"} EOF ``` - **`INVALID_CREDENTIALS`**: Ask user to verify, update file, re-run. - No account → https://musicbrainz.org/register --- ## Step 1: Login Every Playwright script that writes to MB must start with login. Use this exact pattern: ```javascript import { readFileSync } from 'fs'; const creds = JSON.parse(readFileSync( process.env.HOME + '/.openclaw/skills/musicbrainz/.credentials.json' )); await page.goto('https://musicbrainz.org/login', { waitUntil: 'networkidle', timeout: 30000 }); await page.fill('#id-username', creds.username); await page.fill('#id-password', creds.password); // IMPORTANT: There are TWO submit buttons on the page (search + login). // Must click the one with "Log in" text. await page.evaluate(() => { Array.from(document.querySelectorAll('button[type="submit"]')) .find(b => b.textContent.includes('Log in'))?.click(); }); await page.waitForTimeout(5000); // Verify: should redirect to /user/<username> if (page.url().includes('/login')) { throw new Error('Login failed'); } ``` ### Key gotcha The MusicBrainz login page has **two forms**: a search form and the login form. Both have `button[type="submit"]`. Using `page.click('button[type="submit"]')` hits the search button. **Always** use the evaluate pattern above to find the button by text content. --- ## Step 2: Add Artist **When:** Artist doesn't exist on MusicBrainz. Check first with `mb_lookup.sh` or the search API. ### Script structure ```javascript // ... browser launch + login (Step 1) ... await page.goto('https://musicbrainz.org/artist/create', { waitUntil: 'networkidle', timeout: 30000 }); await page.waitForTimeout(2000); // Fill fields await page.fill('#id-edit-artist\\.name', 'Artist Name'); await page.fill('#id-edit-artist\\.sort_name', 'Name, Artist'); // "Last, First" or "Name, The" await page.selectOption('#id-edit-artist\\.type_id', '2'); // 1=Person, 2=Group, 4=Other await page.fill('#id-edit-artist\\.edit_note', 'Adding artist from Spotify: https://open.spotify.com/artist/<id>'); // Submit — use form.submit() to avoid button ambiguity await page.evaluate(() => { document.getElementById('id-edit-artist.name')?.closest('form')?.submit(); }); await page.waitForTimeout(5000); // Extract MBID from redirect URL const artistUrl = page.url(); const artistMbid = artistUrl.match(/artist\/([a-f0-9-]{36})/)?.[1]; console.log('Artist MBID:', artistMbid); await browser.close(); ``` ### After completion Report the artist MBID and link to user. Store the MBID — needed for adding releases. --- ## Step 3: Add Release **Every release follows this identical flow**, regardless of what happened before (login, artist creation, previous releases). Each is a fresh Playwright script. ### Form field IDs (for reference) | Field | ID | Values | |-------|----|--------| | Title | `name` | text | | Artist | `ac-source-single-artist` | text (autocomplete) | | Release group | `release-group` | text (autocomplete) | | Primary type | `primary-type` | `1`=Album, `2`=Single, `3`=EP | | Status | `status` | `1`=Official | | Language | `language` | `120`=English, `145`=German | | Script | `script` | `28`=Latin, `12`=Cyrillic | | Year | `event-date-0` | YYYY | | Month | `.partial-date-month` | MM | | Day | `.partial-date-day` | DD | | Country | `country-0` | `240`=[Worldwide] | | Label | `label-0` | text (autocomplete) | | Barcode | `barcode` | text | | No barcode | `no-barcode` | checkbox | | Packaging | `packaging` | `7`=None | ### Known traps 1. **Release group autocomplete blocks the form.** After filling the title and selecting an artist, the release group field auto-searches and opens a popup dialog ("Add medium") that makes all other form fields invisible to Playwright (offsetParent = null, zero-size rects). You **must** dismiss this before interacting with other fields. 2. **Artist autocomplete can match wrong entity.** Typing "The Bride Wore Black" and pressing ArrowDown+Enter may select a similarly-named *release group* or a different artist. **Use the MBID** in the autocomplete field for exact matching. 3. **React selects don't respond to `page.selectOption()` when not visible.** Use `evaluate` with native setters instead. 4. **Track parser dialog vs Add Medium dialog.** The track parser opens in a jQuery UI dialog. There may be multiple dialogs open. Always target by dialog title text. 5. **Track parser may double tracks.** The parse can produce duplicate entries. Always verify track count after parsing. ### Script structure ```javascript // ... browser launch + login (Step 1) ... // Navigate to empty add-release form await page.goto('https://musicbrainz.org/release/add', { waitUntil: 'networkidle', timeout: 30000 }); await page.waitForTimeout(3000); // === TITLE === // Use evaluate with native setter — keyboard typing is fragile await page.evaluate((title) => { const el = document.getElementById('name'); const setter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' ).set; setter.call(el, title); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }, 'Album Title'); await page.waitForTimeout(500); // === ARTIST === // Paste the MBID for exact matching (avoids wrong autocomplete selections) const artistInput = page.locator('#ac-source-single-artist'); await artistInput.click(); await page.keyboard.type('<artist-mbid>', { delay: 5 }); await page.waitForTimeout(4000); await page.keyboard.press('ArrowDown'); await page.waitForTimeout(500); await page.keyboard.press('Enter'); await page.waitForTimeout(3000); // Verify artist matched (background turns green) const artistOk = await page.evaluate(() => { const input = document.getElementById('ac-source-single-artist'); return window.getComputedStyle(input).backgroundColor.includes('177'); // rgb(177,235,176) }); if (!artistOk) throw new Error('Artist not matched'); // === DISMISS BLOCKERS === // The release-group autocomplete and "Add medium" dialog may have opened. // Close everything before touching other fields. await page.evaluate(() => { // Hide all autocomplete dropdowns document.querySelectorAll('ul.ui-autocomplete') .forEach(ul => ul.style.display = 'none'); // Close any open dialogs (Add medium, search, etc.) document.querySelectorAll('.ui-dialog').forEach(d => { if (d.style.display !== 'none' && d.offsetParent !== null) { const close = d.querySelector('.ui-dialog-titlebar-close'); if (close) close.click(); } }); // Blur active element document.activeElement?.blur(); }); await page.waitForTimeout(1000); // === METADATA (all via evaluate — fields may not be "visible" to Playwright) === await page.evaluate(({ year, month, day }) => { function setSelect(id, val) { const el = document.getElementById(id); if (!el || el.disabled) return; el.value = val; el.dispatchEvent(new Event('change', { bubbles: true })); } function setInput(id, val) { const el = document.getElementById(id); if (!el) return; const setter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' ).set; setter.call(el, val); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } setSelect('primary-type', '1'); // Album setSelect('status', '1'); // Official setSelect('language', '120'); // English setSelect('script', '28'); // Latin setSelect('packaging', '7'); // None setSelect('country-0', '240'); // [Worldwide] setInput('event-date-0', year); const dateContainer = document.getElementById('event-date-0')?.closest('.partial-date'); if (dateContainer) { const setter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' ).set; const monthEl = dateContainer.querySelector('.partial-date-month'); const dayEl = dateContainer.querySelector('.partial-date-day'); if (monthEl) { setter.call(monthEl, month); monthEl.dispatchEvent(new Event('input', { bubbles: true })); } if (dayEl) { setter.call(dayEl, day); dayEl.dispatchEvent(new Event('input', { bubbles: true })); } } // No barcode const cb = document.querySelector('#no-barcode'); if (cb && !cb.checked) cb.click(); }, { year: '2007', month: '7', day: '10' }); await page.waitForTimeout(1000); // === TRACKLIST === // Switch to Tracklist tab await page.evaluate(() => { Array.from(document.querySelectorAll('.ui-tabs-anchor')) .find(t => t.textContent === 'Tracklist')?.click(); }); await page.waitForTimeout(2000); // Set medium format to Digital Media await page.evaluate(() => { const sel = document.querySelector('#tracklist select'); if (sel) { const dm = Array.from(sel.options).find(o => o.text === 'Digital Media'); if (dm) { sel.value = dm.value; sel.dispatchEvent(new Event('change', { bubbles: true })); } } }); // Open track parser await page.evaluate(() => { Array.from(document.querySelectorAll('button')) .find(b => b.textContent === 'Track parser')?.click(); }); await page.waitForTimeout(1500); // Fill track parser dialog — target by dialog title to avoid "Add medium" dialog const trackText = '1. Track One (3:47)\n2. Track Two (2:50)'; // build from data await page.evaluate((text) => { for (const dialog of document.querySelectorAll('.ui-dialog')) { if (dialog.style.display === 'none') continue; const title = dialog.querySelector('.ui-dialog-title'); if (!title?.textContent.includes('Track parser')) continue; const ta = dialog.querySelector('textarea'); if (ta) { const setter = Object.getOwnPropertyDescriptor( window.HTMLTextAreaElement.prototype, 'value' ).set; setter.call(ta, text); ta.dispatchEvent(new Event('input', { bubbles: true })); } Array.from(dialog.querySelectorAll('button')) .find(b => b.textContent === 'Parse tracks')?.click(); break; } }, trackText); await page.waitForTimeout(3000); // Close track parser dialog await page.evaluate(() => { for (const d of document.querySelectorAll('.ui-dialog')) { if (d.querySelector('.ui-dialog-title')?.textContent.includes('Track parser')) { d.querySelector('.ui-dialog-titlebar-close')?.click(); } } }); await page.waitForTimeout(1000); // Confirm capitalization if warning appears await page.evaluate(() => { document.querySelectorAll('input[type="checkbox"]').forEach(cb => { const text = cb.closest('label')?.textContent || cb.parentElement?.textContent || ''; if (text.includes('capitalized correctly') && !cb.checked) cb.click(); }); }); // Verify track count const trackCount = await page.evaluate(() => document.querySelectorAll('tr.track').length ); // === EXTERNAL LINKS (Spotify URL) === await page.evaluate(() => { Array.from(document.querySelectorAll('.ui-tabs-anchor')) .find(t => t.textContent === 'Release information')?.click(); }); await page.waitForTimeout(1000); await page.evaluate((url) => { const input = document.querySelector('input[type="url"]'); if (input) { const setter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' ).set; setter.call(input, url); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } }, 'https://open.spotify.com/album/<id>'); await page.waitForTimeout(2000); // === EDIT NOTE === await page.evaluate(() => { Array.from(document.querySelectorAll('.ui-tabs-anchor')) .find(t => t.textContent === 'Edit note')?.click(); }); await page.waitForTimeout(1000); await page.evaluate((note) => { const ta = document.getElementById('edit-note-text'); if (ta) { const setter = Object.getOwnPropertyDescriptor( window.HTMLTextAreaElement.prototype, 'value' ).set; setter.call(ta, note); ta.dispatchEvent(new Event('input', { bubbles: true })); } }, 'Added from Spotify: <url>'); await page.waitForTimeout(500); // === SUBMIT === const canSubmit = await page.evaluate(() => { const btn = Array.from(document.querySelectorAll('button')) .find(b => b.textContent.includes('Enter edit')); return btn && !btn.disabled; }); if (canSubmit) { await page.evaluate(() => { Array.from(document.querySelectorAll('button')) .find(b => b.textContent.includes('Enter edit'))?.click(); }); await page.waitForTimeout(15000); const mbid = page.url().match(/release\/([a-f0-9-]{36})/)?.[1]; console.log('Release MBID:', mbid); } await browser.close(); ``` --- ## Step 4: Upload Cover Art **Run as a separate script after the release exists.** ```javascript // ... browser launch + login (Step 1) ... // Download cover image locally first (avoids CORS issues in-page) // Do this via exec before the Playwright script: // curl -sL "<spotify-640px-url>" -o /tmp/cover.jpg await page.goto( 'https://musicbrainz.org/release/<mbid>/add-cover-art', { waitUntil: 'networkidle', timeout: 30000 } ); await page.waitForTimeout(3000); // Upload via Playwright's setInputFiles (works with jQuery File Upload) await page.locator('input[type="file"]').setInputFiles('/tmp/cover.jpg'); await page.waitForTimeout(3000); // Wait for upload processing — poll until submit button enables for (let i = 0; i < 15; i++) { const enabled = await page.evaluate(() => { const btn = document.getElementById('add-cover-art-submit'); return btn && !btn.disabled; }); if (enabled) break; await page.waitForTimeout(2000); } // Check "Front" type await page.evaluate(() => { document.querySelectorAll('input[type="checkbox"]').forEach(cb => { const label = cb.closest('label')?.textContent || ''; if (label.includes('Front') && !cb.checked) cb.click(); }); }); // Submit await page.locator('#add-cover-art-submit').click(); await page.waitForTimeout(10000); // Success = redirect to /release/<mbid>/cover-art console.log('Final URL:', page.url()); await browser.close(); ``` ### Cover art URL from Spotify Find the image hash on the album page (prefix `ab67616d00001e02` = 300px). Replace with `ab67616d0000b273` for 640×640: ``` https://i.scdn.co/image/ab67616d0000b273<hash> ``` ### Download before upload Always download the image locally first with `curl`, then use `setInputFiles()`. Do NOT use in-page `fetch()` — it can hit CORS issues and the jQuery uploader's signature flow doesn't reliably trigger. --- ## Lookups (API — no login needed) ```bash bash scripts/mb_lookup.sh <spotify-url> ``` Returns MB entity ID or "NOT FOUND". For other queries see `references/api.md`. ### Search by name (no Spotify link) ```bash # Build User-Agent: use bot name + MB username from .credentials.json if available MB_USER=$(jq -r '.username // empty' ~/.openclaw/skills/musicbrainz/.credentials.json 2>/dev/null || true) UA="${OPENCLAW_BOT_NAME:-OpenClawBot}/1.0 (${MB_USER:-anonymous}@users.noreply.musicbrainz.org)" curl -s "https://musicbrainz.org/ws/2/artist/?query=<name>&fmt=json&limit=5" \ -H "User-Agent: $UA" | jq '.artists[] | {id, name, score}' ``` --- ## Scraping Spotify Use the oembed endpoint to get the artist/album name without a browser: ```bash curl -s "https://open.spotify.com/oembed?url=<spotify-url>" | jq '.title' ``` For full discography scraping, use Playwright on `/artist/<id>/discography/all` and expand the viewport incrementally until all lazy-loaded content renders (see the expand loop pattern in `references/api.md`). --- ## Progress Reporting (MANDATORY) **Message the user after EACH discrete operation completes:** - Artist created → name + MBID + link - Release submitted → title + MBID + link - Cover art uploaded → confirmation Never go silent for more than a few minutes during multi-release work. --- ## Key Facts - New accounts are "Beginner" — edits queue for 7-day vote - Digital releases: Packaging=None, Country=[Worldwide], no barcode - MusicBrainz uses Knockout.js (not React) for forms — native property setters + `dispatchEvent` are required - `page.selectOption()` only works when the select element is visible in the viewport; use `evaluate` when elements may be hidden behind dialogs - Seeding docs: https://musicbrainz.org/doc/Development/Seeding/Release_Editor

path: README.md
content: # MusicBrainz Importer

A skill for looking up and adding music metadata on [MusicBrainz](https://musicbrainz.org). Works with [Hermes](https://hermes.openclaw.ai) and any agent that supports SKILL.md-based skills.

## What it does

- **Look up** artists, releases, and release groups on MusicBrainz via API
- **Find** MusicBrainz entries linked to Spotify URLs
- **Add artists** to MusicBrainz via browser automation (Playwright)
- **Add releases** with full metadata: tracklists, release dates, Spotify links, cover art
- **Upload cover art** from Spotify to MusicBrainz releases

## Requirements

- **Node.js** (for Playwright scripts)
- **Playwright** with Chromium (`npx playwright install chromium`)
- **curl** and **jq** (for API lookups)
- A [MusicBrainz account](https://musicbrainz.org/register) (for write operations)

## Install

### Via npx skills

```bash
npx skills add its-clawdia/musicbrainz-importer
```

### Via ClawHub

```bash
clawhub install musicbrainz-importer
```

### Manual

Clone into your Hermes skills directory:

```bash
git clone https://github.com/its-clawdia/musicbrainz-importer.git ~/.hermes/skills/openclaw-imports/musicbrainz-importer
```

## Setup

Store your MusicBrainz credentials for write operations:

```bash
cat > ~/.hermes/skills/openclaw-imports/musicbrainz-importer/.credentials.json << 'EOF'
{"username": "YOUR_MB_USERNAME", "password": "YOUR_MB_PASSWORD"}
EOF
```

Verify with:

```bash
bash ~/.hermes/skills/openclaw-imports/musicbrainz-importer/scripts/preflight.sh
```

## License

MIT


path: _meta.json
content: ownerId: kn76pgpang537fmhp1q4yvx6vs83v1tj
slug: musicbrainz-importer
version: 1.2.0
publishedAt: 1746194400000

path: references/add-release.md
content: # Adding a Release — Reference

## Form Element IDs

| Field | Element ID | Type | Common Values |
|-------|-----------|------|---------------|
| Title | `name` | input text | Release title |
| Artist | `ac-source-single-artist` | input text (autocomplete) | Artist name or MBID |
| Release group | `release-group` | input text (autocomplete) | Auto-populates from title |
| Primary type | `primary-type` | select | `1`=Album, `2`=Single, `3`=EP |
| Secondary types | `secondary-types` | select-multiple | Compilation, Live, etc. |
| Status | `status` | select | `1`=Official |
| Language | `language` | select | `120`=English, `145`=German |
| Script | `script` | select | `28`=Latin, `12`=Cyrillic |
| Year | `event-date-0` | input text | YYYY format, placeholder "YYYY" |
| Month | `.partial-date-month` | input text (class) | MM format |
| Day | `.partial-date-day` | input text (class) | DD format |
| Country | `country-0` | select | `240`=[Worldwide], `81`=Germany |
| Label | `label-0` | input text (autocomplete) | Label name or MBID |
| Catalog number | `catno-0` | input text | |
| Barcode | `barcode` | input text | |
| No barcode checkbox | `no-barcode` | checkbox | |
| Packaging | `packaging` | select | `7`=None |
| Disambiguation | `comment` | input text | |
| Edit note | `edit-note-text` | textarea | |

## Tabs

The release editor uses jQuery UI tabs:

| Tab | Anchor text | Notes |
|-----|------------|-------|
| Release information | `Release information` | Main form fields |
| Release duplicates | `Release duplicates` | Auto-populated, may be disabled |
| Tracklist | `Tracklist` | Medium format, track parser |
| Recordings | `Recordings` | Auto-linked, may be disabled |
| Edit note | `Edit note` | Edit note + submit button |

Switch tabs:
```javascript
Array.from(document.querySelectorAll('.ui-tabs-anchor'))
  .find(t => t.textContent === 'Tracklist')?.click();
```

## Setting Form Values (Knockout.js)

MusicBrainz uses **Knockout.js** (not React). Form values must be set via native property setters to trigger Knockout's data binding:

### Select elements
```javascript
function setSelect(id, value) {
  const el = document.getElementById(id);
  if (!el || el.disabled) return;
  el.value = value;
  el.dispatchEvent(new Event('change', { bubbles: true }));
}
```

### Input elements
```javascript
function setInput(id, value) {
  const el = document.getElementById(id);
  if (!el) return;
  const setter = Object.getOwnPropertyDescriptor(
    window.HTMLInputElement.prototype, 'value'
  ).set;
  setter.call(el, value);
  el.dispatchEvent(new Event('input', { bubbles: true }));
  el.dispatchEvent(new Event('change', { bubbles: true }));
}
```

### Textarea elements
```javascript
function setTextarea(id, value) {
  const el = document.getElementById(id);
  if (!el) return;
  const setter = Object.getOwnPropertyDescriptor(
    window.HTMLTextAreaElement.prototype, 'value'
  ).set;
  setter.call(el, value);
  el.dispatchEvent(new Event('input', { bubbles: true }));
  el.dispatchEvent(new Event('change', { bubbles: true }));
}
```

## Track Parser

The track parser opens in a jQuery UI dialog. There may be **multiple dialogs** open simultaneously (Track parser + Add medium). Always target by title:

```javascript
for (const dialog of document.querySelectorAll('.ui-dialog')) {
  if (dialog.style.display === 'none') continue;
  const title = dialog.querySelector('.ui-dialog-title');
  if (!title?.textContent.includes('Track parser')) continue;
  // ... work with this dialog ...
}
```

### Track text format
```
1. Track Title (M:SS)
2. Another Track (M:SS)
```

### After parsing
- Close the dialog via `.ui-dialog-titlebar-close`
- Check for capitalization warning checkbox ("I confirm these titles are capitalized correctly")
- Verify track count matches expected

## Known Blockers

### Release group autocomplete + "Add medium" dialog
After entering a title and selecting an artist, MusicBrainz auto-searches for matching release groups and may open an "Add medium" dialog. These overlays make all form fields below them **invisible to Playwright** (zero bounding rect, null offsetParent).

**Fix:** Dismiss all blockers before touching any other field:
```javascript
await page.evaluate(() => {
  document.querySelectorAll('ul.ui-autocomplete')
    .forEach(ul => ul.style.display = 'none');
  document.querySelectorAll('.ui-dialog').forEach(d => {
    if (d.style.display !== 'none' && d.offsetParent !== null) {
      d.querySelector('.ui-dialog-titlebar-close')?.click();
    }
  });
  document.activeElement?.blur();
});
```

### `page.selectOption()` fails with "element is not visible"
When dialogs are covering form elements, Playwright's `selectOption()` and `click()` timeout. Use `evaluate()` instead — it operates on the DOM directly regardless of visual occlusion.

## Cover Art Upload

### URL
```
https://musicbrainz.org/release/<mbid>/add-cover-art
```

### Method
Download the image locally first, then use Playwright's `setInputFiles()`:

```javascript
// Before the script:
//   curl -sL "<cover-url>" -o /tmp/cover.jpg

await page.locator('input[type="file"]').setInputFiles('/tmp/cover.jpg');
```

### Spotify cover art URLs
- `ab67616d00001e02<hash>` = 300×300
- `ab67616d0000b273<hash>` = 640×640 (use this one)

### Submit button
- ID: `add-cover-art-submit`
- Starts disabled, enables after upload processing completes
- Poll until enabled, then click

### Duplicate entries
The jQuery uploader sometimes creates two file entries from one upload. Check and remove duplicates before submitting.

## Seeding (Alternative to Manual Form Fill)

POST form data to pre-fill the release editor. See `scripts/seed_release.mjs` for the JSON schema and field mapping.

**Caveat:** Seeding populates DOM values but Knockout.js bindings don't always pick them up. The form may show validation errors ("title required", "artist required") even though the fields appear filled. If seeding fails validation, fall back to the manual form fill approach in SKILL.md Step 3.

Seeding docs: https://musicbrainz.org/doc/Development/Seeding/Release_Editor


path: references/api.md
content: # MusicBrainz API Reference

## Base URL
`https://musicbrainz.org/ws/2/`

Always append `&fmt=json` for JSON responses.

## User-Agent
All API requests require a meaningful User-Agent header. Build it from your bot name and MusicBrainz username:
```
User-Agent: <BotName>/1.0 (<mb-username>@users.noreply.musicbrainz.org)
```
If credentials are available in `.credentials.json`, read the username from there.

## Rate Limiting
- 1 request per second for unauthenticated requests
- Use `web_fetch` for API calls (not browser)

## Key Endpoints

### Lookup by Spotify URL
Find MB entity linked to a Spotify URL:
```
GET /ws/2/url/?resource=https://open.spotify.com/artist/<id>&fmt=json
```
Returns `{id, resource}` — the `id` is the URL entity MBID.

To get the linked artist/release, expand with `inc=artist-rels` or `inc=release-rels`:
```
GET /ws/2/url/<url-mbid>?inc=artist-rels&fmt=json
```
The `relations[].artist.id` is the artist MBID.

### Artist Lookup
```
GET /ws/2/artist/<mbid>?inc=release-groups&fmt=json
```

### Release Group Lookup
```
GET /ws/2/release-group/<mbid>?inc=releases&fmt=json
```

### Search Artists
```
GET /ws/2/artist/?query=<name>&fmt=json&limit=10
```

### Search Releases
```
GET /ws/2/release/?query=release:<title>%20AND%20artist:<name>&fmt=json
```

## Checking if a Spotify Album Exists on MB
1. `GET /ws/2/url/?resource=https://open.spotify.com/album/<id>&fmt=json`
2. If 200 with result → already linked
3. If 404 or no result → not on MB (or not linked)


path: references/seeding.md
content: # Release Editor Seeding

Full parameter reference + recipes for pre-filling the Add Release form via POST.

Official docs: https://musicbrainz.org/doc/Development/Seeding/Release_Editor
Live example: https://musicbrainz.org/static/tests/seed-love-bug.html

## Why seed

Seeding replaces ~90% of the manual form-filling work. Instead of:
- typing the title via native input setters
- pasting the artist MBID into the autocomplete, waiting, arrow-down, enter
- dismissing the auto-triggered release-group dialog
- selecting type/status/language/script/packaging/country via evaluate
- entering date via partial-date fields
- switching to Tracklist tab, opening track parser, pasting, handling duplicate-track bug
- switching to Release info tab, typing the URL
- switching to Edit note tab, typing note

You just POST a form to `/release/add` with all fields populated. The server renders the editor pre-filled. The agent only needs to click "Enter edit".

## Endpoint

POST (or GET with query string) to: `https://musicbrainz.org/release/add`

All fields optional except `name`.

## Parameters

### Release
- `name` — release title (required)
- `comment` — disambiguation
- `annotation` — multi-line
- `barcode` — string, or `none` to indicate no barcode
- `language` — ISO 639-3 (eng, deu, jpn)
- `script` — ISO 15924 (Latn, Cyrl)
- `status` — `official` | `promotion` | `bootleg` | `pseudo-release`
- `packaging` — English packaging name (e.g. `None`, `Jewel Case`, `Digipak`)

### Release group
Pick ONE of:
- `release_group` — MBID of existing RG
- `type` — RG type name in English; repeat for multiple secondary types (e.g. `Album`, `Live`). Only one primary type is kept.

### Release events (`events.x.*`, x is 0-indexed)
- `events.x.date.year`
- `events.x.date.month`
- `events.x.date.day`
- `events.x.country` — ISO country code (`US`, `GB`, `XW` for [Worldwide])

### Labels (`labels.x.*`)
- `labels.x.mbid`
- `labels.x.catalog_number`
- `labels.x.name` — used for search if no MBID

### Artist credit (release-level)
- `artist_credit.names.x.mbid` — exact artist match
- `artist_credit.names.x.name` — credited name (defaults to artist's current name)
- `artist_credit.names.x.artist.name` — search prefill (skip if mbid provided)
- `artist_credit.names.x.join_phrase` — e.g. ` & `, ` feat. `

### Mediums & tracks
- `mediums.x.format` — English format name (e.g. `Digital Media`, `CD`, `12" Vinyl`)
- `mediums.x.name` — optional medium name
- `mediums.x.track.y.name` — track title
- `mediums.x.track.y.number` — free-form (`1`, `2`, `A1`, `B2`)
- `mediums.x.track.y.recording` — MBID of existing recording (optional)
- `mediums.x.track.y.length` — `MM:SS` or integer ms

Per-track artist credits (when track artists differ from release):
- `mediums.x.track.y.artist_credit.names.z.mbid`
- `mediums.x.track.y.artist_credit.names.z.name`
- `mediums.x.track.y.artist_credit.names.z.artist.name`
- `mediums.x.track.y.artist_credit.names.z.join_phrase`

### URL relationships (`urls.x.*`)
- `urls.x.url`
- `urls.x.link_type` — integer link type ID (optional; editor prompts otherwise)

Common release link type IDs:
- 85 — Spotify
- 50 — Discogs
- 82 — allmusic
- 78 — lyrics
- 704 — Bandcamp

When unsure, omit `link_type` — MB auto-detects well-known URLs.

### Other
- `edit_note` — the edit note text
- `redirect_uri` — redirect after submission; MBID appended as `?release_mbid=<uuid>`

## x/y/z indexing rules

- Zero-indexed
- Must be consecutive starting from 0
- Each medium must have at least one track

## Delivery: POST form submission

Seed payloads often exceed URL length limits. Use POST.

### Delivery

The `seed_release.mjs` script emits a JS payload at `/tmp/openclaw/uploads/seed-release.js`. Run it from an already-loaded MB page to preserve the session cookie:

```
browser_navigate("https://musicbrainz.org/")
# then paste the .js payload contents:
browser_console(expression=<file contents>)
```

The payload builds a hidden POST form on the current page and submits it. Because the page origin is `musicbrainz.org`, cookies are sent and you land on the pre-populated release editor logged in.

### Alternative: Playwright page.evaluate

After `page.goto('about:blank')`, inject and submit a form:

```javascript
await page.evaluate((fields) => {
  const form = document.createElement('form');
  form.method = 'POST';
  form.action = 'https://musicbrainz.org/release/add';
  form.acceptCharset = 'UTF-8';
  for (const [k, v] of Object.entries(fields)) {
    const values = Array.isArray(v) ? v : [v];
    for (const val of values) {
      const input = document.createElement('input');
      input.type = 'hidden';
      input.name = k;
      input.value = val;
      form.appendChild(input);
    }
  }
  document.body.appendChild(form);
  form.submit();
}, seedFields);
```

## Example: Digital album with Spotify link

```json
{
  "name": "Album Title",
  "artist_credit.names.0.mbid": "<artist-mbid>",
  "artist_credit.names.0.name": "Artist Name",
  "type": "Album",
  "status": "official",
  "language": "eng",
  "script": "Latn",
  "packaging": "None",
  "barcode": "none",
  "events.0.date.year": "2007",
  "events.0.date.month": "7",
  "events.0.date.day": "10",
  "events.0.country": "XW",
  "mediums.0.format": "Digital Media",
  "mediums.0.track.0.name": "Track One",
  "mediums.0.track.0.number": "1",
  "mediums.0.track.0.length": "3:47",
  "mediums.0.track.1.name": "Track Two",
  "mediums.0.track.1.number": "2",
  "mediums.0.track.1.length": "2:50",
  "urls.0.url": "https://open.spotify.com/album/<id>",
  "urls.0.link_type": "85",
  "edit_note": "Added from Spotify: https://open.spotify.com/album/<id>"
}
```

## Full workflow with seeding

1. Login (same as before — `/login`, click "Log in" button, verify redirect)
2. Build seed dict from your data source (Spotify oembed, MB artist lookup for MBID)
3. Write JSON to `/tmp/seed.json`
4. `python3 scripts/build_seed_html.py /tmp/seed.json /tmp/seed.html`
5. `browser_navigate("file:///tmp/seed.html")`
6. Browser lands on release editor pre-populated
7. `browser_snapshot()` to verify: title, artist (green bg = matched), date, track count
8. Click "Enter edit" button
9. Extract MBID from redirect URL

## What seeding does NOT cover

- Login (still required before POSTing)
- Cover art upload (separate `/release/<mbid>/add-cover-art` flow — unchanged)
- Capitalization-warning checkbox (may still appear)
- Final "Enter edit" click

## Gotchas

- **Worldwide country code is `XW`** (ISO), not the numeric `240` used in the browser form's internal `country-0` select.
- **`packaging` uses the English name** (`None`, `Digipak`), not a numeric id.
- **`type` is repeatable**: pass `type=Album` + `type=Live` for secondary types.
- **Track length** accepts `MM:SS` OR integer ms. Prefer `MM:SS`.
- **Artist MBID bypasses the wrong-autocomplete bug** — no more arrow-down/enter dance.
- **No release-group dialog interruption** — seed bypasses the auto-search popup.
- **Session must already be logged in** — seeding does not authenticate.
- If MB silently rejects a value, the field just renders blank. Always `browser_snapshot` after the POST to verify before submitting.


path: scripts/mb_lookup.sh
content: #!/usr/bin/env bash
# Lookup a Spotify URL on MusicBrainz to find linked entities.
# Usage: mb_lookup.sh <spotify-url>
# Example: mb_lookup.sh https://open.spotify.com/artist/3AnRJXttxRO7191Fxwkaxz

set -euo pipefail

URL="${1:?Usage: mb_lookup.sh <spotify-url>}"
# Load MB username from credentials file if not already set
if [ -z "${MB_USERNAME:-}" ]; then
  SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
  MB_USERNAME=$(jq -r '.username // empty' "$SKILL_DIR/.credentials.json" 2>/dev/null || true)
fi
CONTACT="${MB_USERNAME:-anonymous}@users.noreply.musicbrainz.org"
UA="${OPENCLAW_BOT_NAME:-OpenClawBot}/1.0 ($CONTACT)"
BASE="https://musicbrainz.org/ws/2"

echo "Looking up: $URL"

# Step 1: Find URL entity
RESULT=$(curl -s -A "$UA" "$BASE/url/?resource=$URL&fmt=json")
URL_MBID=$(echo "$RESULT" | jq -r '.id // empty')

if [ -z "$URL_MBID" ]; then
  echo "NOT FOUND on MusicBrainz"
  exit 1
fi

echo "URL MBID: $URL_MBID"

# Step 2: Get linked entities
RELS=$(curl -s -A "$UA" "$BASE/url/$URL_MBID?inc=artist-rels+release-rels+release-group-rels&fmt=json")
echo "$RELS" | jq '{
  url_id: .id,
  resource: .resource,
  relations: [.relations[] | {
    type: .type,
    direction: .direction,
    target_type: ."target-type",
    entity: (
      if ."target-type" == "artist" then .artist
      elif ."target-type" == "release" then .release
      elif ."target-type" == "release_group" then ."release-group"
      else null end
    ) | {id, name, disambiguation, type, country}
  }]
}'


path: scripts/preflight.sh
content: #!/usr/bin/env bash
# Preflight credential check for MusicBrainz skill.
# Checks credentials file first, then falls back to env vars.
# Exit 0 = good, exit 1 = missing creds, exit 2 = invalid creds.
set -euo pipefail

SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CRED_FILE="$SKILL_DIR/.credentials.json"

# Load from credentials file if it exists
if [ -f "$CRED_FILE" ]; then
  FILE_USER=$(jq -r '.username // empty' "$CRED_FILE" 2>/dev/null || true)
  FILE_PASS=$(jq -r '.password // empty' "$CRED_FILE" 2>/dev/null || true)
  if [ -n "$FILE_USER" ] && [ -n "$FILE_PASS" ]; then
    export MB_USERNAME="$FILE_USER"
    export MB_PASSWORD="$FILE_PASS"
  fi
fi

# Check if we have credentials from either source
if [ -z "${MB_USERNAME:-}" ] || [ -z "${MB_PASSWORD:-}" ]; then
  echo "MISSING_CREDENTIALS"
  echo "No MusicBrainz credentials found."
  echo "Checked: $CRED_FILE and MB_USERNAME/MB_PASSWORD env vars."
  exit 1
fi

# Validate credentials by attempting login
echo "Checking MusicBrainz credentials for user: $MB_USERNAME"

COOKIE_JAR=$(mktemp)
trap "rm -f $COOKIE_JAR" EXIT

# Fetch login page to get session cookie
curl -s -c "$COOKIE_JAR" -o /dev/null "https://musicbrainz.org/login"

# Attempt login
RESPONSE=$(curl -s -w "\n%{http_code}" \
  -b "$COOKIE_JAR" -c "$COOKIE_JAR" \
  -X POST "https://musicbrainz.org/login" \
  -d "username=${MB_USERNAME}&[REDACTED_SECRET]" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -L --max-redirs 3)

HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')

if echo "$BODY" | grep -q "Log Out" 2>/dev/null; then
  echo "CREDENTIALS_VALID"
  echo "Authenticated as: $MB_USERNAME"
  exit 0
fi

if echo "$BODY" | grep -qi "Incorrect username or password" 2>/dev/null; then
  echo "INVALID_CREDENTIALS"
  echo "The username/password combination is incorrect."
  exit 2
fi

echo "UNKNOWN_STATUS (HTTP $HTTP_CODE)"
echo "Could not confirm credentials. MusicBrainz may be rate-limiting or unavailable."
echo "Proceeding anyway — login will be attempted in the browser during editing."
exit 0


path: scripts/seed_release.mjs
content: #!/usr/bin/env node
/**
 * Seed a MusicBrainz release from a JSON definition.
 * 
 * Usage: node seed_release.mjs <release.json>
 * 
 * Outputs: multipart/form-data body suitable for POST to /release/add
 * In practice: generates an HTML file that auto-submits the form,
 * which can be opened in the browser to land on a pre-filled release editor.
 * 
 * JSON schema:
 * {
 *   "name": "Endstation",
 *   "artist": { "mbid": "78e8899b-...", "name": "kevin" },
 *   "type": "Album",           // Album, EP, Single, Broadcast, Other
 *   "status": "official",      // official, promotion, bootleg, pseudo-release
 *   "language": "deu",         // ISO 639-3
 *   "script": "Latn",          // ISO 15924
 *   "packaging": "None",
 *   "barcode": "none",         // "none" = no barcode, or actual barcode string
 *   "events": [{ "year": 2023, "month": 4, "day": 28, "country": "XW" }],
 *   "labels": [{ "name": "Vertigo Berlin", "mbid": "bb7f25ac-...", "catalog": "" }],
 *   "tracks": [
 *     { "name": "Erbgut", "length": "0:54" },
 *     { "name": "Filter", "length": "2:24" }
 *   ],
 *   "urls": [{ "url": "https://open.spotify.com/album/...", "link_type": 85 }],
 *   "edit_note": "Added from Spotify"
 * }
 */

import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';

const jsonPath = process.argv[2];
if (!jsonPath) {
  console.error('Usage: node seed_release.mjs <release.json>');
  process.exit(1);
}

const release = JSON.parse(readFileSync(jsonPath, 'utf-8'));

// Build form fields
const fields = [];
function add(name, value) {
  if (value !== undefined && value !== null && value !== '') {
    fields.push({ name, value: String(value) });
  }
}

// Release info
add('name', release.name);
if (release.type) add('type', release.type);
if (release.release_group) add('release_group', release.release_group);
if (release.comment) add('comment', release.comment);
if (release.annotation) add('annotation', release.annotation);
if (release.status) add('status', release.status);
if (release.language) add('language', release.language);
if (release.script) add('script', release.script);
if (release.packaging) add('packaging', release.packaging);
if (release.barcode !== undefined) add('barcode', release.barcode);

// Artist credit
if (release.artist) {
  const a = release.artist;
  if (a.mbid) add('artist_credit.names.0.mbid', a.mbid);
  if (a.name) add('artist_credit.names.0.artist.name', a.name);
  if (a.credited_name) add('artist_credit.names.0.name', a.credited_name);
}
if (release.artists) {
  release.artists.forEach((a, i) => {
    if (a.mbid) add(`artist_credit.names.${i}.mbid`, a.mbid);
    if (a.name) add(`artist_credit.names.${i}.artist.name`, a.name);
    if (a.credited_name) add(`artist_credit.names.${i}.name`, a.credited_name);
    if (a.join_phrase) add(`artist_credit.names.${i}.join_phrase`, a.join_phrase);
  });
}

// Events
if (release.events) {
  release.events.forEach((e, i) => {
    if (e.year) add(`events.${i}.date.year`, e.year);
    if (e.month) add(`events.${i}.date.month`, e.month);
    if (e.day) add(`events.${i}.date.day`, e.day);
    if (e.country) add(`events.${i}.country`, e.country);
  });
}

// Labels
if (release.labels) {
  release.labels.forEach((l, i) => {
    if (l.mbid) add(`labels.${i}.mbid`, l.mbid);
    if (l.name) add(`labels.${i}.name`, l.name);
    if (l.catalog !== undefined) add(`labels.${i}.catalog_number`, l.catalog);
  });
}

// Mediums / tracks
const tracks = release.tracks || [];
add('mediums.0.format', release.medium_format || 'Digital Media');
if (release.medium_name) add('mediums.0.name', release.medium_name);
tracks.forEach((t, i) => {
  add(`mediums.0.track.${i}.name`, t.name);
  if (t.length) add(`mediums.0.track.${i}.length`, t.length);
  if (t.number) add(`mediums.0.track.${i}.number`, t.number);
  if (t.recording) add(`mediums.0.track.${i}.recording`, t.recording);
  // Track artist credit (if different from release artist)
  if (t.artist) {
    if (t.artist.mbid) add(`mediums.0.track.${i}.artist_credit.names.0.mbid`, t.artist.mbid);
    if (t.artist.name) add(`mediums.0.track.${i}.artist_credit.names.0.artist.name`, t.artist.name);
  }
});

// URLs
if (release.urls) {
  release.urls.forEach((u, i) => {
    add(`urls.${i}.url`, u.url);
    if (u.link_type) add(`urls.${i}.link_type`, u.link_type);
  });
}

// Edit note
if (release.edit_note) add('edit_note', release.edit_note);

// Generate self-submitting HTML form
const htmlFields = fields.map(f => 
  `  <input type="hidden" name="${f.name.replace(/"/g, '&quot;')}" value="${f.value.replace(/"/g, '&quot;')}">`
).join('\n');

const html = `<!DOCTYPE html>
<html>
<head><title>Seeding MusicBrainz Release</title></head>
<body>
<p>Submitting to MusicBrainz release editor...</p>
<form id="seed" method="post" action="https://musicbrainz.org/release/add">
${htmlFields}
</form>
<script>document.getElementById('seed').submit();</script>
</body>
</html>`;

const outPath = resolve('/tmp/openclaw/uploads/seed-release.html');
writeFileSync(outPath, html);
console.log(`Seed form written to: ${outPath}`);
console.log(`Fields: ${fields.length}`);
console.log('\nTo use: open this file in the MB-authenticated browser session.');

// Also output fields as summary
console.log('\n--- Seeded fields ---');
fields.forEach(f => console.log(`  ${f.name} = ${f.value}`));


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)