diff --git a/core/exports/__init__.py b/core/exports/__init__.py new file mode 100644 index 00000000..41b21484 --- /dev/null +++ b/core/exports/__init__.py @@ -0,0 +1 @@ +"""Data export builders.""" diff --git a/core/exports/watchlist_export.py b/core/exports/watchlist_export.py new file mode 100644 index 00000000..404c768b --- /dev/null +++ b/core/exports/watchlist_export.py @@ -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'] diff --git a/tests/test_watchlist_export.py b/tests/test_watchlist_export.py new file mode 100644 index 00000000..41171291 --- /dev/null +++ b/tests/test_watchlist_export.py @@ -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 diff --git a/web_server.py b/web_server.py index 2dbc1907..3460c054 100644 --- a/web_server.py +++ b/web_server.py @@ -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""" diff --git a/webui/index.html b/webui/index.html index 545b79b2..e1b7884c 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2507,6 +2507,10 @@ 👁️ Watch All Unwatched + diff --git a/webui/static/library.js b/webui/static/library.js index 4adcda46..65b8e02a 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -9010,3 +9010,100 @@ function _arecEsc(s) { function _arecEscAttr(s) { return _arecEsc(s).replace(/"/g, '"'); } + + +// ════════════════════════════════════════════════════════════════════════════ +// 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 = + ''; + 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 = '
Building export…
'; + 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 = + '' + count + ' artists' + fmt.toUpperCase() + ''; + if (fmt === 'json') { + let parsed; try { parsed = JSON.parse(content || '[]'); } catch (e) { parsed = []; } + body.innerHTML = '
' + _jsonSyntaxHighlight(parsed) + '
'; + } else { + body.innerHTML = '
' + _arecEsc(content || '(empty)') + '
'; + } + } catch (err) { + body.innerHTML = '
Export failed: ' + _arecEsc(err.message || String(err)) + '
'; + } + }; + + 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(); +} diff --git a/webui/static/style.css b/webui/static/style.css index 94c1f058..33341f3c 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; }