soulsync/core/exports/artist_export.py
BoulderBadgeDad a789fb71c0 Library export: export the whole library roster too (corruption's request)
Extends the watchlist export to the full library. The exporter is now general
(core/exports/artist_export.py, renamed from watchlist_export) — adds tidal/qobuz
links and an extra_fields passthrough, so the library export also carries
lastfm/genius URLs + soul_id, and an optional "library counts" toggle adds owned
album/track counts per artist.

- GET /api/library/artists/export?format=&links=&contents= — pulls every artists
  row, normalizes onto the canonical *_artist_id keys, optionally GROUP-BY counts
  for album/track totals.
- The export modal is now openArtistExportModal(scope): "Export Library" button in
  the library header + the existing "Export" on the watchlist bar (a thin wrapper).
  Library mode shows the extra "library counts" toggle.

Tests (11): builder across formats + the new tidal/qobuz links + extra_fields
columns; watchlist + library endpoint wiring. 64 integrity green; ruff clean.
2026-06-11 22:59:21 -07:00

109 lines
4.2 KiB
Python

"""Export an artist roster — watchlist OR library — 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 (normalizing each source's fields onto the canonical
``*_artist_id`` keys below) and hands them here; the UI just picks options and
downloads. Always exports the name + whatever source IDs each artist has;
``include_links`` adds external discography URLs; ``extra_fields`` passes through
source-specific extras (e.g. library album/track counts) in a stable order.
"""
from __future__ import annotations
import csv
import io
import json
from typing import Any, Dict, List, Optional
# Canonical 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}',
'tidal_artist_id': lambda i: f'https://tidal.com/artist/{i}',
'qobuz_artist_id': lambda i: f'https://www.qobuz.com/artist/{i}',
}
# Stable order so CSV columns + JSON keys are deterministic. amazon carries an id
# but no clean public URL.
_ID_FIELDS = ['spotify_artist_id', 'musicbrainz_artist_id', 'deezer_artist_id',
'discogs_artist_id', 'itunes_artist_id', 'tidal_artist_id',
'qobuz_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, extra_fields: List[str]) -> Dict[str, Any]:
row: Dict[str, Any] = {'name': _name(a)}
for f in _ID_FIELDS:
if a.get(f):
row[f] = str(a[f])
for f in extra_fields:
if a.get(f) not in (None, ''):
row[f] = 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_artist_export(artists: Optional[List[Dict[str, Any]]],
fmt: str = 'json', include_links: bool = False,
extra_fields: Optional[List[str]] = None) -> str:
"""Return the roster serialized in ``fmt`` (json | csv | txt).
- ``txt`` → one artist name per line.
- ``csv`` → name + each source-id column + ``extra_fields`` columns (+ a
*_url column per service when ``include_links``).
- ``json`` → a list of objects: name, present source ids, present extras, and
a ``links`` map when ``include_links``.
"""
artists = artists or []
extra_fields = list(extra_fields 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 + extra_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]
line += [str(a.get(f) if a.get(f) is not None else '') for f in extra_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, extra_fields) 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_artist_export', 'export_mime_and_ext', 'VALID_FORMATS']