Watchlist: export the roster to JSON / CSV / text (corruption's request)

An "Export" button on the watchlist filter bar opens a modal (same aesthetic as the
artist DB-record inspector) to export your whole watchlist roster — each artist's
name + source IDs (spotify / musicbrainz / deezer / discogs / itunes / amazon),
with an optional "external links" toggle that adds the discography URLs built from
those IDs. Live preview, copy, and download in the chosen format.

- core/exports/watchlist_export.py: pure builder (json/csv/txt + links, present-IDs
  only, deterministic columns) — the single source of truth, fully unit-tested.
- GET /api/watchlist/export?format=&links= shapes the roster + returns it (with
  X-Export-Count / X-Export-Ext headers for the modal).
- Frontend reuses the DB-record helpers (_jsonSyntaxHighlight / _arecCopy).

Tests (8): builder across json/csv/txt, links on/off, present-ids-only, empty +
bad-format fallback, mime/ext, and endpoint wiring. ruff clean; 64 integrity green.

Scoped to the watchlist for v1; library-wide export + a "library contents"
(owned albums/tracks) option are natural follow-ups.
This commit is contained in:
BoulderBadgeDad 2026-06-11 22:48:58 -07:00
parent 4d2772765c
commit f8652c106b
7 changed files with 325 additions and 0 deletions

1
core/exports/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Data export builders."""

View file

@ -0,0 +1,96 @@
"""Export a watchlist (or library) artist roster to JSON / CSV / plain text
(corruption's request).
Pure shaping + formatting so it's the single source of truth and unit-testable —
web_server fetches the artists and hands them here; the UI just picks options and
downloads. Always exports the name + whatever source IDs each artist has; the
``include_links`` option adds the external discography URLs built from those IDs.
"""
from __future__ import annotations
import csv
import io
import json
from typing import Any, Dict, List, Optional
# Source id field → external URL builder.
_LINKS = {
'spotify_artist_id': lambda i: f'https://open.spotify.com/artist/{i}',
'musicbrainz_artist_id': lambda i: f'https://musicbrainz.org/artist/{i}',
'deezer_artist_id': lambda i: f'https://www.deezer.com/artist/{i}',
'discogs_artist_id': lambda i: f'https://www.discogs.com/artist/{i}',
'itunes_artist_id': lambda i: f'https://music.apple.com/artist/{i}',
}
# Order is stable so CSV columns + JSON keys are deterministic.
_ID_FIELDS = ['spotify_artist_id', 'musicbrainz_artist_id', 'deezer_artist_id',
'discogs_artist_id', 'itunes_artist_id', 'amazon_artist_id']
VALID_FORMATS = ('json', 'csv', 'txt')
def _name(a: Dict[str, Any]) -> str:
return str(a.get('artist_name') or a.get('name') or '').strip()
def _short(field: str) -> str:
return field.replace('_artist_id', '')
def _row(a: Dict[str, Any], include_links: bool) -> Dict[str, Any]:
row: Dict[str, Any] = {'name': _name(a)}
for f in _ID_FIELDS:
if a.get(f):
row[f] = str(a[f])
if include_links:
links = {_short(f): b(a[f]) for f, b in _LINKS.items() if a.get(f)}
if links:
row['links'] = links
return row
def build_watchlist_export(artists: Optional[List[Dict[str, Any]]],
fmt: str = 'json', include_links: bool = False) -> str:
"""Return the roster serialized in ``fmt`` (json | csv | txt).
- ``txt`` one artist name per line.
- ``csv`` name + each source-id column (+ a *_url column per service when
``include_links``).
- ``json`` a list of objects: name, present source ids, and a ``links`` map
when ``include_links``.
"""
artists = artists or []
fmt = (fmt or 'json').lower()
if fmt not in VALID_FORMATS:
fmt = 'json'
if fmt == 'txt':
return '\n'.join(n for n in (_name(a) for a in artists) if n)
if fmt == 'csv':
cols = ['name'] + _ID_FIELDS
if include_links:
cols += [f'{_short(f)}_url' for f in _LINKS]
out = io.StringIO()
w = csv.writer(out)
w.writerow(cols)
for a in artists:
line = [_name(a)] + [str(a.get(f) or '') for f in _ID_FIELDS]
if include_links:
line += [_LINKS[f](a[f]) if a.get(f) else '' for f in _LINKS]
w.writerow(line)
return out.getvalue()
return json.dumps([_row(a, include_links) for a in artists], indent=2, ensure_ascii=False)
def export_mime_and_ext(fmt: str):
"""(content-type, file extension) for a format."""
return {
'json': ('application/json', 'json'),
'csv': ('text/csv', 'csv'),
'txt': ('text/plain', 'txt'),
}.get((fmt or 'json').lower(), ('application/json', 'json'))
__all__ = ['build_watchlist_export', 'export_mime_and_ext', 'VALID_FORMATS']

View file

@ -0,0 +1,88 @@
"""Watchlist roster export builder (corruption's request) — JSON / CSV / txt,
optional external links, deterministic columns."""
from __future__ import annotations
import csv
import io
import json
from core.exports.watchlist_export import build_watchlist_export, export_mime_and_ext
_ARTISTS = [
{'artist_name': 'Rob Zombie', 'spotify_artist_id': 'sp1',
'musicbrainz_artist_id': 'mb1', 'deezer_artist_id': 'dz1'},
{'artist_name': 'Nobody IDs', 'spotify_artist_id': None},
]
def test_txt_is_names_one_per_line():
out = build_watchlist_export(_ARTISTS, fmt='txt')
assert out == 'Rob Zombie\nNobody IDs'
def test_json_includes_present_ids_only():
out = json.loads(build_watchlist_export(_ARTISTS, fmt='json'))
assert out[0]['name'] == 'Rob Zombie'
assert out[0]['spotify_artist_id'] == 'sp1' and out[0]['musicbrainz_artist_id'] == 'mb1'
assert 'deezer_artist_id' in out[0]
assert out[1] == {'name': 'Nobody IDs'} # null id dropped, no links key
def test_json_links_when_requested():
out = json.loads(build_watchlist_export(_ARTISTS, fmt='json', include_links=True))
assert out[0]['links']['spotify'] == 'https://open.spotify.com/artist/sp1'
assert out[0]['links']['musicbrainz'] == 'https://musicbrainz.org/artist/mb1'
assert 'links' not in out[1] # no ids → no links
def test_csv_header_and_rows():
out = build_watchlist_export(_ARTISTS, fmt='csv')
rows = list(csv.reader(io.StringIO(out)))
assert rows[0][0] == 'name' and 'spotify_artist_id' in rows[0]
assert rows[1][0] == 'Rob Zombie'
assert rows[2][0] == 'Nobody IDs'
def test_csv_adds_url_columns_with_links():
out = build_watchlist_export(_ARTISTS, fmt='csv', include_links=True)
header = next(csv.reader(io.StringIO(out)))
assert 'spotify_url' in header and 'discogs_url' in header
def test_empty_and_bad_format():
assert build_watchlist_export([], fmt='txt') == ''
assert build_watchlist_export(None, fmt='json') == '[]'
assert build_watchlist_export(_ARTISTS, fmt='nonsense').startswith('[') # falls back to json
def test_mime_and_ext():
assert export_mime_and_ext('csv') == ('text/csv', 'csv')
assert export_mime_and_ext('txt') == ('text/plain', 'txt')
assert export_mime_and_ext('weird') == ('application/json', 'json')
# ── endpoint wiring (empty watchlist → valid shapes + headers) ──────────────
import os, tempfile # noqa: E402
os.environ['DATABASE_PATH'] = os.path.join(tempfile.mkdtemp(prefix='soulsync-testdb-wlexp-'), 'w.db')
os.environ['SOULSYNC_TEST_DB_READY'] = '1'
import pytest # noqa: E402
web_server = pytest.importorskip('web_server')
@pytest.fixture
def client():
return web_server.app.test_client()
def test_export_endpoint_wiring(client):
r = client.get('/api/watchlist/export?format=json')
assert r.status_code == 200
assert r.data.decode().strip() == '[]'
assert r.headers.get('X-Export-Count') == '0'
assert r.headers.get('X-Export-Ext') == 'json'
r2 = client.get('/api/watchlist/export?format=csv&links=1')
assert r2.status_code == 200 and r2.headers.get('X-Export-Ext') == 'csv'
assert 'spotify_url' in r2.data.decode().splitlines()[0] # header row with links

View file

@ -26722,6 +26722,35 @@ def get_watchlist_artists():
logger.error(f"Error getting watchlist artists: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/watchlist/export', methods=['GET'])
def export_watchlist():
"""Export the watchlist roster (name + source IDs, optionally external links)
as json / csv / txt. Returns the content for the export modal to preview +
download (X-Export-Count / X-Export-Ext headers carry the metadata)."""
try:
from core.exports.watchlist_export import build_watchlist_export, export_mime_and_ext
fmt = (request.args.get('format', 'json') or 'json').lower()
include_links = request.args.get('links', '') in ('1', 'true', 'yes')
database = get_database()
artists = [{
'artist_name': a.artist_name,
'spotify_artist_id': a.spotify_artist_id,
'itunes_artist_id': a.itunes_artist_id,
'deezer_artist_id': getattr(a, 'deezer_artist_id', None),
'discogs_artist_id': getattr(a, 'discogs_artist_id', None),
'musicbrainz_artist_id': getattr(a, 'musicbrainz_artist_id', None),
'amazon_artist_id': getattr(a, 'amazon_artist_id', None),
} for a in database.get_watchlist_artists(profile_id=get_current_profile_id())]
content = build_watchlist_export(artists, fmt=fmt, include_links=include_links)
mime, ext = export_mime_and_ext(fmt)
return Response(content, mimetype=mime,
headers={'X-Export-Count': str(len(artists)), 'X-Export-Ext': ext})
except Exception as e:
logger.error(f"Watchlist export failed: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/watchlist/add', methods=['POST'])
def add_to_watchlist():
"""Add an artist to the watchlist"""

View file

@ -2507,6 +2507,10 @@
<span class="watchlist-all-icon">👁️</span>
<span class="watchlist-all-text">Watch All Unwatched</span>
</button>
<button class="library-watchlist-all-btn watchlist-export-btn" id="watchlist-export-btn" onclick="openWatchlistExportModal()" title="Export your watchlist roster (JSON / CSV / text)">
<span class="watchlist-all-icon"></span>
<span class="watchlist-all-text">Export</span>
</button>
</div>
<!-- Metadata Source Filter -->

View file

@ -9010,3 +9010,100 @@ function _arecEsc(s) {
function _arecEscAttr(s) {
return _arecEsc(s).replace(/"/g, '&quot;');
}
// ════════════════════════════════════════════════════════════════════════════
// Watchlist export — bulk export the watchlist roster to JSON / CSV / text, with
// optional external discography links. Reuses the DB-record modal aesthetic +
// helpers (_jsonSyntaxHighlight / _arecCopy / _arecEsc). #export-request
// ════════════════════════════════════════════════════════════════════════════
async function openWatchlistExportModal() {
const existing = document.getElementById('wl-export-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'wl-export-overlay';
overlay.className = 'arec-overlay';
overlay.innerHTML =
'<div class="arec-card" role="dialog" aria-label="Export watchlist">' +
'<div class="arec-header">' +
'<div class="arec-title-wrap">' +
'<div class="arec-title"><span class="arec-dot"></span>Export Watchlist</div>' +
'<div class="arec-sub">your watchlist artists — name, source IDs, optional links</div>' +
'</div>' +
'<button class="arec-close" id="wlx-close" title="Close (Esc)">&times;</button>' +
'</div>' +
'<div class="arec-toolbar">' +
'<div class="arec-tabs" id="wlx-format">' +
'<button class="arec-tab active" data-fmt="json">JSON</button>' +
'<button class="arec-tab" data-fmt="csv">CSV</button>' +
'<button class="arec-tab" data-fmt="txt">Text</button>' +
'</div>' +
'<label class="wlx-opt"><input type="checkbox" id="wlx-links"> external links</label>' +
'<div class="arec-actions">' +
'<button class="arec-btn" id="wlx-copy">Copy</button>' +
'<button class="arec-btn" id="wlx-download">Download</button>' +
'</div>' +
'</div>' +
'<div class="arec-body" id="wlx-body"><div class="arec-loading">Building export…</div></div>' +
'<div class="arec-footer" id="wlx-footer"></div>' +
'</div>';
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('visible'));
let fmt = 'json', links = false, content = '';
const close = () => {
overlay.classList.remove('visible');
document.removeEventListener('keydown', onKey);
setTimeout(() => overlay.remove(), 220);
};
const onKey = (e) => { if (e.key === 'Escape') close(); };
document.addEventListener('keydown', onKey);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
overlay.querySelector('#wlx-close').onclick = close;
const refresh = async () => {
const body = document.getElementById('wlx-body');
body.innerHTML = '<div class="arec-loading">Building export…</div>';
try {
const res = await fetch('/api/watchlist/export?format=' + fmt + '&links=' + (links ? '1' : '0'));
content = await res.text();
const count = res.headers.get('X-Export-Count') || '?';
document.getElementById('wlx-footer').innerHTML =
'<span><b>' + count + '</b> artists</span><span class="arec-id">' + fmt.toUpperCase() + '</span>';
if (fmt === 'json') {
let parsed; try { parsed = JSON.parse(content || '[]'); } catch (e) { parsed = []; }
body.innerHTML = '<pre class="arec-code">' + _jsonSyntaxHighlight(parsed) + '</pre>';
} else {
body.innerHTML = '<pre class="arec-code">' + _arecEsc(content || '(empty)') + '</pre>';
}
} catch (err) {
body.innerHTML = '<div class="arec-error">Export failed: ' + _arecEsc(err.message || String(err)) + '</div>';
}
};
overlay.querySelectorAll('#wlx-format .arec-tab').forEach(t => {
t.onclick = () => {
overlay.querySelectorAll('#wlx-format .arec-tab').forEach(x => x.classList.remove('active'));
t.classList.add('active');
fmt = t.dataset.fmt;
refresh();
};
});
document.getElementById('wlx-links').addEventListener('change', (e) => { links = e.target.checked; refresh(); });
document.getElementById('wlx-copy').onclick = () => _arecCopy(content, 'Export copied');
document.getElementById('wlx-download').onclick = () => {
const ext = fmt;
const mime = fmt === 'json' ? 'application/json' : (fmt === 'csv' ? 'text/csv' : 'text/plain');
const blob = new Blob([content || ''], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'watchlist_export.' + ext;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
if (typeof showToast === 'function') showToast('Saved watchlist_export.' + ext, 'success');
};
refresh();
}

View file

@ -68243,3 +68243,13 @@ body.app-locked > *:not(#launch-pin-overlay):not(#login-overlay):not(script):not
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.0); }
50% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.18); }
}
/* Watchlist export modal — reuses the .arec-* card; just the option label + button */
.wlx-opt {
display: inline-flex; align-items: center; gap: 6px;
font: 12px/1 'JetBrains Mono', ui-monospace, monospace;
color: #8b93b0; cursor: pointer; user-select: none;
}
.wlx-opt input { accent-color: #7aa2f7; }
.watchlist-export-btn .watchlist-all-icon { font-weight: 700; }