Merge pull request #579 from Nezreka/dev

Dev
This commit is contained in:
BoulderBadgeDad 2026-05-13 19:08:17 -07:00 committed by GitHub
commit 9865caa789
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 16231 additions and 2669 deletions

View file

@ -25,6 +25,13 @@ __pycache__/
dist/
build/
# Frontend build artifacts and local dependency caches
webui/.tanstack/
webui/.vite/
webui/node_modules/
webui/test-results/
webui/static/dist/
# Virtual environments
venv/
env/

View file

@ -9,9 +9,9 @@ on:
workflow_dispatch:
inputs:
version_tag:
description: 'Version tag (e.g. 2.5.1)'
description: 'Version tag (e.g. 2.5.2)'
required: true
default: '2.5.1'
default: '2.5.2'
jobs:
build-and-push:

View file

@ -1,6 +1,16 @@
# SoulSync WebUI Dockerfile
# Multi-architecture support for AMD64 and ARM64
FROM node:24-slim AS webui-builder
WORKDIR /app/webui
COPY webui/package.json webui/package-lock.json ./
RUN npm ci
COPY webui/ ./
RUN npm run build
# Stage 1: Builder — install Python dependencies with compilation tools
FROM python:3.11-slim AS builder
@ -54,6 +64,7 @@ RUN useradd --create-home --shell /bin/bash --uid 1000 soulsync
# in tools/), it gets counted twice in the image. Cin caught this on
# 2026-05-08 — see the .dockerignore comment for the same incident.
COPY --chown=soulsync:soulsync . .
COPY --chown=soulsync:soulsync --from=webui-builder /app/webui/static/dist /app/webui/static/dist
# Create runtime mount-point directories the app expects to exist.
# NOTE: /app/data is for database FILES, /app/database is the Python package

View file

@ -278,19 +278,38 @@ The template points at `boulderbadgedad/soulsync:latest` (stable) by default. To
```bash
git clone https://github.com/Nezreka/SoulSync
cd SoulSync
pip install -r requirements.txt
python -m pip install -r requirements.txt
gunicorn -c gunicorn.conf.py wsgi:application
# Open http://localhost:8008
```
For local development and tests:
### Local Development
Use two terminals so the backend and Vite stay independent:
1. Backend
```bash
python -m pip install -r requirements-dev.txt
gunicorn -c gunicorn.dev.conf.py wsgi:application
```
The dev Gunicorn config watches backend files and restarts the Python server when they change.
2. Frontend
```bash
cd webui
npm ci
npm run dev
```
Vite hot reloads the React side when you change webui files.
Run tests separately when needed:
```bash
pip install -r requirements-dev.txt
pytest
gunicorn -c gunicorn.dev.conf.py wsgi:application
python -m pytest
```
If you want a convenience launcher, `python dev.py` starts both halves together
on any OS. `./dev.sh` remains available as a Unix shell wrapper.
---
## Setup Guide
@ -413,17 +432,14 @@ SoulSync uses a `dev` → `main` flow:
2. Branch off `dev`: `git checkout -b fix/your-change dev`
3. Make your changes and commit
4. Push and open a PR against **`dev`** (not `main`)
5. CI (`build-and-test.yml`) runs ruff lint + compile + pytest on your branch — wait for green
5. CI (`build-and-test.yml`) runs ruff lint + compile + `python -m pytest` on your branch — wait for green
6. A maintainer reviews and merges
### Running locally
```bash
pip install -r requirements-dev.txt
python -m ruff check . # must be 0 errors
python -m pytest # all tests must pass
gunicorn -c gunicorn.dev.conf.py wsgi:application
```
Use the [Local Development](#local-development) section above for the full repo-wide setup and the portable dev launcher.
For web UI work, see [webui/README.md](webui/README.md). It keeps the React-side notes close to the app while this file stays the single place for repo-wide dev instructions.
Ruff config lives in `pyproject.toml`. The ruleset is intentionally lenient — it catches real bugs (undefined names, import shadowing, closure-in-loop) without style nits.

View file

@ -126,7 +126,11 @@ def build_source_only_artist_detail(
allow_fallback=True,
skip_cache=False,
max_pages=0,
limit=50,
# Match the Download Discography endpoint cap (200).
# Spotify already paginates all; Deezer / iTunes / Discogs /
# Hydrabase clamp at the outer limit. 200 covers prolific
# catalogues without exceeding iTunes/Discogs internal caps.
limit=200,
artist_source_ids={source: artist_id},
dedup_variants=False,
),

View file

@ -212,6 +212,7 @@ class Album:
album_type: str
image_url: Optional[str] = None
external_urls: Optional[Dict[str, str]] = None
explicit: Optional[bool] = None
@classmethod
def from_deezer_album(cls, album_data: Dict[str, Any]) -> 'Album':
@ -243,7 +244,8 @@ class Album:
total_tracks=album_data.get('nb_tracks', 0),
album_type=album_type,
image_url=image_url,
external_urls=external_urls if external_urls else None
external_urls=external_urls if external_urls else None,
explicit=bool(album_data.get('explicit_lyrics', False)),
)

View file

@ -565,6 +565,7 @@ class HydrabaseClient:
album_type=item_type,
image_url=item.get('image_url'),
external_urls=ext_urls,
explicit=item.get('explicit'),
))
except Exception as e:
logger.debug(f"Skipping malformed Hydrabase artist album: {e}")

View file

@ -167,7 +167,8 @@ class Album:
album_type: str
image_url: Optional[str] = None
external_urls: Optional[Dict[str, str]] = None
explicit: Optional[bool] = None
@classmethod
def from_itunes_album(cls, album_data: Dict[str, Any]) -> 'Album':
# Get highest quality artwork
@ -209,7 +210,8 @@ class Album:
total_tracks=track_count,
album_type=album_type,
image_url=image_url,
external_urls=external_urls if external_urls else None
external_urls=external_urls if external_urls else None,
explicit=album_data.get('collectionExplicitness') == 'explicit',
)
@dataclass

View file

@ -0,0 +1,161 @@
"""Write `artist.jpg` to the artist's folder on disk.
Navidrome has no API for setting an artist image it reads
`artist.jpg` (or `artist.png` / `folder.jpg`) directly from the
artist's folder during library scans. Plex and Jellyfin have API
uploads (already implemented elsewhere), but their `read_from_disk`
behavior also picks up `artist.jpg` as a fallback, so writing the
file to disk is a portable mechanism that works for every server.
Pre-existing reference: issue #572 (rhwc) — Navidrome users only
saw album-art-derived artist thumbnails. SoulSync's
`update_artist_poster()` for Navidrome at `core/navidrome_client.py`
was a NO-OP (returned True without doing anything).
This module is the pure helpers backing the new endpoint. No
network, no DB, no Flask. Each function is testable in isolation
with `tmp_path` fixtures.
"""
from __future__ import annotations
import os
from typing import Optional, Tuple
import requests
from utils.logging_config import get_logger
logger = get_logger("library.artist_image")
_ARTIST_IMAGE_FILENAME = "artist.jpg"
# Reasonable timeout for the image download. Artist images from
# Spotify/Deezer are typically 100-500KB so a generous timeout still
# completes in a few seconds on a slow connection.
_DEFAULT_IMAGE_DOWNLOAD_TIMEOUT = 30
def derive_artist_folder(album_folder: str) -> str:
"""Derive the artist's folder from an album's folder.
Standard SoulSync path templates produce
``<library_root>/<artist>/<album>/...`` so the artist folder is
one level up from the album folder. Returns empty string for
empty input; preserves the platform's path separator.
Doesn't validate that the result exists on disk. Caller checks.
"""
if not album_folder or not isinstance(album_folder, str):
return ""
# Trim trailing separator so dirname doesn't return the album
# folder unchanged on inputs like "Music/Drake/Views/".
trimmed = album_folder.rstrip("/").rstrip("\\")
parent = os.path.dirname(trimmed)
return parent or ""
def pick_artist_image_url(artist_obj) -> Optional[str]:
"""Return the URL to use for the artist image, if any.
Reads the `image_url` attribute from a typed Artist dataclass
(Spotify / Deezer / Discogs / etc every typed Artist exposes
this). Source converters already pick the largest variant the
provider returns (Spotify upgrades to 640+, Deezer uses
`picture_xl` at ~1000px) so we don't need to re-rank here.
Returns None when the attribute is missing or empty.
"""
if artist_obj is None:
return None
image_url = getattr(artist_obj, "image_url", "")
if not image_url or not isinstance(image_url, str):
return None
image_url = image_url.strip()
return image_url or None
def download_image_bytes(url: str, timeout: int = _DEFAULT_IMAGE_DOWNLOAD_TIMEOUT) -> Optional[bytes]:
"""Fetch image bytes from a URL.
Returns None on any failure (HTTP error, timeout, non-image
content-type, empty body). Caller surfaces a user-facing error.
Doesn't raise.
"""
if not url or not isinstance(url, str):
return None
try:
resp = requests.get(url, timeout=timeout, stream=True)
except Exception as exc:
logger.debug("artist image fetch failed for %s: %s", url, exc)
return None
if resp.status_code != 200:
logger.debug("artist image fetch %s returned status %s", url, resp.status_code)
return None
content_type = (resp.headers.get("Content-Type") or "").lower()
if "image" not in content_type:
logger.debug("artist image URL %s returned non-image content-type %s", url, content_type)
return None
try:
body = resp.content
except Exception as exc:
logger.debug("artist image read failed for %s: %s", url, exc)
return None
if not body:
return None
return body
def write_artist_jpg(
folder: str,
image_bytes: bytes,
*,
overwrite: bool = False,
) -> Tuple[bool, str]:
"""Write `artist.jpg` to the given folder.
Returns ``(True, written_path)`` on success or ``(False, reason)``
on failure. Atomic write via `<filename>.tmp` + os.replace so a
partial write never leaves a corrupt file on disk.
When `overwrite=False` and the target file already exists,
returns ``(False, 'file exists')`` without touching anything
respects user-supplied artist images.
"""
if not folder or not isinstance(folder, str):
return False, "no folder provided"
if not image_bytes:
return False, "no image bytes"
if not os.path.isdir(folder):
return False, f"folder does not exist: {folder}"
target = os.path.join(folder, _ARTIST_IMAGE_FILENAME)
if os.path.exists(target) and not overwrite:
return False, "artist.jpg already exists; pass overwrite=True to replace"
tmp = target + ".tmp"
try:
with open(tmp, "wb") as f:
f.write(image_bytes)
os.replace(tmp, target)
except Exception as exc:
# Best-effort cleanup of the partial temp file. Not worth
# propagating any error here — primary write already failed.
try:
if os.path.exists(tmp):
os.remove(tmp)
except Exception: # noqa: S110 — cleanup, not critical
pass
return False, f"write failed: {exc}"
return True, target
__all__ = [
"derive_artist_folder",
"pick_artist_image_url",
"download_image_bytes",
"write_artist_jpg",
]

367
core/library/file_tags.py Normal file
View file

@ -0,0 +1,367 @@
"""Read embedded tags from an audio file for the Audit Trail UI.
The Audit Trail modal on the Library History view needs to show
exactly what tags are currently embedded in a downloaded file
title/artist/album metadata, MusicBrainz/Spotify/Tidal IDs,
ReplayGain values, ISRC, cover-art presence, lyrics, and anything
else SoulSync or its background enrichment workers wrote.
The file is the single source of truth. A persisted snapshot at
post-process time would drift the moment a background worker
(audiodb, lastfm, genius, deezer enrichment, lyrics fetch) writes
more tags, or if the user manually re-tags. So the audit endpoint
reads the file live on demand.
This module is the pure mutagen wrapper. Returns a canonical
JSON-serializable dict; never raises (failure modes degrade to an
``{'available': False, 'reason': '...'}`` shape so the caller can
surface a useful error to the user).
Frontend renders the canonical shape directly no per-source
mapping at the API layer.
"""
from __future__ import annotations
import os
from typing import Any, Dict
from core.metadata.common import get_mutagen_symbols
from utils.logging_config import get_logger
logger = get_logger("library.file_tags")
# ID3 frame names that carry textual values we want to surface
# under the "core" tag group. mutagen exposes ID3 frames keyed by
# their 4-letter codes, so map those codes to friendly labels.
_ID3_TEXT_FRAMES = {
"TIT2": "title",
"TPE1": "artist",
"TPE2": "album_artist",
"TALB": "album",
"TDRC": "date",
"TCON": "genre",
"TRCK": "tracknumber",
"TPOS": "discnumber",
"TBPM": "bpm",
"TMOO": "mood",
"TCOP": "copyright",
"TPUB": "publisher",
"TLAN": "language",
}
# TXXX-style ID3 frames carry user-defined keys via their `desc`
# attribute. We pick known descriptions out of those.
_KNOWN_TXXX_DESCS = {
"MusicBrainz Album Id": "musicbrainz_albumid",
"MusicBrainz Artist Id": "musicbrainz_artistid",
"MusicBrainz Album Artist Id": "musicbrainz_albumartistid",
"MusicBrainz Release Group Id": "musicbrainz_releasegroupid",
"MusicBrainz Release Track Id": "musicbrainz_releasetrackid",
"MusicBrainz Track Id": "musicbrainz_trackid",
"Spotify Track Id": "spotify_track_id",
"Spotify Artist Id": "spotify_artist_id",
"Spotify Album Id": "spotify_album_id",
"Tidal Track Id": "tidal_track_id",
"Tidal Artist Id": "tidal_artist_id",
"Tidal Album Id": "tidal_album_id",
"Deezer Track Id": "deezer_track_id",
"Deezer Artist Id": "deezer_artist_id",
"Deezer Album Id": "deezer_album_id",
"AudioDB Track Id": "audiodb_track_id",
"AudioDB Artist Id": "audiodb_artist_id",
"AudioDB Album Id": "audiodb_album_id",
"iTunes Track Id": "itunes_track_id",
"iTunes Artist Id": "itunes_artist_id",
"iTunes Album Id": "itunes_album_id",
"Genius Track Id": "genius_track_id",
"Genius Url": "genius_url",
"LastFm Url": "lastfm_url",
"ASIN": "asin",
"BARCODE": "barcode",
"CATALOGNUMBER": "catalognumber",
"ISRC": "isrc",
"ORIGINALDATE": "originaldate",
"RELEASECOUNTRY": "releasecountry",
"RELEASESTATUS": "releasestatus",
"RELEASETYPE": "releasetype",
"SCRIPT": "script",
"MEDIA": "media",
"TOTALDISCS": "totaldiscs",
"TOTALTRACKS": "tracktotal",
"STYLE": "style",
"QUALITY": "quality",
"Artists": "artists",
"replaygain_track_gain": "replaygain_track_gain",
"replaygain_track_peak": "replaygain_track_peak",
"replaygain_album_gain": "replaygain_album_gain",
"replaygain_album_peak": "replaygain_album_peak",
}
# Vorbis (FLAC/OGG/OPUS) tag keys map 1:1 with our friendly names —
# Vorbis is the most permissive container, every key is just a
# string. mutagen surfaces them as lowercase by convention.
# This passlist filters out the noise (encoder, comment, ...) and
# whitelists everything we want to show.
_VORBIS_ALLOWED_KEYS = frozenset({
"title", "artist", "albumartist", "album_artist", "album",
"date", "year", "genre", "tracknumber", "discnumber",
"tracktotal", "totaltracks", "totaldiscs", "bpm", "mood",
"copyright", "publisher", "language", "style", "quality",
"isrc", "barcode", "catalognumber", "asin", "script",
"media", "originaldate", "releasecountry", "releasestatus",
"releasetype", "artists", "composer", "performer",
"musicbrainz_albumid", "musicbrainz_artistid",
"musicbrainz_albumartistid", "musicbrainz_releasegroupid",
"musicbrainz_releasetrackid", "musicbrainz_trackid",
"spotify_track_id", "spotify_artist_id", "spotify_album_id",
"tidal_track_id", "tidal_artist_id", "tidal_album_id",
"deezer_track_id", "deezer_artist_id", "deezer_album_id",
"audiodb_track_id", "audiodb_artist_id", "audiodb_album_id",
"itunes_track_id", "itunes_artist_id", "itunes_album_id",
"genius_track_id", "genius_url", "lastfm_url",
"replaygain_track_gain", "replaygain_track_peak",
"replaygain_album_gain", "replaygain_album_peak",
"lyrics", "unsyncedlyrics",
})
def read_embedded_tags(file_path: str) -> Dict[str, Any]:
"""Read embedded tags from an audio file via mutagen.
Returns a dict with one of two shapes:
- ``{"available": True, "format": "...", "bitrate": ..., "tags": {...}, "has_picture": bool}``
on success. ``tags`` is a flat dict of lowercase friendly key
string value (lists joined with ', '). Long fields like
``lyrics`` are returned in full caller decides how to display.
- ``{"available": False, "reason": "..."}`` when the file doesn't
exist, isn't readable, or mutagen can't recognise the format.
Never raises. Caller surfaces ``reason`` to the user verbatim.
"""
if not file_path or not isinstance(file_path, str):
return {"available": False, "reason": "No file path on this row."}
if not os.path.exists(file_path):
return {
"available": False,
"reason": f"File no longer exists at: {file_path}",
}
symbols = get_mutagen_symbols()
if symbols is None:
return {"available": False, "reason": "Mutagen is unavailable."}
try:
audio = symbols.File(file_path)
except Exception as exc:
logger.debug("Mutagen open failed for %s: %s", file_path, exc)
return {
"available": False,
"reason": f"Could not open file: {exc}",
}
if audio is None:
return {
"available": False,
"reason": "File format not recognised by mutagen.",
}
fmt = type(audio).__name__
bitrate = 0
duration = 0.0
try:
if getattr(audio, "info", None) is not None:
bitrate = int(getattr(audio.info, "bitrate", 0) or 0)
duration = float(getattr(audio.info, "length", 0) or 0)
except Exception as exc: # noqa: S110 — optional info, missing is fine
logger.debug("audio info read failed: %s", exc)
has_picture = _detect_picture(audio, symbols)
tags = _extract_tags(audio, symbols)
return {
"available": True,
"format": fmt,
"bitrate": bitrate,
"duration": duration,
"has_picture": has_picture,
"tags": tags,
}
def _detect_picture(audio: Any, symbols: Any) -> bool:
"""True when the file has at least one embedded cover-art picture."""
# FLAC / OGG-Vorbis expose pictures via `audio.pictures` list.
pictures = getattr(audio, "pictures", None)
if pictures:
return True
# ID3 stores pictures as APIC frames.
tags = getattr(audio, "tags", None)
if tags is None:
return False
try:
if hasattr(tags, "getall"):
apics = tags.getall("APIC")
if apics:
return True
# MP4 covers under 'covr' key.
if "covr" in tags and tags["covr"]:
return True
# Vorbis embedded base64 picture frame.
if "metadata_block_picture" in tags:
return True
except Exception as exc: # noqa: S110 — optional probe, missing is fine
logger.debug("picture detect failed: %s", exc)
return False
def _extract_tags(audio: Any, symbols: Any) -> Dict[str, str]:
"""Flatten the audio file's tag store to a {key: string} dict.
Handles the three container families we ship: ID3 (MP3),
Vorbis-like (FLAC/OGG/OPUS), and MP4. Everything else falls
through to a generic key/value dump.
"""
out: Dict[str, str] = {}
tags = getattr(audio, "tags", None)
if tags is None:
return out
# ID3 path.
if isinstance(tags, symbols.ID3):
for code, label in _ID3_TEXT_FRAMES.items():
frame = tags.get(code)
if frame is not None:
val = _stringify(frame)
if val:
out[label] = val
# TXXX user-defined frames (most of our extra IDs / replay
# gain / source IDs live here).
try:
for frame in tags.getall("TXXX"):
desc = getattr(frame, "desc", "")
if not desc:
continue
# mutagen's TXXX comparison is case-sensitive; the
# dict lookup matches the exact desc string.
key = _KNOWN_TXXX_DESCS.get(desc) or desc.lower().replace(" ", "_")
val = _stringify(frame)
if val:
out[key] = val
except Exception as exc: # noqa: S110 — optional TXXX walk
logger.debug("ID3 TXXX walk failed: %s", exc)
# USLT (unsynchronised lyrics).
try:
for frame in tags.getall("USLT"):
val = _stringify(frame)
if val:
out.setdefault("lyrics", val)
except Exception as exc: # noqa: S110 — optional USLT walk
logger.debug("ID3 USLT walk failed: %s", exc)
return out
# MP4 path.
if isinstance(audio, symbols.MP4):
_MP4_MAP = {
"\xa9nam": "title",
"\xa9ART": "artist",
"aART": "album_artist",
"\xa9alb": "album",
"\xa9day": "date",
"\xa9gen": "genre",
"trkn": "tracknumber",
"disk": "discnumber",
"\xa9lyr": "lyrics",
"tmpo": "bpm",
"cprt": "copyright",
}
for key, label in _MP4_MAP.items():
if key in tags:
val = _stringify(tags[key])
if val:
out[label] = val
# Freeform MP4 atoms — prefix ----:com.apple.iTunes:
for k in tags.keys():
if not isinstance(k, str) or not k.startswith("----"):
continue
label = k.split(":")[-1].lower()
val = _stringify(tags[k])
if val:
out[label] = val
return out
# Vorbis-like (FLAC, OGG, OPUS): tags acts dict-like, values are
# lists of strings.
try:
for raw_key in tags.keys():
if not isinstance(raw_key, str):
continue
lower = raw_key.lower()
if lower not in _VORBIS_ALLOWED_KEYS:
# Pass through anything that looks like a known
# source/ID-style key even if not in the allowed
# set — covers `*_id`, `*_url` shapes we didn't
# explicitly list.
if not (lower.endswith("_id") or lower.endswith("_url") or lower.startswith("musicbrainz_")):
continue
val = _stringify(tags[raw_key])
if val:
out[lower] = val
except Exception as exc: # noqa: S110 — optional vorbis walk
logger.debug("Vorbis tag walk failed: %s", exc)
return out
def _stringify(value: Any) -> str:
"""Coerce a mutagen tag value into a human-readable string.
mutagen returns various shapes depending on the container
bare strings, lists of strings, frame objects with `.text` or
`.data` attributes, MP4Cover objects, integer tuples (trkn,
disk), etc. Best-effort flatten.
"""
if value is None:
return ""
if isinstance(value, str):
return value.strip()
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, (list, tuple)):
parts = []
for item in value:
if isinstance(item, tuple):
# (track_num, total) shape from MP4 trkn / disk.
if len(item) >= 1 and item[0]:
if len(item) >= 2 and item[1]:
parts.append(f"{item[0]}/{item[1]}")
else:
parts.append(str(item[0]))
continue
s = _stringify(item)
if s:
parts.append(s)
return ", ".join(parts)
# mutagen frame objects: prefer .text, then .data, then str().
text = getattr(value, "text", None)
if text is not None and text is not value:
return _stringify(text)
data = getattr(value, "data", None)
if isinstance(data, (str, bytes)):
try:
return data.decode("utf-8", errors="replace").strip() if isinstance(data, bytes) else data.strip()
except Exception:
return ""
try:
return str(value).strip()
except Exception:
return ""
__all__ = ["read_embedded_tags"]

View file

@ -267,6 +267,33 @@ def _build_album_info_typed(album_data: Dict[str, Any], album_id: str,
return ctx
_ALBUM_TYPE_CANONICAL = {'album', 'single', 'ep', 'compilation'}
def _normalize_album_type(value: Any, default: str = 'album') -> str:
"""Map a raw album-type value from any source to the canonical
lowercase token the path templates use (``album`` / ``single`` /
``ep`` / ``compilation``).
Different metadata sources expose the album type under different
keys AND with different casing Tidal returns ``ALBUM``, MB
returns ``Album``, Deezer's ``record_type`` is already lowercase.
Without this normalization the legacy duck-typed builder accepted
only Spotify-shaped lowercase ``album_type``, so every other
source's discography ended up filed under ``Album/`` regardless
of actual type (Discord report, CAL, 2026-05-12).
Unknown values fall back to ``default`` rather than passing
through keeps stray strings out of the path template.
"""
if value is None:
return default
v = str(value).strip().lower()
if not v:
return default
return v if v in _ALBUM_TYPE_CANONICAL else default
def _build_album_info_legacy(album_data: Any, album_id: str,
album_name: str, artist_name: str) -> Dict[str, Any]:
"""Original duck-typed extraction. Kept as the fallback when the
@ -310,7 +337,12 @@ def _build_album_info_legacy(album_data: Any, album_id: str,
'image_url': image_url,
'images': images,
'release_date': _extract_lookup_value(album_data, 'release_date', default='') or '',
'album_type': _extract_lookup_value(album_data, 'album_type', default='album') or 'album',
'album_type': _normalize_album_type(
_extract_lookup_value(
album_data, 'album_type', 'record_type', 'type', 'primary-type',
default=None,
)
),
'total_tracks': _extract_lookup_value(album_data, 'total_tracks', 'track_count', default=0) or 0,
}

View file

@ -150,6 +150,7 @@ def _build_discography_release_dict(release: Any, artist_id: str,
'image_url': typed_album.image_url,
'total_tracks': typed_album.total_tracks or 0,
'external_urls': typed_album.external_urls or {},
'explicit': typed_album.explicit,
}
release_id = _extract_lookup_value(release, 'id', 'album_id', 'release_id')
@ -168,6 +169,7 @@ def _build_discography_release_dict(release: Any, artist_id: str,
'image_url': _extract_lookup_value(release, 'image_url', 'thumb_url', 'cover_image'),
'total_tracks': _extract_lookup_value(release, 'total_tracks', default=0) or 0,
'external_urls': _extract_lookup_value(release, 'external_urls', default={}) or {},
'explicit': _extract_lookup_value(release, 'explicit'),
}
@ -436,6 +438,7 @@ def _build_artist_detail_release_card(release: Dict[str, Any],
'track_count': typed_album.total_tracks or 0,
'owned': None,
'track_completion': 'checking',
'explicit': typed_album.explicit,
}
if typed_album.release_date:
card['release_date'] = typed_album.release_date
@ -470,6 +473,7 @@ def _build_artist_detail_release_card(release: Dict[str, Any],
'track_count': _extract_lookup_value(release, 'track_count', 'total_tracks', default=0) or 0,
'owned': None,
'track_completion': 'checking',
'explicit': _extract_lookup_value(release, 'explicit'),
}
if release_date:

View file

@ -100,6 +100,8 @@ class Album:
label: Optional[str] = None # Record label / publisher
barcode: Optional[str] = None # UPC/EAN — Discogs/MusicBrainz only
explicit: Optional[bool] = None # True=explicit, False=clean, None=unknown
# Source provenance
source: str = '' # 'spotify' / 'itunes' / etc — set by converter
external_ids: Dict[str, str] = field(default_factory=dict)
@ -193,6 +195,8 @@ class Album:
release_date = release_date.split('T', 1)[0]
primary_genre = _str(raw.get('primaryGenreName'))
ce = _str(raw.get('collectionExplicitness'))
explicit = True if ce == 'explicit' else (False if ce in ('notExplicit', 'cleaned') else None)
return cls(
id=_str(raw.get('collectionId')),
name=name,
@ -203,6 +207,7 @@ class Album:
image_url=_itunes_artwork(raw.get('artworkUrl100')),
artist_id=artist_id,
genres=[primary_genre] if primary_genre else [],
explicit=explicit,
source='itunes',
external_ids=external_ids,
external_urls=external_urls,
@ -238,6 +243,8 @@ class Album:
if raw.get('link'):
external_urls['deezer'] = _str(raw['link'])
_el = raw.get('explicit_lyrics')
explicit = bool(_el) if _el is not None else None
return cls(
id=_str(raw.get('id')),
name=_str(raw.get('title')),
@ -251,6 +258,7 @@ class Album:
if isinstance(g, dict) and g.get('name')],
label=_str(raw.get('label')) or None,
barcode=external_ids.get('upc'),
explicit=explicit,
source='deezer',
external_ids=external_ids,
external_urls=external_urls,
@ -513,6 +521,8 @@ class Album:
if raw.get('soul_id'):
external_ids['soul'] = _str(raw['soul_id'])
_he = raw.get('explicit')
explicit = bool(_he) if _he is not None else None
return cls(
id=_str(raw.get('id')),
name=_str(raw.get('name') or raw.get('title')),
@ -522,6 +532,7 @@ class Album:
album_type=_str(raw.get('album_type'), default='album'),
image_url=_str(raw.get('image_url') or raw.get('thumb_url')) or None,
artist_id=_str(raw.get('artist_id')) or None,
explicit=explicit,
source='hydrabase',
external_ids=external_ids,
)

19
core/webui/__init__.py Normal file
View file

@ -0,0 +1,19 @@
"""WebUI delivery helpers."""
from core.webui.assets import (
build_webui_vite_assets,
clear_webui_vite_manifest_cache,
default_static_url_builder,
get_webui_vite_manifest_path,
load_webui_vite_manifest,
)
from core.webui.spa import should_serve_webui_spa
__all__ = [
"build_webui_vite_assets",
"clear_webui_vite_manifest_cache",
"default_static_url_builder",
"get_webui_vite_manifest_path",
"load_webui_vite_manifest",
"should_serve_webui_spa",
]

126
core/webui/assets.py Normal file
View file

@ -0,0 +1,126 @@
"""WebUI Vite asset helpers."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Callable
from utils.logging_config import get_logger as _create_logger
logger = _create_logger("webui.assets")
DEFAULT_WEBUI_VITE_ENTRY = "src/app/main.tsx"
DEFAULT_WEBUI_VITE_BASE = "/static/dist/"
DEFAULT_WEBUI_VITE_DEV_URL = "http://127.0.0.1:5173"
DEFAULT_WEBUI_VITE_DEV_ENV = "SOULSYNC_WEBUI_VITE_DEV"
DEFAULT_WEBUI_VITE_URL_ENV = "SOULSYNC_WEBUI_VITE_URL"
_MANIFEST_CACHE: dict[str, tuple[float | None, dict[str, Any]]] = {}
def get_webui_vite_manifest_path() -> Path:
"""Return the generated Vite manifest path inside the repo."""
return Path(__file__).resolve().parents[2] / "webui" / "static" / "dist" / ".vite" / "manifest.json"
def clear_webui_vite_manifest_cache() -> None:
"""Clear the in-process manifest cache. Primarily useful for tests."""
_MANIFEST_CACHE.clear()
def default_static_url_builder(filename: str) -> str:
"""Build a Flask-style static URL without requiring Flask imports."""
return f"/static/{filename.lstrip('/')}"
def _env_truthy(value: str | None) -> bool:
return (value or "").lower() in {"1", "true", "yes", "on"}
def _resolve_dev_mode(dev: bool | None) -> bool:
if dev is not None:
return bool(dev)
return _env_truthy(os.environ.get(DEFAULT_WEBUI_VITE_DEV_ENV))
def _resolve_dev_url(dev_url: str | None) -> str:
if dev_url:
return dev_url.rstrip("/")
return os.environ.get(DEFAULT_WEBUI_VITE_URL_ENV, DEFAULT_WEBUI_VITE_DEV_URL).rstrip("/")
def load_webui_vite_manifest(manifest_path: str | Path | None = None) -> dict[str, Any]:
"""Load and cache the generated Vite manifest."""
path = Path(manifest_path) if manifest_path else get_webui_vite_manifest_path()
cache_key = str(path)
manifest_mtime = None
if path.exists():
try:
manifest_mtime = path.stat().st_mtime
except OSError:
manifest_mtime = None
cached = _MANIFEST_CACHE.get(cache_key)
if cached and cached[0] == manifest_mtime:
return cached[1]
if path.exists():
try:
with path.open("r", encoding="utf-8") as handle:
manifest = json.load(handle)
except Exception as exc:
logger.warning("Failed to load webui manifest: %s", exc)
manifest = {}
else:
manifest = {}
_MANIFEST_CACHE[cache_key] = (manifest_mtime, manifest)
return manifest
def build_webui_vite_assets(
placement: str = "body",
*,
dev: bool | None = None,
dev_url: str | None = None,
entry: str = DEFAULT_WEBUI_VITE_ENTRY,
manifest_path: str | Path | None = None,
manifest_loader: Callable[[], dict[str, Any]] | None = None,
static_url_builder: Callable[[str], str] | None = None,
) -> str:
"""Return HTML tags for the WebUI bundle or dev client."""
if placement not in ("head", "body"):
return ""
dev_mode = _resolve_dev_mode(dev)
vite_url = _resolve_dev_url(dev_url)
static_url = static_url_builder or default_static_url_builder
if dev_mode:
if placement == "head":
return ""
base = DEFAULT_WEBUI_VITE_BASE.rstrip("/")
return "\n".join([
f'<script type="module" src="{vite_url}{base}/@vite/client"></script>',
f'<script type="module" src="{vite_url}{base}/{entry.lstrip("/")}"></script>',
])
loader = manifest_loader or (lambda: load_webui_vite_manifest(manifest_path))
manifest = loader()
entry_meta = manifest.get(entry)
if not entry_meta:
return ""
if placement == "head":
return "\n".join(
f'<link rel="stylesheet" href="{static_url(f"dist/{css_file}")}">'
for css_file in entry_meta.get("css", [])
)
entry_file = entry_meta.get("file")
if not entry_file:
return ""
return f'<script type="module" src="{static_url(f"dist/{entry_file}")}"></script>'

23
core/webui/spa.py Normal file
View file

@ -0,0 +1,23 @@
"""WebUI SPA routing helpers."""
from __future__ import annotations
EXACT_EXCLUDED_PATHS = {"/callback", "/status"}
PREFIX_EXCLUDED_PATHS = (
"/api",
"/auth",
"/callback/",
"/deezer/",
"/socket.io",
"/static",
"/stream",
"/tidal/",
)
def should_serve_webui_spa(pathname: str) -> bool:
"""Return True when a request path should fall through to the SPA."""
normalized = pathname.rstrip("/") or "/"
if normalized in EXACT_EXCLUDED_PATHS:
return False
return not normalized.startswith(PREFIX_EXCLUDED_PATHS)

348
dev.py Executable file
View file

@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""SoulSync development launcher.
Starts the backend and Vite dev server together, restarts the backend when
backend source files change, and handles shutdown cleanly across platforms.
"""
from __future__ import annotations
import atexit
import os
import shutil
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parent
LOG_DIR = ROOT_DIR / 'logs'
GUNICORN_CONFIG = ROOT_DIR / 'gunicorn.dev.conf.py'
VITE_URL = os.environ.get('SOULSYNC_WEBUI_VITE_URL', 'http://127.0.0.1:5173').rstrip('/')
VITE_LOG_FILE = Path(os.environ.get('SOULSYNC_WEBUI_VITE_LOG', str(LOG_DIR / 'webui-vite.log')))
INCLUDED_SUFFIXES = {'.py', '.html', '.jinja', '.jinja2'}
SHUTDOWN_GRACE_SECONDS = int(os.environ.get('SOULSYNC_SHUTDOWN_GRACE_SECONDS', '10'))
FORCE_KILL_ON_SHUTDOWN = os.environ.get('SOULSYNC_FORCE_KILL_ON_SHUTDOWN', '1').lower() in {
'1',
'true',
'yes',
'on',
}
shutdown_requested = False
managed_processes: list[tuple[str, subprocess.Popen, object | None]] = []
def resolve_command(*candidates: str) -> str | None:
for candidate in candidates:
resolved = shutil.which(candidate)
if resolved:
return resolved
return None
def is_excluded(path: Path) -> bool:
try:
relative = path.relative_to(ROOT_DIR)
except ValueError:
return False
parts = relative.parts
if not parts:
return False
if any(part == '__pycache__' for part in parts):
return True
if parts[0] in {'.git', 'logs'}:
return True
if len(parts) >= 2 and parts[0] == 'webui' and parts[1] == 'node_modules':
return True
if len(parts) >= 3 and parts[0] == 'webui' and parts[1] == 'static' and parts[2] == 'dist':
return True
return False
def build_backend_env(direct_mode: bool) -> dict[str, str]:
env = os.environ.copy()
env.setdefault('SOULSYNC_WEB_DEV_NO_CACHE', '1')
env.setdefault('SOULSYNC_WEBUI_VITE_DEV', '1')
env.setdefault('SOULSYNC_WEBUI_VITE_URL', VITE_URL)
env.setdefault('SOULSYNC_WEBUI_VITE_LOG', str(VITE_LOG_FILE))
env.setdefault('SOULSYNC_CONFIG_PATH', str(ROOT_DIR / 'config' / 'config.json'))
if direct_mode:
env.setdefault('SOULSYNC_WEB_BIND_HOST', '127.0.0.1')
env.setdefault('SOULSYNC_WEB_BIND_PORT', '8008')
return env
def start_process(label: str, cmd: list[str], *, log_file: Path | None = None, env: dict[str, str] | None = None) -> tuple[subprocess.Popen, object | None]:
log_handle = None
stdout = None
stderr = None
if log_file is not None:
log_file.parent.mkdir(parents=True, exist_ok=True)
log_handle = log_file.open('ab')
stdout = log_handle
stderr = log_handle
creationflags = 0
start_new_session = False
if os.name == 'nt':
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
else:
start_new_session = True
try:
proc = subprocess.Popen(
cmd,
cwd=str(ROOT_DIR),
env=env,
stdin=subprocess.DEVNULL,
stdout=stdout,
stderr=stderr,
creationflags=creationflags,
start_new_session=start_new_session,
)
except Exception:
if log_handle is not None:
log_handle.close()
raise
managed_processes.append((label, proc, log_handle))
return proc, log_handle
def wait_for_exit(proc: subprocess.Popen, seconds: int) -> bool:
checks = max(1, int(seconds * 10))
for _ in range(checks):
if proc.poll() is not None:
return True
time.sleep(0.1)
return proc.poll() is not None
def stop_process(label: str, proc: subprocess.Popen, log_handle: object | None) -> None:
if proc.poll() is not None:
if log_handle is not None:
log_handle.close()
return
print(f'Stopping {label}...')
try:
if os.name == 'nt':
proc.terminate()
else:
os.killpg(proc.pid, signal.SIGTERM)
except ProcessLookupError:
pass
if not wait_for_exit(proc, SHUTDOWN_GRACE_SECONDS):
if not FORCE_KILL_ON_SHUTDOWN:
print(f'{label} did not exit in time; skipping forced kill for this test run.')
else:
print(f'{label} did not exit in time; forcing shutdown...')
if os.name == 'nt':
subprocess.run(
['taskkill', '/T', '/F', '/PID', str(proc.pid)],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
else:
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
wait_for_exit(proc, 5)
if log_handle is not None:
log_handle.close()
def cleanup() -> None:
global shutdown_requested
if shutdown_requested:
return
shutdown_requested = True
for label, proc, log_handle in reversed(managed_processes):
stop_process(label, proc, log_handle)
def compute_backend_watch_state() -> str:
rows: list[str] = []
for dirpath, dirnames, filenames in os.walk(ROOT_DIR):
current_dir = Path(dirpath)
if is_excluded(current_dir):
dirnames[:] = []
continue
dirnames[:] = [
name
for name in dirnames
if not is_excluded(current_dir / name) and name != '__pycache__'
]
for filename in filenames:
path = current_dir / filename
if path.suffix not in INCLUDED_SUFFIXES:
continue
try:
stat = path.stat()
except FileNotFoundError:
continue
rows.append(f'{stat.st_mtime_ns} {path}')
return '\n'.join(sorted(rows))
def start_vite() -> subprocess.Popen:
npm = resolve_command('npm', 'npm.cmd')
if npm is None:
raise SystemExit('npm is required to run the Vite dev server.')
print(f'Starting Vite dev server at {VITE_URL}...')
vite_cmd = [
npm,
'--prefix',
str(ROOT_DIR / 'webui'),
'run',
'dev',
'--',
'--host',
'127.0.0.1',
'--port',
'5173',
]
proc, _ = start_process('Vite dev server', vite_cmd, log_file=VITE_LOG_FILE, env=os.environ.copy())
return proc
def wait_for_vite_ready(vite_proc: subprocess.Popen) -> None:
ready_url = f'{VITE_URL}/static/dist/@vite/client'
vite_ready = False
for _ in range(50):
if vite_proc.poll() is not None:
print('Warning: Vite dev server exited before it became ready.')
break
try:
with urllib.request.urlopen(ready_url, timeout=1) as response:
if response.status < 400:
vite_ready = True
break
except (urllib.error.URLError, TimeoutError, OSError):
pass
time.sleep(0.2)
if vite_ready:
print('Vite dev server is ready.')
else:
print('Warning: timed out waiting for the Vite dev server.')
print('The backend will still start, but the frontend may not hot-reload yet.')
def start_backend() -> tuple[subprocess.Popen, object | None]:
backend_mode = os.environ.get('SOULSYNC_DEV_BACKEND', '').strip().lower()
direct_mode = backend_mode == 'direct'
gunicorn_mode = backend_mode == 'gunicorn'
if not backend_mode:
if os.name == 'nt':
direct_mode = True
elif resolve_command('gunicorn') is None:
print('gunicorn not found; falling back to direct Python server.')
direct_mode = True
else:
gunicorn_mode = True
print('Starting SoulSync web server...')
if gunicorn_mode:
gunicorn = resolve_command('gunicorn')
if gunicorn is None:
raise SystemExit('gunicorn is not available but SOULSYNC_DEV_BACKEND=gunicorn was requested.')
print(f'Using Gunicorn config: {GUNICORN_CONFIG}')
cmd = [gunicorn, '-c', str(GUNICORN_CONFIG), 'wsgi:application']
else:
print('Using direct Python server for backend.')
cmd = [sys.executable, str(ROOT_DIR / 'web_server.py')]
proc, log_handle = start_process(
'SoulSync web server',
cmd,
env=build_backend_env(direct_mode),
)
return proc, log_handle
def watch_and_run_backend() -> None:
last_state = compute_backend_watch_state()
backend_proc, backend_log = start_backend()
try:
while not shutdown_requested:
time.sleep(1)
if backend_proc.poll() is not None:
print('SoulSync web server exited. Restarting...')
stop_process('SoulSync web server', backend_proc, backend_log)
managed_processes.pop()
backend_proc, backend_log = start_backend()
last_state = compute_backend_watch_state()
continue
current_state = compute_backend_watch_state()
if current_state != last_state:
print('Detected backend file changes. Restarting SoulSync web server...')
last_state = current_state
stop_process('SoulSync web server', backend_proc, backend_log)
managed_processes.pop()
backend_proc, backend_log = start_backend()
finally:
if backend_proc.poll() is None:
stop_process('SoulSync web server', backend_proc, backend_log)
if managed_processes:
managed_processes.pop()
def main() -> int:
if not (ROOT_DIR / 'webui' / 'node_modules').is_dir():
print('webui/node_modules is missing.')
print('Run: cd webui && npm ci')
return 1
vite_proc = start_vite()
try:
wait_for_vite_ready(vite_proc)
print(f'Vite log: {VITE_LOG_FILE}')
print('Backend file watching is enabled.')
watch_and_run_backend()
finally:
cleanup()
return 0
def _handle_signal(signum: int, _frame) -> None:
raise SystemExit(130 if signum == signal.SIGINT else 143)
signal.signal(signal.SIGINT, _handle_signal)
if hasattr(signal, 'SIGTERM'):
signal.signal(signal.SIGTERM, _handle_signal)
if hasattr(signal, 'SIGBREAK'):
signal.signal(signal.SIGBREAK, _handle_signal)
atexit.register(cleanup)
if __name__ == '__main__':
raise SystemExit(main())

16
dev.sh Executable file
View file

@ -0,0 +1,16 @@
#!/bin/sh
# Compatibility wrapper for the Python launcher.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if command -v python3 >/dev/null 2>&1; then
exec python3 "$SCRIPT_DIR/dev.py" "$@"
fi
if command -v python >/dev/null 2>&1; then
exec python "$SCRIPT_DIR/dev.py" "$@"
fi
echo "Python is required to run the SoulSync dev launcher."
exit 1

View file

@ -0,0 +1,267 @@
# WebUI Page Migration Analysis
Snapshot date: 2026-05-02
## Summary
- The shell route manifest now has 18 page ids.
- `issues` is still the only React-owned route.
- Since the last snapshot, the biggest changes are:
- `downloads` was renamed into `search`.
- The live queue became `active-downloads`.
- `watchlist` and `wishlist` became full sidebar pages.
- `tools` was split off from `dashboard`.
- `artists` is no longer a route id.
- The shell is also more modular now. The old monolithic `script.js` has been split across `core.js`, `init.js`, `shared-helpers.js`, and feature modules such as `search.js`, `api-monitor.js`, `pages-extra.js`, `stats-automations.js`, and `wishlist-tools.js`.
- Current profile compatibility still normalizes old `downloads` and `artists` references to `search`, so the docs and the route ids are not always using the same historical language.
## What Changed Since The Last Snapshot
- `search` is now the canonical route for the old download/search experience.
- `active-downloads` owns the dedicated live queue that used to sit inside the search flow.
- `watchlist` and `wishlist` moved out of dashboard-era chrome and into their own routes.
- `tools` moved off the dashboard into a dedicated sidebar page.
- `dashboard` is a bit narrower now, because several operational surfaces were split out.
- `artist-detail` is still a first-class route, but its permission relationship is now tied to `library` and `search`, not to an `artists` page.
- The contextual help system still contains some historical `downloads` and `artists` wording, so those labels should be treated as legacy text rather than route ids.
## Current Architecture
- `webui/index.html` still hosts the Flask-rendered shell, the sidebar, the media player, the legacy `.page` containers, and the React mount point.
- `webui/static/core.js` now holds a lot of the shared global state that used to live in the old monolith.
- `webui/static/init.js` still owns page activation, permission gating, nav highlighting, legacy routing, and the `window.SoulSyncWebRouter` bridge.
- `webui/static/shell-bridge.js` and the TanStack Router adapter still decide whether a route is handled by the React host or handed back to the legacy shell.
- `issues` remains the reference pattern for React-owned pages: route manifest ownership, shell bridge integration, route-local data loading, and detail-modal behavior all live in the React subtree.
- The legacy shell is now spread across feature modules rather than one giant coordinator file, which makes the migration seams a little clearer than they were a month ago.
### Route and Compatibility Notes
- Manifest page ids: `dashboard`, `sync`, `search`, `discover`, `playlist-explorer`, `watchlist`, `wishlist`, `automations`, `active-downloads`, `library`, `tools`, `artist-detail`, `stats`, `import`, `settings`, `issues`, `help`, `hydrabase`.
- `downloads` and `artists` are no longer manifest ids.
- HTML `.page` containers exist for every legacy page plus `webui-react-root` for React.
- `watchlist`, `wishlist`, and `active-downloads` are now standalone route targets instead of dashboard overlays.
- `tools` is now a dedicated page, so dashboard can be treated as a monitoring hub instead of the one-stop maintenance surface.
- `help` and `issues` remain always-allowed for non-admins.
- `settings` remains admin-only.
- `artist-detail` is allowed when the profile can access `library` or `search`.
## Cross-Cutting Features
- Profile and permission routing still live in the shell bootstrap.
- Shell chrome and nav highlighting are still shared shell responsibilities.
- Media player behavior, queue handling, and global overlays still cut across multiple pages.
- Socket/WebSocket and polling behavior remain the biggest migration risks for live pages.
- The help system, tours, and helper annotations still reference some historical route names, so route-migration work should use the manifest as the source of truth.
- Visual effects such as `particles.js` and `worker-orbs.js` remain shell-global.
## Scoring Rubric
Each page is scored from 1 to 5 on five axes:
- Rendering surface size: HTML/UI area and number of distinct render states
- State/coupling complexity: amount of local state plus coupling to other pages or shell-global state
- Async/realtime complexity: fetch fan-out, polling, WebSocket/live progress, streaming, or long-running workflows
- Cross-cutting shell dependency: reliance on shared shell behaviors, globals, overlays, or non-route contracts
- Testability/parity difficulty: how hard it is to prove route parity without regressions
Rollups:
- Migration effort
- `Low`: total score 9-11
- `Medium`: total score 12-17
- `High`: total score 18-21
- `Very High`: total score 22-25
- Regression risk
- `Low`: mostly isolated UI with limited async and minimal shell coupling
- `Medium`: moderate data flow or workflow complexity with bounded blast radius
- `High`: broad coupling, many async states, or sensitive user workflows
## Summary Matrix
| Page | Owner | Scores (R/S/A/C/T) | Effort | Risk | Recommended Wave |
| --- | --- | --- | --- | --- | --- |
| `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 0 |
| `help` | Legacy | 3 / 2 / 1 / 1 / 2 | Low | Low | Wave 1 |
| `hydrabase` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 |
| `stats` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 |
| `import` | Legacy | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Wave 1 |
| `search` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 2 |
| `watchlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
| `wishlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
| `active-downloads` | Legacy | 3 / 4 / 4 / 3 / 4 | High | High | Wave 4 |
| `tools` | Legacy | 4 / 4 / 4 / 4 / 4 | High | High | Wave 4 |
| `dashboard` | Legacy | 4 / 4 / 4 / 4 / 4 | High | High | Wave 5 |
| `discover` | Legacy | 5 / 5 / 4 / 4 / 5 | Very High | High | Wave 6 |
| `playlist-explorer` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 7 |
| `library` | Legacy | 4 / 5 / 4 / 4 / 5 | Very High | High | Wave 8 |
| `artist-detail` | Legacy | 5 / 5 / 4 / 5 / 5 | Very High | High | Wave 8 |
| `sync` | Legacy | 5 / 5 / 5 / 4 / 5 | Very High | High | Wave 9 |
| `settings` | Legacy | 5 / 5 / 4 / 5 / 5 | Very High | High | Wave 10 |
| `automations` | Legacy | 4 / 5 / 4 / 3 / 4 | High | High | Wave 10 |
## Page Catalog
### Wave 0: Baseline
#### `issues`
- Current owner: React.
- Primary files: `webui/src/routes/issues/*`, `webui/src/platform/shell/*`, `webui/src/app/router.tsx`.
- Main surface: counts cards, filtered issue list, issue-detail modal, mutation flows.
- Key coupling: shell page gating, shell nav badge refresh, bridge-controlled chrome, React Query cache.
- Why it stays first: it is already the canonical React route pattern and the migration baseline.
### Wave 1: Safest wins
#### `help`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/docs.js`, `webui/static/helper.js`.
- Main surface: docs navigation, long-form sections, screenshots, lightbox behavior.
- Key coupling: mostly shell chrome and docs deep linking.
- Recommendation: still one of the safest early migrations, but keep the helper system shell-owned for now.
#### `hydrabase`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/init.js`.
- Main surface: connection state, saved credentials, peer count, comparison list.
- Key coupling: profile gating and a small amount of shell state.
- Recommendation: low-risk route with a narrow surface.
#### `stats`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/stats-automations.js`.
- Main surface: listening stats, charts, ranked lists, database storage visualization.
- Key coupling: chart rendering, some deep links back into library routes.
- Recommendation: early migration candidate with good parity-test potential.
#### `import`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`.
- Main surface: staging files, album and singles matching, suggestion cards, processing queue.
- Key coupling: settings-derived staging path assumptions and downstream library state.
- Recommendation: still bounded enough for an early wave, though more workflow-heavy than `help` or `stats`.
### Wave 2: Search split
#### `search`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/search.js`, `webui/static/downloads.js`, `webui/static/shared-helpers.js`.
- Main surface: basic search, enhanced search, source picker, fallback banner, download launch flow.
- Key coupling: global search widget parity, shared search controller, download modal handoff, legacy DOM ids that still say `downloads`.
- Recommendation: this is the renamed download/search surface, so it should be treated as a distinct migration from the queue view, not as the old monolith.
### Wave 3: Watchlist pair
#### `watchlist`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/api-monitor.js`, `webui/static/helper.js`.
- Main surface: watched-artist grid, per-artist config, scan status, global override banner, bulk actions.
- Key coupling: discovery and wishlist generation, scan polling, per-profile access rules.
- Recommendation: good mid-complexity route once the shell bridge and route-local data patterns are stable.
#### `wishlist`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/api-monitor.js`, `webui/static/wishlist-tools.js`, `webui/static/helper.js`.
- Main surface: track queue, cycle state, live processing, nebula visualization, countdown timers.
- Key coupling: watchlist scans, playlist sync handoff, download processing, live polling.
- Recommendation: visually distinctive but still bounded enough to follow `watchlist` in the same program wave.
### Wave 4: Operational split
#### `active-downloads`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/pages-extra.js`.
- Main surface: centralized live download list, status filters, batch grouping, batch history, cancellation controls.
- Key coupling: polling every few seconds, download batch hydration, nav badge counts, server-side download state.
- Recommendation: the old embedded queue moved here, so this page should be treated as the queue sibling of `search`.
#### `tools`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/wishlist-tools.js`, `webui/static/stats-automations.js`, `webui/static/helper.js`.
- Main surface: database updater, metadata updater, quality scan, duplicate clean, retag, backups, maintenance sections.
- Key coupling: lots of operational actions and several background jobs, but less dashboard chrome than before.
- Recommendation: split-off from the dashboard, but still operational enough to stay in a later wave.
### Wave 5: Monitoring hub
#### `dashboard`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/init.js`, `webui/static/wishlist-tools.js`, `webui/static/api-monitor.js`, `webui/static/worker-orbs.js`.
- Main surface: service cards, enrichment workers, library status, recent syncs, system stats, activity feed, quick nav.
- Key coupling: almost every global subsystem eventually shows up here.
- Recommendation: narrower than the old snapshot because tools moved out, but still one of the central shell surfaces.
### Wave 6: Broad discovery surface
#### `discover`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/discover.js`, `webui/static/helper.js`.
- Main surface: hero carousel, recent releases, genre browser, decade browser, similar artists, seasonal picks.
- Key coupling: watchlist-derived recommendations, discovery pool, download handoffs, many semi-independent sections.
- Recommendation: broad rendering surface and heavy fetch fan-out make this a high-risk migration.
### Wave 7: Visual interaction route
#### `playlist-explorer`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/wishlist-tools.js`, `webui/static/helper.js`.
- Main surface: playlist cards, discovery tree, selection model, zoom/pan interactions, wishlist submission.
- Key coupling: document-level pointer handling, discovery workflow, artist navigation.
- Recommendation: interactive enough to wait until the team is comfortable migrating richer stateful views.
### Wave 8: Library stack
#### `library`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/library.js`, `webui/static/helper.js`.
- Main surface: searchable artist grid, watchlist filters, pagination, download bubbles, deep links to detail.
- Key coupling: tightly bound to `artist-detail`, watchlist systems, and library-wide expectations.
- Recommendation: should be migrated alongside the detail route, not as an isolated quick win.
#### `artist-detail`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/library.js`, `webui/static/downloads.js`, `webui/static/helper.js`.
- Main surface: hero section, discography views, bulk operations, inline editing, tag writing, reorganize, quality actions.
- Key coupling: explicitly coupled to `library`, plus downloads, playback, metadata services, and file-organization settings.
- Recommendation: treat this as part of the library stack and keep it out of early waves.
### Wave 9: Multi-source orchestration
#### `sync`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/sync-spotify.js`, `webui/static/sync-services.js`, `webui/static/wishlist-tools.js`.
- Main surface: mirrored playlists, source tabs, discovery, sync history, playlist import flows, M3U export.
- Key coupling: the heaviest async orchestration in the app, with long-running workflows and state rehydration.
- Recommendation: one of the last major migrations.
### Wave 10: Final complex authoring/admin routes
#### `settings`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/settings.js`, `webui/static/helper.js`.
- Main surface: service credentials, download config, quality settings, file organization, appearance, advanced settings.
- Key coupling: almost every other page depends on settings-derived behavior or stored configuration.
- Recommendation: late migration because the blast radius is very large.
#### `automations`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`.
- Main surface: automation list, visual builder, run history, one-click hub groups, config editing.
- Key coupling: nested editable state, polling, run/deploy/toggle flows, and other system-level actions.
- Recommendation: save this for the final wave with the other complex authoring surfaces.
## Platform Unlocks
- `search` likely unlocks reusable search-controller and download-launch primitives for the global search widget and other search entry points.
- `watchlist` likely unlocks artist-card, per-artist config, and scan-status primitives for `discover` and `wishlist`.
- `wishlist` likely unlocks queue/cycle visualization, live polling, and retry-state handling for `active-downloads` and sync-driven download flows.
- `active-downloads` likely unlocks batch grouping, queue filtering, and cancellation patterns for other download-related surfaces.
- `tools` likely unlocks maintenance-card and operational-action patterns that can be reused from `dashboard`.
- `library` + `artist-detail` still unlock entity-detail patterns, bulk actions, and file-management workflows.
## Why Earlier Waves Are Safer
- Wave 1 routes are either mostly static or bounded data UIs with limited cross-route side effects.
- Wave 2 adds the renamed search surface without dragging in the full queue history.
- Wave 3 introduces the new watchlist/wishlist split, which is important but still narrower than discovery or library management.
- Wave 4 adds the live queue and tools split once the route-local patterns are already in place.
- Wave 5 keeps the dashboard after its maintenance responsibilities have been peeled away.
- Waves 6-10 defer the broadest, most coupled, or most orchestration-heavy surfaces until the team has the most leverage.
## Final Recommendation
- Keep `issues` as the reference implementation and preserve the existing bridge contract.
- Treat `search`, `watchlist`, `wishlist`, `active-downloads`, and `tools` as the current route ids, and keep `downloads` and `artists` only as compatibility history.
- Migrate the safe routes first: `help`, `hydrabase`, `stats`, and `import`.
- Use `search` as the next meaningful proving ground now that the download queue has been split out.
- Avoid pulling `settings`, `sync`, `library`, `artist-detail`, or `automations` forward unless there is a separate product priority strong enough to justify the added regression risk.

View file

@ -1,11 +1,27 @@
"""Gunicorn configuration for local development."""
from pathlib import Path
import os
bind = "127.0.0.1:8008"
worker_class = "gthread"
workers = 1
threads = 4
reload = True
raw_env = ["SOULSYNC_WEB_DEV_NO_CACHE=1"]
_ROOT_DIR = Path(__file__).resolve().parent
_VITE_URL = os.environ.get('SOULSYNC_WEBUI_VITE_URL', 'http://127.0.0.1:5173').rstrip('/')
_VITE_LOG = os.environ.get('SOULSYNC_WEBUI_VITE_LOG', str(_ROOT_DIR / 'logs' / 'webui-vite.log'))
# Dev Gunicorn config and Vite dev server are paired on purpose.
raw_env = [
"SOULSYNC_WEB_DEV_NO_CACHE=1",
"SOULSYNC_WEBUI_VITE_DEV=1",
f"SOULSYNC_CONFIG_PATH={os.environ.get('SOULSYNC_CONFIG_PATH', str(_ROOT_DIR / 'config' / 'config.json'))}",
f"SOULSYNC_LOG_LEVEL={os.environ.get('SOULSYNC_LOG_LEVEL', '')}",
f"SOULSYNC_WEBUI_VITE_URL={_VITE_URL}",
f"SOULSYNC_WEBUI_VITE_LOG={_VITE_LOG}",
]
# Keep requests from hanging forever on slow external services.
timeout = 120

View file

@ -0,0 +1,228 @@
"""Pin the pure helpers in `core/library/artist_image.py`.
These back the new artist-image-to-disk feature added for issue
#572 (Navidrome can't show real artist photos because Navidrome has
no API for setting them only reads `artist.jpg` from the artist
folder on disk).
Tests are intentionally fixture-driven (tmp_path) so they actually
exercise the filesystem code (atomic replace, overwrite guard,
missing folder), not just mock interactions.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# derive_artist_folder
# ---------------------------------------------------------------------------
class TestDeriveArtistFolder:
def test_one_level_up_from_album(self):
from core.library.artist_image import derive_artist_folder
# POSIX path
assert derive_artist_folder("/music/Drake/Views") == "/music/Drake"
def test_handles_trailing_slash(self):
"""Caller might pass an album folder with a trailing slash.
Without trimming, `os.path.dirname` returns the input unchanged
silently breaks the up-one-level contract."""
from core.library.artist_image import derive_artist_folder
assert derive_artist_folder("/music/Drake/Views/") == "/music/Drake"
def test_empty_string_returns_empty(self):
from core.library.artist_image import derive_artist_folder
assert derive_artist_folder("") == ""
def test_none_returns_empty(self):
from core.library.artist_image import derive_artist_folder
assert derive_artist_folder(None) == ""
def test_non_string_returns_empty(self):
"""Defensive — caller might hand us a Path object or similar.
Currently we require str; return empty rather than raise."""
from core.library.artist_image import derive_artist_folder
assert derive_artist_folder(42) == ""
# ---------------------------------------------------------------------------
# pick_artist_image_url
# ---------------------------------------------------------------------------
class TestPickArtistImageUrl:
def test_returns_image_url_when_set(self):
from core.library.artist_image import pick_artist_image_url
artist = SimpleNamespace(image_url="https://example.com/drake.jpg")
assert pick_artist_image_url(artist) == "https://example.com/drake.jpg"
def test_returns_none_when_empty_string(self):
from core.library.artist_image import pick_artist_image_url
assert pick_artist_image_url(SimpleNamespace(image_url="")) is None
def test_returns_none_when_attribute_missing(self):
from core.library.artist_image import pick_artist_image_url
assert pick_artist_image_url(SimpleNamespace()) is None
def test_returns_none_when_artist_is_none(self):
from core.library.artist_image import pick_artist_image_url
assert pick_artist_image_url(None) is None
def test_strips_whitespace(self):
from core.library.artist_image import pick_artist_image_url
artist = SimpleNamespace(image_url=" https://example.com/drake.jpg ")
assert pick_artist_image_url(artist) == "https://example.com/drake.jpg"
def test_returns_none_when_non_string(self):
from core.library.artist_image import pick_artist_image_url
# int / list / dict would all hit the `isinstance(..., str)` guard
assert pick_artist_image_url(SimpleNamespace(image_url=42)) is None
assert pick_artist_image_url(SimpleNamespace(image_url=["url"])) is None
# ---------------------------------------------------------------------------
# download_image_bytes
# ---------------------------------------------------------------------------
def _fake_response(status_code=200, content_type="image/jpeg", body=b"\x89PNG..."):
resp = MagicMock()
resp.status_code = status_code
resp.headers = {"Content-Type": content_type}
resp.content = body
return resp
class TestDownloadImageBytes:
def test_returns_bytes_on_success(self):
from core.library import artist_image as ai
fake = _fake_response(body=b"image-data-here")
with patch.object(ai, "requests") as r:
r.get.return_value = fake
result = ai.download_image_bytes("https://example.com/x.jpg")
assert result == b"image-data-here"
def test_returns_none_on_404(self):
from core.library import artist_image as ai
fake = _fake_response(status_code=404)
with patch.object(ai, "requests") as r:
r.get.return_value = fake
assert ai.download_image_bytes("https://example.com/x.jpg") is None
def test_returns_none_on_non_image_content_type(self):
"""Defensive: if a URL returns HTML or JSON (e.g. an error page),
don't try to write it as artist.jpg."""
from core.library import artist_image as ai
fake = _fake_response(content_type="text/html")
with patch.object(ai, "requests") as r:
r.get.return_value = fake
assert ai.download_image_bytes("https://example.com/x.jpg") is None
def test_returns_none_on_empty_body(self):
from core.library import artist_image as ai
fake = _fake_response(body=b"")
with patch.object(ai, "requests") as r:
r.get.return_value = fake
assert ai.download_image_bytes("https://example.com/x.jpg") is None
def test_returns_none_on_exception(self):
"""Network timeout / DNS failure / etc shouldn't raise to
the caller caller just sees None and surfaces a generic
'image fetch failed' error to the user."""
from core.library import artist_image as ai
with patch.object(ai, "requests") as r:
r.get.side_effect = RuntimeError("network down")
assert ai.download_image_bytes("https://example.com/x.jpg") is None
def test_returns_none_for_empty_url(self):
from core.library.artist_image import download_image_bytes
assert download_image_bytes("") is None
assert download_image_bytes(None) is None
# ---------------------------------------------------------------------------
# write_artist_jpg
# ---------------------------------------------------------------------------
class TestWriteArtistJpg:
def test_writes_file_on_success(self, tmp_path):
from core.library.artist_image import write_artist_jpg
success, path = write_artist_jpg(str(tmp_path), b"image-bytes")
assert success is True
assert os.path.exists(path)
assert open(path, "rb").read() == b"image-bytes"
def test_returns_failure_when_folder_missing(self, tmp_path):
from core.library.artist_image import write_artist_jpg
missing = str(tmp_path / "does-not-exist")
success, reason = write_artist_jpg(missing, b"image-bytes")
assert success is False
assert "does not exist" in reason
def test_returns_failure_for_empty_bytes(self, tmp_path):
from core.library.artist_image import write_artist_jpg
success, reason = write_artist_jpg(str(tmp_path), b"")
assert success is False
assert "image bytes" in reason
def test_returns_failure_for_empty_folder(self):
from core.library.artist_image import write_artist_jpg
success, reason = write_artist_jpg("", b"image-bytes")
assert success is False
assert "folder" in reason
def test_respects_existing_file_without_overwrite(self, tmp_path):
"""Default overwrite=False protects user-supplied artist.jpg
from being clobbered by a programmatic update. User must opt
in to overwrite."""
from core.library.artist_image import write_artist_jpg
target = tmp_path / "artist.jpg"
target.write_bytes(b"user-supplied")
success, reason = write_artist_jpg(str(tmp_path), b"new-bytes")
assert success is False
assert "already exists" in reason
# Existing file must be untouched.
assert target.read_bytes() == b"user-supplied"
def test_overwrites_when_flag_set(self, tmp_path):
from core.library.artist_image import write_artist_jpg
target = tmp_path / "artist.jpg"
target.write_bytes(b"old-bytes")
success, path = write_artist_jpg(str(tmp_path), b"new-bytes", overwrite=True)
assert success is True
assert target.read_bytes() == b"new-bytes"
def test_atomic_write_no_temp_left_on_success(self, tmp_path):
"""`.tmp` artifact must be cleaned up by `os.replace`. Don't
leave litter behind for the next backup / sync run to puzzle
over."""
from core.library.artist_image import write_artist_jpg
success, _ = write_artist_jpg(str(tmp_path), b"image-bytes")
assert success is True
assert not (tmp_path / "artist.jpg.tmp").exists()
def test_atomic_write_cleans_temp_on_failure(self, tmp_path, monkeypatch):
"""If `os.replace` fails (permission, cross-device, etc),
the helper should remove the temp file rather than leaving
a half-written `.tmp` on disk."""
from core.library import artist_image as ai
def _failing_replace(src, dst):
raise OSError("simulated replace failure")
monkeypatch.setattr(os, "replace", _failing_replace)
success, reason = ai.write_artist_jpg(str(tmp_path), b"image-bytes")
assert success is False
assert "write failed" in reason
# Temp must not be left behind
assert not (tmp_path / "artist.jpg.tmp").exists()

View file

@ -0,0 +1,354 @@
"""Pin `read_embedded_tags` — pure mutagen reader backing the audit
trail's "Embedded Tags" section.
Tests use mock mutagen objects to verify the extraction logic
without needing real audio fixtures checked in. The reader handles
three container families:
- ID3 (MP3): text frames keyed by 4-letter codes + TXXX user-defined
frames keyed by `desc`.
- Vorbis-like (FLAC, OGG, OPUS): dict-like tags, lowercase keys,
list-of-strings values.
- MP4: dict-like with weird atom keys including the iTunes
``----:com.apple.iTunes:`` freeform atoms.
Every test pins ONE behavior easier to debug when one regresses.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Boundary cases — bad inputs, missing files, mutagen returns None
# ---------------------------------------------------------------------------
def test_returns_unavailable_for_empty_path():
from core.library.file_tags import read_embedded_tags
result = read_embedded_tags('')
assert result['available'] is False
assert 'No file path' in result['reason']
def test_returns_unavailable_for_none():
from core.library.file_tags import read_embedded_tags
result = read_embedded_tags(None) # type: ignore[arg-type]
assert result['available'] is False
def test_returns_unavailable_when_file_missing(tmp_path):
from core.library.file_tags import read_embedded_tags
fake = tmp_path / 'gone.mp3'
result = read_embedded_tags(str(fake))
assert result['available'] is False
assert 'no longer exists' in result['reason']
def test_returns_unavailable_when_mutagen_returns_none(tmp_path):
"""File exists but mutagen can't recognise the format — should
fall through to a clear `available: false` rather than raising."""
real = tmp_path / 'garbage.txt'
real.write_bytes(b'not audio')
from core.library import file_tags as ft
with patch.object(ft, 'get_mutagen_symbols') as g:
symbols = MagicMock()
symbols.File.return_value = None
g.return_value = symbols
result = ft.read_embedded_tags(str(real))
assert result['available'] is False
assert 'not recognised' in result['reason']
def test_mutagen_open_exception_swallowed(tmp_path):
"""Mutagen raises on a malformed file — caller still gets a
clean error dict, no propagated exception."""
real = tmp_path / 'malformed.mp3'
real.write_bytes(b'not really an mp3')
from core.library import file_tags as ft
with patch.object(ft, 'get_mutagen_symbols') as g:
symbols = MagicMock()
symbols.File.side_effect = RuntimeError('mutagen blew up')
g.return_value = symbols
result = ft.read_embedded_tags(str(real))
assert result['available'] is False
assert 'Could not open file' in result['reason']
assert 'mutagen blew up' in result['reason']
# ---------------------------------------------------------------------------
# ID3 path (MP3) — TIT2/TPE1/TALB + TXXX user-defined frames
# ---------------------------------------------------------------------------
def _build_id3_audio(symbols, frames, txxx_frames=None, pictures=False):
"""Helper to build a fake mutagen ID3 audio object.
`frames` is a dict of {code: text}. `txxx_frames` is a list of
(desc, text) tuples for user-defined ID3 frames.
"""
tags = MagicMock()
tags.__class__ = symbols.ID3
frame_map = {}
for code, text in frames.items():
f = SimpleNamespace(text=[text])
frame_map[code] = f
tags.get.side_effect = lambda code: frame_map.get(code)
def _getall(code):
if code == 'TXXX':
return [SimpleNamespace(desc=d, text=[t]) for d, t in (txxx_frames or [])]
if code == 'USLT':
return []
if code == 'APIC':
return [object()] if pictures else []
return []
tags.getall.side_effect = _getall
audio = MagicMock()
audio.tags = tags
audio.info = SimpleNamespace(bitrate=320000, length=204.5)
type(audio).__name__ = 'MP3'
return audio
def test_id3_extracts_core_text_frames(tmp_path):
real = tmp_path / 't.mp3'
real.write_bytes(b'\x00')
from core.library import file_tags as ft
with patch.object(ft, 'get_mutagen_symbols') as g:
symbols = MagicMock()
symbols.ID3 = MagicMock # isinstance check uses this
audio = _build_id3_audio(symbols, frames={
'TIT2': 'Without Me',
'TPE1': 'Eminem',
'TPE2': 'Eminem',
'TALB': 'The Eminem Show',
'TDRC': '2002',
'TCON': 'Hip-Hop',
'TRCK': '10/20',
'TPOS': '1',
})
symbols.MP4 = type('MP4', (), {}) # not an MP4
symbols.File.return_value = audio
g.return_value = symbols
result = ft.read_embedded_tags(str(real))
assert result['available'] is True
assert result['tags']['title'] == 'Without Me'
assert result['tags']['artist'] == 'Eminem'
assert result['tags']['album_artist'] == 'Eminem'
assert result['tags']['album'] == 'The Eminem Show'
assert result['tags']['date'] == '2002'
assert result['tags']['genre'] == 'Hip-Hop'
assert result['tags']['tracknumber'] == '10/20'
assert result['tags']['discnumber'] == '1'
def test_id3_extracts_txxx_known_descriptions(tmp_path):
"""Source IDs land in TXXX frames keyed by description. Reader
maps known descs to friendly snake_case keys."""
real = tmp_path / 't.mp3'
real.write_bytes(b'\x00')
from core.library import file_tags as ft
with patch.object(ft, 'get_mutagen_symbols') as g:
symbols = MagicMock()
symbols.ID3 = MagicMock
symbols.MP4 = type('MP4', (), {})
audio = _build_id3_audio(symbols, frames={'TIT2': 'X'}, txxx_frames=[
('Spotify Track Id', 'sp_abc'),
('MusicBrainz Release Group Id', 'mb_def'),
('replaygain_track_gain', '-9.90 dB'),
('replaygain_track_peak', '1.161449'),
])
symbols.File.return_value = audio
g.return_value = symbols
result = ft.read_embedded_tags(str(real))
assert result['tags']['spotify_track_id'] == 'sp_abc'
assert result['tags']['musicbrainz_releasegroupid'] == 'mb_def'
assert result['tags']['replaygain_track_gain'] == '-9.90 dB'
assert result['tags']['replaygain_track_peak'] == '1.161449'
def test_id3_unknown_txxx_desc_falls_back_to_snake_case(tmp_path):
real = tmp_path / 't.mp3'
real.write_bytes(b'\x00')
from core.library import file_tags as ft
with patch.object(ft, 'get_mutagen_symbols') as g:
symbols = MagicMock()
symbols.ID3 = MagicMock
symbols.MP4 = type('MP4', (), {})
audio = _build_id3_audio(symbols, frames={'TIT2': 'X'}, txxx_frames=[
('Custom Vendor Field', 'foo'),
])
symbols.File.return_value = audio
g.return_value = symbols
result = ft.read_embedded_tags(str(real))
# Unknown desc → lowercased + underscored
assert result['tags']['custom_vendor_field'] == 'foo'
def test_id3_detects_apic_cover_art(tmp_path):
real = tmp_path / 't.mp3'
real.write_bytes(b'\x00')
from core.library import file_tags as ft
with patch.object(ft, 'get_mutagen_symbols') as g:
symbols = MagicMock()
symbols.ID3 = MagicMock
symbols.MP4 = type('MP4', (), {})
audio = _build_id3_audio(symbols, frames={'TIT2': 'X'}, pictures=True)
symbols.File.return_value = audio
g.return_value = symbols
result = ft.read_embedded_tags(str(real))
assert result['has_picture'] is True
# ---------------------------------------------------------------------------
# Vorbis-like (FLAC) — dict-style lowercase keys, list values
# ---------------------------------------------------------------------------
def test_vorbis_passes_through_whitelisted_keys(tmp_path):
real = tmp_path / 't.flac'
real.write_bytes(b'\x00')
from core.library import file_tags as ft
with patch.object(ft, 'get_mutagen_symbols') as g:
symbols = MagicMock()
# Not ID3, not MP4 — falls through to the vorbis branch.
symbols.ID3 = type('ID3', (), {})
symbols.MP4 = type('MP4', (), {})
tags = {
'title': ['Teenage Dream'],
'artist': ['Katy Perry'],
'album': ['Teenage Dream'],
'date': ['2010'],
'isrc': ['USCA21001255'],
'musicbrainz_albumid': ['mb-album-id'],
'tidal_track_id': ['14165831'],
'unrelated_internal_key': ['skip-me'],
}
audio = MagicMock()
audio.tags = tags
audio.info = SimpleNamespace(bitrate=900000, length=180.0)
audio.pictures = []
type(audio).__name__ = 'FLAC'
symbols.File.return_value = audio
g.return_value = symbols
result = ft.read_embedded_tags(str(real))
assert result['tags']['title'] == 'Teenage Dream'
assert result['tags']['artist'] == 'Katy Perry'
assert result['tags']['isrc'] == 'USCA21001255'
assert result['tags']['musicbrainz_albumid'] == 'mb-album-id'
assert result['tags']['tidal_track_id'] == '14165831'
# Non-whitelisted, non-_id/_url keys are dropped.
assert 'unrelated_internal_key' not in result['tags']
def test_vorbis_pass_through_for_unknown_id_url_keys(tmp_path):
"""Vendor-prefixed `*_id` / `*_url` keys should pass through even
if they're not in the explicit whitelist — covers future
enrichment workers we haven't anticipated."""
real = tmp_path / 't.flac'
real.write_bytes(b'\x00')
from core.library import file_tags as ft
with patch.object(ft, 'get_mutagen_symbols') as g:
symbols = MagicMock()
symbols.ID3 = type('ID3', (), {})
symbols.MP4 = type('MP4', (), {})
tags = {
'title': ['X'],
'beatport_track_id': ['bp_xyz'],
'songkick_url': ['https://...'],
}
audio = MagicMock()
audio.tags = tags
audio.info = SimpleNamespace(bitrate=900000, length=1.0)
audio.pictures = []
symbols.File.return_value = audio
g.return_value = symbols
result = ft.read_embedded_tags(str(real))
assert result['tags']['beatport_track_id'] == 'bp_xyz'
assert result['tags']['songkick_url'] == 'https://...'
def test_vorbis_detects_pictures(tmp_path):
real = tmp_path / 't.flac'
real.write_bytes(b'\x00')
from core.library import file_tags as ft
with patch.object(ft, 'get_mutagen_symbols') as g:
symbols = MagicMock()
symbols.ID3 = type('ID3', (), {})
symbols.MP4 = type('MP4', (), {})
audio = MagicMock()
audio.tags = {'title': ['X']}
audio.info = SimpleNamespace(bitrate=900000, length=1.0)
audio.pictures = [object()] # one embedded image
symbols.File.return_value = audio
g.return_value = symbols
result = ft.read_embedded_tags(str(real))
assert result['has_picture'] is True
# ---------------------------------------------------------------------------
# Format + bitrate metadata
# ---------------------------------------------------------------------------
def test_returns_format_and_bitrate(tmp_path):
real = tmp_path / 't.mp3'
real.write_bytes(b'\x00')
from core.library import file_tags as ft
with patch.object(ft, 'get_mutagen_symbols') as g:
symbols = MagicMock()
symbols.ID3 = MagicMock
symbols.MP4 = type('MP4', (), {})
audio = _build_id3_audio(symbols, frames={'TIT2': 'X'})
type(audio).__name__ = 'MP3' # mutagen exposes class name
audio.info = SimpleNamespace(bitrate=320000, length=204.5)
symbols.File.return_value = audio
g.return_value = symbols
result = ft.read_embedded_tags(str(real))
assert result['format'] == 'MP3'
assert result['bitrate'] == 320000
assert result['duration'] == pytest.approx(204.5)
# ---------------------------------------------------------------------------
# Stringify defensive cases
# ---------------------------------------------------------------------------
class TestStringify:
def test_list_of_strings_joined(self):
from core.library.file_tags import _stringify
assert _stringify(['a', 'b', 'c']) == 'a, b, c'
def test_tuple_pair_joined_with_slash(self):
"""MP4 trkn / disk values come as (current, total) tuples."""
from core.library.file_tags import _stringify
assert _stringify([(10, 20)]) == '10/20'
def test_int_coerced_to_string(self):
from core.library.file_tags import _stringify
assert _stringify(42) == '42'
def test_none_returns_empty(self):
from core.library.file_tags import _stringify
assert _stringify(None) == ''
def test_frame_with_text_attribute_unwrapped(self):
"""mutagen frames expose `.text` as a list of strings."""
from core.library.file_tags import _stringify
frame = SimpleNamespace(text=['Title Here'])
assert _stringify(frame) == 'Title Here'
def test_whitespace_stripped(self):
from core.library.file_tags import _stringify
assert _stringify(' spaced ') == 'spaced'

View file

@ -0,0 +1,148 @@
"""Pin `_normalize_album_type` + the legacy fallback's multi-key
lookup for `album_type`.
Discord report (CAL, 2026-05-12): downloading an artist's discography
with `$albumtype` in the path template put every release under
`Album/` regardless of actual type EPs, singles, all dumped into
`Album/`. Trace: `_build_album_info_legacy` only checked the
`album_type` key. Different sources expose the type under different
names (Deezer `record_type`, Tidal/MB `type` / `primary-type`, often
uppercase). Spotify-shaped lowercase `album_type` was the only path
that worked; everything else defaulted to `album`.
Fix widens the legacy lookup to check `album_type`, `record_type`,
`type`, `primary-type` and routes the value through
`_normalize_album_type` which lowercases, validates against the
canonical token set, and falls back to `album` for unknowns.
These tests pin both the normalizer (pure helper) and the wired
behavior in `_build_album_info_legacy` (smoke).
"""
from __future__ import annotations
import pytest
from core.metadata.album_tracks import (
_build_album_info_legacy,
_normalize_album_type,
)
# ---------------------------------------------------------------------------
# Pure helper
# ---------------------------------------------------------------------------
class TestNormalizeAlbumType:
@pytest.mark.parametrize('raw', ['album', 'ALBUM', 'Album', ' Album '])
def test_album_variants_normalize_to_album(self, raw):
assert _normalize_album_type(raw) == 'album'
@pytest.mark.parametrize('raw', ['single', 'SINGLE', 'Single'])
def test_single_variants_normalize_to_single(self, raw):
assert _normalize_album_type(raw) == 'single'
@pytest.mark.parametrize('raw', ['ep', 'EP', 'Ep'])
def test_ep_variants_normalize_to_ep(self, raw):
assert _normalize_album_type(raw) == 'ep'
def test_compilation_preserved(self):
"""Spotify exposes 'compilation' as a distinct type. Preserve
it so users with a `$albumtype` template get a separate folder
instead of compilations getting demoted into `album/`."""
assert _normalize_album_type('compilation') == 'compilation'
@pytest.mark.parametrize('raw', [None, '', ' '])
def test_empty_inputs_return_default(self, raw):
assert _normalize_album_type(raw) == 'album'
def test_unknown_value_returns_default(self):
"""Stray strings (e.g. 'mixtape', 'box-set') don't pass through
they'd produce nonsense folder names. Default to album."""
assert _normalize_album_type('mixtape') == 'album'
assert _normalize_album_type('box-set') == 'album'
def test_custom_default_honored(self):
assert _normalize_album_type('weird', default='single') == 'single'
assert _normalize_album_type(None, default='ep') == 'ep'
def test_non_string_value_handled(self):
"""Defensive: source might hand us an int / bool / dict.
Should not crash."""
assert _normalize_album_type(0) == 'album'
assert _normalize_album_type({'name': 'foo'}) == 'album'
# ---------------------------------------------------------------------------
# Legacy builder — alt-key support
# ---------------------------------------------------------------------------
class TestLegacyBuilderAlbumTypeAltKeys:
"""The bug was sources whose `album_type` lives under an alt key.
Pin each known shape produces the correct canonical token."""
def _build(self, album_data):
return _build_album_info_legacy(
album_data, album_id='id1', album_name='Test', artist_name='Artist',
)
def test_spotify_shape_album_type_key(self):
info = self._build({'album_type': 'single'})
assert info['album_type'] == 'single'
def test_deezer_shape_record_type_key(self):
"""Deezer's API returns `record_type` not `album_type`. CAL's
EPs returned `record_type='ep'` but the legacy reader missed it
and defaulted to album."""
info = self._build({'record_type': 'ep'})
assert info['album_type'] == 'ep'
def test_tidal_shape_type_key_uppercase(self):
"""Tidal returns `type='ALBUM'/'EP'/'SINGLE'`. Uppercase + alt
key = double-miss before the fix."""
info = self._build({'type': 'EP'})
assert info['album_type'] == 'ep'
def test_musicbrainz_shape_primary_type_key(self):
"""Some flattened MB shapes carry `primary-type` at the top
level (typed path handles release-group nesting; legacy hits
the flattened cases)."""
info = self._build({'primary-type': 'Single'})
assert info['album_type'] == 'single'
def test_album_type_wins_when_multiple_keys_present(self):
"""When both `album_type` AND `record_type` exist, prefer the
Spotify-canonical key. `_extract_lookup_value` checks left to
right pin that ordering."""
info = self._build({'album_type': 'album', 'record_type': 'ep'})
assert info['album_type'] == 'album'
def test_no_type_key_defaults_to_album(self):
"""Source response with no type field at all → defaults to
`album` (legacy behavior preserved for genuinely-missing data)."""
info = self._build({'name': 'Some Album'})
assert info['album_type'] == 'album'
def test_unknown_type_value_defaults_to_album(self):
"""`type='Mixtape'` → not in canonical set → default. Prevents
a stray value from poisoning the path template."""
info = self._build({'type': 'Mixtape'})
assert info['album_type'] == 'album'
def test_type_track_collision_defaults_to_album(self):
"""`type` is a generic key name — many dict shapes use it
for entity discrimination (Deezer track responses carry
`type='track'`, Spotify search results use it for
`artist`/`track`/`album`/etc). If a caller hands us a dict
that has `type='track'` (track data passed by mistake, or a
merged shape where `type` discriminates entity rather than
album category), the normalizer must treat it as unknown and
default to `album` not silently classify the result as some
bogus 'track' folder."""
info = self._build({'type': 'track'})
assert info['album_type'] == 'album'
info = self._build({'type': 'artist'})
assert info['album_type'] == 'album'

View file

@ -0,0 +1,96 @@
"""Tests for the WebUI asset helpers."""
from __future__ import annotations
import json
import os
import time
import pytest
from core.webui import (
build_webui_vite_assets,
clear_webui_vite_manifest_cache,
load_webui_vite_manifest,
should_serve_webui_spa,
)
def test_build_webui_vite_assets_renders_dev_scripts():
html = build_webui_vite_assets("body", dev=True, dev_url="http://127.0.0.1:5173")
assert html == (
'<script type="module" src="http://127.0.0.1:5173/static/dist/@vite/client"></script>\n'
'<script type="module" src="http://127.0.0.1:5173/static/dist/src/app/main.tsx"></script>'
)
def test_build_webui_vite_assets_renders_manifest_assets():
manifest = {
"src/app/main.tsx": {
"css": ["assets/main.css"],
"file": "assets/main.js",
}
}
html_head = build_webui_vite_assets(
"head",
manifest_loader=lambda: manifest,
static_url_builder=lambda filename: f"/assets/{filename}",
)
html_body = build_webui_vite_assets(
"body",
manifest_loader=lambda: manifest,
static_url_builder=lambda filename: f"/assets/{filename}",
)
assert html_head == '<link rel="stylesheet" href="/assets/dist/assets/main.css">'
assert html_body == '<script type="module" src="/assets/dist/assets/main.js"></script>'
@pytest.mark.parametrize(
"pathname",
[
"/api/issues",
"/auth/spotify",
"/callback",
"/callback/extra",
"/deezer/callback",
"/socket.io",
"/static/app.js",
"/stream/file",
"/tidal/callback",
"/status",
],
)
def test_should_serve_webui_spa_blocks_reserved_paths(pathname):
assert should_serve_webui_spa(pathname) is False
@pytest.mark.parametrize(
"pathname",
[
"/",
"/issues",
"/issues?issueId=7",
"/artists/Opeth",
"/discover",
],
)
def test_should_serve_webui_spa_allows_client_routes(pathname):
assert should_serve_webui_spa(pathname) is True
def test_load_webui_vite_manifest_reloads_when_file_changes(tmp_path):
clear_webui_vite_manifest_cache()
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text(json.dumps({"src/app/main.tsx": {"file": "assets/one.js"}}))
first = load_webui_vite_manifest(manifest_path)
assert first["src/app/main.tsx"]["file"] == "assets/one.js"
manifest_path.write_text(json.dumps({"src/app/main.tsx": {"file": "assets/two.js"}}))
future = time.time() + 10
os.utime(manifest_path, (future, future))
second = load_webui_vite_manifest(manifest_path)
assert second["src/app/main.tsx"]["file"] == "assets/two.js"

View file

@ -20,7 +20,7 @@ from pathlib import Path
from urllib.parse import urljoin, urlparse
from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Flask, render_template, request, jsonify, redirect, send_file, send_from_directory, Response, session, g, abort
from flask import Flask, abort, render_template, request, jsonify, redirect, send_file, send_from_directory, Response, session, g
from flask_socketio import SocketIO, emit, join_room, leave_room
from utils.logging_config import get_logger, setup_logging
from utils.async_helpers import run_async
@ -40,7 +40,7 @@ logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, system-info, update check, etc.
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
_SOULSYNC_BASE_VERSION = "2.5.1"
_SOULSYNC_BASE_VERSION = "2.5.2"
def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -97,6 +97,7 @@ from core.metadata.cache import get_metadata_cache
from core.metadata import registry as metadata_registry
from core.metadata import is_internal_image_host
from core.metadata import normalize_image_url as fix_artist_image_url
from core.webui import build_webui_vite_assets, should_serve_webui_spa
from core.metadata.registry import (
clear_cached_metadata_client,
get_metadata_source_label,
@ -292,13 +293,12 @@ app.jinja_env.auto_reload = DEV_STATIC_NO_CACHE
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 if DEV_STATIC_NO_CACHE else 31536000
# Cache-bust query string for static assets — appended to every
# url_for('static', ...) URL via the context processor below. Computed
# once per process start so each server restart invalidates the
# browser's cached copy of every JS/CSS file. This is the surefire
# fix for "user has stale JS even after Ctrl+Shift+R" — the URL
# itself changes, so the browser cannot reuse a previously-cached
# response no matter what its Cache-Control header said.
# Cache-bust query string for static assets. Computed once per process
# start so each server restart invalidates the browser's cached copy of
# every JS/CSS file. This is the surefire fix for "user has stale JS
# even after Ctrl+Shift+R" — the URL itself changes, so the browser
# cannot reuse a previously-cached response no matter what its
# Cache-Control header said.
import time as _cache_bust_time
_STATIC_CACHE_BUST = str(int(_cache_bust_time.time()))
@ -371,6 +371,11 @@ def _log_rejected_socketio_origin():
request.headers.get('X-Forwarded-Proto', ''),
)
@app.context_processor
def inject_webui_assets():
return {
'vite_assets': build_webui_vite_assets,
}
# --- Profile Context (before_request hook) ---
@app.before_request
@ -514,7 +519,26 @@ def get_spotify_client_for_profile(profile_id=None):
return metadata_registry.get_spotify_client_for_profile(profile_id)
# Valid page IDs for profile permission validation
VALID_PAGE_IDS = {'dashboard', 'sync', 'search', 'downloads', 'discover', 'artists', 'automations', 'library', 'import', 'settings', 'help'}
VALID_PAGE_IDS = {
'dashboard',
'sync',
'search',
'discover',
'playlist-explorer',
'watchlist',
'wishlist',
'automations',
'active-downloads',
'library',
'tools',
'artist-detail',
'stats',
'import',
'settings',
'help',
'hydrabase',
'issues',
}
def check_download_permission():
"""Check if current profile has download permission. Returns error response or None if allowed."""
@ -3325,9 +3349,9 @@ def service_worker():
@app.route('/<path:page>')
def spa_catch_all(page):
# Serve index.html for client-side routes; let Flask handle real routes first.
if page.startswith(('api/', 'static/', 'auth/', 'callback', 'deezer/', 'tidal/', 'status')):
if not should_serve_webui_spa(f'/{page}'):
abort(404)
return render_template('index.html')
return index()
# --- API Endpoints ---
@ -8416,6 +8440,41 @@ def get_library_history():
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/library/history/<int:history_id>/file-tags')
def get_library_history_file_tags(history_id: int):
"""Read embedded tags from the actual audio file for one library
history row. Backs the Audit Trail modal's "Embedded Tags" section.
The file is the single source of truth persisted snapshot
columns drift the moment a background worker writes more tags.
`read_embedded_tags` returns a uniform dict; we pass through.
"""
try:
db = get_database()
entries, _total = db.get_library_history(event_type=None, page=1, limit=200)
entry = next((e for e in entries if e.get('id') == history_id), None)
if entry is None:
# Wider lookup — pagination above may not have caught older rows.
conn = db._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM library_history WHERE id = ?", (history_id,))
row = cursor.fetchone()
entry = dict(row) if row else None
if entry is None:
return jsonify({'success': False, 'error': 'history row not found'}), 404
raw_path = entry.get('file_path') or ''
resolved = _resolve_library_file_path(raw_path) if raw_path else None
target_path = resolved or raw_path
from core.library.file_tags import read_embedded_tags
result = read_embedded_tags(target_path)
return jsonify({'success': True, **result})
except Exception as e:
logger.error(f"Error reading file tags for history {history_id}: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/library/artists')
def get_library_artists():
"""Get artists for the library page with search, filtering, and pagination"""
@ -8662,7 +8721,13 @@ def get_artist_detail(artist_id):
allow_fallback=True,
skip_cache=False,
max_pages=0,
limit=50,
# Match the Download Discography endpoint cap (200)
# so the artist detail view sees the same release
# set the modal lists. Spotify already paginates
# all; Deezer/iTunes/Discogs/Hydrabase respect the
# outer limit. 200 matches iTunes/Discogs internal
# caps and covers prolific catalogues.
limit=200,
artist_source_ids=artist_source_ids,
),
)
@ -8835,6 +8900,143 @@ def get_similar_artists(artist_name):
"error": str(e)
}), 500
@app.route('/api/artist/<artist_id>/write-image-to-disk', methods=['POST'])
def write_artist_image_to_disk(artist_id):
"""Write `artist.jpg` to the artist's folder on disk.
Issue #572 (rhwc): Navidrome has no API for setting an artist
image it reads `artist.jpg` from the artist's folder during
library scans. SoulSync's `update_artist_poster` for Navidrome
is a NO-OP today. This endpoint closes the gap by:
1. Resolving the artist's folder on disk via any of their albums'
tracks (`_resolve_library_file_path` handles Docker mount
translation + the same library-path probes #558 settled on)
2. Fetching an artist photo URL from the configured metadata source
priority chain (Spotify Deezer ... already wired through
`core.metadata_service.get_artist_image_url`)
3. Downloading the image bytes and writing `<artist>/artist.jpg`
atomically via the pure helpers in `core/library/artist_image.py`
4. Triggering a Navidrome library scan so the file gets picked
up immediately
Request body (JSON, all optional):
- ``image_url`` explicit URL to use, bypassing metadata
source resolution (useful for "use this exact photo" UX)
- ``overwrite`` when True, replace existing `artist.jpg`
(default False respects user-supplied files)
- ``source_override`` pin the metadata source for URL
resolution (e.g. ``"deezer"``)
"""
try:
from core.library.artist_image import (
derive_artist_folder,
download_image_bytes,
write_artist_jpg,
)
from core.metadata_service import get_artist_image_url as _get_artist_image_url
data = request.get_json(silent=True) or {}
explicit_url = (data.get('image_url') or '').strip() or None
overwrite = bool(data.get('overwrite', False))
source_override = (data.get('source_override') or '').strip().lower() or None
db = get_database()
try:
artist_id_int = int(artist_id)
except (TypeError, ValueError):
return jsonify({"success": False, "error": "Invalid artist id"}), 400
artist_row = db.get_artist(artist_id_int)
if artist_row is None:
return jsonify({"success": False, "error": "Artist not found"}), 404
# Find a track file on disk so we can derive the artist folder.
# Walk albums in DB order; first one with a resolvable track wins.
albums = db.get_albums_by_artist(artist_id_int)
if not albums:
return jsonify({"success": False,
"error": "No albums for this artist; cannot derive folder."}), 400
resolved_track_path = None
for album in albums:
tracks = db.get_tracks_by_album(album.id)
for tr in tracks:
if not getattr(tr, 'file_path', None):
continue
candidate = _resolve_library_file_path(tr.file_path) or tr.file_path
if candidate and os.path.exists(candidate):
resolved_track_path = candidate
break
if resolved_track_path:
break
if not resolved_track_path:
return jsonify({"success": False,
"error": "Could not locate any track file on disk to derive the artist folder. "
"Configure Settings → Library → Music Paths to point at the library mount."}), 400
album_folder = os.path.dirname(resolved_track_path)
artist_folder = derive_artist_folder(album_folder)
if not artist_folder or not os.path.isdir(artist_folder):
return jsonify({"success": False,
"error": f"Resolved artist folder is invalid: {artist_folder!r}"}), 400
# Pick the image URL. Explicit override (from request body)
# wins so users can paste a specific photo URL. Otherwise
# resolve from the active metadata source.
if explicit_url:
image_url = explicit_url
else:
try:
image_url = _get_artist_image_url(
artist_id_int,
source_override=source_override,
artist_name=getattr(artist_row, 'name', None),
)
except Exception as exc:
logger.error(f"artist image lookup failed: {exc}")
image_url = None
if not image_url:
return jsonify({"success": False,
"error": "No artist image URL found from metadata sources."}), 404
image_bytes = download_image_bytes(image_url)
if not image_bytes:
return jsonify({"success": False,
"error": f"Failed to download image from {image_url}"}), 502
success, detail = write_artist_jpg(artist_folder, image_bytes, overwrite=overwrite)
if not success:
return jsonify({"success": False, "error": detail}), 400
# If the active media server is Navidrome, trigger a scan so
# the new file gets indexed without waiting for the next
# automatic scan cycle.
scan_triggered = False
try:
active_server = config_manager.get_active_media_server()
if active_server == 'navidrome':
nav = media_server_engine.client('navidrome')
if nav is not None:
nav.trigger_library_scan()
scan_triggered = True
except Exception as exc:
logger.debug(f"Navidrome scan trigger after artist image write failed: {exc}")
return jsonify({
"success": True,
"written_to": detail,
"image_url": image_url,
"scan_triggered": scan_triggered,
})
except Exception as e:
logger.error(f"Error writing artist image to disk: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/artist/<artist_id>/image', methods=['GET'])
def get_artist_image(artist_id):
"""Get an artist image URL using source-aware metadata resolution."""
@ -9046,7 +9248,17 @@ def get_artist_discography(artist_id):
allow_fallback=True,
skip_cache=False,
max_pages=0,
limit=50,
# Discord report: prolific artists (Bach, Beatles
# complete box, deep dance/electronic catalogues)
# showed only ~50 entries in the Download Discography
# modal. Spotify's `max_pages=0` already paginates
# through everything (per-page is clamped to 10
# internally), but Deezer / iTunes / Discogs /
# Hydrabase all honor the outer `limit` as a hard
# cap. 200 lines up with iTunes's and Discogs's own
# internal caps and covers near-everyone's full
# catalogue.
limit=200,
artist_source_ids=artist_source_ids or None,
),
)
@ -35610,8 +35822,11 @@ def start_runtime_services():
# Direct execution: python web_server.py (dev/Windows fallback)
# Production should use: gunicorn -c gunicorn.conf.py wsgi:application
if _DIRECT_RUN:
web_run_host = os.environ.get('SOULSYNC_WEB_BIND_HOST', '0.0.0.0')
web_run_port = int(os.environ.get('SOULSYNC_WEB_BIND_PORT', '8008'))
display_host = '127.0.0.1' if web_run_host in {'0.0.0.0', '::'} else web_run_host
logger.info("Starting SoulSync Web UI Server...")
logger.info("Open your browser and navigate to http://127.0.0.1:8008")
logger.info(f"Open your browser and navigate to http://{display_host}:{web_run_port}")
logger.info("Tip: For production, use gunicorn -c gunicorn.conf.py wsgi:application")
start_runtime_services()
socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True)
socketio.run(app, host=web_run_host, port=web_run_port, debug=False, allow_unsafe_werkzeug=True)

4
webui/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.vite
node_modules
static/dist
test-results

18
webui/.oxfmtrc.json Normal file
View file

@ -0,0 +1,18 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": ["src/routeTree.gen.ts"],
"singleQuote": true,
"sortImports": {
"groups": [
"type-import",
["value-builtin", "value-external"],
"type-internal",
"value-internal",
["type-parent", "type-sibling", "type-index"],
["value-parent", "value-sibling", "value-index"],
"unknown"
]
},
"sortPackageJson": true,
"trailingComma": "all"
}

8
webui/.oxlintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": ["src/routeTree.gen.ts"],
"options": {
"typeAware": true,
"typeCheck": true
}
}

138
webui/README.md Normal file
View file

@ -0,0 +1,138 @@
# WebUI Hybrid Rendering
SoulSync's web UI is in a transition phase:
- most pages still render through the legacy vanilla JS shell
- `/issues` is rendered by the new React app
- a small shell bridge keeps both runtimes aware of the active page, profile context, and navigation state
## How It Fits Together
```mermaid
flowchart LR
Browser["Browser parses /webui/index.html"]
Legacy["Legacy shell scripts\n(core.js -> ... -> init.js)"]
Bridge["shell-bridge.js\nwindow.SoulSyncWebShellBridge"]
React["Vite React app\nsrc/app/main.tsx"]
Router["TanStack Router\nwindow.SoulSyncWebRouter"]
Browser --> Legacy
Browser --> React
Legacy --> Bridge
React --> Router
Router --> Bridge
Bridge --> Legacy
```
## Runtime Roles
- `webui/static/init.js`
- boots the legacy shell
- selects the active profile
- handles the legacy page loading flow
- `webui/static/shell-bridge.js`
- owns the browser-side bridge object
- exposes `window.SoulSyncWebShellBridge`
- owns the shared page chrome and route handoff helpers
- `webui/src/app/main.tsx`
- mounts the React app
- binds `window.SoulSyncWebRouter`
- `webui/src/platform/shell/route-controllers.tsx`
- listens for bridge readiness
- keeps React pages aligned with the shell
## Load Order
The current order in `index.html` matters:
1. legacy shell scripts load first
2. `init.js` sets up the shell runtime
3. `shell-bridge.js` publishes the bridge and shared chrome helpers after the shell state exists
4. the Vite React app is injected through `{{ vite_assets('body') }}` and boots as a module after parsing
That order avoids load-time references to missing globals and keeps the React side able to react to bridge readiness events. The React entry can start fetching early, but the shell bridge and legacy globals are already available by the time the React runtime starts acting on them.
## Notes
- The bridge is intentionally small and browser-only.
- This is the start of the migration, not a full replacement of the legacy shell.
- When adding another React page, check whether it needs:
- a route entry in `webui/src/platform/shell/route-manifest.ts`
- bridge typings in `webui/src/platform/shell/globals.d.ts`
- a legacy fallback path in `webui/static/init.js`
- bridge glue or handoff logic in `webui/static/shell-bridge.js`
## Folder Layout
The React webui uses a small set of predictable folders so route slices stay easy to extend,
test, and understand.
```text
webui/src/
app/ React bootstrap, router, query client, shared API client
components/ Shared UI primitives
platform/ Shell bridge and browser/platform integration
routes/ Route-local code and TanStack Router pages
test/ Shared test utilities and setup helpers
```
### Route Slices
- Keep route-specific code inside `webui/src/routes/<route>/`.
- Put the routing entry in `route.tsx`.
- Put route-local UI in a `-ui/` folder.
- Prefix non-routing files with `-` so TanStack Router ignores them.
- Keep the route slice small and cohesive.
- Prefer a few files with clear responsibilities over many tiny files with overlapping names.
Example:
```text
webui/src/routes/issues/
route.tsx
-issues.types.ts
-issues.api.ts
-issues.helpers.ts
-issues.api.test.ts
-issues.helpers.test.ts
-ui/
issues-page.tsx
issue-detail-modal.tsx
issue-domain-host.tsx
```
The initial `issues` slice is the model to follow:
- `-issues.api.ts` holds request code and query options
- `-issues.helpers.ts` holds pure normalization and formatting
- `-issues.types.ts` holds shared types
- `-ui/` holds the page, modal, and legacy handoff UI
### Shared Code
- Put reusable UI in `webui/src/components/`.
- Put shell integration in `webui/src/platform/`.
- Put bootstrap and app-wide wiring in `webui/src/app/`.
- Move code up a level only when it is genuinely shared.
- Avoid creating new conventions that overlap with existing ones.
### Testing Choices
We have a lot of testing tools available, but we do not need all of them for every feature.
- Use plain unit tests for pure functions and small transforms.
- Use React component or route tests when the behavior lives in the UI or router.
- Use MSW-backed tests when request shape, response handling, or error handling matters.
- Use Playwright when the behavior is best proven end-to-end with the server and browser together.
- Prefer the smallest test setup that still proves the thing that can regress.
## Development
The repo root now owns the full local-dev instructions. Start there for the
portable launcher and backend/frontend setup:
1. [README.md](../README.md) for the end-to-end dev flow
2. `npm run check` and `npm run fix` for React-side linting and formatting

View file

@ -12,6 +12,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css', v=static_v) }}">
{{ vite_assets('head')|safe }}
</head>
<body>
@ -103,6 +104,8 @@
<option value="sync">Sync</option>
<option value="search">Search</option>
<option value="discover">Discover</option>
<option value="watchlist">Watchlist</option>
<option value="wishlist">Wishlist</option>
<option value="automations">Automations</option>
<option value="active-downloads">Downloads</option>
<option value="library">Library</option>
@ -117,6 +120,8 @@
<label><input type="checkbox" value="sync" checked> Sync</label>
<label><input type="checkbox" value="search" checked> Search</label>
<label><input type="checkbox" value="discover" checked> Discover</label>
<label><input type="checkbox" value="watchlist" checked> Watchlist</label>
<label><input type="checkbox" value="wishlist" checked> Wishlist</label>
<label><input type="checkbox" value="automations" checked> Automations</label>
<label><input type="checkbox" value="active-downloads" checked> Downloads</label>
<label><input type="checkbox" value="library" checked> Library</label>
@ -189,7 +194,7 @@
<!-- Navigation Section -->
<nav class="sidebar-nav">
<button class="nav-button active" data-page="dashboard">
<button class="nav-button" data-page="dashboard">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="4" rx="1"/><rect x="3" y="14" width="7" height="4" rx="1"/><rect x="14" y="11" width="7" height="7" rx="1"/><line x1="5" y1="20" x2="5" y2="22"/><line x1="8" y1="19" x2="8" y2="22"/></svg></span>
<span class="nav-text">Dashboard</span>
</button>
@ -301,9 +306,10 @@
<div class="main-content">
<!-- Global particle canvas for page background animations -->
<canvas id="page-particles-canvas"></canvas>
<div id="webui-react-root" class="page" data-react-app="router"></div>
<!-- Dashboard Page -->
<div class="page active" id="dashboard-page">
<div class="page" id="dashboard-page">
<div class="dashboard-container">
<div class="dashboard-header">
<div class="header-text">
@ -1844,7 +1850,7 @@
</div>
</div>
<!-- Downloads Page -->
<!-- Search Page -->
<div class="page" id="search-page">
<!--
This top-level container replicates the QSplitter from downloads.py,
@ -2091,8 +2097,6 @@
</div>
</div>
<!-- Automations Page -->
<div class="page" id="automations-page">
<!-- List View -->
@ -2168,7 +2172,7 @@
</div>
</div>
<div class="adl-list" id="adl-list">
<div class="adl-empty" id="adl-empty">No downloads yet. Start one from Search, Sync, Discover, or Artists.</div>
<div class="adl-empty" id="adl-empty">No downloads yet. Start one from Search, Sync, Discover, or Library.</div>
</div>
</div>
</div>
@ -2186,7 +2190,10 @@
<div class="adl-batch-history-section" id="adl-batch-history-section" style="display:none">
<div class="adl-batch-history-header" onclick="adlToggleBatchHistory()">
<span>Recent History</span>
<svg class="adl-batch-history-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
<div class="adl-batch-history-header-actions">
<button class="library-history-btn" onclick="event.stopPropagation();openLibraryHistoryModal()" title="View full download + import history">Download History</button>
<svg class="adl-batch-history-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
</div>
<div class="adl-batch-history-list" id="adl-batch-history-list">
<!-- Completed batch history rendered by JS -->
@ -2385,6 +2392,12 @@
<span class="enhance-icon"></span>
<span class="enhance-text">Enhance Quality</span>
</button>
<button class="library-artist-write-image-btn" id="library-artist-write-image-btn"
onclick="writeArtistImageToDisk()"
title="Write artist.jpg to the artist folder on disk (Navidrome reads this)">
<span class="write-image-icon">🖼️</span>
<span class="write-image-text">Write Artist Image</span>
</button>
</div>
<div class="artist-genres-container" id="artist-genres"></div>
<div class="artist-hero-bio" id="artist-hero-bio" style="display:none;"></div>
@ -2533,7 +2546,7 @@
</div>
<!-- Similar Artists Section (works for both library and source artists).
Uses its own scoped IDs because the inline Artists page has a section
Uses its own scoped IDs because the artist detail view has a section
with the same base IDs and both elements live in the DOM at once. -->
<div class="similar-artists-section" id="ad-similar-artists-section">
<div class="similar-artists-header">
@ -6133,85 +6146,6 @@
</div>
</div>
</div>
<!-- Issues Page -->
<div class="page" id="issues-page">
<div class="issues-container">
<div class="issues-header">
<div class="issues-header-left">
<h2 class="issues-title">Issues</h2>
<p class="issues-subtitle" id="issues-subtitle">Track and resolve library problems</p>
</div>
<div class="issues-header-right">
<div class="issues-filters" id="issues-filters">
<select id="issues-filter-status" class="issues-filter-select" onchange="loadIssuesPage()">
<option value="">All Status</option>
<option value="open" selected>Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="dismissed">Dismissed</option>
</select>
<select id="issues-filter-category" class="issues-filter-select" onchange="loadIssuesPage()">
<option value="">All Categories</option>
<optgroup label="Track Issues">
<option value="wrong_track">Wrong Track</option>
<option value="wrong_artist">Wrong Artist</option>
<option value="wrong_album">Wrong Album</option>
<option value="audio_quality">Audio Quality</option>
</optgroup>
<optgroup label="Album Issues">
<option value="wrong_cover">Wrong Cover Art</option>
<option value="duplicate_tracks">Duplicate Tracks</option>
<option value="missing_tracks">Missing Tracks</option>
<option value="incomplete_album">Incomplete Album</option>
</optgroup>
<optgroup label="Both">
<option value="wrong_metadata">Wrong Metadata</option>
<option value="other">Other</option>
</optgroup>
</select>
</div>
</div>
</div>
<div class="issues-stats" id="issues-stats"></div>
<div class="issues-list" id="issues-list">
<div class="issues-empty">Loading issues...</div>
</div>
</div>
</div>
<!-- Report Issue Modal -->
<div class="modal-overlay hidden" id="report-issue-overlay">
<div class="enhanced-bulk-modal report-issue-modal">
<div class="enhanced-bulk-modal-header">
<h3 id="report-issue-title">Report an Issue</h3>
<button class="enhanced-bulk-modal-close" onclick="closeReportIssueModal()">&times;</button>
</div>
<div class="enhanced-bulk-modal-body" id="report-issue-body">
<!-- Populated dynamically -->
</div>
<div class="enhanced-bulk-modal-footer">
<button class="enhanced-bulk-btn secondary" onclick="closeReportIssueModal()">Cancel</button>
<button class="enhanced-bulk-btn primary" id="report-issue-submit-btn" onclick="submitIssue()">Submit Issue</button>
</div>
</div>
</div>
<!-- Issue Detail Modal (Admin) -->
<div class="modal-overlay hidden" id="issue-detail-overlay">
<div class="enhanced-bulk-modal issue-detail-modal">
<div class="enhanced-bulk-modal-header">
<h3 id="issue-detail-title">Issue Details</h3>
<button class="enhanced-bulk-modal-close" onclick="closeIssueDetailModal()">&times;</button>
</div>
<div class="enhanced-bulk-modal-body" id="issue-detail-body">
<!-- Populated dynamically -->
</div>
<div class="enhanced-bulk-modal-footer" id="issue-detail-footer">
<button class="enhanced-bulk-btn secondary" onclick="closeIssueDetailModal()">Close</button>
</div>
</div>
</div>
<!-- Help & Docs Page -->
@ -7639,6 +7573,16 @@
</div>
</div>
<!-- Download Audit Trail Modal -->
<div class="modal-overlay hidden" id="download-audit-overlay" onclick="if(event.target===this)closeDownloadAuditModal()">
<div class="download-audit-modal">
<button class="download-audit-close" onclick="closeDownloadAuditModal()" title="Close">&times;</button>
<div class="download-audit-hero" id="download-audit-hero"></div>
<div class="download-audit-tabs" id="download-audit-tabs"></div>
<div class="download-audit-body" id="download-audit-body"></div>
</div>
</div>
<!-- Sync History Modal -->
<div class="modal-overlay hidden" id="sync-history-overlay" onclick="if(event.target===this)closeSyncHistoryModal()">
<div class="sync-history-modal">
@ -7829,8 +7773,9 @@
<script src="{{ url_for('static', filename='vendor/socket.io.min.js', v=static_v) }}"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
{{ vite_assets('body')|safe }}
<script src="{{ url_for('static', filename='setup-wizard.js', v=static_v) }}"></script>
<!-- Split modules (was: script.js) — core.js must load first, init.js last -->
<!-- Split modules (was: script.js) — core.js must load first, init.js before shell-bridge.js -->
<script src="{{ url_for('static', filename='core.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='shared-helpers.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='media-player.js', v=static_v) }}"></script>
@ -7849,6 +7794,7 @@
<script src="{{ url_for('static', filename='stats-automations.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='pages-extra.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='init.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='shell-bridge.js', v=static_v) }}"></script>
<!-- Notification bell + floating helper toggle — always accessible above modals -->
<!-- Ambient glow under the global search bar. Radial gradient, brightest
directly under the bar, tapering out toward the window corners.

5028
webui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

44
webui/package.json Normal file
View file

@ -0,0 +1,44 @@
{
"name": "soulsync-webui",
"private": true,
"type": "module",
"scripts": {
"check": "oxfmt --check src && oxlint --type-check src",
"dev": "vite",
"fix": "oxfmt src && oxlint --type-check src --fix",
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"@tanstack/react-form": "^1.29.1",
"@tanstack/react-query": "^5.100.5",
"@tanstack/react-router": "^1.168.24",
"clsx": "^2.1.1",
"ky": "^2.0.2",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"zod": "^4.4.2"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tanstack/router-plugin": "^1.167.27",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^29.0.2",
"msw": "^2.14.2",
"oxfmt": "^0.47.0",
"oxlint": "^1.62.0",
"oxlint-tsgolint": "^0.22.1",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5"
},
"packageManager": "npm@11.13.0"
}

View file

@ -0,0 +1,13 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
use: {
launchOptions: {
executablePath: '/usr/bin/chromium',
},
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8008',
trace: 'on-first-retry',
},
});

View file

@ -0,0 +1,50 @@
import type { ResponsePromise } from 'ky';
import { HTTPError } from 'ky';
import { describe, expect, it, vi } from 'vitest';
import { readJson } from './api-client';
function createHttpError(body: unknown, status = 400) {
const response = new Response(JSON.stringify(body), {
status,
statusText: 'Bad Request',
headers: {
'Content-Type': 'application/json',
},
});
const request = new Request('https://example.com/api/test');
const error = new HTTPError(response, request, {} as any);
error.data = body;
return error;
}
describe('readJson', () => {
it('returns parsed JSON', async () => {
const json = vi.fn().mockResolvedValue({ ok: true });
const promise = { json } as unknown as ResponsePromise<{ ok: boolean }>;
await expect(readJson(promise)).resolves.toEqual({ ok: true });
expect(json).toHaveBeenCalledTimes(1);
});
it('keeps HTTPError instances intact and uses the payload message', async () => {
const error = createHttpError({ error: 'Nope' }, 403);
const json = vi.fn().mockRejectedValue(error);
const promise = { json } as unknown as ResponsePromise<unknown>;
const result = readJson(promise);
await expect(result).rejects.toBe(error);
await expect(result).rejects.toHaveProperty('message', 'Nope');
});
it('falls back to the HTTPError message when the payload is unhelpful', async () => {
const error = createHttpError({ detail: 'missing error field' }, 404);
const json = vi.fn().mockRejectedValue(error);
const promise = { json } as unknown as ResponsePromise<unknown>;
const result = readJson(promise);
await expect(result).rejects.toBe(error);
await expect(result).rejects.toHaveProperty('message', error.message);
});
});

View file

@ -0,0 +1,41 @@
import type { ResponsePromise } from 'ky';
import ky, { HTTPError } from 'ky';
const apiBaseUrl =
typeof globalThis.location === 'object'
? new URL('/api/', globalThis.location.origin).toString()
: 'http://localhost/api/';
export const apiClient = ky.create({
baseUrl: apiBaseUrl,
retry: 0,
});
export async function readJson<T>(promise: ResponsePromise<T>): Promise<T> {
try {
return await promise.json<T>();
} catch (error) {
if (error instanceof HTTPError) {
error.message = getHttpErrorMessage(error.data, error.message);
}
throw error;
}
}
type JsonErrorPayload = {
error?: unknown;
message?: unknown;
};
function getHttpErrorMessage(data: unknown, fallback: string): string {
if (typeof data === 'string' && data.trim()) return data;
if (!data || typeof data !== 'object') return fallback;
const payload = data as JsonErrorPayload;
if (typeof payload.error === 'string' && payload.error.trim()) return payload.error;
if (typeof payload.message === 'string' && payload.message.trim()) return payload.message;
return fallback;
}

23
webui/src/app/main.tsx Normal file
View file

@ -0,0 +1,23 @@
import '@vitejs/plugin-react/preamble';
import { createRoot } from 'react-dom/client';
import { bindWindowWebRouter } from '@/platform/shell/bridge';
import { ROUTER_ROOT_ID } from '@/platform/shell/route-controllers';
import { createAppQueryClient } from './query-client';
import { AppRouterProvider, createAppRouter } from './router';
export async function bootstrapApp() {
const container = document.getElementById(ROUTER_ROOT_ID);
if (!container) return null;
const queryClient = createAppQueryClient();
const router = createAppRouter({ queryClient });
bindWindowWebRouter(router);
createRoot(container).render(<AppRouterProvider router={router} queryClient={queryClient} />);
return { queryClient, router };
}
void bootstrapApp();

View file

@ -0,0 +1,13 @@
import { QueryClient } from '@tanstack/react-query';
export function createAppQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 10_000,
},
},
});
}

View file

@ -0,0 +1,179 @@
import { createMemoryHistory } from '@tanstack/react-router';
import { render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
SHELL_PROFILE_CONTEXT_CHANGED_EVENT,
type ShellBridge,
type ShellProfileContext,
type ShellPageId,
} from '@/platform/shell/bridge';
import { createAppQueryClient } from './query-client';
import { AppRouterProvider, createAppRouter } from './router';
function mockIssuesFetch() {
return vi.fn(async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
if (url.includes('/api/issues/counts')) {
return new Response(
JSON.stringify({
success: true,
counts: { open: 2, in_progress: 1, resolved: 0, dismissed: 0, total: 3 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
if (url.includes('/api/issues?')) {
return new Response(
JSON.stringify({
success: true,
total: 1,
issues: [
{
id: 7,
entity_type: 'album',
entity_id: 'album-7',
category: 'wrong_cover',
title: 'Wrong cover art',
status: 'open',
priority: 'normal',
snapshot_data: '{}',
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch request: ${url}`);
});
}
function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
return {
getCurrentProfileContext: vi.fn(() => ({ profileId: 1, isAdmin: false })),
isPageAllowed: vi.fn(() => true),
getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'),
resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'),
setActivePageChrome: vi.fn(),
activateLegacyPath: vi.fn(),
showReactHost: vi.fn(),
...overrides,
};
}
describe('createAppRouter', () => {
beforeEach(() => {
vi.stubGlobal('fetch', mockIssuesFetch());
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
window.SoulSyncWebShellBridge = undefined;
window.SoulSyncIssueDomain = undefined;
});
it('creates one shared query client and applies router defaults', () => {
const queryClient = createAppQueryClient();
const router = createAppRouter({ queryClient });
expect(router.options.context?.queryClient).toBe(queryClient);
expect(router.options.defaultPreload).toBe('intent');
expect(router.options.defaultPreloadStaleTime).toBe(0);
expect(router.options.scrollRestoration).toBe(true);
expect(router.options.defaultErrorComponent).toBeDefined();
expect(router.options.defaultNotFoundComponent).toBeDefined();
});
it('renders migrated React routes directly and updates shell chrome', async () => {
window.SoulSyncWebShellBridge = createShellBridge();
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/issues'] });
const router = createAppRouter({ history, queryClient });
render(<AppRouterProvider router={router} queryClient={queryClient} />);
await waitFor(() => {
expect(screen.getByTestId('issues-board')).toBeInTheDocument();
});
expect(window.SoulSyncWebShellBridge?.showReactHost).toHaveBeenCalledWith('issues');
expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('issues');
expect(window.SoulSyncWebShellBridge?.activateLegacyPath).not.toHaveBeenCalled();
});
it('routes non-migrated paths through the legacy fallback handler', async () => {
window.SoulSyncWebShellBridge = createShellBridge();
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/search'] });
const router = createAppRouter({ history, queryClient });
render(<AppRouterProvider router={router} queryClient={queryClient} />);
await waitFor(() => {
expect(window.SoulSyncWebShellBridge?.activateLegacyPath).toHaveBeenCalledWith('/search');
});
});
it('redirects disallowed React routes back to the profile home page', async () => {
window.SoulSyncWebShellBridge = createShellBridge({
isPageAllowed: vi.fn((pageId) => pageId !== 'issues'),
});
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/issues'] });
const router = createAppRouter({ history, queryClient });
render(<AppRouterProvider router={router} queryClient={queryClient} />);
await waitFor(() => {
expect(history.location.pathname).toBe('/discover');
});
});
it('waits for profile context before rendering React routes', async () => {
const getCurrentProfileContext = vi.fn<() => ShellProfileContext | null>(() => null);
window.SoulSyncWebShellBridge = createShellBridge({
getCurrentProfileContext,
});
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/issues'] });
const router = createAppRouter({ history, queryClient });
render(<AppRouterProvider router={router} queryClient={queryClient} />);
expect(screen.queryByTestId('issues-board')).not.toBeInTheDocument();
getCurrentProfileContext.mockReturnValue({ profileId: 1, isAdmin: false });
window.dispatchEvent(new CustomEvent(SHELL_PROFILE_CONTEXT_CHANGED_EVENT));
await waitFor(() => {
expect(screen.getByTestId('issues-board')).toBeInTheDocument();
});
});
it('redirects the root route to the profile home page', async () => {
window.SoulSyncWebShellBridge = createShellBridge({
getProfileHomePage: vi.fn<() => ShellPageId>(() => 'search'),
});
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/'] });
const router = createAppRouter({ history, queryClient });
render(<AppRouterProvider router={router} queryClient={queryClient} />);
await waitFor(() => {
expect(window.SoulSyncWebShellBridge?.activateLegacyPath).toHaveBeenCalledWith('/search');
});
expect(history.location.pathname).toBe('/search');
});
});

82
webui/src/app/router.tsx Normal file
View file

@ -0,0 +1,82 @@
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query';
import { createRouter, type RouterHistory } from '@tanstack/react-router';
import { RouterProvider } from '@tanstack/react-router';
import { getShellBridge } from '@/platform/shell/bridge';
import { routeTree } from '@/routeTree.gen';
import { createAppQueryClient } from './query-client';
export interface AppRouterContext {
queryClient: QueryClient;
platform: {
getShellBridge: typeof getShellBridge;
};
}
export function createAppRouter(
options: {
history?: RouterHistory;
queryClient?: QueryClient;
context?: Partial<AppRouterContext>;
} = {},
) {
const queryClient = options.queryClient ?? createAppQueryClient();
const context: AppRouterContext = {
...options.context,
queryClient,
platform: {
getShellBridge,
...options.context?.platform,
},
};
return createRouter({
routeTree,
history: options.history,
context,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
scrollRestoration: true,
defaultErrorComponent: DefaultErrorComponent,
defaultNotFoundComponent: DefaultNotFoundComponent,
});
}
export function AppRouterProvider({
router,
queryClient,
}: {
router: ReturnType<typeof createAppRouter>;
queryClient: QueryClient;
}) {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createAppRouter>;
}
}
export function DefaultErrorComponent() {
return (
<div role="alert">
<h2>Something went wrong</h2>
<p>Please refresh the page and try again.</p>
</div>
);
}
export function DefaultNotFoundComponent() {
return (
<div role="status">
<h2>Page not found</h2>
<p>The requested page could not be found.</p>
</div>
);
}

View file

@ -0,0 +1,110 @@
.backdrop {
position: fixed;
inset: 0;
z-index: 10999;
background: rgba(5, 9, 16, 0.86);
backdrop-filter: blur(18px);
}
.viewport {
position: fixed;
inset: 0;
z-index: 11000;
display: grid;
place-items: center;
padding: 18px;
}
.popup {
width: min(1060px, 100%);
max-height: min(90vh, 980px);
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 28px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(16, 22, 34, 0.98), rgba(12, 16, 25, 0.98));
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.45);
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 20px 22px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.headerContent {
min-width: 0;
}
.headerMeta {
margin-top: 6px;
}
.title {
margin: 0;
font-size: 1.4rem;
}
.close {
flex-shrink: 0;
width: 40px;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 16px;
cursor: pointer;
}
.body {
flex: 1 1 auto;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px;
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
padding: 16px 22px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
@media (max-width: 640px) {
.viewport {
padding: 12px;
}
.popup {
max-height: calc(100vh - 24px);
border-radius: 22px;
}
.header {
padding: 18px;
}
.body {
padding: 18px;
}
.footer {
flex-direction: column;
align-items: stretch;
padding: 0 18px 18px;
}
.footer > button {
width: 100%;
}
}

View file

@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import { Dialog } from '@base-ui/react/dialog';
import clsx from 'clsx';
import styles from './dialog.module.css';
export function DialogFrame({
children,
className,
onOpenChange,
open,
}: {
children: ReactNode;
className?: string;
onOpenChange: (open: boolean) => void;
open: boolean;
}) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Backdrop className={styles.backdrop} />
<Dialog.Viewport className={styles.viewport}>
<Dialog.Popup className={clsx(styles.popup, className)}>{children}</Dialog.Popup>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog.Root>
);
}
export function DialogHeader({
children,
closeLabel = 'Close dialog',
title,
}: {
children?: ReactNode;
closeLabel?: string;
title: ReactNode;
}) {
return (
<div className={styles.header}>
<div className={styles.headerContent}>
<Dialog.Title className={styles.title}>{title}</Dialog.Title>
{children ? <div className={styles.headerMeta}>{children}</div> : null}
</div>
<Dialog.Close className={styles.close} aria-label={closeLabel}>
×
</Dialog.Close>
</div>
);
}
export function DialogBody({ children }: { children: ReactNode }) {
return <div className={styles.body}>{children}</div>;
}
export function DialogFooter({ children }: { children: ReactNode }) {
return <div className={styles.footer}>{children}</div>;
}

View file

@ -0,0 +1 @@
export { DialogBody, DialogFooter, DialogFrame, DialogHeader } from './dialog';

View file

@ -0,0 +1,294 @@
.field {
display: flex;
flex-direction: column;
gap: 10px;
}
.fieldHeader {
display: flex;
flex-direction: column;
gap: 4px;
}
.fieldLabel {
color: #fff;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
}
.fieldHelper {
color: rgba(255, 255, 255, 0.45);
font-size: 12px;
line-height: 1.45;
}
.fieldControl {
display: flex;
flex-direction: column;
}
.textInput,
.textArea {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
font: inherit;
font-size: 14px;
line-height: 1.5;
padding: 12px 14px;
transition:
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.textInput::placeholder,
.textArea::placeholder {
color: rgba(255, 255, 255, 0.38);
}
.textInput:hover,
.textArea:hover {
border-color: rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.06);
}
.textInput:focus,
.textArea:focus {
outline: none;
border-color: rgba(var(--accent-light-rgb), 0.55);
background: rgba(255, 255, 255, 0.07);
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
}
.textArea {
min-height: 128px;
resize: vertical;
}
.select {
width: auto;
min-width: 130px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.035)),
rgba(255, 255, 255, 0.05);
background-image:
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.035)),
rgba(255, 255, 255, 0.05),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='%23888'%3E%3Cpath d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
background-repeat: no-repeat, no-repeat, no-repeat;
background-position:
0 0,
0 0,
right 12px center;
background-size:
auto,
auto,
10px 6px;
color: #fff;
font: inherit;
font-size: 13px;
line-height: 1.5;
padding: 8px 32px 8px 12px;
transition:
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
appearance: none;
-webkit-appearance: none;
color-scheme: dark;
cursor: pointer;
}
.select:hover {
border-color: rgba(255, 255, 255, 0.16);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.045)),
rgba(255, 255, 255, 0.06);
}
.select:focus {
outline: none;
border-color: rgba(var(--accent-light-rgb), 0.55);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.05)),
rgba(255, 255, 255, 0.07);
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
}
.select option,
.select optgroup {
background: #1a1a2e;
color: #fff;
}
.optionCardGroup {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
}
.optionCard {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
padding: 14px 16px;
text-align: left;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.03)),
rgba(255, 255, 255, 0.03);
color: #fff;
cursor: pointer;
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease,
background 0.18s ease;
}
.optionCard:hover {
transform: translateY(-1px);
border-color: rgba(255, 255, 255, 0.14);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.04)),
rgba(255, 255, 255, 0.04);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
}
.optionCardSelected {
border-color: rgba(var(--accent-light-rgb), 0.45);
background:
linear-gradient(180deg, rgba(var(--accent-rgb), 0.18), rgba(255, 255, 255, 0.04)),
rgba(255, 255, 255, 0.05);
box-shadow:
0 0 0 1px rgba(var(--accent-light-rgb), 0.1),
0 14px 32px rgba(0, 0, 0, 0.26);
}
.optionCardIcon {
flex-shrink: 0;
font-size: 18px;
line-height: 1;
margin-top: 1px;
}
.optionCardBody {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 5px;
}
.optionCardTitle {
font-size: 14px;
font-weight: 600;
line-height: 1.3;
}
.optionCardDescription {
color: rgba(255, 255, 255, 0.48);
font-size: 12px;
line-height: 1.45;
}
.optionButtonGroup {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.optionButton {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 600;
min-width: 80px;
padding: 10px 14px;
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease,
background 0.18s ease;
}
.optionButton:hover {
transform: translateY(-1px);
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.08);
}
.optionButtonSelected {
border-color: rgba(var(--accent-light-rgb), 0.5);
background: rgba(var(--accent-rgb), 0.18);
box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.08);
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 600;
line-height: 1.2;
min-height: 36px;
padding: 8px 12px;
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease,
background 0.18s ease,
color 0.18s ease;
}
.button:hover:not(:disabled) {
transform: translateY(-1px);
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.08);
}
.button:focus-visible {
outline: none;
border-color: rgba(var(--accent-light-rgb), 0.55);
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
}
.button:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.formError {
color: #ff8d8d;
font-size: 12px;
line-height: 1.45;
}
.formActions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}

View file

@ -0,0 +1,131 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, it } from 'vitest';
import {
Button,
FormActions,
FormError,
FormField,
OptionButton,
OptionButtonGroup,
OptionCard,
OptionCardGroup,
Select,
TextArea,
TextInput,
} from './form';
function FormDemo() {
const [title, setTitle] = useState('');
const [details, setDetails] = useState('');
const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover');
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
const [status, setStatus] = useState('open');
return (
<form>
<FormField label="Title" helperText="Short summary" htmlFor="title-input">
<TextInput
id="title-input"
placeholder="Enter title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</FormField>
<FormField label="Details" helperText="Longer explanation" htmlFor="details-input">
<TextArea
id="details-input"
placeholder="Enter details"
rows={3}
value={details}
onChange={(event) => setDetails(event.target.value)}
/>
</FormField>
<FormField label="Category" helperText="Pick one">
<OptionCardGroup>
<OptionCard
description="Album art is wrong"
icon="🖼️"
onClick={() => setCategory('wrong_cover')}
selected={category === 'wrong_cover'}
title="Wrong Cover"
/>
<OptionCard
description="Metadata needs fixing"
icon="🏷️"
onClick={() => setCategory('wrong_metadata')}
selected={category === 'wrong_metadata'}
title="Wrong Metadata"
/>
</OptionCardGroup>
</FormField>
<FormField label="Priority" helperText="Set urgency">
<OptionButtonGroup>
{(['low', 'normal', 'high'] as const).map((value) => (
<OptionButton
key={value}
onClick={() => setPriority(value)}
selected={priority === value}
>
{value[0].toUpperCase()}
{value.slice(1)}
</OptionButton>
))}
</OptionButtonGroup>
</FormField>
<FormField label="Status" helperText="Shared select primitive" htmlFor="status-select">
<Select
id="status-select"
value={status}
onChange={(event) => setStatus(event.target.value)}
>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
</Select>
</FormField>
<FormError message="Validation failed" />
<FormActions>
<Button type="button">Cancel</Button>
<Button type="submit">Save</Button>
</FormActions>
</form>
);
}
describe('form primitives', () => {
it('render accessible controls and support selection state', () => {
render(<FormDemo />);
expect(screen.getByLabelText('Title')).toHaveAttribute('placeholder', 'Enter title');
expect(screen.getByLabelText('Details')).toHaveAttribute('placeholder', 'Enter details');
expect(screen.getByText('Short summary')).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('Validation failed');
expect(screen.getByLabelText('Status')).toHaveValue('open');
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } });
expect(screen.getByLabelText('Status')).toHaveValue('resolved');
const wrongCover = screen.getByRole('button', { name: /wrong cover/i });
const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
expect(wrongCover).toHaveAttribute('aria-pressed', 'true');
expect(wrongMetadata).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(wrongMetadata);
expect(wrongCover).toHaveAttribute('aria-pressed', 'false');
expect(wrongMetadata).toHaveAttribute('aria-pressed', 'true');
const highPriority = screen.getByRole('button', { name: 'High' });
expect(highPriority).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(highPriority);
expect(highPriority).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,190 @@
import { Button as BaseButton } from '@base-ui/react/button';
import { Field } from '@base-ui/react/field';
import { Input as BaseInput } from '@base-ui/react/input';
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
import clsx from 'clsx';
import {
forwardRef,
type ComponentPropsWithoutRef,
type ButtonHTMLAttributes,
type SelectHTMLAttributes,
type ReactNode,
type TextareaHTMLAttributes,
} from 'react';
import styles from './form.module.css';
export interface FormFieldProps {
children: ReactNode;
className?: string;
error?: ReactNode;
helperText?: ReactNode;
htmlFor?: string;
label: ReactNode;
}
export function FormField({
children,
className,
error,
helperText,
htmlFor,
label,
}: FormFieldProps) {
return (
<Field.Root className={clsx(styles.field, className)}>
<div className={styles.fieldHeader}>
{htmlFor ? (
<label className={styles.fieldLabel} htmlFor={htmlFor}>
{label}
</label>
) : (
<Field.Label className={styles.fieldLabel}>{label}</Field.Label>
)}
{helperText ? (
<Field.Description className={styles.fieldHelper}>{helperText}</Field.Description>
) : null}
</div>
<div className={styles.fieldControl}>{children}</div>
{error ? <FormError message={error} /> : null}
</Field.Root>
);
}
type BaseInputProps = ComponentPropsWithoutRef<typeof BaseInput>;
export type TextInputProps = Omit<BaseInputProps, 'className'> & {
className?: string;
};
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
{ className, ...props },
ref,
) {
return <BaseInput ref={ref} className={clsx(styles.textInput, className)} {...props} />;
});
export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
{ className, ...props },
ref,
) {
return <textarea ref={ref} className={clsx(styles.textArea, className)} {...props} />;
});
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
{ className, ...props },
ref,
) {
return <select ref={ref} className={clsx(styles.select, className)} {...props} />;
});
export function OptionCardGroup({
className,
children,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={clsx(styles.optionCardGroup, className)}>{children}</div>;
}
export interface OptionCardProps extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'title' | 'value'
> {
className?: string;
description?: ReactNode;
icon?: ReactNode;
selected?: boolean;
title?: ReactNode;
value?: string;
}
export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(function OptionCard(
{ className, children, description, icon, selected = false, title, type = 'button', ...props },
ref,
) {
return (
<BaseToggle
ref={ref}
pressed={selected}
className={clsx(styles.optionCard, selected && styles.optionCardSelected, className)}
type={type}
{...props}
>
{children ?? (
<>
{icon ? <div className={styles.optionCardIcon}>{icon}</div> : null}
<div className={styles.optionCardBody}>
{title ? <div className={styles.optionCardTitle}>{title}</div> : null}
{description ? <div className={styles.optionCardDescription}>{description}</div> : null}
</div>
</>
)}
</BaseToggle>
);
});
export function OptionButtonGroup({
className,
children,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={clsx(styles.optionButtonGroup, className)}>{children}</div>;
}
export interface OptionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {
className?: string;
selected?: boolean;
value?: string;
}
export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(function OptionButton(
{ className, children, selected = false, type = 'button', ...props },
ref,
) {
return (
<BaseToggle
ref={ref}
pressed={selected}
className={clsx(styles.optionButton, selected && styles.optionButtonSelected, className)}
type={type}
{...props}
>
{children}
</BaseToggle>
);
});
type BaseButtonProps = ComponentPropsWithoutRef<typeof BaseButton>;
export type ButtonProps = Omit<BaseButtonProps, 'className'> & {
className?: string;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, type = 'button', ...props },
ref,
) {
return <BaseButton ref={ref} className={clsx(styles.button, className)} type={type} {...props} />;
});
export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
if (!message) return null;
return (
<div className={clsx(styles.formError, className)} role="alert">
{message}
</div>
);
}
export function FormActions({ className, children }: { children: ReactNode; className?: string }) {
return <div className={clsx(styles.formActions, className)}>{children}</div>;
}

View file

@ -0,0 +1,13 @@
export {
Button,
FormActions,
FormError,
FormField,
OptionButton,
OptionButtonGroup,
OptionCard,
OptionCardGroup,
Select,
TextArea,
TextInput,
} from './form';

View file

@ -0,0 +1 @@
export { Show } from './show';

View file

@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Show } from './show';
describe('Show', () => {
it('renders children when the condition is true', () => {
render(
<Show when={true}>
<span>Visible</span>
</Show>,
);
expect(screen.getByText('Visible')).toBeInTheDocument();
});
it('renders fallback when the condition is false', () => {
render(
<Show fallback={<span>Hidden</span>} when={false}>
<span>Visible</span>
</Show>,
);
expect(screen.getByText('Hidden')).toBeInTheDocument();
expect(screen.queryByText('Visible')).not.toBeInTheDocument();
});
it('supports render-prop children', () => {
render(<Show when="Ada">{(name) => <span>{name}</span>}</Show>);
expect(screen.getByText('Ada')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
type ShowChildren<T> = ReactNode | ((value: NonNullable<T>) => ReactNode);
export function Show<T>({
fallback = null,
children,
when,
}: {
children: ShowChildren<T>;
fallback?: ReactNode;
when: T;
}) {
if (!when) {
return <>{fallback}</>;
}
if (typeof children === 'function') {
return <>{(children as (value: NonNullable<T>) => ReactNode)(when as NonNullable<T>)}</>;
}
return <>{children}</>;
}

View file

@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ShellProfileContext } from './bridge';
import { SHELL_PROFILE_CONTEXT_CHANGED_EVENT, waitForShellContext } from './bridge';
describe('waitForShellContext', () => {
beforeEach(() => {
window.SoulSyncWebShellBridge = undefined;
});
it('resolves immediately when the shell already has a profile', async () => {
window.SoulSyncWebShellBridge = {
getProfileHomePage: vi.fn(() => 'discover'),
isPageAllowed: vi.fn(() => true),
activateLegacyPath: vi.fn(),
getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })),
resolveLegacyPath: vi.fn(() => 'issues'),
setActivePageChrome: vi.fn(),
showReactHost: vi.fn(),
} as NonNullable<typeof window.SoulSyncWebShellBridge>;
await expect(waitForShellContext()).resolves.toEqual({
bridge: window.SoulSyncWebShellBridge,
profile: {
profileId: 2,
isAdmin: true,
},
});
});
it('waits for the legacy shell to publish profile context', async () => {
const getCurrentProfileContext = vi.fn<() => ShellProfileContext | null>(() => null);
window.SoulSyncWebShellBridge = {
getProfileHomePage: vi.fn(() => 'discover'),
isPageAllowed: vi.fn(() => true),
activateLegacyPath: vi.fn(),
getCurrentProfileContext,
resolveLegacyPath: vi.fn(() => 'issues'),
setActivePageChrome: vi.fn(),
showReactHost: vi.fn(),
} as NonNullable<typeof window.SoulSyncWebShellBridge>;
const contextPromise = waitForShellContext();
getCurrentProfileContext.mockReturnValue({ profileId: 5, isAdmin: false });
window.dispatchEvent(new CustomEvent(SHELL_PROFILE_CONTEXT_CHANGED_EVENT));
await expect(contextPromise).resolves.toEqual({
bridge: window.SoulSyncWebShellBridge,
profile: {
profileId: 5,
isAdmin: false,
},
});
});
});

View file

@ -0,0 +1,101 @@
import type { AnyRouter } from '@tanstack/react-router';
import {
getShellRouteByPageId,
normalizeShellPath,
resolveShellPageFromPath,
shellRouteManifest,
type ShellPageId,
type ShellRouteDefinition,
} from './route-manifest';
export interface ShellProfileContext {
profileId: number;
isAdmin: boolean;
}
export interface ShellContext {
bridge: ShellBridge;
profile: ShellProfileContext;
}
export type ShellBridge = NonNullable<typeof window.SoulSyncWebShellBridge>;
export const SHELL_BRIDGE_READY_EVENT = 'ss:webui-shell-bridge-ready';
export const SHELL_PROFILE_CONTEXT_CHANGED_EVENT = 'ss:webui-profile-context-changed';
export function getShellBridge(): ShellBridge | null {
return window.SoulSyncWebShellBridge ?? null;
}
export function getShellProfileContext(bridge = getShellBridge()): ShellProfileContext | null {
return bridge?.getCurrentProfileContext() ?? null;
}
export function getShellContext(bridge = getShellBridge()): ShellContext | null {
const profile = getShellProfileContext(bridge);
if (!bridge || !profile) return null;
return { bridge, profile };
}
export function getProfileHomePath(bridge = getShellBridge()): `/${string}` {
const pageId = bridge?.getProfileHomePage() ?? 'discover';
return getShellRouteByPageId(pageId)?.path ?? '/discover';
}
export async function waitForShellContext(): Promise<ShellContext> {
const currentContext = getShellContext();
if (currentContext) return currentContext;
return await new Promise<ShellContext>((resolve) => {
const cleanup = () => {
window.removeEventListener(SHELL_BRIDGE_READY_EVENT, handleReady);
window.removeEventListener(SHELL_PROFILE_CONTEXT_CHANGED_EVENT, handleProfileChange);
};
const settleIfReady = () => {
const shell = getShellContext();
if (!shell) return;
cleanup();
resolve(shell);
};
const handleReady = () => {
settleIfReady();
};
const handleProfileChange = () => {
settleIfReady();
};
window.addEventListener(SHELL_BRIDGE_READY_EVENT, handleReady);
window.addEventListener(SHELL_PROFILE_CONTEXT_CHANGED_EVENT, handleProfileChange);
settleIfReady();
});
}
export function bindWindowWebRouter(router: AnyRouter) {
window.SoulSyncWebRouter = {
routeManifest: [...shellRouteManifest],
getCurrentPath() {
return normalizeShellPath(window.location.pathname);
},
resolvePageId(pathname: string) {
return resolveShellPageFromPath(pathname);
},
async navigateToPage(pageId, options) {
const route = getShellRouteByPageId(pageId);
if (!route) return false;
await router.navigate({
href: route.path,
replace: options?.replace === true,
});
return true;
},
};
}
export type { ShellPageId, ShellRouteDefinition };

41
webui/src/platform/shell/globals.d.ts vendored Normal file
View file

@ -0,0 +1,41 @@
import type {
DownloadMissingAlbumWorkflowInput,
WishlistAlbumWorkflowInput,
} from '@/platform/workflows/album-workflows';
import type { IssueDomainBridge } from '@/routes/issues/-issues.types';
import type { ShellProfileContext, ShellRouteDefinition, ShellPageId } from './bridge';
declare global {
interface Window {
showToast?: (message: string, type?: string, durationOrContext?: number | string) => void;
SoulSyncIssueDomain?: IssueDomainBridge;
SoulSyncWorkflowActions?: {
openDownloadMissingAlbum: (input: DownloadMissingAlbumWorkflowInput) => void | Promise<void>;
openAddToWishlistAlbum: (input: WishlistAlbumWorkflowInput) => void | Promise<void>;
notify?: (message: string, type?: string) => void;
};
SoulSyncWebRouter?: {
routeManifest: ShellRouteDefinition[];
getCurrentPath: () => string;
resolvePageId: (pathname: string) => ShellPageId | null;
navigateToPage: (
pageId: ShellPageId,
options?: {
replace?: boolean;
},
) => Promise<boolean>;
};
SoulSyncWebShellBridge?: {
getCurrentProfileContext: () => ShellProfileContext | null;
isPageAllowed: (pageId: ShellPageId) => boolean;
getProfileHomePage: () => ShellPageId;
resolveLegacyPath: (pathname: string) => ShellPageId | null;
setActivePageChrome: (pageId: ShellPageId) => void;
activateLegacyPath: (pathname: string) => void;
showReactHost: (pageId: ShellPageId) => void;
};
}
}
export {};

View file

@ -0,0 +1,57 @@
import { useRouteContext, useRouter } from '@tanstack/react-router';
import { useEffect, useLayoutEffect } from 'react';
import { getProfileHomePath, type ShellContext, type ShellPageId } from './bridge';
export const ROUTER_ROOT_ID = 'webui-react-root';
export function useShellContext(): ShellContext {
const context = useRouteContext({
from: '__root__',
select: (routeContext) => routeContext.shell,
});
return context;
}
export function useShellBridge() {
return useShellContext().bridge;
}
export function useProfile() {
return useShellContext().profile;
}
export function LegacyRouteController({ pathname }: { pathname: string }) {
const bridge = useShellBridge();
useEffect(() => {
if (!bridge) return;
bridge.activateLegacyPath(pathname);
}, [bridge, pathname]);
return null;
}
export function useReactPageShell(pageId: ShellPageId) {
const bridge = useShellBridge();
const router = useRouter();
useLayoutEffect(() => {
if (!bridge) return;
if (!bridge.isPageAllowed(pageId)) return;
bridge.setActivePageChrome(pageId);
bridge.showReactHost(pageId);
}, [bridge, pageId]);
useEffect(() => {
if (!bridge) return;
if (!bridge.isPageAllowed(pageId)) {
void router.navigate({ href: getProfileHomePath(bridge), replace: true });
return;
}
}, [bridge, pageId, router]);
return bridge;
}

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import {
getShellRouteByPageId,
legacyShellRoutes,
normalizeShellPath,
reactShellRoutes,
resolveLegacyShellPageFromPath,
resolveShellPageFromPath,
shellRouteManifest,
} from './route-manifest';
describe('shellRouteManifest', () => {
it('resolves page ids from explicit paths', () => {
expect(resolveShellPageFromPath('/issues')).toBe('issues');
expect(resolveShellPageFromPath('/discover')).toBe('discover');
expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist');
expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads');
expect(resolveShellPageFromPath('/artist-detail')).toBe('artist-detail');
expect(resolveShellPageFromPath('/artists')).toBeNull();
});
it('treats the root path as unresolved so the shell can redirect to the profile home', () => {
expect(resolveShellPageFromPath('/')).toBeNull();
});
it('normalizes trailing slashes before resolving', () => {
expect(normalizeShellPath('/issues/')).toBe('/issues');
expect(resolveShellPageFromPath('/issues/')).toBe('issues');
});
it('keeps a route entry for every manifest page id', () => {
expect(shellRouteManifest).not.toHaveLength(0);
expect(getShellRouteByPageId('dashboard')?.path).toBe('/dashboard');
expect(getShellRouteByPageId('hydrabase')?.path).toBe('/hydrabase');
expect(getShellRouteByPageId('watchlist')?.path).toBe('/watchlist');
expect(getShellRouteByPageId('tools')?.path).toBe('/tools');
expect(getShellRouteByPageId('artist-detail')?.path).toBe('/artist-detail');
});
it('tracks whether a route is rendered by React or the legacy shell', () => {
expect(getShellRouteByPageId('issues')?.kind).toBe('react');
expect(getShellRouteByPageId('discover')?.kind).toBe('legacy');
expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['issues']);
expect(legacyShellRoutes.some((route) => route.pageId === 'dashboard')).toBe(true);
});
it('only resolves legacy page ids for legacy-owned paths', () => {
expect(resolveLegacyShellPageFromPath('/search')).toBe('search');
expect(resolveLegacyShellPageFromPath('/active-downloads')).toBe('active-downloads');
expect(resolveLegacyShellPageFromPath('/tools')).toBe('tools');
expect(resolveLegacyShellPageFromPath('/issues')).toBeNull();
expect(resolveLegacyShellPageFromPath('/does-not-exist')).toBeNull();
});
});

View file

@ -0,0 +1,80 @@
export const shellPageIds = [
'dashboard',
'sync',
'search',
'discover',
'playlist-explorer',
'watchlist',
'wishlist',
'automations',
'active-downloads',
'library',
'tools',
'artist-detail',
'stats',
'import',
'settings',
'issues',
'help',
'hydrabase',
] as const;
export type ShellPageId = (typeof shellPageIds)[number];
export type ShellRouteKind = 'legacy' | 'react';
export interface ShellRouteDefinition {
pageId: ShellPageId;
path: `/${string}`;
kind: ShellRouteKind;
}
export const shellRouteManifest: readonly ShellRouteDefinition[] = [
{ pageId: 'dashboard', path: '/dashboard', kind: 'legacy' },
{ pageId: 'sync', path: '/sync', kind: 'legacy' },
{ pageId: 'search', path: '/search', kind: 'legacy' },
{ pageId: 'discover', path: '/discover', kind: 'legacy' },
{ pageId: 'playlist-explorer', path: '/playlist-explorer', kind: 'legacy' },
{ pageId: 'watchlist', path: '/watchlist', kind: 'legacy' },
{ pageId: 'wishlist', path: '/wishlist', kind: 'legacy' },
{ pageId: 'automations', path: '/automations', kind: 'legacy' },
{ pageId: 'active-downloads', path: '/active-downloads', kind: 'legacy' },
{ pageId: 'import', path: '/import', kind: 'legacy' },
{ pageId: 'library', path: '/library', kind: 'legacy' },
{ pageId: 'tools', path: '/tools', kind: 'legacy' },
{ pageId: 'artist-detail', path: '/artist-detail', kind: 'legacy' },
{ pageId: 'stats', path: '/stats', kind: 'legacy' },
{ pageId: 'settings', path: '/settings', kind: 'legacy' },
{ pageId: 'issues', path: '/issues', kind: 'react' },
{ pageId: 'help', path: '/help', kind: 'legacy' },
{ pageId: 'hydrabase', path: '/hydrabase', kind: 'legacy' },
] as const;
const routeByPageId = new Map(shellRouteManifest.map((route) => [route.pageId, route]));
const routeByPath = new Map(shellRouteManifest.map((route) => [route.path, route]));
export const reactShellRoutes = shellRouteManifest.filter((route) => route.kind === 'react');
export const legacyShellRoutes = shellRouteManifest.filter((route) => route.kind === 'legacy');
export function normalizeShellPath(pathname: string): string {
if (!pathname) return '/';
if (pathname === '/') return '/';
const normalized = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
return normalized || '/';
}
export function getShellRouteByPageId(pageId: ShellPageId): ShellRouteDefinition | undefined {
return routeByPageId.get(pageId);
}
export function getShellRouteByPath(pathname: string): ShellRouteDefinition | undefined {
return routeByPath.get(normalizeShellPath(pathname) as `/${string}`);
}
export function resolveShellPageFromPath(pathname: string): ShellPageId | null {
return getShellRouteByPath(pathname)?.pageId ?? null;
}
export function resolveLegacyShellPageFromPath(pathname: string): ShellPageId | null {
const route = getShellRouteByPath(pathname);
return route?.kind === 'legacy' ? route.pageId : null;
}

View file

@ -0,0 +1,194 @@
import { apiClient } from '@/app/api-client';
export interface AlbumWorkflowLaunchInput {
spotifyAlbumId?: string;
artistName?: string;
albumName?: string;
source?: string;
}
export interface DownloadMissingAlbumWorkflowInput {
virtualPlaylistId: string;
playlistName: string;
tracks: Array<Record<string, unknown>>;
album: Record<string, unknown>;
artist: Record<string, unknown>;
albumType: string;
forceDownload: boolean;
registerDownload?: boolean;
}
export interface WishlistAlbumWorkflowInput {
tracks: Array<Record<string, unknown>>;
album: Record<string, unknown>;
artist: Record<string, unknown>;
albumType: string;
}
interface AlbumSearchResult {
id?: string;
name?: string;
title?: string;
artist?: string;
}
interface AlbumApiResponse {
id?: string;
name?: string;
album_type?: string;
images?: Array<{ url?: string }>;
image_url?: string | null;
release_date?: string;
total_tracks?: number;
artists?: Array<{ id?: string | null; name?: string }>;
tracks?: Array<Record<string, unknown>>;
}
interface EnhancedSearchResponse {
spotify_albums?: AlbumSearchResult[];
itunes_albums?: AlbumSearchResult[];
}
function getWorkflowBridge() {
const bridge = window.SoulSyncWorkflowActions;
if (!bridge) {
throw new Error('Album workflow host is not ready yet');
}
return bridge;
}
function notify(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') {
if (window.SoulSyncWorkflowActions?.notify) {
window.SoulSyncWorkflowActions.notify(message, type);
return;
}
window.showToast?.(message, type);
}
async function searchAlbum(input: AlbumWorkflowLaunchInput): Promise<AlbumSearchResult> {
const query = `${input.artistName || ''} ${input.albumName || ''}`.trim();
if (!query) {
throw new Error('No album ID or artist/album info available');
}
const searchData =
(await apiClient
.post('enhanced-search', {
json: { query },
})
.json<EnhancedSearchResponse>()) ?? {};
const foundAlbum = searchData.spotify_albums?.[0] ?? searchData.itunes_albums?.[0];
if (!foundAlbum?.id) {
throw new Error(
`Could not find "${input.albumName || 'album'}" by ${input.artistName || 'unknown artist'}`,
);
}
return foundAlbum;
}
async function fetchAlbum(input: AlbumWorkflowLaunchInput): Promise<AlbumApiResponse> {
let albumId = input.spotifyAlbumId || '';
let albumName = input.albumName || '';
let artistName = input.artistName || '';
if (!albumId) {
const foundAlbum = await searchAlbum(input);
albumId = foundAlbum.id || '';
albumName = foundAlbum.name || foundAlbum.title || albumName;
artistName = foundAlbum.artist || artistName;
}
const searchParams = new URLSearchParams({ name: albumName, artist: artistName });
try {
return (
(await apiClient
.get(`spotify/album/${encodeURIComponent(albumId)}`, { searchParams })
.json<AlbumApiResponse>()) ?? {}
);
} catch (error) {
if (!input.spotifyAlbumId || (!input.artistName && !input.albumName)) {
throw error;
}
const foundAlbum = await searchAlbum(input);
const fallbackParams = new URLSearchParams({
name: foundAlbum.name || foundAlbum.title || albumName,
artist: foundAlbum.artist || artistName,
});
return (
(await apiClient
.get(`spotify/album/${encodeURIComponent(foundAlbum.id || '')}`, {
searchParams: fallbackParams,
})
.json<AlbumApiResponse>()) ?? {}
);
}
}
async function resolveAlbumWorkflowData(input: AlbumWorkflowLaunchInput) {
const albumData = await fetchAlbum(input);
if (!albumData.tracks?.length) {
throw new Error(`No tracks available for "${input.albumName || albumData.name || 'album'}"`);
}
const albumArtists = albumData.artists?.length
? albumData.artists
: [{ name: input.artistName || 'Unknown Artist' }];
const artistName = input.artistName || albumArtists[0]?.name || 'Unknown Artist';
const albumType = albumData.album_type || 'album';
const album = {
name: albumData.name || input.albumName || 'Unknown Album',
id: albumData.id || input.spotifyAlbumId || '',
album_type: albumType,
images: albumData.images || [],
image_url: albumData.image_url || albumData.images?.[0]?.url || null,
release_date: albumData.release_date,
total_tracks: albumData.total_tracks,
artists: albumArtists,
};
const tracks = albumData.tracks.map((track) => ({
...track,
artists: albumArtists,
album,
}));
return {
album,
albumType,
artist: { id: `workflow_${artistName}`, name: artistName, image_url: '' },
artistName,
tracks,
};
}
export async function launchAlbumDownloadWorkflow(input: AlbumWorkflowLaunchInput) {
const bridge = getWorkflowBridge();
const { album, albumType, artist, artistName, tracks } = await resolveAlbumWorkflowData(input);
const resolvedAlbumId = String(album.id || input.spotifyAlbumId || Date.now());
const source = input.source || 'album';
await bridge.openDownloadMissingAlbum({
virtualPlaylistId: `${source}_download_${resolvedAlbumId}`,
playlistName: `[${artistName}] ${String(album.name || 'Unknown Album')}`,
tracks,
album,
artist,
albumType,
forceDownload: true,
registerDownload: true,
});
}
export async function launchAlbumWishlistWorkflow(input: AlbumWorkflowLaunchInput) {
const bridge = getWorkflowBridge();
const { album, albumType, artist, tracks } = await resolveAlbumWorkflowData(input);
await bridge.openAddToWishlistAlbum({
album,
artist,
tracks,
albumType,
});
notify('Wishlist workflow opened', 'success');
}

View file

@ -0,0 +1,95 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SplatRouteImport } from './routes/$'
import { Route as IssuesRouteRouteImport } from './routes/issues/route'
import { Route as IndexRouteImport } from './routes/index'
const SplatRoute = SplatRouteImport.update({
id: '/$',
path: '/$',
getParentRoute: () => rootRouteImport,
} as any)
const IssuesRouteRoute = IssuesRouteRouteImport.update({
id: '/issues',
path: '/issues',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/issues': typeof IssuesRouteRoute
'/$': typeof SplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/issues': typeof IssuesRouteRoute
'/$': typeof SplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/issues': typeof IssuesRouteRoute
'/$': typeof SplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/issues' | '/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/issues' | '/$'
id: '__root__' | '/' | '/issues' | '/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
IssuesRouteRoute: typeof IssuesRouteRoute
SplatRoute: typeof SplatRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/$': {
id: '/$'
path: '/$'
fullPath: '/$'
preLoaderRoute: typeof SplatRouteImport
parentRoute: typeof rootRouteImport
}
'/issues': {
id: '/issues'
path: '/issues'
fullPath: '/issues'
preLoaderRoute: typeof IssuesRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
IssuesRouteRoute: IssuesRouteRoute,
SplatRoute: SplatRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

12
webui/src/routes/$.tsx Normal file
View file

@ -0,0 +1,12 @@
import { createFileRoute } from '@tanstack/react-router';
import { LegacyRouteController } from '@/platform/shell/route-controllers';
export const Route = createFileRoute('/$')({
component: LegacyFallbackRouteComponent,
});
function LegacyFallbackRouteComponent() {
const { _splat } = Route.useParams();
return <LegacyRouteController pathname={`/${_splat}`} />;
}

View file

@ -0,0 +1,20 @@
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router';
import type { AppRouterContext } from '@/app/router';
import { waitForShellContext } from '@/platform/shell/bridge';
import { IssueDomainHost } from './issues/-ui/issue-domain-host';
export const Route = createRootRouteWithContext<AppRouterContext>()({
beforeLoad: async () => {
const shell = await waitForShellContext();
return { shell };
},
component: () => (
<>
<Outlet />
<IssueDomainHost />
</>
),
});

View file

@ -0,0 +1,19 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
import { getProfileHomePath } from '@/platform/shell/bridge';
import { LegacyRouteController } from '@/platform/shell/route-controllers';
export const Route = createFileRoute('/')({
beforeLoad: ({ context, location }) => {
if (location.pathname !== '/') return;
const { bridge } = context.shell;
throw redirect({ href: getProfileHomePath(bridge), replace: true });
},
component: IndexRouteComponent,
});
function IndexRouteComponent() {
return <LegacyRouteController pathname="/" />;
}

View file

@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest';
import { HttpResponse, http, server } from '@/test/msw';
import type { IssueRecord } from './-issues.types';
import {
createIssue,
deleteIssue,
fetchIssue,
fetchIssueCounts,
fetchIssueList,
updateIssue,
} from './-issues.api';
const counts = {
open: 4,
in_progress: 2,
resolved: 1,
dismissed: 3,
total: 10,
};
const issue: IssueRecord = {
id: 17,
profile_id: 2,
entity_type: 'track',
entity_id: '987',
category: 'wrong_metadata',
title: 'Wrong metadata',
description: 'Title is incorrect',
status: 'open',
priority: 'normal',
snapshot_data: null,
created_at: '2026-05-01T10:00:00.000Z',
updated_at: '2026-05-01T10:30:00.000Z',
};
describe('issue api', () => {
it('fetches issue counts with the profile header', async () => {
server.use(
http.get('/api/issues/counts', ({ request }) => {
expect(request.headers.get('X-Profile-Id')).toBe('42');
return HttpResponse.json({
success: true,
counts,
});
}),
);
await expect(fetchIssueCounts(42)).resolves.toEqual(counts);
});
it('includes list filters and surfaces backend error messages', async () => {
server.use(
http.get('/api/issues', ({ request }) => {
const url = new URL(request.url);
expect(request.headers.get('X-Profile-Id')).toBe('7');
expect(url.searchParams.get('limit')).toBe('100');
expect(url.searchParams.get('status')).toBe('open');
expect(url.searchParams.get('category')).toBe('wrong_metadata');
return HttpResponse.json(
{
error: 'Issue list unavailable',
},
{ status: 500 },
);
}),
);
await expect(
fetchIssueList(7, {
status: 'open',
category: 'wrong_metadata',
}),
).rejects.toThrow('Issue list unavailable');
});
it('falls back when an issue payload is missing the record', async () => {
server.use(
http.get('/api/issues/:issueId', ({ params, request }) => {
expect(request.headers.get('X-Profile-Id')).toBe('8');
expect(params.issueId).toBe('19');
return HttpResponse.json({
success: false,
});
}),
);
await expect(fetchIssue(8, 19)).rejects.toThrow('Issue not found');
});
it('normalizes create issue payloads before posting', async () => {
server.use(
http.post('/api/issues', async ({ request }) => {
expect(request.headers.get('X-Profile-Id')).toBe('13');
expect(request.headers.get('Content-Type')).toContain('application/json');
await expect(request.json()).resolves.toEqual({
entity_type: 'album',
entity_id: 'album-55',
category: 'wrong_cover',
title: 'Missing cover',
description: '',
priority: 'normal',
});
return HttpResponse.json({
success: true,
issue,
});
}),
);
await expect(
createIssue(13, {
entity_type: 'album',
entity_id: 'album-55',
category: 'wrong_cover',
title: 'Missing cover',
}),
).resolves.toEqual(issue);
});
it('posts issue updates to the correct endpoint', async () => {
server.use(
http.put('/api/issues/:issueId', async ({ params, request }) => {
expect(request.headers.get('X-Profile-Id')).toBe('21');
expect(params.issueId).toBe('17');
await expect(request.json()).resolves.toEqual({
status: 'resolved',
admin_response: 'Fixed upstream',
});
return HttpResponse.json({
success: true,
});
}),
);
await expect(
updateIssue(21, 17, {
status: 'resolved',
admin_response: 'Fixed upstream',
}),
).resolves.toBeUndefined();
});
it('surfaces delete errors from the server', async () => {
server.use(
http.delete('/api/issues/:issueId', ({ params, request }) => {
expect(request.headers.get('X-Profile-Id')).toBe('5');
expect(params.issueId).toBe('91');
return HttpResponse.json(
{
error: 'Cannot delete issue',
},
{ status: 403 },
);
}),
);
await expect(deleteIssue(5, 91)).rejects.toThrow('Cannot delete issue');
});
});

View file

@ -0,0 +1,154 @@
import { queryOptions, type QueryClient } from '@tanstack/react-query';
import { apiClient, readJson } from '@/app/api-client';
import type {
CreateIssuePayload,
IssueCounts,
IssueCountsResponse,
IssueDetailResponse,
IssueListResponse,
IssueRecord,
IssuesSearch,
} from './-issues.types';
const DEFAULT_LIMIT = 100;
export const ISSUES_QUERY_KEY = ['issues'] as const;
function createIssueHeaders(profileId: number, extra?: HeadersInit): Headers {
const headers = new Headers(extra);
headers.set('X-Profile-Id', String(profileId || 1));
return headers;
}
export async function fetchIssueCounts(profileId: number): Promise<IssueCounts> {
const payload = await readJson<IssueCountsResponse>(
apiClient.get('issues/counts', {
headers: createIssueHeaders(profileId),
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to load issue counts');
}
return payload.counts;
}
export async function fetchIssueList(
profileId: number,
search: Pick<IssuesSearch, 'status' | 'category'>,
): Promise<IssueListResponse> {
const params = new URLSearchParams();
params.set('limit', String(DEFAULT_LIMIT));
if (search.status !== 'all') {
params.set('status', search.status);
}
if (search.category !== 'all') {
params.set('category', search.category);
}
const payload = await readJson<IssueListResponse>(
apiClient.get('issues', {
headers: createIssueHeaders(profileId),
searchParams: params,
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to load issues');
}
return payload;
}
export async function fetchIssue(profileId: number, issueId: number): Promise<IssueRecord> {
const payload = await readJson<IssueDetailResponse>(
apiClient.get(`issues/${issueId}`, {
headers: createIssueHeaders(profileId),
}),
);
if (!payload.success || !payload.issue) {
throw new Error(payload.error || 'Issue not found');
}
return payload.issue;
}
export async function updateIssue(
profileId: number,
issueId: number,
updates: { status?: string; admin_response?: string },
): Promise<void> {
const payload = await readJson<{ success: boolean; error?: string }>(
apiClient.put(`issues/${issueId}`, {
headers: createIssueHeaders(profileId),
json: updates,
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to update issue');
}
}
export async function createIssue(
profileId: number,
payload: CreateIssuePayload,
): Promise<IssueRecord | null> {
const response = await readJson<{
success: boolean;
issue?: IssueRecord;
error?: string;
}>(
apiClient.post('issues', {
headers: createIssueHeaders(profileId, { 'Content-Type': 'application/json' }),
json: {
entity_type: payload.entity_type,
entity_id: String(payload.entity_id),
category: payload.category,
title: payload.title,
description: payload.description || '',
priority: payload.priority || 'normal',
},
}),
);
if (!response.success) {
throw new Error(response.error || 'Failed to submit issue');
}
return response.issue ?? null;
}
export async function deleteIssue(profileId: number, issueId: number): Promise<void> {
const payload = await readJson<{ success: boolean; error?: string }>(
apiClient.delete(`issues/${issueId}`, {
headers: createIssueHeaders(profileId),
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to delete issue');
}
}
export function issueCountsQueryOptions(profileId: number) {
return queryOptions({
queryKey: [...ISSUES_QUERY_KEY, 'counts', profileId],
queryFn: () => fetchIssueCounts(profileId),
});
}
export function issueListQueryOptions(
profileId: number,
search: Pick<IssuesSearch, 'status' | 'category'>,
) {
return queryOptions({
queryKey: [...ISSUES_QUERY_KEY, 'list', profileId, search.status, search.category],
queryFn: () => fetchIssueList(profileId, search),
});
}
export function issueDetailQueryOptions(profileId: number, issueId: number) {
return queryOptions({
queryKey: [...ISSUES_QUERY_KEY, 'detail', profileId, issueId],
queryFn: () => fetchIssue(profileId, issueId),
enabled: issueId > 0,
});
}
export function invalidateIssuesQueries(queryClient: QueryClient) {
return queryClient.invalidateQueries({ queryKey: ISSUES_QUERY_KEY });
}

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { ISSUE_CATEGORY_META } from './-issues.helpers';
import { issueSearchSchema } from './-issues.types';
describe('issueSearchSchema', () => {
it('falls back to all for unknown categories', () => {
expect(issueSearchSchema.parse({ category: 'not_real' })).toEqual({
status: 'open',
category: 'all',
issueId: undefined,
});
});
it('preserves known categories', () => {
expect(issueSearchSchema.parse({ category: 'wrong_metadata' })).toEqual({
status: 'open',
category: 'wrong_metadata',
issueId: undefined,
});
});
it('falls back to open for unknown statuses', () => {
expect(issueSearchSchema.parse({ status: 'not_real' })).toEqual({
status: 'open',
category: 'all',
issueId: undefined,
});
});
it('drops invalid issue ids', () => {
expect(issueSearchSchema.parse({ issueId: 'abc123' })).toEqual({
status: 'open',
category: 'all',
issueId: undefined,
});
});
it('normalizes numeric issue ids', () => {
expect(issueSearchSchema.parse({ issueId: '7' })).toEqual({
status: 'open',
category: 'all',
issueId: 7,
});
});
it('keeps the legacy category icons', () => {
expect(ISSUE_CATEGORY_META.wrong_metadata.icon).toBe('✎');
expect(ISSUE_CATEGORY_META.wrong_cover.icon).toBe('📷');
expect(ISSUE_CATEGORY_META.audio_quality.icon).toBe('🎵');
});
});

View file

@ -0,0 +1,162 @@
import {
type IssueCategory,
type IssueRecord,
type IssueSnapshot,
type IssuePriority,
type IssueStatus,
} from './-issues.types';
export const ISSUE_CATEGORY_META: Record<
IssueCategory,
{ label: string; icon: string; description: string; applies: Array<'track' | 'album' | 'artist'> }
> = {
wrong_track: {
label: 'Wrong Track',
icon: '❌',
description: 'This file plays a different song than expected',
applies: ['track'],
},
wrong_metadata: {
label: 'Wrong Metadata',
icon: '✎',
description: 'Title, artist, year, or other tags are incorrect',
applies: ['track', 'album'],
},
wrong_cover: {
label: 'Wrong Cover Art',
icon: '📷',
description: 'The artwork is wrong or missing',
applies: ['album'],
},
wrong_artist: {
label: 'Wrong Artist',
icon: '👤',
description: 'This track is filed under the wrong artist',
applies: ['track'],
},
duplicate_tracks: {
label: 'Duplicate Tracks',
icon: '🔁',
description: 'The same track appears more than once in this album',
applies: ['album'],
},
missing_tracks: {
label: 'Missing Tracks',
icon: '❓',
description: 'Tracks that should be here are missing',
applies: ['album'],
},
audio_quality: {
label: 'Audio Quality',
icon: '🎵',
description: 'Audio has quality issues like clipping or low bitrate',
applies: ['track'],
},
wrong_album: {
label: 'Wrong Album',
icon: '💿',
description: 'This track belongs to a different album',
applies: ['track'],
},
incomplete_album: {
label: 'Incomplete Album',
icon: '⚠',
description: 'Album is partially downloaded',
applies: ['album'],
},
other: {
label: 'Other',
icon: '💬',
description: 'Any other issue not listed above',
applies: ['track', 'album', 'artist'],
},
};
export const ISSUE_STATUS_META: Record<IssueStatus, { label: string; className: string }> = {
open: { label: 'Open', className: 'is-open' },
in_progress: { label: 'In Progress', className: 'is-progress' },
resolved: { label: 'Resolved', className: 'is-resolved' },
dismissed: { label: 'Dismissed', className: 'is-dismissed' },
};
export function getIssueCategoriesForEntity(entityType: IssueRecord['entity_type']) {
return Object.entries(ISSUE_CATEGORY_META).filter(([, category]) =>
category.applies.includes(entityType),
);
}
export function createDefaultIssueTitle(category: string, entityName: string): string {
const label = getIssueCategoryMeta(category)?.label || 'Issue';
return `${label}: ${entityName || 'Unknown'}`;
}
export function getIssueCategoryMeta(category: string) {
return ISSUE_CATEGORY_META[category as IssueCategory];
}
export function getIssueStatusMeta(status: string) {
return ISSUE_STATUS_META[status as IssueStatus];
}
export function parseSnapshot(snapshot: IssueRecord['snapshot_data']): IssueSnapshot {
if (!snapshot) {
return {};
}
if (typeof snapshot === 'string') {
try {
return JSON.parse(snapshot) as IssueSnapshot;
} catch {
return {};
}
}
return snapshot;
}
export function getEntityLabel(entityType: IssueRecord['entity_type']): string {
return entityType === 'track' ? 'Track' : entityType === 'album' ? 'Album' : 'Artist';
}
export function getEntityName(issue: IssueRecord, snapshot: IssueSnapshot): string {
const entityLabel = getEntityLabel(issue.entity_type);
return String(snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`);
}
export function getEntityDetails(issue: IssueRecord, snapshot: IssueSnapshot): string[] {
const details: string[] = [];
if (issue.entity_type === 'track') {
if (snapshot.artist_name) details.push(String(snapshot.artist_name));
if (snapshot.album_title) details.push(String(snapshot.album_title));
} else if (issue.entity_type === 'album') {
if (snapshot.artist_name) details.push(String(snapshot.artist_name));
} else if (issue.entity_type === 'artist' && snapshot.name) {
details.push(String(snapshot.name));
}
return details;
}
export function getIssueArtwork(snapshot: IssueSnapshot): string {
return String(snapshot.thumb_url || snapshot.album_thumb || snapshot.artist_thumb || '');
}
export function formatIssueDate(value?: string): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function formatStatusLabel(status: string): string {
return getIssueStatusMeta(status)?.label || status.replace(/_/g, ' ');
}
export function getPriorityClassName(priority: string): IssuePriority {
if (priority === 'high') return 'high';
if (priority === 'low') return 'low';
return 'normal';
}

View file

@ -0,0 +1,164 @@
import { z } from 'zod';
export const ISSUE_ENTITY_TYPE_VALUES = ['track', 'album', 'artist'] as const;
export type IssueEntityType = (typeof ISSUE_ENTITY_TYPE_VALUES)[number];
export const ISSUE_CATEGORY_VALUES = [
'wrong_track',
'wrong_metadata',
'wrong_cover',
'wrong_artist',
'duplicate_tracks',
'missing_tracks',
'audio_quality',
'wrong_album',
'incomplete_album',
'other',
] as const;
export type IssueCategory = (typeof ISSUE_CATEGORY_VALUES)[number];
export const ISSUE_STATUS_VALUES = ['open', 'in_progress', 'resolved', 'dismissed'] as const;
export type IssueStatus = (typeof ISSUE_STATUS_VALUES)[number];
export const ISSUE_PRIORITY_VALUES = ['low', 'normal', 'high'] as const;
export type IssuePriority = (typeof ISSUE_PRIORITY_VALUES)[number];
export const ISSUE_SEARCH_STATUS_VALUES = [
'open',
'all',
'in_progress',
'resolved',
'dismissed',
] as const;
export const ISSUE_SEARCH_CATEGORY_VALUES = ['all', ...ISSUE_CATEGORY_VALUES] as const;
export const issueSearchSchema = z.object({
status: z.enum(ISSUE_SEARCH_STATUS_VALUES).default('open').catch('open'),
category: z.enum(ISSUE_SEARCH_CATEGORY_VALUES).default('all').catch('all'),
issueId: z.coerce.number().int().positive().optional().catch(undefined),
});
export type IssuesSearch = z.infer<typeof issueSearchSchema>;
export interface IssueTrackRow extends Record<string, unknown> {
bitrate?: string | number;
disc_number?: string | number;
duration?: string | number;
format?: string;
id?: string | number;
title?: string;
track_number?: string | number;
}
export interface IssueSnapshot {
[key: string]: unknown;
album_track_count?: string | number;
bitrate?: string | number;
bpm?: string | number;
disc_number?: string | number;
duration?: string | number;
file_path?: string;
format?: string;
genres?: string[];
label?: string;
name?: string;
record_type?: string;
title?: string;
track_count?: string | number;
tracks?: IssueTrackRow[];
track_number?: string | number;
year?: string | number;
artist_name?: string;
album_title?: string;
thumb_url?: string;
artist_thumb?: string;
album_thumb?: string;
spotify_album_id?: string;
spotify_artist_id?: string;
spotify_track_id?: string;
artist_id?: string | number;
album_id?: string | number;
quality?: string;
artist_musicbrainz_id?: string;
musicbrainz_release_id?: string;
musicbrainz_recording_id?: string;
artist_deezer_id?: string;
album_deezer_id?: string;
track_deezer_id?: string;
artist_tidal_id?: string;
album_tidal_id?: string;
artist_qobuz_id?: string | number;
album_qobuz_id?: string | number;
}
export interface IssueRecord {
id: number;
profile_id: number;
entity_type: IssueEntityType;
entity_id: string;
category: string;
title: string;
description?: string | null;
status: string;
priority: string;
snapshot_data: IssueSnapshot | string | null;
created_at?: string;
updated_at?: string;
resolved_at?: string | null;
resolved_by?: number | null;
admin_response?: string | null;
reporter_name?: string | null;
reporter_color?: string | null;
reporter_avatar?: string | null;
}
export interface IssueCounts {
open: number;
in_progress: number;
resolved: number;
dismissed: number;
total: number;
}
export interface IssueListResponse {
success: boolean;
issues: IssueRecord[];
total: number;
error?: string;
}
export interface IssueDetailResponse {
success: boolean;
issue?: IssueRecord;
error?: string;
}
export interface IssueCountsResponse {
success: boolean;
counts: IssueCounts;
error?: string;
}
export interface CreateIssuePayload {
entity_type: IssueEntityType;
entity_id: string;
category: string;
title: string;
description?: string;
priority?: IssuePriority;
}
export interface IssueReportPayload {
entityType: IssueEntityType;
entityId: string | number;
entityName: string;
artistName?: string;
albumTitle?: string;
}
export interface IssueDomainBridge {
openReportIssue: (payload: IssueReportPayload) => void;
refresh: () => void;
closeReportIssue?: () => void;
}

View file

@ -0,0 +1,273 @@
import { createMemoryHistory } from '@tanstack/react-router';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge';
import { createAppQueryClient } from '@/app/query-client';
import { AppRouterProvider, createAppRouter } from '@/app/router';
function createResponse(body: unknown, ok = true, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
return {
getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })),
isPageAllowed: vi.fn(() => true),
getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'),
resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'),
setActivePageChrome: vi.fn(),
activateLegacyPath: vi.fn(),
showReactHost: vi.fn(),
...overrides,
};
}
function renderIssuesRoute(initialEntries = ['/issues']) {
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries });
const router = createAppRouter({ history, queryClient });
return {
history,
router,
...render(<AppRouterProvider router={router} queryClient={queryClient} />),
};
}
const workflowActions = {
openDownloadMissingAlbum: vi.fn(),
openAddToWishlistAlbum: vi.fn(),
};
describe('issues route', () => {
beforeEach(() => {
workflowActions.openDownloadMissingAlbum.mockReset();
workflowActions.openAddToWishlistAlbum.mockReset();
window.SoulSyncWebShellBridge = createShellBridge();
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
if (url.includes('/api/issues/counts')) {
return createResponse({
success: true,
counts: {
open: 2,
in_progress: 1,
resolved: 0,
dismissed: 0,
total: 3,
},
});
}
if (url.includes('/api/issues?')) {
return createResponse({
success: true,
total: 1,
issues: [
{
id: 7,
profile_id: 2,
entity_type: 'album',
entity_id: '15',
category: 'wrong_metadata',
title: 'Bad tags',
description: 'Album title is wrong',
status: 'open',
priority: 'normal',
snapshot_data: {
title: 'Album Name',
artist_name: 'Artist',
thumb_url: 'https://example.com/thumb.jpg',
spotify_album_id: 'abc123',
track_number: 1,
duration: 245,
format: 'FLAC',
bitrate: 1411,
},
created_at: '2026-04-03 10:30:00',
reporter_name: 'Ada',
},
],
});
}
if (url.includes('/api/issues/7')) {
return createResponse({
success: true,
issue: {
id: 7,
profile_id: 2,
entity_type: 'album',
entity_id: '15',
category: 'wrong_metadata',
title: 'Bad tags',
description: 'Album title is wrong',
status: 'open',
priority: 'normal',
snapshot_data: {
title: 'Album Name',
artist_name: 'Artist',
thumb_url: 'https://example.com/thumb.jpg',
spotify_album_id: 'abc123',
track_number: 1,
duration: 245,
format: 'FLAC',
bitrate: 1411,
},
created_at: '2026-04-03 10:30:00',
reporter_name: 'Ada',
},
});
}
if (url.includes('/api/spotify/album/abc123')) {
return createResponse({
id: 'abc123',
name: 'Album Name',
album_type: 'album',
images: [{ url: 'https://example.com/thumb.jpg' }],
total_tracks: 1,
artists: [{ name: 'Artist' }],
tracks: [{ id: 'track-1', name: 'Track 1' }],
});
}
return createResponse({ success: true });
}) as unknown as typeof fetch,
);
vi.stubGlobal('SoulSyncWorkflowActions', workflowActions);
vi.stubGlobal('showToast', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
window.SoulSyncWebShellBridge = undefined;
});
it('renders stats and list items through the app router', async () => {
renderIssuesRoute();
await waitFor(() => expect(screen.getByTestId('issue-counts')).toHaveTextContent('2'));
expect(await screen.findByTestId('issue-card-7')).toHaveTextContent('Bad tags');
});
it('loads the detail modal from the route search state', async () => {
renderIssuesRoute(['/issues?issueId=7']);
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
expect(await screen.findByTitle('Spotify Album')).toHaveAttribute(
'href',
'https://open.spotify.com/album/abc123',
);
});
it('stores filters in route search state', async () => {
renderIssuesRoute();
const status = await screen.findByRole('combobox', { name: /status/i });
fireEvent.change(status, { target: { value: 'resolved' } });
await waitFor(() => expect(status).toHaveValue('resolved'));
});
it('opens and closes the detail modal', async () => {
const { history } = renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
await waitFor(() => expect(history.location.search).toContain('issueId=7'));
fireEvent.click(screen.getByRole('button', { name: /close issue detail/i }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await waitFor(() => expect(history.location.search).toBe('?status=open&category=all'));
});
it('closes the detail modal with Escape', async () => {
const { history } = renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
await waitFor(() => expect(history.location.search).toContain('issueId=7'));
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await waitFor(() => expect(history.location.search).toBe('?status=open&category=all'));
});
it('focuses the detail modal close button on open', async () => {
renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
const closeButton = await screen.findByRole('button', {
name: /close issue detail/i,
});
await waitFor(() => expect(closeButton).toHaveFocus());
});
it('invokes the shared workflow adapter for admin downloads', async () => {
renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
fireEvent.click(await screen.findByRole('button', { name: /download album/i }));
await waitFor(() => expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalled());
expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalledWith(
expect.objectContaining({
virtualPlaylistId: 'issue_download_abc123',
playlistName: '[Artist] Album Name',
}),
);
});
it('opens the global React issue composer through the domain bridge', async () => {
const fetchMock = vi.mocked(fetch);
renderIssuesRoute();
await waitFor(() => expect(window.SoulSyncIssueDomain).toBeDefined());
act(() => {
window.SoulSyncIssueDomain?.openReportIssue({
entityType: 'album',
entityId: 15,
entityName: 'Album Name',
artistName: 'Artist',
});
});
fireEvent.click(await screen.findByRole('button', { name: /wrong cover art/i }));
const titleInput = screen.getByLabelText(/title/i);
const descriptionInput = screen.getByLabelText(/details/i);
const submitButton = screen.getByRole('button', { name: /submit issue/i });
const form = submitButton.closest('form');
expect(titleInput).toHaveValue('Wrong Cover Art: Album Name');
fireEvent.change(titleInput, { target: { value: '' } });
expect(submitButton).toBeDisabled();
fireEvent.submit(form!);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Please provide a title for the issue');
});
fireEvent.change(titleInput, { target: { value: 'Custom report title' } });
fireEvent.blur(titleInput);
fireEvent.change(descriptionInput, {
target: { value: 'Detailed reproduction notes' },
});
fireEvent.click(screen.getByRole('button', { name: /high/i }));
fireEvent.click(screen.getByRole('button', { name: /wrong metadata/i }));
expect(titleInput).toHaveValue('Custom report title');
expect(descriptionInput).toHaveValue('Detailed reproduction notes');
expect(screen.getByRole('button', { name: /high/i })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /submit issue/i }));
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([request]) => request instanceof Request && request.method === 'POST',
),
).toBe(true);
});
});
it('does not render track details for album issues', async () => {
renderIssuesRoute(['/issues?issueId=7']);
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
expect(screen.queryByText('Track Details')).not.toBeInTheDocument();
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,736 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog';
import { Button } from '@/components/form';
import { Show } from '@/components/primitives';
import { useProfile } from '@/platform/shell/route-controllers';
import {
launchAlbumDownloadWorkflow,
launchAlbumWishlistWorkflow,
} from '@/platform/workflows/album-workflows';
import type { IssueRecord, IssueTrackRow } from '../-issues.types';
import { deleteIssue, issueDetailQueryOptions, updateIssue } from '../-issues.api';
import {
formatIssueDate,
formatStatusLabel,
getIssueArtwork,
getPriorityClassName,
getIssueCategoryMeta,
ISSUE_CATEGORY_META,
parseSnapshot,
} from '../-issues.helpers';
import styles from './issue-detail-modal.module.css';
export function IssueDetailModal({
issueId,
onClose,
onMutationSuccess,
}: {
issueId?: number;
onClose: () => void;
onMutationSuccess: () => void;
}) {
const { isAdmin, profileId } = useProfile();
const selectedIssueQuery = useQuery({
...issueDetailQueryOptions(profileId, issueId ?? 0),
enabled: issueId != null,
});
const issue = selectedIssueQuery.data ?? null;
const queryError = selectedIssueQuery.error;
const queryLoading = selectedIssueQuery.isLoading;
const [adminResponse, setAdminResponse] = useState('');
const isOpen = Boolean(issueId || queryLoading || queryError);
useEffect(() => {
setAdminResponse(issue?.admin_response || '');
}, [issue?.admin_response, issue?.id]);
const updateMutation = useMutation({
mutationFn: async (payload: { issueId: number; status: string; adminResponse: string }) => {
await updateIssue(profileId, payload.issueId, {
status: payload.status,
admin_response: payload.adminResponse,
});
},
onSuccess: async () => {
onMutationSuccess();
},
});
const deleteMutation = useMutation({
mutationFn: async (issueId: number) => {
await deleteIssue(profileId, issueId);
},
onSuccess: async () => {
onMutationSuccess();
},
});
const downloadWorkflowMutation = useMutation({
mutationFn: launchAlbumDownloadWorkflow,
onError: notifyWorkflowError,
onSuccess: onClose,
});
const wishlistWorkflowMutation = useMutation({
mutationFn: launchAlbumWishlistWorkflow,
onError: notifyWorkflowError,
onSuccess: onClose,
});
const statusButtons = useMemo(() => {
if (!issue) return null;
if (isAdmin) {
if (issue.status === 'open' || issue.status === 'in_progress') {
return (
<>
{issue.status === 'open' && (
<Button
className={styles.modalButtonProgress}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'in_progress',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Mark In Progress
</Button>
)}
<Button
className={styles.modalButtonResolve}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'resolved',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Resolve
</Button>
<Button
className={styles.modalButtonDismiss}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'dismissed',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Dismiss
</Button>
</>
);
}
return (
<Button
className={styles.modalButtonReopen}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'open',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Reopen
</Button>
);
}
if (issue.status === 'open') {
return (
<Button
className={styles.modalButtonDelete}
type="button"
onClick={() => {
if (window.confirm('Withdraw this issue?')) {
deleteMutation.mutate(issue.id);
}
}}
disabled={deleteMutation.isPending}
>
Withdraw
</Button>
);
}
return null;
}, [adminResponse, deleteMutation, isAdmin, issue, updateMutation]);
if (!issue && !queryLoading && !queryError) {
return null;
}
const snapshot = issue ? parseSnapshot(issue.snapshot_data) : {};
const issueArtwork = getIssueArtwork(snapshot);
const issueCategoryMeta = issue ? getIssueCategoryMeta(issue.category) : undefined;
const issueCategoryLabel = issue
? `${issueCategoryMeta?.icon || ''} ${issueCategoryMeta?.label || ISSUE_CATEGORY_META.other.label}`.trim()
: '';
const externalLinks = getExternalLinks(snapshot);
const trackMetaItems = getTrackMetaItems(snapshot);
const trackRows = Array.isArray(snapshot.tracks) ? snapshot.tracks : [];
const priorityLevel = issue ? getPriorityClassName(issue.priority) : 'normal';
const albumMetaParts = issue ? getAlbumMetaParts(issue, snapshot) : [];
const genreTags = Array.isArray(snapshot.genres) ? snapshot.genres.slice(0, 5) : [];
const albumWorkflowInput = {
spotifyAlbumId: String(snapshot.spotify_album_id || ''),
artistName: String(snapshot.artist_name || ''),
albumName: String(snapshot.album_title || snapshot.title || ''),
source: 'issue',
};
return (
<DialogFrame
open={isOpen}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onClose();
}
}}
className={styles.issueDetailDialog}
>
<DialogHeader
title={issue ? `Issue #${issue.id}` : 'Issue details'}
closeLabel="Close issue detail"
/>
<DialogBody>{renderIssueDetailContent()}</DialogBody>
<DialogFooter>
<Button className={styles.modalButtonSecondary} type="button" onClick={onClose}>
Close
</Button>
{issue && (
<>
{statusButtons}
{isAdmin && (
<Button
className={styles.modalButtonDelete}
type="button"
onClick={() => {
if (window.confirm('Delete this issue?')) {
deleteMutation.mutate(issue.id);
}
}}
disabled={deleteMutation.isPending}
>
Delete
</Button>
)}
</>
)}
</DialogFooter>
</DialogFrame>
);
function renderIssueDetailContent() {
if (queryLoading) {
return (
<div className={styles.issuesLoading}>
<div className={styles.issuesSpinner} />
Loading issue details...
</div>
);
}
if (queryError) {
return (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyTitle}>Failed to load issue</div>
<div className={styles.issuesEmptyText}>
{queryError instanceof Error ? queryError.message : 'Unknown error'}
</div>
</div>
);
}
if (!issue) {
return null;
}
return (
<>
<div className={styles.issueHero}>
<div className={styles.issueHeroArtGroup}>
<Show when={issue.entity_type === 'artist' && issueArtwork}>
<img className={styles.issueHeroArtistThumb} src={issueArtwork} alt="" />
</Show>
<Show
when={issueArtwork}
fallback={
<div className={styles.issueHeroAlbumPlaceholder}>
{issueCategoryMeta?.icon || ISSUE_CATEGORY_META.other.icon}
</div>
}
>
<img className={styles.issueHeroAlbumArt} src={issueArtwork} alt="" />
</Show>
</div>
<div className={styles.issueHeroInfo}>
<Show when={issue.entity_type !== 'artist' && snapshot.artist_name}>
{(artistName) => <div className={styles.issueHeroArtist}>{String(artistName)}</div>}
</Show>
<div className={styles.issueHeroAlbum}>
{String(
issue.entity_type === 'artist'
? snapshot.name || issue.title
: snapshot.album_title || snapshot.title || issue.title,
)}
</div>
<Show when={issue.entity_type === 'track'}>
<div className={styles.issueHeroTrackName}> {issue.title}</div>
</Show>
<Show when={issue.entity_type !== 'artist' && albumMetaParts.length > 0}>
<div className={styles.issueHeroMeta}>{albumMetaParts.join(' - ')}</div>
</Show>
<Show when={genreTags.length}>
<div className={styles.issueHeroGenres}>
{genreTags.map((genre) => (
<span className={styles.issueHeroGenreTag} key={String(genre)}>
{String(genre)}
</span>
))}
</div>
</Show>
<Show when={externalLinks.length}>
<div className={styles.issueExternalLinks}>
{externalLinks.map((link) => (
<Show
key={`${link.service}-${link.type}-${link.label}`}
when={link.url}
fallback={
<span
className={`${styles.issueExternalLink} ${styles[link.className]}`}
title={`${link.service} ${link.type}: ${link.id}`}
>
<span className={styles.issueExternalLinkService}>{link.service}</span>
<span className={styles.issueExternalLinkType}>{link.type}</span>
</span>
}
>
<a
className={`${styles.issueExternalLink} ${styles[link.className]}`}
href={link.url}
target="_blank"
rel="noreferrer"
title={`${link.service} ${link.type}`}
>
<span className={styles.issueExternalLinkService}>{link.service}</span>
<span className={styles.issueExternalLinkType}>{link.type}</span>
</a>
</Show>
))}
</div>
</Show>
</div>
</div>
<div className={styles.issueDetailInfoBar}>
<div className={styles.issueDetailInfoLeft}>
<span
className={`${styles.issuePriorityDot} ${getPriorityDotClassName(priorityLevel)}`}
/>
<span className={`${styles.issueStatusBadge} ${getStatusClassName(issue.status)}`}>
{formatStatusLabel(issue.status)}
</span>
<span className={styles.issueDetailCategory}>{issueCategoryLabel}</span>
</div>
<div className={styles.issueDetailInfoRight}>
<span className={styles.issueDetailDate}>
Reported {formatIssueDate(issue.created_at)}
</span>
<Show when={issue.resolved_at}>
{(resolvedAt) => (
<span className={styles.issueDetailDate}>
Resolved {formatIssueDate(resolvedAt)}
</span>
)}
</Show>
<Show when={isAdmin && issue.reporter_name}>
{(reporterName) => (
<span className={styles.issueDetailProfile}>by {reporterName}</span>
)}
</Show>
</div>
</div>
<Show when={issue.entity_type !== 'artist' && isAdmin}>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Actions</div>
<div className={styles.issueActionButtons}>
<Button
className={styles.issueActionDownload}
type="button"
disabled={downloadWorkflowMutation.isPending}
onClick={() => downloadWorkflowMutation.mutate(albumWorkflowInput)}
>
{downloadWorkflowMutation.isPending ? 'Loading...' : 'Download Album'}
</Button>
<Button
className={styles.issueActionWishlist}
type="button"
disabled={wishlistWorkflowMutation.isPending}
onClick={() => wishlistWorkflowMutation.mutate(albumWorkflowInput)}
>
{wishlistWorkflowMutation.isPending ? 'Loading...' : 'Add to Wishlist'}
</Button>
</div>
</div>
</Show>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Issue</div>
<div className={styles.issueDetailTitleText}>{issue.title}</div>
<div
className={issue.description ? styles.issueDetailDescription : styles.issueDetailNoDesc}
>
{issue.description || 'No additional details provided'}
</div>
</div>
<Show when={issue.entity_type === 'track' && trackMetaItems.length > 0}>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Track Details</div>
<div className={styles.issueDetailMetaGrid}>
{trackMetaItems.map((item) => (
<div className={styles.issueMetaItem} key={item.label}>
<span className={styles.issueMetaIcon}>{item.icon}</span>
<span className={styles.issueMetaLabel}>{item.label}</span>
<span className={styles.issueMetaValue}>{item.value}</span>
</div>
))}
</div>
</div>
</Show>
<Show when={snapshot.file_path}>
{(filePath) => (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>File Path</div>
<div className={styles.issueDetailFilepath}>{String(filePath)}</div>
</div>
)}
</Show>
<Show when={trackRows.length}>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>
Track Listing{' '}
<span className={styles.issueDetailSectionCount}>{trackRows.length} tracks</span>
</div>
<div className={styles.issueDetailTracklist}>{renderTrackListing(trackRows)}</div>
</div>
</Show>
<Show when={isAdmin}>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Response</div>
<textarea
className={styles.issueDetailResponseTextarea}
id="issue-detail-response-input"
value={adminResponse}
onChange={(event) => setAdminResponse(event.target.value)}
placeholder="Write a response to the reporter..."
rows={3}
/>
</div>
</Show>
<Show when={!isAdmin && issue.admin_response}>
{(response) => (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Response</div>
<div className={styles.issueDetailAdminResponse}>{response}</div>
</div>
)}
</Show>
</>
);
}
}
function renderTrackListing(trackRows: IssueTrackRow[]) {
const nodes: ReactNode[] = [];
let lastDisc: number | null = null;
const hasMultiDisc = trackRows.some((track) => Number(track.disc_number || 1) > 1);
trackRows.forEach((track, index) => {
const disc = Number(track.disc_number || 1);
if (hasMultiDisc && disc !== lastDisc) {
nodes.push(
<div className={styles.issueDetailTracklistDisc} key={`disc-${disc}-${index}`}>
Disc {disc}
</div>,
);
lastDisc = disc;
}
const format = String(track.format || '').toUpperCase();
const bitrateValue = typeof track.bitrate === 'number' ? track.bitrate : Number(track.bitrate);
const bitrate = Number.isFinite(bitrateValue) && bitrateValue > 0 ? `${bitrateValue}k` : '';
const duration = formatDuration(track.duration);
const formatClassName = getTrackFormatClassName(format);
const bitrateClassName = getTrackBitrateClassName(bitrateValue, format);
nodes.push(
<div
className={styles.issueDetailTracklistRow}
key={String(track.id || `${track.title}-${index}`)}
>
<span className={styles.issueDetailTracklistNum}>{String(track.track_number || '-')}</span>
<span className={styles.issueDetailTracklistTitle}>{String(track.title || 'Unknown')}</span>
<span className={styles.issueDetailTracklistDur}>{duration}</span>
<span className={styles.issueDetailTracklistMeta}>
<Show when={format}>
<span className={`${styles.issueTrackBadge} ${formatClassName}`}>{format}</span>
</Show>
<Show when={bitrate}>
<span className={`${styles.issueTrackBadge} ${bitrateClassName}`}>{bitrate}</span>
</Show>
</span>
</div>,
);
});
return nodes;
}
function getPriorityDotClassName(priority: string) {
if (priority === 'high') return styles.issuePriorityHigh;
if (priority === 'low') return styles.issuePriorityLow;
return styles.issuePriorityNormal;
}
function getTrackFormatClassName(format: string) {
const lower = format.toLowerCase();
if (lower === 'flac') return styles.issueTrackBadgeFlac;
if (lower === 'mp3') return styles.issueTrackBadgeMp3;
return styles.issueTrackBadgeOther;
}
function getTrackBitrateClassName(bitrate: number, format: string) {
const lower = format.toLowerCase();
if (!Number.isFinite(bitrate) || bitrate <= 0) return styles.issueTrackBadgeOther;
if (bitrate >= 320 || lower === 'flac') return styles.issueTrackBadgeHigh;
if (bitrate >= 192) return styles.issueTrackBadgeMedium;
return styles.issueTrackBadgeLow;
}
function getStatusClassName(status: string) {
if (status === 'in_progress') return styles.issueStatusProgress;
if (status === 'resolved') return styles.issueStatusResolved;
if (status === 'dismissed') return styles.issueStatusDismissed;
return styles.issueStatusOpen;
}
function notifyWorkflowError(error: unknown) {
const message = error instanceof Error ? error.message : 'Workflow failed';
window.showToast?.(message, 'error');
}
function formatDuration(value: unknown): string {
const duration = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(duration) || duration <= 0) return '';
const seconds = duration > 10000 ? Math.floor(duration / 1000) : Math.floor(duration);
const minutes = Math.floor(seconds / 60);
const remaining = seconds % 60;
return `${minutes}:${String(remaining).padStart(2, '0')}`;
}
function getExternalLinks(snapshot: ReturnType<typeof parseSnapshot>) {
const links: Array<{
className:
| 'issueExternalLinkSpotify'
| 'issueExternalLinkMusicBrainz'
| 'issueExternalLinkDeezer'
| 'issueExternalLinkTidal'
| 'issueExternalLinkQobuz';
id?: string | number;
label: string;
service: string;
type: string;
url?: string;
}> = [];
if (snapshot.spotify_artist_id) {
links.push({
className: 'issueExternalLinkSpotify',
label: 'Spotify Artist',
service: 'Spotify',
type: 'Artist',
url: `https://open.spotify.com/artist/${snapshot.spotify_artist_id}`,
});
}
if (snapshot.spotify_album_id) {
links.push({
className: 'issueExternalLinkSpotify',
label: 'Spotify Album',
service: 'Spotify',
type: 'Album',
url: `https://open.spotify.com/album/${snapshot.spotify_album_id}`,
});
}
if (snapshot.spotify_track_id) {
links.push({
className: 'issueExternalLinkSpotify',
label: 'Spotify Track',
service: 'Spotify',
type: 'Track',
url: `https://open.spotify.com/track/${snapshot.spotify_track_id}`,
});
}
if (snapshot.artist_musicbrainz_id) {
links.push({
className: 'issueExternalLinkMusicBrainz',
label: 'MusicBrainz Artist',
service: 'MusicBrainz',
type: 'Artist',
url: `https://musicbrainz.org/artist/${snapshot.artist_musicbrainz_id}`,
});
}
if (snapshot.musicbrainz_release_id) {
links.push({
className: 'issueExternalLinkMusicBrainz',
label: 'MusicBrainz Release',
service: 'MusicBrainz',
type: 'Release',
url: `https://musicbrainz.org/release/${snapshot.musicbrainz_release_id}`,
});
}
if (snapshot.musicbrainz_recording_id) {
links.push({
className: 'issueExternalLinkMusicBrainz',
label: 'MusicBrainz Recording',
service: 'MusicBrainz',
type: 'Recording',
url: `https://musicbrainz.org/recording/${snapshot.musicbrainz_recording_id}`,
});
}
if (snapshot.artist_deezer_id) {
links.push({
className: 'issueExternalLinkDeezer',
label: 'Deezer Artist',
service: 'Deezer',
type: 'Artist',
url: `https://www.deezer.com/artist/${snapshot.artist_deezer_id}`,
});
}
if (snapshot.album_deezer_id) {
links.push({
className: 'issueExternalLinkDeezer',
label: 'Deezer Album',
service: 'Deezer',
type: 'Album',
url: `https://www.deezer.com/album/${snapshot.album_deezer_id}`,
});
}
if (snapshot.track_deezer_id) {
links.push({
className: 'issueExternalLinkDeezer',
label: 'Deezer Track',
service: 'Deezer',
type: 'Track',
url: `https://www.deezer.com/track/${snapshot.track_deezer_id}`,
});
}
if (snapshot.artist_tidal_id) {
links.push({
className: 'issueExternalLinkTidal',
label: 'Tidal Artist',
service: 'Tidal',
type: 'Artist',
url: `https://listen.tidal.com/artist/${snapshot.artist_tidal_id}`,
});
}
if (snapshot.album_tidal_id) {
links.push({
className: 'issueExternalLinkTidal',
label: 'Tidal Album',
service: 'Tidal',
type: 'Album',
url: `https://listen.tidal.com/album/${snapshot.album_tidal_id}`,
});
}
if (snapshot.artist_qobuz_id) {
links.push({
className: 'issueExternalLinkQobuz',
id: snapshot.artist_qobuz_id,
label: 'Qobuz Artist',
service: 'Qobuz',
type: 'Artist',
});
}
if (snapshot.album_qobuz_id) {
links.push({
className: 'issueExternalLinkQobuz',
id: snapshot.album_qobuz_id,
label: 'Qobuz Album',
service: 'Qobuz',
type: 'Album',
});
}
return links;
}
function getAlbumMetaParts(
issue: IssueRecord,
snapshot: ReturnType<typeof parseSnapshot>,
): string[] {
if (issue.entity_type === 'artist') return [];
const parts: string[] = [];
if (snapshot.year) parts.push(String(snapshot.year));
if (snapshot.record_type) {
const recordType = String(snapshot.record_type);
parts.push(recordType.charAt(0).toUpperCase() + recordType.slice(1));
}
const trackCount =
issue.entity_type === 'album' ? snapshot.track_count : snapshot.album_track_count;
if (trackCount) parts.push(`${trackCount} tracks`);
if (snapshot.label) parts.push(String(snapshot.label));
return parts;
}
function getTrackMetaItems(snapshot: ReturnType<typeof parseSnapshot>) {
const items: Array<{ icon: string; label: string; value: string }> = [];
if (snapshot.track_number) {
items.push({
icon: '#',
label: 'Track',
value: String(snapshot.track_number),
});
}
const duration = formatDuration(snapshot.duration);
if (duration) items.push({ icon: 'T', label: 'Duration', value: duration });
if (snapshot.format) items.push({ icon: 'F', label: 'Format', value: String(snapshot.format) });
if (snapshot.bitrate)
items.push({
icon: 'B',
label: 'Bitrate',
value: `${snapshot.bitrate} kbps`,
});
if (snapshot.bpm) items.push({ icon: 'M', label: 'BPM', value: String(snapshot.bpm) });
if (snapshot.quality)
items.push({
icon: 'Q',
label: 'Quality',
value: String(snapshot.quality),
});
return items;
}

View file

@ -0,0 +1,378 @@
import { useForm } from '@tanstack/react-form';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { z } from 'zod';
import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog';
import {
Button,
FormError,
FormField,
OptionButton,
OptionButtonGroup,
OptionCard,
OptionCardGroup,
TextArea,
TextInput,
} from '@/components/form';
import { Show } from '@/components/primitives';
import { useProfile } from '@/platform/shell/route-controllers';
import type { IssueReportPayload } from '../-issues.types';
import { createIssue, issueCountsQueryOptions, invalidateIssuesQueries } from '../-issues.api';
import {
createDefaultIssueTitle,
getIssueCategoriesForEntity,
getEntityLabel,
} from '../-issues.helpers';
import { ISSUE_CATEGORY_VALUES, ISSUE_PRIORITY_VALUES } from '../-issues.types';
import styles from './issue-detail-modal.module.css';
export function IssueDomainHost() {
const queryClient = useQueryClient();
const profile = useProfile();
const [reportPayload, setReportPayload] = useState<IssueReportPayload | null>(null);
const profileId = profile.profileId;
const refreshIssues = useCallback(() => {
void invalidateIssuesQueries(queryClient);
}, [queryClient]);
const countsQuery = useQuery({
...issueCountsQueryOptions(profileId),
});
useEffect(() => {
if (countsQuery.data) {
updateBadge(countsQuery.data.open || 0);
}
}, [countsQuery.data]);
useEffect(() => {
window.SoulSyncIssueDomain = {
openReportIssue(payload) {
setReportPayload(payload);
},
closeReportIssue() {
setReportPayload(null);
},
refresh: refreshIssues,
};
return () => {
if (window.SoulSyncIssueDomain?.openReportIssue) {
window.SoulSyncIssueDomain = undefined;
}
};
}, [queryClient]);
return (
<Show when={reportPayload}>
{(payload) => (
<ReportIssueModal
key={`${payload.entityType}:${payload.entityId}`}
payload={payload}
profileId={profileId}
onClose={() => setReportPayload(null)}
onSubmitted={() => {
setReportPayload(null);
refreshIssues();
}}
/>
)}
</Show>
);
}
function ReportIssueModal({
onClose,
onSubmitted,
payload,
profileId,
}: {
onClose: () => void;
onSubmitted: () => void;
payload: IssueReportPayload;
profileId: number;
}) {
const categories = useMemo(
() => getIssueCategoriesForEntity(payload.entityType),
[payload.entityType],
);
const createMutation = useMutation({
mutationFn: async (values: ReportIssueFormValues) => {
await createIssue(profileId, {
entity_type: payload.entityType,
entity_id: String(payload.entityId),
category: values.category,
title: values.title,
description: values.description,
priority: values.priority,
});
},
onSuccess: () => {
notify('Issue reported successfully', 'success');
onSubmitted();
},
});
const form = useForm({
defaultValues: DEFAULT_REPORT_ISSUE_VALUES,
validators: {
onSubmit: ({ value }) => validateReportIssueForm(profileId, value),
},
onSubmit: async ({ value, formApi }) => {
const normalizedValues = normalizeReportIssueFormValues(value);
createMutation.reset();
formApi.setErrorMap({ onSubmit: undefined });
try {
await createMutation.mutateAsync(normalizedValues);
} catch (mutationError) {
const message =
mutationError instanceof Error ? mutationError.message : 'Failed to submit issue';
formApi.setErrorMap({ onSubmit: message });
notify(message, 'error');
throw mutationError;
}
},
});
return (
<DialogFrame
open={true}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onClose();
}
}}
className={styles.reportIssueDialog}
>
<DialogHeader
title={`Report Issue - ${getEntityLabel(payload.entityType)}`}
closeLabel="Close report issue modal"
/>
<DialogBody>
<form
className={styles.reportIssueForm}
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit().catch(() => undefined);
}}
>
<div className={styles.reportIssueEntityInfo}>
<div className={styles.reportIssueEntityName}>{payload.entityName}</div>
<Show when={payload.artistName}>
{(artistName) => (
<div className={styles.reportIssueEntityArtist}>
{artistName}
<Show when={payload.albumTitle}>{(albumTitle) => ` - ${albumTitle}`}</Show>
</div>
)}
</Show>
</div>
<FormField
label="What's the problem?"
helperText="Pick the closest match for the report."
>
<form.Field name="category">
{(field) => (
<OptionCardGroup>
{categories.map(([category, meta]) => (
<OptionCard
key={category}
description={meta.description}
icon={meta.icon}
onClick={() => {
field.handleChange(category);
createMutation.reset();
form.setErrorMap({ onSubmit: undefined });
if (!form.getFieldMeta('title')?.isTouched) {
form.setFieldValue(
'title',
createDefaultIssueTitle(category, payload.entityName),
{ dontUpdateMeta: true },
);
}
}}
selected={field.state.value === category}
title={meta.label}
/>
))}
</OptionCardGroup>
)}
</form.Field>
</FormField>
<form.Subscribe selector={(state) => state.values.category}>
{(selectedCategory) => (
<Show when={selectedCategory}>
<>
<form.Field name="title">
{(field) => (
<FormField
helperText="A short summary that makes the problem obvious."
htmlFor="report-issue-title-input"
label="Title"
>
<TextInput
id="report-issue-title-input"
maxLength={200}
onBlur={field.handleBlur}
onChange={(event) => {
field.handleChange(event.target.value);
createMutation.reset();
form.setErrorMap({ onSubmit: undefined });
}}
placeholder="Brief summary of the issue..."
value={field.state.value}
/>
</FormField>
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormField
helperText="Include any details that will help triage the issue."
htmlFor="report-issue-desc-input"
label="Details"
>
<TextArea
id="report-issue-desc-input"
maxLength={2000}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
placeholder="Provide more details about what's wrong..."
rows={4}
value={field.state.value}
/>
</FormField>
)}
</form.Field>
<form.Field name="priority">
{(field) => (
<FormField
helperText="Set the urgency if this needs faster attention."
label="Priority"
>
<OptionButtonGroup>
{ISSUE_PRIORITY_VALUES.map((priority) => (
<OptionButton
key={priority}
onClick={() => field.handleChange(priority)}
selected={field.state.value === priority}
>
{priority[0].toUpperCase()}
{priority.slice(1)}
</OptionButton>
))}
</OptionButtonGroup>
</FormField>
)}
</form.Field>
</>
</Show>
)}
</form.Subscribe>
<form.Subscribe selector={(state) => state.errors}>
{(errors) => {
const error = getReportIssueFormError(errors);
return <FormError message={error} />;
}}
</form.Subscribe>
<DialogFooter>
<Button className={styles.modalButtonSecondary} type="button" onClick={onClose}>
Cancel
</Button>
<form.Subscribe
selector={(state) => ({
category: state.values.category,
isSubmitting: state.isSubmitting,
title: state.values.title,
})}
>
{(state) => {
const isSubmitting = state.isSubmitting || createMutation.isPending;
return (
<Button
className={styles.modalButtonPrimary}
type="submit"
disabled={!state.category || !state.title.trim() || isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Issue'}
</Button>
);
}}
</form.Subscribe>
</DialogFooter>
</form>
</DialogBody>
</DialogFrame>
);
}
const DEFAULT_REPORT_ISSUE_VALUES: ReportIssueFormValues = {
category: '',
description: '',
priority: 'normal',
title: '',
};
const reportIssueFormSchema = z.object({
category: z
.string()
.trim()
.pipe(z.enum(ISSUE_CATEGORY_VALUES, { error: 'Please select an issue category' })),
description: z.string().trim(),
priority: z.enum(ISSUE_PRIORITY_VALUES),
title: z.string().trim().min(1, 'Please provide a title for the issue'),
});
type ReportIssueFormValues = z.input<typeof reportIssueFormSchema>;
type NormalizedReportIssueFormValues = z.output<typeof reportIssueFormSchema>;
function notify(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') {
window.showToast?.(message, type);
}
function updateBadge(openCount: number) {
const badge = document.getElementById('issues-nav-badge');
if (!badge) return;
badge.textContent = String(openCount || 0);
badge.classList.toggle('hidden', !openCount);
}
function normalizeReportIssueFormValues(
values: ReportIssueFormValues,
): NormalizedReportIssueFormValues {
return reportIssueFormSchema.parse(values);
}
function validateReportIssueForm(
profileId: number,
values: ReportIssueFormValues,
): string | undefined {
if (!profileId) return 'Profile is still loading';
const result = reportIssueFormSchema.safeParse(values);
if (result.success) return undefined;
return result.error.issues[0]?.message || 'Unable to submit this issue';
}
function getReportIssueFormError(errors: Array<unknown>): string {
const error = errors.find(Boolean);
if (!error) return '';
if (typeof error === 'string') return error;
if (error instanceof Error) return error.message;
if (typeof error === 'number' || typeof error === 'boolean' || typeof error === 'bigint') {
return String(error);
}
return 'Unable to submit this issue';
}

View file

@ -0,0 +1,725 @@
.issuesContainer {
max-width: 1000px;
margin: 0 auto;
padding: 24px 20px;
color: #fff;
}
.issuesHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
}
.issuesHeaderLeft {
flex: 1;
min-width: 200px;
}
.issuesTitle {
font-size: 24px;
font-weight: 600;
color: #fff;
margin: 0 0 4px;
}
.issuesSubtitle {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
margin: 0;
}
.issuesHeaderRight {
display: flex;
align-items: center;
gap: 10px;
}
.issuesFilters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.issuesStats {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.issuesStatCard {
flex: 1;
min-width: 100px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 14px 16px;
text-align: center;
transition: border-color 0.2s;
}
.issuesStatNumber {
font-size: 22px;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
.issuesStatLabel {
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
.issuesStatOpen {
border-left: 3px solid #ffa500;
}
.issuesStatProgress {
border-left: 3px solid #4da6ff;
}
.issuesStatResolved {
border-left: 3px solid #4ade80;
}
.issuesStatDismissed {
border-left: 3px solid #888;
}
.issuesStatTotal {
border-left: 3px solid rgba(var(--accent-light-rgb), 0.6);
}
.issuesList {
display: flex;
flex-direction: column;
gap: 8px;
}
.issuesLoading,
.issuesEmpty {
text-align: center;
padding: 60px 20px;
color: rgba(255, 255, 255, 0.4);
}
.issuesLoading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px;
font-size: 14px;
}
.issuesSpinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-top-color: rgba(var(--accent-light-rgb), 0.7);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.issuesEmptyIcon {
font-size: 40px;
margin-bottom: 12px;
opacity: 0.5;
}
.issuesEmptyTitle {
font-size: 16px;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 6px;
}
.issuesEmptyText {
font-size: 13px;
color: rgba(255, 255, 255, 0.35);
}
.issueCard {
display: flex;
align-items: stretch;
gap: 14px;
width: 100%;
padding: 14px 16px;
text-align: left;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.03);
color: inherit;
cursor: pointer;
transition:
transform 0.2s ease,
border-color 0.2s ease,
background 0.2s ease,
box-shadow 0.2s ease;
}
.issueCard:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.24);
}
.issueCardLeft {
flex-shrink: 0;
display: flex;
align-items: center;
}
.issueCardThumb {
width: 52px;
height: 52px;
border-radius: 8px;
object-fit: cover;
}
.issueCardThumbPlaceholder {
width: 52px;
height: 52px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.issueCardCenter {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.issueCardTitleRow {
display: flex;
align-items: center;
gap: 6px;
}
.issueCardCategoryIcon {
font-size: 14px;
flex-shrink: 0;
}
.issueCardTitle {
font-size: 14px;
font-weight: 500;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.issueCardResponded {
font-size: 13px;
flex-shrink: 0;
opacity: 0.7;
}
.issueCardEntity {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
flex-wrap: wrap;
}
.issueCardEntityType {
background: rgba(255, 255, 255, 0.08);
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.issueCardEntityName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.issueCardMetaLine {
color: rgba(255, 255, 255, 0.35);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.issueCardDescription {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 500px;
}
.issueCardFooter {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 11px;
color: rgba(255, 255, 255, 0.3);
}
.issueCardProfile {
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
border-radius: 4px;
}
.issueCardDate {
opacity: 0.8;
}
.issueCardRight {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 8px;
}
.issueStatusBadge {
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.3px;
white-space: nowrap;
}
.issueStatusOpen {
background: rgba(255, 165, 0, 0.15);
color: #ffa500;
border: 1px solid rgba(255, 165, 0, 0.25);
}
.issueStatusProgress {
background: rgba(77, 166, 255, 0.15);
color: #4da6ff;
border: 1px solid rgba(77, 166, 255, 0.25);
}
.issueStatusResolved {
background: rgba(74, 222, 128, 0.15);
color: #4ade80;
border: 1px solid rgba(74, 222, 128, 0.25);
}
.issueStatusDismissed {
background: rgba(136, 136, 136, 0.15);
color: #888;
border: 1px solid rgba(136, 136, 136, 0.25);
}
.issuePriorityDot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.issuePriorityLow {
background: #666;
}
.issuePriorityNormal {
background: #4da6ff;
}
.issuePriorityHigh {
background: #ff4d4d;
box-shadow: 0 0 6px rgba(255, 77, 77, 0.4);
}
.modalOverlay {
position: fixed;
inset: 0;
z-index: 11000;
background: rgba(5, 9, 16, 0.76);
backdrop-filter: blur(18px);
display: grid;
place-items: center;
padding: 18px;
}
.modal {
width: min(1060px, 100%);
max-height: min(90vh, 980px);
overflow: auto;
border-radius: 28px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(16, 22, 34, 0.98), rgba(12, 16, 25, 0.98));
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.45);
}
.issueDetailModal {
max-width: 750px;
width: 95vw;
max-height: 85vh;
}
.modalHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 20px 22px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.modalHeaderTitle {
margin: 0 0 8px;
font-size: 1.4rem;
}
.modalClose {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: #fff;
border-radius: 14px;
width: 40px;
height: 40px;
cursor: pointer;
}
.modalBody {
padding: 22px;
display: grid;
gap: 18px;
}
.issueHero {
display: flex;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
margin-bottom: 14px;
}
.issueHeroArtGroup {
position: relative;
flex-shrink: 0;
}
.issueHeroArtistThumb {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
position: absolute;
top: -6px;
left: -10px;
border: 2px solid rgba(30, 30, 30, 0.9);
z-index: 1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
.issueHeroAlbumArt {
width: 140px;
height: 140px;
border-radius: 10px;
object-fit: cover;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.issueHeroAlbumPlaceholder {
width: 140px;
height: 140px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
color: rgba(255, 255, 255, 0.15);
}
.issueHeroInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.issueHeroArtist {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.issueHeroAlbum {
font-size: 20px;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
.issueHeroTrackName {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
margin-top: 2px;
}
.issueHeroMeta {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
margin-top: 2px;
}
.issueDetailInfoBar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
margin-bottom: 14px;
}
.issueDetailInfoLeft,
.issueDetailInfoRight {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.issueDetailCategory {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
}
.issueDetailDate {
color: rgba(255, 255, 255, 0.35);
font-size: 12px;
}
.issueDetailProfile {
background: rgba(255, 255, 255, 0.06);
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
}
.issueDetailSection {
margin-bottom: 16px;
}
.issueDetailSectionTitle {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 8px;
font-weight: 500;
}
.issueDetailTitleText {
font-size: 15px;
font-weight: 500;
color: #fff;
margin-bottom: 8px;
}
.issueDetailDescription {
font-size: 13px;
color: rgba(255, 255, 255, 0.65);
line-height: 1.6;
white-space: pre-wrap;
}
.issueDetailNoDesc {
font-size: 13px;
color: rgba(255, 255, 255, 0.25);
font-style: italic;
}
.issueDetailMetaGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
}
.issueMetaItem {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
}
.issueMetaIcon {
font-size: 14px;
opacity: 0.5;
flex-shrink: 0;
width: 18px;
text-align: center;
}
.issueMetaLabel {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: rgba(255, 255, 255, 0.35);
flex-shrink: 0;
}
.issueMetaValue {
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
margin-left: auto;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
.issueActionButtons {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.issueActionButton {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s ease;
font-family: inherit;
}
.issueActionButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.issueActionDownload {
background: rgba(var(--accent-light-rgb), 0.12);
color: rgb(var(--accent-light-rgb));
border-color: rgba(var(--accent-light-rgb), 0.25);
}
.issueActionDownload:hover:not(:disabled) {
background: rgba(var(--accent-light-rgb), 0.22);
border-color: rgba(var(--accent-light-rgb), 0.4);
}
.issueActionWishlist {
background: rgba(255, 165, 0, 0.1);
color: #ffa500;
border-color: rgba(255, 165, 0, 0.2);
}
.issueActionWishlist:hover:not(:disabled) {
background: rgba(255, 165, 0, 0.2);
border-color: rgba(255, 165, 0, 0.35);
}
@media (max-width: 900px) {
.issuesStats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.issueHero {
flex-direction: column;
align-items: center;
text-align: center;
}
.issueHeroInfo {
align-items: center;
}
.issueDetailInfoBar {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 720px) {
.issuesContainer {
padding: 18px;
}
.issuesHeaderRight {
width: 100%;
}
.issuesFilters {
width: 100%;
}
.issuesStats {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.issuesList {
gap: 10px;
}
.issueCard {
flex-direction: column;
}
.issueCardLeft {
width: 100%;
}
.issueCardThumb,
.issueCardThumbPlaceholder {
width: 100%;
height: 180px;
}
.issueCardRight {
align-items: flex-start;
flex-direction: row;
justify-content: space-between;
}
}

View file

@ -0,0 +1,422 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { Select } from '@/components/form';
import { Show } from '@/components/primitives';
import { useProfile, useReactPageShell } from '@/platform/shell/route-controllers';
import type { IssueCounts, IssuePriority, IssueRecord, IssuesSearch } from '../-issues.types';
import {
issueCountsQueryOptions,
issueListQueryOptions,
invalidateIssuesQueries,
} from '../-issues.api';
import {
formatIssueDate,
getEntityDetails,
getEntityLabel,
getEntityName,
getIssueArtwork,
getPriorityClassName,
ISSUE_CATEGORY_META,
ISSUE_STATUS_META,
getIssueCategoryMeta,
getIssueStatusMeta,
parseSnapshot,
} from '../-issues.helpers';
import { ISSUE_CATEGORY_VALUES, ISSUE_SEARCH_STATUS_VALUES } from '../-issues.types';
import { Route } from '../route';
import { IssueDetailModal } from './issue-detail-modal';
import styles from './issues-page.module.css';
export function IssuesPage() {
useReactPageShell('issues');
const queryClient = useQueryClient();
const navigate = useNavigate({ from: Route.fullPath });
const params = Route.useSearch();
const clearIssueSelection = () => {
void navigate({
to: Route.fullPath,
search: (prev) => ({ ...prev, issueId: undefined }),
replace: true,
});
};
const handleMutationSuccess = () => {
clearIssueSelection();
void invalidateIssuesQueries(queryClient);
};
return (
<>
<IssueBoard />
<IssueDetailModal
issueId={params.issueId}
onClose={clearIssueSelection}
onMutationSuccess={handleMutationSuccess}
/>
</>
);
}
function IssueBoard() {
const { isAdmin, profileId } = useProfile();
const navigate = useNavigate({ from: Route.fullPath });
const params = Route.useSearch();
const countsQuery = useQuery({
...issueCountsQueryOptions(profileId),
});
const issuesQuery = useQuery({
...issueListQueryOptions(profileId, params),
});
const openIssue = (issueId: number) => {
void navigate({
to: Route.fullPath,
search: (prev) => ({ ...prev, issueId }),
});
};
const onCategoryChange = (category: IssuesSearch['category']) => {
void navigate({
to: Route.fullPath,
search: (prev) => ({ ...prev, category }),
replace: true,
});
};
const onStatusChange = (status: IssuesSearch['status']) => {
void navigate({
to: Route.fullPath,
search: (prev) => ({ ...prev, status }),
replace: true,
});
};
return (
<div className={styles.issuesContainer} data-testid="issues-board">
<IssueBoardHeader
isAdmin={isAdmin}
category={params.category}
status={params.status}
onCategoryChange={onCategoryChange}
onStatusChange={onStatusChange}
/>
<IssueBoardStats counts={countsQuery.data ?? EMPTY_ISSUE_COUNTS} />
<IssueBoardList
categoryFilter={params.category}
issues={issuesQuery.data?.issues ?? []}
issuesError={issuesQuery.error}
issuesLoading={issuesQuery.isLoading}
showReporterName={isAdmin}
onIssueSelect={openIssue}
statusFilter={params.status}
/>
</div>
);
}
function IssueBoardHeader({
category,
isAdmin,
status,
onCategoryChange,
onStatusChange,
}: {
category: IssuesSearch['category'];
isAdmin: boolean;
status: IssuesSearch['status'];
onCategoryChange: (category: IssuesSearch['category']) => void;
onStatusChange: (status: IssuesSearch['status']) => void;
}) {
return (
<div className={styles.issuesHeader} id="issues-header">
<div className={styles.issuesHeaderLeft}>
<h2 className={styles.issuesTitle}>Issues</h2>
<p className={styles.issuesSubtitle} id="issues-subtitle">
{isAdmin
? 'Manage and resolve reported library problems'
: 'Track and resolve library problems'}
</p>
</div>
<div className={styles.issuesHeaderRight}>
<div className={styles.issuesFilters} id="issues-filters">
<Select
id="issues-filter-status"
aria-label="Status"
value={status}
onChange={(event) => onStatusChange(event.target.value as IssuesSearch['status'])}
>
{ISSUE_SEARCH_STATUS_VALUES.map((option) => (
<option key={option} value={option}>
{getIssueStatusFilterLabel(option)}
</option>
))}
</Select>
<Select
id="issues-filter-category"
aria-label="Category"
value={category}
onChange={(event) => onCategoryChange(event.target.value as IssuesSearch['category'])}
>
<option value="all">All Categories</option>
{ISSUE_CATEGORY_FILTER_GROUPS.map((group) => (
<optgroup key={group.label} label={group.label}>
{getIssueCategoryFilterOptions(group).map((option) => (
<option key={option} value={option}>
{ISSUE_CATEGORY_META[option].label}
</option>
))}
</optgroup>
))}
</Select>
</div>
</div>
</div>
);
}
function IssueBoardStats({ counts }: { counts: IssueCounts }) {
return (
<div className={styles.issuesStats} id="issues-stats" data-testid="issue-counts">
<IssueStatCard className={styles.issuesStatOpen} label="Open" value={counts.open} />
<IssueStatCard
className={styles.issuesStatProgress}
label="In Progress"
value={counts.in_progress}
/>
<IssueStatCard
className={styles.issuesStatResolved}
label="Resolved"
value={counts.resolved}
/>
<IssueStatCard
className={styles.issuesStatDismissed}
label="Dismissed"
value={counts.dismissed}
/>
<IssueStatCard className={styles.issuesStatTotal} label="Total" value={counts.total} />
</div>
);
function IssueStatCard({
className,
label,
value,
}: {
className: string;
label: string;
value: number;
}) {
return (
<div className={`${styles.issuesStatCard} ${className}`}>
<div className={styles.issuesStatNumber}>{value}</div>
<div className={styles.issuesStatLabel}>{label}</div>
</div>
);
}
}
function IssueBoardList({
categoryFilter,
issues,
issuesError,
issuesLoading,
onIssueSelect,
showReporterName,
statusFilter,
}: {
categoryFilter: string;
issues: IssueRecord[];
issuesError: unknown;
issuesLoading: boolean;
onIssueSelect: (issueId: number) => void;
showReporterName: boolean;
statusFilter: IssuesSearch['status'];
}) {
return (
<div className={styles.issuesList} id="issues-list" data-testid="issue-list">
<IssueBoardListContent />
</div>
);
function IssueBoardListContent() {
if (issuesLoading) {
return (
<div className={styles.issuesLoading}>
<div className={styles.issuesSpinner} />
Loading issues...
</div>
);
}
if (issuesError) {
return (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyTitle}>Failed to load issues</div>
<div className={styles.issuesEmptyText}>
{issuesError instanceof Error ? issuesError.message : 'Unknown error'}
</div>
</div>
);
}
if (issues.length === 0) {
return (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyIcon} aria-hidden="true">
🔍
</div>
<div className={styles.issuesEmptyTitle}>No issues found</div>
<div className={styles.issuesEmptyText}>
{statusFilter !== 'open' || categoryFilter !== 'all'
? 'Try adjusting your filters'
: 'No issues have been reported yet'}
</div>
</div>
);
}
return issues.map((issue) => (
<IssueBoardCard
key={issue.id}
issue={issue}
showReporterName={showReporterName}
onIssueSelect={onIssueSelect}
/>
));
}
}
function IssueBoardCard({
issue,
showReporterName,
onIssueSelect,
}: {
issue: IssueRecord;
showReporterName: boolean;
onIssueSelect: (issueId: number) => void;
}) {
const snapshot = parseSnapshot(issue.snapshot_data);
const artwork = getIssueArtwork(snapshot);
const entityName = getEntityName(issue, snapshot);
const details = getEntityDetails(issue, snapshot);
const statusMeta = getIssueStatusMeta(issue.status) || ISSUE_STATUS_META.open;
const catMeta = getIssueCategoryMeta(issue.category) || ISSUE_CATEGORY_META.other;
const priorityClass = getIssuePriorityClassName(getPriorityClassName(issue.priority));
const statusClassName = getIssueStatusClassName(issue.status);
const createdDate = formatIssueDate(issue.created_at);
return (
<button
className={styles.issueCard}
type="button"
data-testid={`issue-card-${issue.id}`}
onClick={() => onIssueSelect(issue.id)}
>
<div className={styles.issueCardLeft}>
{artwork ? (
<img className={styles.issueCardThumb} src={artwork} alt="" />
) : (
<div className={styles.issueCardThumbPlaceholder}>{catMeta.icon}</div>
)}
</div>
<div className={styles.issueCardCenter}>
<div className={styles.issueCardTitleRow}>
<span className={styles.issueCardCategoryIcon} title={catMeta.label}>
{catMeta.icon}
</span>
<span className={styles.issueCardTitle}>{issue.title}</span>
<Show when={issue.admin_response}>
<span className={styles.issueCardResponded} title="Admin has responded">
💬
</span>
</Show>
</div>
<div className={styles.issueCardEntity}>
<span className={styles.issueCardEntityType}>{getEntityLabel(issue.entity_type)}</span>
<span className={styles.issueCardEntityName}>{entityName}</span>
<Show when={details.length}>
<span className={styles.issueCardMetaLine}>{details.join(' - ')}</span>
</Show>
</div>
<Show when={issue.description}>
<div className={styles.issueCardDescription}>{issue.description}</div>
</Show>
<div className={styles.issueCardFooter}>
<span className={styles.issueCardDate}>{createdDate}</span>
<Show when={showReporterName && issue.reporter_name}>
<span className={styles.issueCardProfile}>by {issue.reporter_name}</span>
</Show>
</div>
</div>
<div className={styles.issueCardRight}>
<span className={`${styles.issueStatusBadge} ${statusClassName}`}>{statusMeta.label}</span>
<span
className={`${styles.issuePriorityDot} ${priorityClass}`}
title={`${issue.priority} priority`}
/>
</div>
</button>
);
}
const EMPTY_ISSUE_COUNTS: IssueCounts = {
open: 0,
in_progress: 0,
resolved: 0,
dismissed: 0,
total: 0,
};
const ISSUE_STATUS_CLASS_NAMES: Record<IssueRecord['status'], string> = {
open: styles.issueStatusOpen,
in_progress: styles.issueStatusProgress,
resolved: styles.issueStatusResolved,
dismissed: styles.issueStatusDismissed,
};
const ISSUE_PRIORITY_CLASS_NAMES: Record<IssuePriority, string> = {
high: styles.issuePriorityHigh,
low: styles.issuePriorityLow,
normal: styles.issuePriorityNormal,
};
function getIssueStatusFilterLabel(status: IssuesSearch['status']): string {
if (status === 'all') return 'All Statuses';
return getIssueStatusMeta(status)?.label || status.replace(/_/g, ' ');
}
function getIssueStatusClassName(status: IssueRecord['status']): string {
return ISSUE_STATUS_CLASS_NAMES[status] || styles.issueStatusOpen;
}
function getIssuePriorityClassName(priority: IssuePriority): string {
return ISSUE_PRIORITY_CLASS_NAMES[priority] || styles.issuePriorityNormal;
}
const ISSUE_CATEGORY_FILTER_GROUPS = [
{
label: 'Track Issues',
matches: (applies: Array<'track' | 'album' | 'artist'>) =>
applies.length === 1 && applies.includes('track'),
},
{
label: 'Album Issues',
matches: (applies: Array<'track' | 'album' | 'artist'>) =>
applies.length === 1 && applies.includes('album'),
},
{
label: 'Both',
matches: (applies: Array<'track' | 'album' | 'artist'>) => applies.length > 1,
},
] as const;
function getIssueCategoryFilterOptions(group: (typeof ISSUE_CATEGORY_FILTER_GROUPS)[number]) {
return ISSUE_CATEGORY_VALUES.filter((category) =>
group.matches(ISSUE_CATEGORY_META[category].applies),
);
}

View file

@ -0,0 +1,41 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
import { getProfileHomePath } from '@/platform/shell/bridge';
import {
issueCountsQueryOptions,
issueDetailQueryOptions,
issueListQueryOptions,
} from './-issues.api';
import { issueSearchSchema } from './-issues.types';
import { IssuesPage } from './-ui/issues-page';
export const Route = createFileRoute('/issues')({
validateSearch: issueSearchSchema,
beforeLoad: ({ context }) => {
const { bridge } = context.shell;
if (!bridge.isPageAllowed('issues')) {
throw redirect({ href: getProfileHomePath(bridge), replace: true });
}
},
loaderDeps: ({ search }) => ({
status: search.status,
category: search.category,
issueId: search.issueId ?? null,
}),
loader: async ({ context, deps }) => {
const { profile } = context.shell;
await Promise.all([
context.queryClient.ensureQueryData(issueCountsQueryOptions(profile.profileId)),
context.queryClient.ensureQueryData(issueListQueryOptions(profile.profileId, deps)),
deps.issueId
? context.queryClient.ensureQueryData(
issueDetailQueryOptions(profile.profileId, deps.issueId),
)
: Promise.resolve(),
]);
},
component: IssuesPage,
});

6
webui/src/test/msw.ts Normal file
View file

@ -0,0 +1,6 @@
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
export { HttpResponse, http };
export const server = setupServer();

1
webui/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -1,5 +1,7 @@
// SoulSync WebUI JavaScript - Replicating PyQt6 GUI Functionality
const PAGE_WILL_CHANGE_EVENT = 'ss:webui-page-will-change';
// Global state management
let currentPage = 'dashboard';
let currentTrack = null;

View file

@ -3413,6 +3413,16 @@ function closeHelperSearch() {
// projects that span multiple commits before shipping. Strip the flag at
// release time and add a real `date:` line at the top of the version block.
const WHATS_NEW = {
'2.5.2': [
// --- May 13, 2026 — 2.5.2 release ---
{ date: 'May 13, 2026 — 2.5.2 release' },
{ title: 'Download Missing Modal: Tracklist Got A Polish Pass', desc: 'visual tune-up only — column layout untouched. hairline row dividers, accent gradient + edge bar on hover, monospace track numbers (glow accent on row hover), monospace tabular duration. status text in both library-match + download-status columns picks up a leading colored dot with a soft halo (green found / amber missing / blue checking / orange downloading / red failed) and pulses while in-flight. artist column centered. soft scrollbar.', page: 'downloads' },
{ title: 'Search Source Picker: Fix Default Always Sticking To Spotify', desc: 'enhanced search + global search source picker always defaulted to spotify even when the user\'s primary metadata source was deezer / itunes / discogs / etc. trace: `shared-helpers.js:createSearchController` reads `/status.metadata_source` to pick the initial active icon, then checks `SOURCE_LABELS[src]` to validate. backend was returning `metadata_source` as a dict (`{source, connected, response_time, ...}` — used elsewhere for connection-state display), so `SOURCE_LABELS[<dict>]` was always undefined, the `if` guard never fired, and `state.activeSource` silently stayed at the hardcoded `\'spotify\'` default. fix: read `.source` off the dict (with forward-compat fallback to plain-string in case any older /status response shape predates the dict change). other consumers (core.js sidebar tile, helper.js status checker, search.js display) already used `?.source` correctly — this was the only stale call site.', page: 'search' },
{ title: 'Download Discography: No Longer Caps Prolific Artists At 50 Releases', desc: 'discord report: clicking "download discography" on an artist with a deep catalogue (bach, beatles complete box, dance / electronic artists with hundreds of remixes) only showed ~50 albums in the modal. trace: `MetadataLookupOptions(limit=50, max_pages=0)` was hardcoded at the discography endpoint and the artist-detail discography view. spotify\'s `max_pages=0` already paginates through everything (per-page is clamped to 10 internally) so spotify-primary users were unaffected. but deezer / itunes / discogs / hydrabase all honor the outer `limit` as a hard cap. fix: bump `limit` from 50 to 200 at all three call sites (`web_server.py` discography endpoint + artist-detail view + `core/artist_source_detail.py`). 200 matches iTunes\'s and Discogs\'s own internal caps and covers near-everyone\'s full catalogue. spotify behavior unchanged.', page: 'library' },
{ title: 'Artist Page: "Write Artist Image" Button (Real Artist Photos For Navidrome)', desc: 'github issue #572 (rhwc): navidrome shows album-art-derived thumbnails as artist photos because navidrome has no api for setting an artist image — it only reads `artist.jpg` from the artist folder during library scans. soulsync\'s `update_artist_poster` for navidrome was a no-op. new button on the artist detail page header writes `artist.jpg` to the artist\'s folder on disk: looks up any album track, resolves it through the path resolver (handles docker mount translation like #558 settled on), goes up one level to the artist folder, fetches the artist photo from the configured metadata source priority chain (spotify primary, fallback to deezer / discogs / etc), downloads with content-type validation + atomic write via `<filename>.tmp + os.replace`. when active server is navidrome, triggers a library scan immediately so the new file gets indexed. respects existing `artist.jpg` files (asks before overwriting) so user-supplied photos aren\'t clobbered. works for plex / jellyfin too as a fallback layer — both servers also read `artist.jpg` from disk. 26 tests pin the pure helpers in `core/library/artist_image.py`: folder derivation (trailing slash / backslash / empty / non-string), image url picking (missing attr / whitespace strip / non-string), download (non-image content-type / 404 / timeout / empty body), and write (atomic replace / temp-cleanup-on-failure / overwrite guard / missing folder).', page: 'library' },
{ title: 'Library History: Per-Download Audit Trail Modal', desc: 'each download row in library history now has an "audit" button that opens a second modal visualizing the download lifecycle as a vertical chain of decision blocks: request → source selected → source match → verification → post processing → final placement. each step has a status (complete / partial / unknown / error) with a color-coded node, plus a card showing what was decided and the supporting metadata. post-processing step infers observable changes from source-vs-final state (format conversion, file rename via tag template, title/artist rewrite, folder template). new "embedded tags" section below the flow reads the audio file live via mutagen at audit-open time and surfaces every tag actually on the file — title / artist / album / album artist / date / genre / track # / disc # / bpm / mood / style / copyright / publisher / release type+status+country / barcode / catalog # / asin / isrc / replaygain values / cover-art status / lyrics / every source id (spotify, tidal, deezer, musicbrainz, audiodb, lastfm, genius, itunes, beatport ...). file is the single source of truth — a persisted snapshot would drift the moment a background enrichment worker writes more tags. clean fallback when file is missing or unreadable. 19 tests pin the pure mutagen reader: id3 path (TIT2/TPE1/TALB + TXXX user-defined frames + USLT + APIC cover-art), vorbis path (FLAC dict-style + pass-through for unknown _id / _url keys), mp4 stub, format+bitrate+duration metadata, defensive paths (empty path, missing file, mutagen returns None, mutagen raises), stringify edge cases (list / tuple / int / frame-with-text / whitespace). files: core/library/file_tags.py (new mutagen reader), web_server.py (new GET /api/library/history/<id>/file-tags endpoint), webui/index.html (audit-overlay modal), webui/static/wishlist-tools.js (renderer + async fetch + tag-grid render), webui/static/style.css (flow + tags section + lyrics block styles).', page: 'wishlist' },
{ title: '$albumtype Folder Template Now Splits EPs / Singles For Non-Spotify Sources', desc: 'discord report (cal): downloading an artist\'s discography with `$albumtype` in the path template put every release under `Album/` regardless of actual type — eps, singles, all dumped into the album folder. trace: the legacy duck-typed album-info builder at `core/metadata/album_tracks.py:_build_album_info_legacy` only checked the `album_type` key. spotify uses `album_type` (lowercase) so spotify discographies worked. but deezer\'s api uses `record_type`, tidal uses `type` (uppercase ALBUM/EP/SINGLE), and some flattened musicbrainz shapes use `primary-type` — none of those matched, all defaulted to `album`. fix: widen the legacy lookup to check `album_type` / `record_type` / `type` / `primary-type` and route the value through a new pure `_normalize_album_type` helper that lowercases + validates against the canonical token set (`album` / `single` / `ep` / `compilation`) and falls back to `album` for unknowns. typed-converter path for spotify / deezer / itunes / discogs / musicbrainz / hydrabase / qobuz unchanged — they were already correct. tidal users were the main offender (no typed converter for dict-shaped tidal data). 25 new tests pin: case-insensitive normalization for each canonical type, compilation preserved (spotify supports it), unknown values default to album, defensive against none / empty / non-string inputs, multi-key precedence (`album_type` wins over `record_type`), each known source shape produces correct token, generic `type=track` / `type=artist` collision case defaults to album rather than poisoning the path.', page: 'tools' },
],
'2.5.1': [
// --- May 12, 2026 — 2.5.1 release ---
{ date: 'May 12, 2026 — 2.5.1 release' },
@ -4329,7 +4339,7 @@ function _getLatestWhatsNewVersion() {
const versions = Object.keys(WHATS_NEW)
.filter(v => _compareVersions(v, buildVer) <= 0)
.sort((a, b) => _compareVersions(b, a));
return versions[0] || '2.5.1';
return versions[0] || '2.5.2';
}
function openWhatsNew() {
@ -4529,17 +4539,6 @@ const TROUBLESHOOT_RULES = [
'Consider switching to iTunes temporarily to continue working'
]
},
{
selector: '.issue-card.status-open, .issues-stat-open',
title: 'Open Issues in Library',
steps: [
'Open issues have been reported for tracks in your library',
'Go to the Issues page to review and resolve them',
'Common issues: wrong track downloaded, bad metadata, low audio quality',
'Each issue has fix suggestions and action buttons'
],
action: { label: 'View Issues', fn: () => navigateToPage('issues') }
},
];
function activateTroubleshootMode() {

View file

@ -1,5 +1,20 @@
// INITIALIZATION
// ===============================
let navigationEpoch = 0;
function notifyPageWillChange(nextPageId) {
const fromPageId = typeof currentPage === 'string' ? currentPage : null;
if (fromPageId === nextPageId) return;
window.dispatchEvent(
new CustomEvent(PAGE_WILL_CHANGE_EVENT, {
detail: {
fromPageId,
toPageId: nextPageId,
},
}),
);
}
// ---- Accent Color System ----
@ -186,55 +201,54 @@ function applyReduceEffects(enabled) {
// ── Profile System ─────────────────────────────────────────────
let currentProfile = null;
const PROFILE_CONTEXT_CHANGED_EVENT = 'ss:webui-profile-context-changed';
// Normalize legacy allowed_pages entries so the profile edit forms (which are
// built from the new pageLabels map containing 'search') don't drop access
// when saving a profile that was stored before the Search page rename.
// 'downloads' was the old Search id, 'artists' was the retired inline page.
function _normalizeLegacyAllowedPages(pages) {
if (!Array.isArray(pages)) return pages;
const mapped = pages.map(id => (id === 'downloads' || id === 'artists') ? 'search' : id);
return [...new Set(mapped)];
function notifyProfileContextChanged() {
window.dispatchEvent(new CustomEvent(PROFILE_CONTEXT_CHANGED_EVENT));
}
function _normalizeLegacyHomePage(homePage) {
if (homePage === 'downloads' || homePage === 'artists') return 'search';
return homePage;
function setCurrentProfile(profile) {
currentProfile = profile;
updateProfileIndicator();
notifyProfileContextChanged();
}
// Temporary compatibility shim until existing profile rows are migrated to
// the current page ids.
const LEGACY_PROFILE_PAGE_ALIASES = {
downloads: 'search',
artists: 'search',
};
function normalizeProfilePageId(pageId) {
return LEGACY_PROFILE_PAGE_ALIASES[pageId] || pageId;
}
function normalizeProfilePageList(pageIds) {
if (!Array.isArray(pageIds)) return pageIds;
return pageIds.map(normalizeProfilePageId);
}
function getProfileHomePage() {
if (!currentProfile) return 'dashboard';
// Legacy profiles stored the Search page as either 'downloads' (the old id
// before the rename) or 'artists' (the retired inline Artists page).
// Both fold into the unified Search page now.
let home = currentProfile.home_page;
if (home === 'downloads' || home === 'artists') home = 'search';
if (home) return home;
if (currentProfile.home_page) return normalizeProfilePageId(currentProfile.home_page);
return currentProfile.is_admin ? 'dashboard' : 'discover';
}
function isPageAllowed(pageId) {
if (!currentProfile) return true;
if (currentProfile.id === 1) return true;
if (pageId === 'help' || pageId === 'issues') return true;
if (pageId === 'settings') return currentProfile.is_admin;
if (pageId === 'artist-detail') {
// artist-detail is reachable from both Library and Search results, so
// either grant unlocks it. Without this, a Search-only profile couldn't
// open a source artist, and a legacy artists-only profile would hit the
// home-redirect recursion path below.
const ap = currentProfile.allowed_pages;
const normalizedPageId = normalizeProfilePageId(pageId);
if (normalizedPageId === 'help' || normalizedPageId === 'issues') return true;
if (normalizedPageId === 'settings') return currentProfile.is_admin;
if (normalizedPageId === 'artist-detail') {
const ap = normalizeProfilePageList(currentProfile.allowed_pages);
if (!ap) return true;
return ap.includes('library') || ap.includes('search')
|| ap.includes('downloads') || ap.includes('artists');
return ap.includes('library') || ap.includes('search');
}
const ap = currentProfile.allowed_pages;
const ap = normalizeProfilePageList(currentProfile.allowed_pages);
if (!ap) return true; // null = all pages
if (ap.includes(pageId)) return true;
// Legacy compat: 'downloads' (old Search id) and 'artists' (retired inline
// Artists page) both fold into the unified 'search' page.
if (pageId === 'search' && (ap.includes('downloads') || ap.includes('artists'))) return true;
if ((pageId === 'downloads' || pageId === 'artists') && ap.includes('search')) return true;
if (ap.includes(normalizedPageId)) return true;
return false;
}
@ -244,6 +258,26 @@ function canDownload() {
return currentProfile.can_download !== false && currentProfile.can_download !== 0;
}
function getCurrentProfileContext() {
if (!currentProfile) return null;
return {
profileId: currentProfile.id,
isAdmin: !!currentProfile.is_admin,
};
}
function activatePage(pageId, options = {}) {
const forceReload = options.forceReload === true;
const pageElement = document.getElementById(`${pageId}-page`);
const isPageVisible = pageElement ? pageElement.classList.contains('active') : false;
if (!forceReload && pageId === currentPage && isPageVisible) return;
showLegacyPage(pageId);
setActivePageChrome(pageId);
loadPageData(pageId);
}
function renderProfileAvatar(el, profile) {
// Renders avatar as image (if avatar_url set) or colored initial fallback
// Preserves existing classes, ensures 'profile-avatar' is present
@ -273,8 +307,7 @@ async function initProfileSystem() {
const currentRes = await fetch('/api/profiles/current');
const currentData = await currentRes.json();
if (currentData.success && currentData.profile) {
currentProfile = currentData.profile;
updateProfileIndicator();
setCurrentProfile(currentData.profile);
// Check if launch PIN is required
if (currentData.launch_pin_required) {
@ -705,10 +738,9 @@ function showPinDialog(profile) {
window.location.reload();
return;
}
currentProfile = data.profile;
dialog.style.display = 'none';
hideProfilePicker();
updateProfileIndicator();
setCurrentProfile(data.profile);
initApp();
return;
} else {
@ -755,8 +787,7 @@ async function selectProfile(profileId) {
});
const data = await res.json();
if (data.success) {
currentProfile = data.profile;
updateProfileIndicator();
setCurrentProfile(data.profile);
// Join profile-scoped WebSocket room for watchlist/wishlist count updates
if (socket && socket.connected) {
socket.emit('profile:join', { profile_id: profileId, old_profile_id: oldProfileId });
@ -1381,6 +1412,96 @@ function _invalidateListenBrainzCache() {
}
}
const PROFILE_PAGE_LABELS = {
dashboard: 'Dashboard',
sync: 'Sync',
search: 'Search',
discover: 'Discover',
watchlist: 'Watchlist',
wishlist: 'Wishlist',
automations: 'Automations',
'active-downloads': 'Downloads',
library: 'Library',
stats: 'Listening Stats',
'playlist-explorer': 'Playlist Explorer',
import: 'Import',
tools: 'Tools',
hydrabase: 'Hydrabase',
issues: 'Issues',
help: 'Help & Docs',
settings: 'Settings',
'artist-detail': 'Artist Detail',
};
function getProfilePageLabel(pageId) {
return PROFILE_PAGE_LABELS[pageId] || pageId.split('-').map(part => part ? part[0].toUpperCase() + part.slice(1) : part).join(' ');
}
function getProfilePageSelectOptions(profileSettings = {}) {
const options = [];
const seen = new Set();
const homeSelect = document.getElementById('new-profile-home-page');
const normalizedHomePage = normalizeProfilePageId(profileSettings.home_page);
if (homeSelect) {
homeSelect.querySelectorAll('option').forEach(option => {
if (!option.value || seen.has(option.value)) return;
options.push({
value: option.value,
label: option.textContent?.trim() || getProfilePageLabel(option.value),
});
seen.add(option.value);
});
}
if (normalizedHomePage && !seen.has(normalizedHomePage)) {
options.push({
value: normalizedHomePage,
label: getProfilePageLabel(normalizedHomePage),
});
seen.add(normalizedHomePage);
}
return options;
}
function getProfilePageAccessOptions(profileSettings = {}) {
const options = [];
const seen = new Set();
const allowedSet = Array.isArray(profileSettings.allowed_pages)
? new Set(normalizeProfilePageList(profileSettings.allowed_pages))
: null;
const accessContainer = document.getElementById('new-profile-allowed-pages');
if (accessContainer) {
accessContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => {
if (seen.has(cb.value)) return;
options.push({
value: cb.value,
label: cb.parentElement?.textContent?.trim() || getProfilePageLabel(cb.value),
checked: cb.disabled ? true : (allowedSet ? allowedSet.has(cb.value) : true),
disabled: cb.disabled,
});
seen.add(cb.value);
});
}
if (allowedSet) {
allowedSet.forEach(pageId => {
if (seen.has(pageId)) return;
options.push({
value: pageId,
label: getProfilePageLabel(pageId),
checked: true,
disabled: false,
});
seen.add(pageId);
});
}
return options;
}
function initProfileManagement() {
const manageBtn = document.getElementById('manage-profiles-btn');
const closeBtn = document.getElementById('profile-manage-close');
@ -1597,13 +1718,8 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
const isAdmin = currentProfile && currentProfile.is_admin;
const isEditingAdmin = profileSettings.is_admin;
const editColors = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#3b82f6', '#ef4444', '#8b5cf6', '#14b8a6'];
// 'search' replaces the legacy 'downloads'/'artists' ids; legacy values are
// normalized on read below so saving a legacy profile upgrades it.
const pageLabels = {
dashboard: 'Dashboard', sync: 'Sync', search: 'Search', discover: 'Discover',
automations: 'Automations', library: 'Library', stats: 'Listening Stats',
'playlist-explorer': 'Playlist Explorer', import: 'Import', help: 'Help & Docs'
};
const pageSelectOptions = getProfilePageSelectOptions(profileSettings);
const pageAccessOptions = getProfilePageAccessOptions(profileSettings);
const form = document.createElement('div');
form.id = 'profile-edit-form';
@ -1653,16 +1769,12 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
defaultOpt.value = '';
defaultOpt.textContent = isEditingAdmin ? 'Default (Dashboard)' : 'Default (Discover)';
homeSelect.appendChild(defaultOpt);
// Normalize legacy ids so the form shows the right options/selection for
// profiles saved before the Search page rename.
const allowedSet = _normalizeLegacyAllowedPages(profileSettings.allowed_pages);
const normalizedHome = _normalizeLegacyHomePage(profileSettings.home_page);
Object.entries(pageLabels).forEach(([id, label]) => {
if (allowedSet && !allowedSet.includes(id)) return; // Skip non-permitted
const normalizedHome = profileSettings.home_page;
pageSelectOptions.forEach(({ value, label }) => {
const opt = document.createElement('option');
opt.value = id;
opt.value = value;
opt.textContent = label;
if (id === normalizedHome) opt.selected = true;
if (value === normalizedHome) opt.selected = true;
homeSelect.appendChild(opt);
});
form.appendChild(homeSelect);
@ -1678,26 +1790,18 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
const apContainer = document.createElement('div');
apContainer.className = 'profile-page-checkboxes';
Object.entries(pageLabels).forEach(([id, label]) => {
pageAccessOptions.forEach(({ value, label, checked, disabled }) => {
const lbl = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = id;
cb.checked = !allowedSet || allowedSet.includes(id);
cb.value = value;
cb.checked = checked;
cb.disabled = disabled;
lbl.appendChild(cb);
lbl.appendChild(document.createTextNode(' ' + label));
apContainer.appendChild(lbl);
pageCheckboxes.push(cb);
});
// Always-on help
const helpLbl = document.createElement('label');
const helpCb = document.createElement('input');
helpCb.type = 'checkbox';
helpCb.checked = true;
helpCb.disabled = true;
helpLbl.appendChild(helpCb);
helpLbl.appendChild(document.createTextNode(' Help & Docs'));
apContainer.appendChild(helpLbl);
form.appendChild(apContainer);
const dlLabel = document.createElement('label');
@ -1727,8 +1831,9 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
// Admin-only fields
if (isAdmin && !isEditingAdmin && pageCheckboxes.length) {
const allChecked = pageCheckboxes.every(cb => cb.checked);
payload.allowed_pages = allChecked ? null : pageCheckboxes.filter(cb => cb.checked).map(cb => cb.value);
const editablePageCheckboxes = pageCheckboxes.filter(cb => !cb.disabled);
const allChecked = editablePageCheckboxes.every(cb => cb.checked);
payload.allowed_pages = allChecked ? null : editablePageCheckboxes.filter(cb => cb.checked).map(cb => cb.value);
payload.can_download = canDlCheckbox ? canDlCheckbox.checked : true;
}
@ -1749,6 +1854,7 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
if (payload.allowed_pages !== undefined) currentProfile.allowed_pages = payload.allowed_pages;
if (payload.can_download !== undefined) currentProfile.can_download = payload.can_download;
updateProfileIndicator();
notifyProfileContextChanged();
}
loadProfileManageList();
} else {
@ -1787,8 +1893,6 @@ function showSelfEditForm() {
const existing = document.getElementById('self-edit-form');
if (existing) existing.remove();
// 'search' replaces the legacy 'downloads'/'artists' ids; legacy values are
// normalized on read below so saving a legacy profile upgrades it.
const pageLabels = {
dashboard: 'Dashboard', sync: 'Sync', search: 'Search', discover: 'Discover',
automations: 'Automations', library: 'Library', stats: 'Listening Stats',
@ -1826,16 +1930,12 @@ function showSelfEditForm() {
defaultOpt.value = '';
defaultOpt.textContent = 'Default (Discover)';
homeSelect.appendChild(defaultOpt);
// Normalize legacy ids so the form shows the right options/selection for
// profiles saved before the Search page rename.
const ap = _normalizeLegacyAllowedPages(currentProfile.allowed_pages);
const normalizedHome = _normalizeLegacyHomePage(currentProfile.home_page);
Object.entries(pageLabels).forEach(([id, label]) => {
if (ap && !ap.includes(id)) return;
const normalizedHome = currentProfile.home_page;
getProfilePageSelectOptions({ home_page: normalizedHome }).forEach(({ value, label }) => {
const opt = document.createElement('option');
opt.value = id;
opt.value = value;
opt.textContent = label;
if (id === normalizedHome) opt.selected = true;
if (value === normalizedHome) opt.selected = true;
homeSelect.appendChild(opt);
});
form.appendChild(homeSelect);
@ -2003,9 +2103,6 @@ function initApp() {
// Start always-on download polling (batched, minimal overhead)
startGlobalDownloadPolling();
// Load issues badge count
loadIssuesBadge();
// Load initial data
loadInitialData();
@ -2037,22 +2134,20 @@ function initializeNavigation() {
});
});
window.addEventListener('popstate', (event) => {
const page = (event.state && event.state.page) || _getPageFromPath();
if (page && page !== currentPage) {
navigateToPage(page, { skipPushState: true });
}
});
}
const _DEEPLINK_VALID_PAGES = new Set([
'dashboard', 'sync', 'search', 'downloads', 'discover', 'artists', 'automations',
'dashboard', 'sync', 'search', 'discover', 'automations',
'library', 'import', 'settings', 'help', 'issues', 'stats', 'watchlist',
'wishlist', 'active-downloads', 'artist-detail', 'playlist-explorer',
'hydrabase', 'tools'
]);
function _getPageFromPath() {
const router = getWebRouter();
const resolved = router?.resolvePageId?.(window.location.pathname);
if (resolved) return resolved;
const path = window.location.pathname.replace(/^\/+|\/+$/g, '');
if (!path) return 'dashboard';
const basePage = path.split('/')[0];
@ -2163,96 +2258,55 @@ function initializeWatchlist() {
}
function navigateToPage(pageId, options = {}) {
// Backwards-compat aliases — both legacy ids fold into the unified Search page.
// 'downloads' was the Search page's old id; 'artists' was the retired inline
// Artists page, now replaced by clicking artists from the unified Search.
if (pageId === 'downloads' || pageId === 'artists') pageId = 'search';
navigationEpoch += 1;
if (pageId === currentPage) return;
if (!options.forceReload && pageId === currentPage) return;
// Permission guard — redirect to home page if not allowed
if (!isPageAllowed(pageId)) {
const home = getProfileHomePage();
if (home !== currentPage && isPageAllowed(home)) {
navigateToPage(home);
navigateToPage(home, options);
}
return;
}
// Update navigation buttons (only if there's a nav button for this page).
// Pages reachable from many surfaces (artist-detail, playlist-explorer)
// intentionally have no [data-page] match here — the sidebar shouldn't
// imply a section the user didn't actually navigate via.
document.querySelectorAll('.nav-button').forEach(btn => {
btn.classList.remove('active');
});
const navButton = document.querySelector(`[data-page="${pageId}"]`);
if (navButton) {
navButton.classList.add('active');
} else if (pageId === 'artist-detail') {
// Artist-detail is a "pseudo-page" reachable from Library, Search,
// or the global search popover — it has no [data-page] match. Treat
// it as a Library context so the sidebar still anchors the user
// somewhere instead of showing a blank active state.
const libraryBtn = document.querySelector('[data-page="library"]');
if (libraryBtn) libraryBtn.classList.add('active');
const router = getWebRouter();
if (router && !options.skipRouteChange) {
notifyPageWillChange(pageId);
const route = router.routeManifest?.find((entry) => entry.pageId === pageId);
if (route?.kind === 'react') {
showReactHost(pageId);
setActivePageChrome(pageId);
}
return router.navigateToPage(pageId, { replace: options.replace === true });
}
// Update pages
document.querySelectorAll('.page').forEach(page => {
page.classList.remove('active');
});
document.getElementById(`${pageId}-page`).classList.add('active');
currentPage = pageId;
// Refresh the Library button label so artist-detail shows a breadcrumb
// ("Library / Artist Name") and other pages show plain "Library".
if (typeof _updateSidebarLibraryBreadcrumb === 'function') {
_updateSidebarLibraryBreadcrumb();
// Fallback path for initial bootstrap or environments without TanStack routing.
const route = router?.routeManifest?.find((entry) => entry.pageId === pageId);
notifyPageWillChange(pageId);
const legacyPageElement = document.getElementById(`${pageId}-page`);
if (route?.kind === 'react' || !legacyPageElement) {
showReactHost(pageId);
setActivePageChrome(pageId);
} else {
activatePage(pageId, { forceReload: options.forceReload === true });
}
if (!options.skipPushState) {
const urlPath = pageId === 'dashboard' ? '/' : '/' + pageId;
if (window.location.pathname !== urlPath) {
history.pushState({ page: pageId }, '', urlPath);
}
}
// Show/hide global search bar (hide on search page where the unified search lives)
if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility();
// Show/hide discover download sidebar based on page
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (downloadSidebar) {
if (pageId === 'discover') {
// Show sidebar on discover page if there are active downloads
const activeDownloads = Object.keys(discoverDownloads || {}).length;
console.log(`📊 [NAVIGATE] Discover page - ${activeDownloads} active downloads`);
if (activeDownloads > 0) {
// Update the sidebar UI to render the bubbles
console.log(`🔄 [NAVIGATE] Updating discover download bar UI`);
updateDiscoverDownloadBar();
if (options.replace === true) {
history.replaceState({ page: pageId }, '', urlPath);
} else {
history.pushState({ page: pageId }, '', urlPath);
}
} else {
// Always hide sidebar on other pages
downloadSidebar.classList.add('hidden');
}
}
// Load page-specific data
loadPageData(pageId);
// Update page background particles
if (window.pageParticles && window._particlesEnabled !== false) window.pageParticles.setPage(pageId);
// Update worker orbs
if (window.workerOrbs) window.workerOrbs.setPage(pageId);
return true;
}
// REPLACE your old loadPageData function with this one:
// REPLACE your old loadPageData function with this corrected one
async function loadPageData(pageId) {
try {
// Stop any active polling when navigating away
@ -2281,7 +2335,6 @@ async function loadPageData(pageId) {
initializeSearchModeToggle();
initializeFilters();
break;
// 'artists' page retired — aliased to 'search' at the top of navigateToPage
case 'active-downloads':
loadActiveDownloadsPage();
break;
@ -2364,9 +2417,6 @@ async function loadPageData(pageId) {
case 'automations':
await loadAutomations();
break;
case 'issues':
await loadIssuesPage();
break;
case 'help':
initializeDocsPage();
break;
@ -2376,14 +2426,3 @@ async function loadPageData(pageId) {
showToast(`Failed to load ${pageId} data`, 'error');
}
}
// ===============================
// SERVICE STATUS MONITORING
// ===============================
// Legacy function - now handled by fetchAndUpdateServiceStatus
// Keeping this for compatibility but it's no longer actively used
// Old updateStatusIndicator function removed - replaced by updateSidebarServiceStatus
// ===============================

View file

@ -700,6 +700,27 @@ let artistDetailPageState = {
enhancedTrackSort: {}
};
function clearArtistDetailPageState() {
if (artistDetailPageState.completionController) {
artistDetailPageState.completionController.abort();
artistDetailPageState.completionController = null;
}
artistDetailPageState.currentArtistId = null;
artistDetailPageState.currentArtistName = null;
artistDetailPageState.currentArtistSource = null;
artistDetailPageState.originStack = [];
}
if (typeof window !== 'undefined') {
window.addEventListener(PAGE_WILL_CHANGE_EVENT, (event) => {
const detail = event.detail || {};
if (detail.fromPageId === 'artist-detail' && detail.toPageId !== 'artist-detail') {
clearArtistDetailPageState();
}
});
}
// Discography filter state
let discographyFilterState = {
categories: { albums: true, eps: true, singles: true },
@ -1838,7 +1859,7 @@ function createReleaseCard(release) {
content.className = "album-card-content";
const _esc = (s) => String(s || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
content.innerHTML = `
<div class="album-card-name" title="${_esc(release.title)}">${_esc(release.title)}</div>
<div class="album-card-name" title="${_esc(release.title)}">${_esc(release.title)}${release.explicit === true ? ' <span class="explicit-badge">E</span>' : ''}</div>
${yearText ? `<div class="album-card-year">${_esc(yearText)}</div>` : ''}
`;
card.appendChild(content);
@ -2486,7 +2507,7 @@ function _renderDiscogCard(release, index, completionData) {
${statusIcon ? `<span class="discog-card-status">${statusIcon}</span>` : ''}
</div>
<div class="discog-card-info">
<div class="discog-card-title">${_esc(albumName)}</div>
<div class="discog-card-title">${_esc(albumName)}${release.explicit === true ? ' <span class="explicit-badge">E</span>' : ''}</div>
<div class="discog-card-meta">${year}${year && tracks ? ' · ' : ''}${tracks ? tracks + ' tracks' : ''}</div>
</div>
<div class="discog-card-check"></div>

View file

@ -3264,42 +3264,6 @@
}
}
/* ======================================
ISSUES PAGE Mobile (supplement)
====================================== */
@media (max-width: 768px) {
.issues-header-right {
width: 100%;
flex-wrap: wrap;
}
.issues-filter-select {
flex: 1;
min-width: 0;
}
.issues-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.issues-stat-card {
min-width: 0;
padding: 10px;
}
.issues-stat-value {
font-size: 18px;
}
.issue-card-right {
flex-wrap: wrap;
gap: 6px;
}
}
/* ======================================
HELP / DOCS PAGE Mobile
====================================== */
@ -3737,4 +3701,4 @@
font-size: 12px;
padding: 9px 12px;
}
}
}

View file

@ -1161,6 +1161,9 @@ async function startDownload(index) {
async function loadInitialData() {
try {
const initialPath = window.location.pathname;
const initialNavigationEpoch = navigationEpoch;
// Load artist bubble state first
await hydrateArtistBubblesFromSnapshot();
@ -1177,14 +1180,24 @@ async function loadInitialData() {
? urlPage
: homePage;
history.replaceState({ page: targetPage }, '', (targetPage === 'dashboard' ? '/' : '/' + targetPage) + window.location.search + window.location.hash);
if (targetPage !== 'dashboard') {
navigateToPage(targetPage, { skipPushState: true });
} else {
await loadDashboardData();
loadDashboardSyncHistory();
if (window.location.pathname !== initialPath || navigationEpoch !== initialNavigationEpoch) {
return;
}
// Always apply the target page to the legacy shell chrome.
const router = getWebRouter();
const route = router?.routeManifest?.find((entry) => entry.pageId === targetPage);
if (route?.kind === 'react') {
showReactHost(targetPage);
setActivePageChrome(targetPage);
if (window.location.pathname !== route.path) {
history.replaceState({ page: targetPage }, '', route.path);
}
return;
}
navigateToPage(targetPage, { skipRouteChange: true, forceReload: true });
} catch (error) {
console.error('Error loading initial data:', error);
}
@ -1218,4 +1231,3 @@ async function loadDashboardData() {
}
// ===========================================

View file

@ -255,11 +255,20 @@ function createSearchController({
// /status is public; /api/settings is admin-only and returns 403 for
// non-admin profiles, which previously caused them to silently fall
// back to 'spotify' regardless of what admin had configured.
//
// /status returns `metadata_source` as a dict (`{source, connected,
// response_time, ...}`) — pre-fix this code treated it as a string,
// so `SOURCE_LABELS[<dict>]` was always undefined and activeSource
// silently stayed at the hardcoded 'spotify' default regardless of
// what the user had configured. Read `.source` off the dict; fall
// back to the legacy string shape for forward-compat with any older
// /status response that might predate the dict change.
try {
const resp = await fetch('/status');
if (resp.ok) {
const status = await resp.json();
const src = status && status.metadata_source;
const ms = status && status.metadata_source;
const src = (ms && typeof ms === 'object') ? ms.source : ms;
if (src && SOURCE_LABELS[src]) state.activeSource = src;
}
} catch (_) { /* best-effort */ }

View file

@ -0,0 +1,174 @@
// SoulSync shell bridge glue
// Keep this file loaded after init.js so the legacy shell runtime state is ready.
function getWebRouter() {
return window.SoulSyncWebRouter ?? null;
}
function showLegacyPage(pageId) {
document.querySelectorAll('.page').forEach(page => {
page.classList.remove('active');
});
const page = document.getElementById(`${pageId}-page`);
if (page) {
page.classList.add('active');
}
const reactHost = document.getElementById('webui-react-root');
if (reactHost) {
reactHost.classList.remove('active');
}
}
function setActivePageChrome(pageId) {
document.querySelectorAll('.nav-button').forEach(btn => {
btn.classList.remove('active');
});
const navButton = document.querySelector(`[data-page="${pageId}"]`);
if (navButton) {
navButton.classList.add('active');
} else if (pageId === 'artist-detail') {
// Artist detail is a Library context, so keep the sidebar anchored there.
const libraryBtn = document.querySelector('[data-page="library"]');
if (libraryBtn) {
libraryBtn.classList.add('active');
}
}
currentPage = pageId;
if (typeof _updateSidebarLibraryBreadcrumb === 'function') _updateSidebarLibraryBreadcrumb();
if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility();
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (downloadSidebar) {
if (pageId === 'discover') {
const activeDownloads = typeof discoverDownloads !== 'undefined'
? Object.keys(discoverDownloads).length
: 0;
if (activeDownloads > 0 && typeof updateDiscoverDownloadBar === 'function') {
updateDiscoverDownloadBar();
}
} else {
downloadSidebar.classList.add('hidden');
}
}
if (window.pageParticles && window._particlesEnabled !== false) window.pageParticles.setPage(pageId);
if (window.workerOrbs) window.workerOrbs.setPage(pageId);
}
function showReactHost(pageId) {
document.querySelectorAll('.page').forEach(page => {
page.classList.remove('active');
});
const host = document.getElementById('webui-react-root');
if (host) {
host.classList.add('active');
}
currentPage = pageId;
if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility();
if (window.pageParticles && window._particlesEnabled !== false) window.pageParticles.setPage(pageId);
if (window.workerOrbs) window.workerOrbs.setPage(pageId);
}
function activateLegacyPath(pathname) {
const router = getWebRouter();
const targetPage = router?.resolvePageId?.(pathname) || _getPageFromPath(pathname);
if (!targetPage) return;
if (!isPageAllowed(targetPage)) {
const home = getProfileHomePage();
if (home !== targetPage) {
navigateToPage(home, { replace: true });
}
return;
}
notifyPageWillChange(targetPage);
activatePage(targetPage, { forceReload: true });
}
function syncActivePageFromLocation() {
const router = getWebRouter();
const targetPage = router?.resolvePageId?.(window.location.pathname) || _getPageFromPath(window.location.pathname);
if (!targetPage) return;
if (!isPageAllowed(targetPage)) {
const home = getProfileHomePage();
if (home !== targetPage) {
navigateToPage(home, { replace: true });
}
return;
}
notifyPageWillChange(targetPage);
const route = router?.routeManifest?.find((entry) => entry.pageId === targetPage);
if (route?.kind === 'react') {
showReactHost(targetPage);
} else {
showLegacyPage(targetPage);
}
setActivePageChrome(targetPage);
}
const SHELL_BRIDGE_READY_EVENT = 'ss:webui-shell-bridge-ready';
function openDownloadMissingAlbumWorkflow(input) {
if (typeof openDownloadMissingModalForArtistAlbum !== 'function') {
throw new Error('Download workflow host is not ready yet');
}
return openDownloadMissingModalForArtistAlbum(
input.virtualPlaylistId,
input.playlistName,
input.tracks,
input.album,
input.artist,
false,
);
}
function openAddToWishlistAlbumWorkflow(input) {
if (typeof openAddToWishlistModal !== 'function') {
throw new Error('Wishlist workflow host is not ready yet');
}
return openAddToWishlistModal(input.album, input.artist, input.tracks, input.albumType);
}
window.SoulSyncWorkflowActions = {
openDownloadMissingAlbum: openDownloadMissingAlbumWorkflow,
openAddToWishlistAlbum: openAddToWishlistAlbumWorkflow,
notify(message, type) {
if (typeof showToast === 'function') {
showToast(message, type);
}
},
};
window.SoulSyncWebShellBridge = {
getCurrentProfileContext() {
if (!currentProfile) return null;
return {
profileId: currentProfile.id,
isAdmin: !!currentProfile.is_admin,
};
},
isPageAllowed(pageId) {
return isPageAllowed(pageId);
},
getProfileHomePage() {
return getProfileHomePage();
},
resolveLegacyPath(pathname) {
return getWebRouter()?.resolvePageId?.(pathname) ?? null;
},
setActivePageChrome(pageId) {
setActivePageChrome(pageId);
},
activateLegacyPath(pathname) {
activateLegacyPath(pathname);
},
showReactHost(pageId) {
showReactHost(pageId);
},
};
window.addEventListener('popstate', syncActivePageFromLocation);
window.dispatchEvent(new CustomEvent(SHELL_BRIDGE_READY_EVENT));

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -3080,6 +3080,10 @@ async function loadMetadataCacheStats() {
// ── Library History Modal ────────────────────────────────────────────
let _libraryHistoryState = { tab: 'download', page: 1, limit: 50 };
// id → entry cache so the per-row audit button can look up the
// full entry without round-tripping JSON through DOM data attrs
// (escape headaches when titles / file paths contain quotes).
const _libraryHistoryEntryCache = {};
function openLibraryHistoryModal() {
const overlay = document.getElementById('library-history-overlay');
@ -3147,6 +3151,10 @@ async function loadLibraryHistory() {
return;
}
// Cache entries by id for the per-row audit button lookup.
data.entries.forEach(e => {
if (e && e.id != null) _libraryHistoryEntryCache[e.id] = e;
});
list.innerHTML = data.entries.map(renderHistoryEntry).join('');
renderHistoryPagination(data.total, page, limit);
} catch (err) {
@ -3217,6 +3225,16 @@ function renderHistoryEntry(entry) {
const hasDetails = sourceDetail || acoustidBadge;
const expandIndicator = hasDetails ? `<span class="lh-expand-btn">&#x25BE;</span>` : '';
// Audit button — only for download events (import rows have no
// source/match/verify decisions to trace). stopPropagation keeps
// the click from triggering the row-expand toggle. Inline onclick
// dispatches through `openDownloadAuditModalById` (a function so
// the script-split-integrity test sees it) rather than touching
// the cache directly.
const auditBtn = entry.event_type === 'download' && entry.id != null
? `<button class="lh-audit-btn" title="View audit trail" onclick="event.stopPropagation();openDownloadAuditModalById(${entry.id})">Audit</button>`
: '';
return `<div class="library-history-entry${hasDetails ? ' lh-expandable' : ''}" ${hasDetails ? 'onclick="this.classList.toggle(\'lh-expanded\')"' : ''}>
${thumb}
<div class="library-history-entry-content">
@ -3227,6 +3245,7 @@ function renderHistoryEntry(entry) {
</div>
<div class="library-history-entry-badges">${badge}</div>
<div class="library-history-entry-time">${formatHistoryTime(entry.created_at)}</div>
${auditBtn}
${expandIndicator}
</div>
${hasDetails ? `<div class="library-history-entry-details">
@ -3259,6 +3278,655 @@ function formatHistoryTime(isoStr) {
} catch { return ''; }
}
// ── Download Audit Trail Modal ──────────────────────────────────────
//
// First-pass implementation per spec: builds a "summary audit" from
// fields already on the library_history row. Steps the backend doesn't
// yet capture (per-source plan, per-candidate scoring, post-processing
// substeps) render as 'Not captured yet' rather than fabricating
// details — preserves trust until the backend event recorder lands.
let _downloadAuditEntry = null;
let _downloadAuditActiveTab = 'lifecycle';
let _downloadAuditActiveStep = 'request';
function openDownloadAuditModalById(historyId) {
const entry = _libraryHistoryEntryCache[historyId];
if (!entry) return;
openDownloadAuditModal(entry);
}
function openDownloadAuditModal(entry) {
if (!entry) return;
_downloadAuditEntry = entry;
_downloadAuditActiveTab = 'lifecycle';
_downloadAuditActiveStep = 'request';
const overlay = document.getElementById('download-audit-overlay');
const hero = document.getElementById('download-audit-hero');
const tabs = document.getElementById('download-audit-tabs');
const body = document.getElementById('download-audit-body');
if (!overlay || !hero || !tabs || !body) return;
hero.innerHTML = renderDownloadAuditHero(entry);
tabs.innerHTML = renderDownloadAuditTabs(_downloadAuditActiveTab);
body.innerHTML = renderDownloadAuditTabPanel(_downloadAuditActiveTab, entry);
overlay.classList.remove('hidden');
// Live tag read for the Tags tab. Fire-and-forget on open so the
// data is ready by the time the user switches to the Tags tab.
if (entry.id != null) {
fetchAndRenderEmbeddedTags(entry.id);
}
}
function switchAuditTab(tabName) {
if (!_downloadAuditEntry) return;
if (!['lifecycle', 'tags', 'lyrics'].includes(tabName)) return;
_downloadAuditActiveTab = tabName;
const tabs = document.getElementById('download-audit-tabs');
const body = document.getElementById('download-audit-body');
if (tabs) tabs.innerHTML = renderDownloadAuditTabs(tabName);
if (body) body.innerHTML = renderDownloadAuditTabPanel(tabName, _downloadAuditEntry);
// Re-fire the live fetch when switching to Tags (in case it
// raced past first mount before the user got there).
if (tabName === 'tags' && _downloadAuditEntry.id != null) {
fetchAndRenderEmbeddedTags(_downloadAuditEntry.id);
}
if (tabName === 'lyrics' && _downloadAuditEntry.id != null) {
fetchAndRenderEmbeddedTags(_downloadAuditEntry.id);
}
}
window.switchAuditTab = switchAuditTab;
function selectAuditStep(stepKey) {
if (!_downloadAuditEntry) return;
_downloadAuditActiveStep = stepKey;
const body = document.getElementById('download-audit-body');
if (body) body.innerHTML = renderDownloadAuditTabPanel('lifecycle', _downloadAuditEntry);
}
window.selectAuditStep = selectAuditStep;
function closeDownloadAuditModal() {
const overlay = document.getElementById('download-audit-overlay');
if (overlay) overlay.classList.add('hidden');
_downloadAuditEntry = null;
}
function renderDownloadAuditHero(entry) {
const title = entry.title || 'Unknown Track';
const artist = entry.artist_name || 'Unknown Artist';
const album = entry.album_name || '';
const year = (entry.created_at || '').slice(0, 4); // fallback to history year
// Album-art thumb from row when present; placeholder otherwise.
const hasValidThumb = entry.thumb_url && (entry.thumb_url.startsWith('http://') || entry.thumb_url.startsWith('https://'));
const art = hasValidThumb
? `<img src="${escapeHtml(entry.thumb_url)}" class="download-audit-hero-art" loading="lazy">`
: `<div class="download-audit-hero-art download-audit-hero-art-placeholder">♪</div>`;
const metaParts = [];
if (artist) metaParts.push(escapeHtml(artist));
if (album) metaParts.push(escapeHtml(album));
if (year && /^\d{4}$/.test(year)) metaParts.push(escapeHtml(year));
const meta = metaParts.join(' · ');
const pills = [];
if (entry.download_source) pills.push(_auditHeroPill('source', entry.download_source));
if (entry.quality) pills.push(_auditHeroPill('quality', entry.quality));
if (entry.acoustid_result) pills.push(_auditHeroPill('verify', formatAcoustidLabel(entry.acoustid_result), entry.acoustid_result));
return `
${art}
<div class="download-audit-hero-text">
<div class="download-audit-hero-title">${escapeHtml(title)}</div>
<div class="download-audit-hero-meta">${meta}</div>
<div class="download-audit-hero-pills">${pills.join('')}</div>
</div>
`;
}
function _auditHeroPill(kind, label, statusHint) {
const cls = statusHint ? ` download-audit-hero-pill-${escapeHtml(statusHint)}` : '';
return `<span class="download-audit-hero-pill download-audit-hero-pill-${kind}${cls}">${escapeHtml(label)}</span>`;
}
function renderDownloadAuditTabs(active) {
const tabs = [
{ key: 'lifecycle', label: 'Lifecycle' },
{ key: 'tags', label: 'Tags' },
{ key: 'lyrics', label: 'Lyrics' },
];
return tabs.map(t =>
`<button class="download-audit-tab${t.key === active ? ' active' : ''}" onclick="switchAuditTab('${t.key}')">${t.label}</button>`
).join('');
}
function renderDownloadAuditTabPanel(tabName, entry) {
if (tabName === 'tags') return renderDownloadAuditTagsTab(entry);
if (tabName === 'lyrics') return renderDownloadAuditLyricsTab(entry);
return renderDownloadAuditLifecycleTab(entry);
}
function renderDownloadAuditLifecycleTab(entry) {
const steps = buildDownloadAuditSteps(entry);
const activeStep = steps.find(s => s.key === _downloadAuditActiveStep) || steps[0];
// Horizontal stepper — compact nodes + short labels under each.
const stepperNodes = steps.map((step, i) => {
const isActive = step.key === activeStep.key;
const icon = _auditNodeIcon(step.status || 'unknown');
const connector = i < steps.length - 1 ? '<span class="download-audit-stepper-line"></span>' : '';
return `<button class="download-audit-stepper-node download-audit-stepper-${step.status || 'unknown'}${isActive ? ' active' : ''}" onclick="selectAuditStep('${step.key}')">
<span class="download-audit-stepper-circle">${icon}</span>
<span class="download-audit-stepper-label">${escapeHtml(_auditStepShortLabel(step.key))}</span>
</button>${connector}`;
}).join('');
// Detail card for the selected step.
const detail = activeStep.detail ? `<div class="download-audit-step-detail">${escapeHtml(activeStep.detail)}</div>` : '';
const meta = (activeStep.meta && activeStep.meta.length > 0)
? `<div class="download-audit-step-meta">${activeStep.meta.map(m => `<div>${escapeHtml(String(m))}</div>`).join('')}</div>`
: '';
return `
<div class="download-audit-stepper">${stepperNodes}</div>
<div class="download-audit-step-card download-audit-step-card-${activeStep.status || 'unknown'}">
<div class="download-audit-step-card-title">${escapeHtml(activeStep.title || 'Step')}</div>
${detail}
${meta}
</div>
<div class="download-audit-final-path">
<span class="download-audit-final-path-label">Final path</span>
<code>${escapeHtml(entry.file_path || 'Not captured yet')}</code>
</div>
`;
}
function _auditStepShortLabel(key) {
return ({
request: 'Request',
source: 'Source',
match: 'Match',
verify: 'Verify',
process: 'Process',
transfer: 'Place',
})[key] || key;
}
function renderDownloadAuditTagsTab(entry) {
// Live tags get filled in by fetchAndRenderEmbeddedTags via the
// slot id below. Until the fetch resolves, show a loading state
// so the panel isn't blank.
return `<div class="download-audit-tags-body" id="download-audit-tags-body">
<div class="download-audit-tags-empty">Reading from file</div>
</div>`;
}
function renderDownloadAuditLyricsTab(entry) {
return `<div class="download-audit-lyrics-body" id="download-audit-lyrics-body">
<div class="download-audit-tags-empty">Reading from file</div>
</div>`;
}
function renderEmbeddedTagsSection(entry) {
// Legacy entry point retained for any other caller — now unused
// by the modal directly (Tags tab owns the live render).
return renderDownloadAuditTagsTab(entry);
}
async function fetchAndRenderEmbeddedTags(historyId) {
const tagsSlot = document.getElementById('download-audit-tags-body');
const lyricsSlot = document.getElementById('download-audit-lyrics-body');
if (!tagsSlot && !lyricsSlot) return;
const renderError = (msg) => {
const html = `<div class="download-audit-tags-empty">${escapeHtml(msg)}</div>`;
if (tagsSlot) tagsSlot.innerHTML = html;
if (lyricsSlot) lyricsSlot.innerHTML = html;
};
try {
const resp = await fetch(`/api/library/history/${historyId}/file-tags`);
if (!resp.ok) { renderError(`Could not read file tags (HTTP ${resp.status}).`); return; }
const data = await resp.json();
if (!data.success) { renderError(data.error || 'Could not read file tags.'); return; }
if (data.available === false) { renderError(data.reason || 'File tags not available.'); return; }
if (tagsSlot) tagsSlot.innerHTML = _renderEmbeddedTagsGrid(data);
if (lyricsSlot) lyricsSlot.innerHTML = _renderLyricsBody(data);
} catch (e) {
renderError(`Could not read file tags: ${String(e)}`);
}
}
function _renderLyricsBody(data) {
const tags = data.tags || {};
const text = (tags.lyrics || tags.unsyncedlyrics || '').toString();
if (!text.trim()) {
return `<div class="download-audit-tags-empty">No lyrics embedded in this file.</div>`;
}
// Highlight the timecodes at the start of each line in dim color
// so the body reads like a clean transcript. Pure CSS via a span
// wrapper around each match.
const html = escapeHtml(text)
.replace(/^(\[\d{1,2}:\d{2}(?:\.\d{1,3})?\])/gm, '<span class="download-audit-lyric-timecode">$1</span>')
.replace(/\n/g, '<br>');
return `<div class="download-audit-lyrics-text">${html}</div>`;
}
// Friendly labels for tag keys (lowercased canonical key →
// human-readable). Keys not in the map render with title-case
// fallback applied at render time.
const _AUDIT_TAG_LABELS = {
title: 'Title',
artist: 'Artist',
artists: 'All Artists',
albumartist: 'Album Artist',
album_artist: 'Album Artist',
album: 'Album',
date: 'Date',
year: 'Year',
originaldate: 'Original Date',
genre: 'Genre',
mood: 'Mood',
style: 'Style',
tracknumber: 'Track #',
tracktotal: 'Total Tracks',
discnumber: 'Disc #',
totaldiscs: 'Total Discs',
bpm: 'BPM',
isrc: 'ISRC',
barcode: 'Barcode',
catalognumber: 'Catalog #',
asin: 'ASIN',
copyright: 'Copyright',
publisher: 'Publisher',
language: 'Language',
script: 'Script',
media: 'Media',
releasetype: 'Release Type',
releasestatus: 'Release Status',
releasecountry: 'Country',
composer: 'Composer',
performer: 'Performer',
quality: 'Quality',
replaygain_track_gain: 'Track Gain',
replaygain_track_peak: 'Track Peak',
replaygain_album_gain: 'Album Gain',
replaygain_album_peak: 'Album Peak',
};
// Source-ID services. Each service has a key-prefix matcher and a
// per-key label map. Order matters for display (MusicBrainz first
// since it's the canonical identity layer).
const _AUDIT_SOURCE_SERVICES = [
{
name: 'MusicBrainz',
prefix: 'musicbrainz_',
labels: {
musicbrainz_trackid: 'Track',
musicbrainz_releasetrackid: 'Recording',
musicbrainz_albumid: 'Album',
musicbrainz_artistid: 'Artist',
musicbrainz_albumartistid: 'Album Artist',
musicbrainz_releasegroupid: 'Release Group',
},
},
{ name: 'Spotify', prefix: 'spotify_', labels: { spotify_track_id: 'Track', spotify_artist_id: 'Artist', spotify_album_id: 'Album' } },
{ name: 'Tidal', prefix: 'tidal_', labels: { tidal_track_id: 'Track', tidal_artist_id: 'Artist', tidal_album_id: 'Album' } },
{ name: 'Deezer', prefix: 'deezer_', labels: { deezer_track_id: 'Track', deezer_artist_id: 'Artist', deezer_album_id: 'Album' } },
{ name: 'AudioDB', prefix: 'audiodb_', labels: { audiodb_track_id: 'Track', audiodb_artist_id: 'Artist', audiodb_album_id: 'Album' } },
{ name: 'iTunes', prefix: 'itunes_', labels: { itunes_track_id: 'Track', itunes_artist_id: 'Artist', itunes_album_id: 'Album' } },
{ name: 'Genius', prefix: 'genius_', labels: { genius_track_id: 'Track', genius_url: 'URL' } },
{ name: 'Last.fm', prefix: 'lastfm_', labels: { lastfm_url: 'URL' } },
{ name: 'Beatport', prefix: 'beatport_', labels: { beatport_track_id: 'Track' } },
];
function _auditFriendlyLabel(key, fallbackToTitleCase = true) {
if (_AUDIT_TAG_LABELS[key]) return _AUDIT_TAG_LABELS[key];
if (!fallbackToTitleCase) return key;
// Snake-case → Title Case
return key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
}
function _renderEmbeddedTagsGrid(data) {
const tags = data.tags || {};
const fmt = data.format || '';
const bitrate = data.bitrate || 0;
const duration = data.duration || 0;
const hasPicture = data.has_picture === true;
// Categorize keys. Anything not matched and not a Source ID
// falls under "Other" — but we drop "Other" entirely when its
// only contents are keys already shown elsewhere (like `quality`,
// which is already a chip on the modal status strip).
const TRACK_KEYS = ['title', 'artist', 'artists', 'tracknumber', 'tracktotal', 'discnumber', 'totaldiscs', 'bpm', 'isrc'];
const ALBUM_KEYS = ['album', 'album_artist', 'albumartist', 'date', 'year', 'originaldate', 'genre', 'mood', 'style', 'copyright', 'publisher', 'language', 'script', 'media', 'releasetype', 'releasestatus', 'releasecountry', 'barcode', 'catalognumber', 'asin'];
const REPLAYGAIN_KEYS = ['replaygain_track_gain', 'replaygain_track_peak', 'replaygain_album_gain', 'replaygain_album_peak'];
const LYRICS_KEYS = ['lyrics', 'unsyncedlyrics'];
// Duplicates of top-bar chips that should NOT appear in the
// "Other" bucket (already visible elsewhere in the modal).
const DUPLICATE_KEYS = new Set(['quality']);
const isSourceKey = (k) => /(_id|_url)$/.test(k) || k.startsWith('musicbrainz_');
const buckets = { track: [], album: [], source: {}, replaygain: [], lyrics: [], other: [] };
Object.keys(tags).sort().forEach(key => {
const val = tags[key];
if (!val) return;
if (DUPLICATE_KEYS.has(key)) return;
if (TRACK_KEYS.includes(key)) buckets.track.push([key, val]);
else if (ALBUM_KEYS.includes(key)) buckets.album.push([key, val]);
else if (REPLAYGAIN_KEYS.includes(key)) buckets.replaygain.push([key, val]);
else if (LYRICS_KEYS.includes(key)) buckets.lyrics.push([key, val]);
else if (isSourceKey(key)) {
// Slot into the matching service. Unmatched IDs land in
// an "Other Sources" bucket.
const svc = _AUDIT_SOURCE_SERVICES.find(s => key.startsWith(s.prefix));
const slot = svc ? svc.name : 'Other Sources';
if (!buckets.source[slot]) buckets.source[slot] = [];
buckets.source[slot].push([key, val, svc]);
} else {
buckets.other.push([key, val]);
}
});
// Horizontal file-info chip strip at the top.
const fileChips = [];
if (fmt) fileChips.push(`<span class="download-audit-chip">${escapeHtml(fmt)}</span>`);
if (bitrate) fileChips.push(`<span class="download-audit-chip">${Math.round(bitrate / 1000)} kbps</span>`);
if (duration > 0) fileChips.push(`<span class="download-audit-chip">${_formatDuration(duration)}</span>`);
fileChips.push(`<span class="download-audit-chip">Cover ${hasPicture ? '✓' : '—'}</span>`);
const fileStrip = `<div class="download-audit-tags-chips">${fileChips.join('')}</div>`;
// Pretty key-value rows using friendly labels.
const renderRow = ([key, val], labelOverride) => _auditTagRow(
labelOverride || _auditFriendlyLabel(key),
val,
);
const sections = [];
if (buckets.track.length > 0) {
sections.push(_renderTagGroup('Track', buckets.track.map(p => renderRow(p)).join('')));
}
if (buckets.album.length > 0) {
sections.push(_renderTagGroup('Album', buckets.album.map(p => renderRow(p)).join('')));
}
if (buckets.replaygain.length > 0) {
sections.push(_renderTagGroup('ReplayGain', buckets.replaygain.map(p => renderRow(p)).join('')));
}
if (buckets.other.length > 0) {
sections.push(_renderTagGroup('Other', buckets.other.map(p => renderRow(p)).join('')));
}
// Source IDs section — sub-card per service so the user can
// scan instead of reading a 14-row dump.
let sourcesBlock = '';
const sourceServiceNames = Object.keys(buckets.source);
if (sourceServiceNames.length > 0) {
const orderedNames = _AUDIT_SOURCE_SERVICES
.map(s => s.name)
.filter(n => buckets.source[n])
.concat(sourceServiceNames.filter(n => !_AUDIT_SOURCE_SERVICES.find(s => s.name === n)));
const subCards = orderedNames.map(name => {
const rows = buckets.source[name].map(([key, val, svc]) => {
const label = svc?.labels[key] || _auditFriendlyLabel(key.replace(/^[a-z]+_/, ''));
return _auditTagRow(label, val);
});
return `<div class="download-audit-source-card">
<div class="download-audit-source-card-title">${escapeHtml(name)}</div>
${rows.join('')}
</div>`;
});
sourcesBlock = `<div class="download-audit-tag-group download-audit-sources">
<div class="download-audit-tag-group-title">Source IDs</div>
<div class="download-audit-source-grid">${subCards.join('')}</div>
</div>`;
}
// Lyrics live in their own tab now — don't render them inline.
if (sections.length === 0 && !sourcesBlock && fileChips.length === 0) {
return `<div class="download-audit-tags-empty">No tags were found on this file.</div>`;
}
return `${fileStrip}
<div class="download-audit-tags-grid">${sections.join('')}</div>
${sourcesBlock}`;
}
function _renderTagGroup(title, rowsHtml) {
return `<div class="download-audit-tag-group">
<div class="download-audit-tag-group-title">${escapeHtml(title)}</div>
${rowsHtml}
</div>`;
}
function _formatDuration(seconds) {
const s = Math.round(seconds || 0);
const m = Math.floor(s / 60);
const rem = s % 60;
return `${m}:${rem.toString().padStart(2, '0')}`;
}
function _auditTagRow(label, value) {
return `<div class="download-audit-tag-row">
<span class="download-audit-tag-key">${escapeHtml(label)}</span>
<span class="download-audit-tag-value">${escapeHtml(String(value))}</span>
</div>`;
}
function renderAuditChip(label) {
return `<span class="download-audit-chip">${escapeHtml(label)}</span>`;
}
function renderAuditStep(step, index, total) {
const status = step.status || 'unknown';
const detail = step.detail ? `<div class="download-audit-card-detail">${escapeHtml(step.detail)}</div>` : '';
const meta = (step.meta && step.meta.length > 0)
? `<div class="download-audit-card-meta">${step.meta.map(m => `<div>${escapeHtml(String(m))}</div>`).join('')}</div>`
: '';
const nodeIcon = _auditNodeIcon(status);
return `
<div class="download-audit-step ${escapeHtml(status)}">
<div class="download-audit-node">${nodeIcon}</div>
<div class="download-audit-card">
<div class="download-audit-card-title">${escapeHtml(step.title || 'Step')}</div>
${detail}
${meta}
</div>
</div>
`;
}
function _auditNodeIcon(status) {
switch (status) {
case 'complete': return '&#x2713;'; // ✓
case 'partial': return '&#x25CB;'; // ○ (open circle = partial signal)
case 'error': return '&#x2715;'; // ✕
default: return '&#x2014;'; // — (unknown / not captured)
}
}
function buildDownloadAuditExplanation(entry) {
const source = entry.download_source || 'an unknown source';
const quality = entry.quality ? ` at ${entry.quality}` : '';
const trackPart = entry.title ? `"${entry.title}"` : 'this track';
const artistPart = entry.artist_name ? ` by ${entry.artist_name}` : '';
return `SoulSync downloaded ${trackPart}${artistPart} from ${source}${quality}. Detailed source-by-source decisions, candidate scoring, and post-processing steps are not captured yet — older entries show a summary built from the recorded history fields.`;
}
function buildDownloadAuditSteps(entry) {
return [
{
key: 'request',
title: 'Request Created',
status: 'complete',
detail: `${entry.title || 'Unknown track'}${entry.artist_name ? ` by ${entry.artist_name}` : ''}`,
meta: entry.album_name ? [`Album: ${entry.album_name}`] : [],
},
{
key: 'source',
title: 'Source Selected',
status: entry.download_source ? 'complete' : 'unknown',
detail: entry.download_source
? `Downloaded via ${entry.download_source}`
: 'Download source was not captured.',
meta: entry.quality ? [`Quality: ${entry.quality}`] : [],
},
{
key: 'match',
title: 'Source Match',
status: (entry.source_track_title || entry.source_filename) ? 'complete' : 'partial',
detail: buildSourceMatchDetail(entry),
meta: buildSourceMatchMeta(entry),
},
{
key: 'verify',
title: 'Verification',
status: auditStatusFromAcoustid(entry.acoustid_result),
detail: buildAcoustidDetail(entry.acoustid_result),
meta: [],
},
(() => {
const inferences = inferPostProcessingDetails(entry);
// Library_history rows are written AFTER post-processing
// finishes, so post-processing is provably DONE — the only
// question is which specific steps we can observe from
// before/after state. When any inference fires, status =
// complete and we list the observed changes. Otherwise
// partial — file landed (final placement complete) but
// source vs final state look identical, so we can't claim
// any specific step ran.
return {
key: 'process',
title: 'Post Processing',
status: inferences.length > 0 ? 'complete' : 'partial',
detail: inferences.length > 0
? 'Observed changes between source and final state:'
: 'No observable changes between source and final state.',
meta: inferences,
};
})(),
{
key: 'transfer',
title: 'Final Placement',
status: entry.file_path ? 'complete' : 'unknown',
detail: entry.file_path
? 'File was finalized and recorded in library history.'
: 'Final path not captured.',
meta: entry.file_path ? [entry.file_path] : [],
},
];
}
function inferPostProcessingDetails(entry) {
// Diff observable fields on the history row to surface what post-
// processing demonstrably did. No fabrication — every bullet here
// is provable from the before/after state recorded on the row.
// ReplayGain / lyrics / per-field tag substitutions have no field
// we can observe, so they don't appear here at all.
const out = [];
const src = entry.source_filename || '';
const dst = entry.file_path || '';
const srcExt = _auditExtractExt(src);
const dstExt = _auditExtractExt(dst);
if (srcExt && dstExt && srcExt !== dstExt) {
out.push(`Format conversion: ${srcExt}${dstExt}`);
}
const srcBase = _auditBasename(src);
const dstBase = _auditBasename(dst);
if (srcBase && dstBase && srcBase !== dstBase) {
out.push(`File renamed via tag template: ${srcBase}${dstBase}`);
}
if (entry.source_track_title && entry.title
&& entry.source_track_title.trim().toLowerCase() !== entry.title.trim().toLowerCase()) {
out.push(`Title rewritten from source tags: "${entry.source_track_title}" → "${entry.title}"`);
}
if (entry.source_artist && entry.artist_name
&& entry.source_artist.trim().toLowerCase() !== entry.artist_name.trim().toLowerCase()) {
out.push(`Artist rewritten from source tags: "${entry.source_artist}" → "${entry.artist_name}"`);
}
// Folder template inference: counts non-empty path segments above
// the filename. A flat `/downloads/file.mp3` (1 dir segment) means
// no template ran; `/library/Artist/[Year] Album/01 - Title.mp3`
// (3+ segments) means a multi-level template was applied.
if (dst) {
const normalized = dst.replace(/\\/g, '/');
const segments = normalized.split('/').filter(s => s.length > 0);
// Last segment is the file; count directory segments above it.
if (segments.length >= 4) {
out.push('Folder template applied (multi-level path)');
}
}
return out;
}
function _auditExtractExt(path) {
if (!path) return '';
const lastDot = path.lastIndexOf('.');
const lastSlash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
if (lastDot <= lastSlash || lastDot === -1) return '';
return path.substring(lastDot).toLowerCase();
}
function _auditBasename(path) {
if (!path) return '';
const normalized = path.replace(/\\/g, '/');
const slash = normalized.lastIndexOf('/');
return slash === -1 ? normalized : normalized.substring(slash + 1);
}
function buildSourceMatchDetail(entry) {
const srcTitle = entry.source_track_title || '';
const srcArtist = entry.source_artist || '';
if (srcTitle || srcArtist) {
return `Matched to ${srcTitle || '?'} by ${srcArtist || '?'}`;
}
if (entry.source_filename) {
return `Matched to ${entry.source_filename}`;
}
return 'Source-side match details were not captured.';
}
function buildSourceMatchMeta(entry) {
const out = [];
if (entry.source_filename) out.push(`File: ${entry.source_filename}`);
if (entry.source_track_id) out.push(`Source ID: ${entry.source_track_id}`);
return out;
}
function auditStatusFromAcoustid(result) {
if (!result) return 'unknown';
if (result === 'pass') return 'complete';
if (result === 'fail' || result === 'error') return 'error';
return 'partial'; // skip / disabled / anything else
}
function buildAcoustidDetail(result) {
if (!result) return 'AcoustID verification was not captured for this download.';
const labels = {
pass: 'AcoustID fingerprint matched the expected track.',
fail: 'AcoustID fingerprint did NOT match — track was flagged.',
skip: 'AcoustID verification was skipped for this download.',
disabled: 'AcoustID verification is disabled in settings.',
error: 'AcoustID verification errored out.',
};
return labels[result] || `AcoustID result: ${result}`;
}
function formatAcoustidLabel(result) {
const labels = { pass: 'AcoustID Verified', fail: 'AcoustID Failed', skip: 'AcoustID Skipped', disabled: 'AcoustID Off', error: 'AcoustID Error' };
return labels[result] || `AcoustID: ${result}`;
}
// Expose for onclick="" handlers wired in renderHistoryEntry.
window.openDownloadAuditModalById = openDownloadAuditModalById;
window.openDownloadAuditModal = openDownloadAuditModal;
window.closeDownloadAuditModal = closeDownloadAuditModal;
function renderHistoryPagination(total, page, limit) {
const pagination = document.getElementById('library-history-pagination');
if (!pagination) return;

View file

@ -0,0 +1,133 @@
import { expect, test, type Page } from '@playwright/test';
import { shellRouteManifest, type ShellPageId } from '../src/platform/shell/route-manifest';
async function selectProfile(page: Page, baseURL: string, profileId = 1) {
const response = await page.request.post(new URL('/api/profiles/select', baseURL).toString(), {
data: { profile_id: profileId },
});
expect(response.ok()).toBe(true);
}
async function waitForShellRoute(page: Page, pageId: string) {
if (pageId === 'issues') {
await expect
.poll(async () => page.evaluate(() => document.querySelector('.page.active')?.id ?? ''), {
timeout: 15000,
})
.toBe('webui-react-root');
await expect(page.getByTestId('issues-board')).toBeVisible({ timeout: 15000 });
return;
}
await expect
.poll(async () =>
page.evaluate(() => document.querySelector('.page.active')?.id ?? ''),
)
.toBe(`${pageId}-page`);
}
function getExpectedNavPage(pageId: ShellPageId): string {
if (pageId === 'artist-detail') {
return '';
}
return pageId;
}
async function expectNavHighlight(page: Page, pageId: ShellPageId) {
const navPage = getExpectedNavPage(pageId);
const activeNavPage = await page.evaluate(() => {
return document.querySelector('.nav-button.active')?.getAttribute('data-page') ?? '';
});
expect(activeNavPage).toBe(navPage);
}
async function verifyIssuesRoute(page: Page) {
const appRoot = page.locator('#webui-react-root');
await expect(appRoot).toBeVisible();
await expect(page.getByTestId('issues-board')).toContainText('Issues');
}
function expectedUrlPattern(path: string): RegExp {
if (path === '/issues') {
return /\/issues(?:\?status=open&category=all)?$/;
}
return new RegExp(`${path.replace('/', '\\/')}$`);
}
test('direct load activates all known top-level routes', async ({ page, baseURL }) => {
if (!baseURL) {
test.skip();
return;
}
await selectProfile(page, baseURL);
for (const route of shellRouteManifest) {
const routePage = await page.context().newPage();
try {
await routePage.goto(new URL(route.path, baseURL).toString(), { waitUntil: 'domcontentloaded' });
await waitForShellRoute(routePage, route.pageId);
await expect(routePage).toHaveURL(expectedUrlPattern(route.path));
await expectNavHighlight(routePage, route.pageId);
if (route.pageId === 'issues') {
await verifyIssuesRoute(routePage);
}
} finally {
await routePage.close();
}
}
});
test('browser history restores top-level routes', async ({ page, baseURL }) => {
if (!baseURL) {
test.skip();
return;
}
await selectProfile(page, baseURL);
await page.goto(new URL('/discover', baseURL).toString(), { waitUntil: 'domcontentloaded' });
await waitForShellRoute(page, 'discover');
await page.getByRole('button', { name: 'Issues' }).click();
await waitForShellRoute(page, 'issues');
await expect(page).toHaveURL(/\/issues(?:\?status=open&category=all)?$/);
await page.goBack();
await waitForShellRoute(page, 'discover');
await expect(page).toHaveURL(/\/discover$/);
await page.goForward();
await waitForShellRoute(page, 'issues');
await expect(page).toHaveURL(/\/issues(?:\?status=open&category=all)?$/);
});
test('browser history leaves artist detail when going back to library', async ({
page,
baseURL,
}) => {
if (!baseURL) {
test.skip();
return;
}
await selectProfile(page, baseURL);
await page.goto(new URL('/library', baseURL).toString(), { waitUntil: 'domcontentloaded' });
await waitForShellRoute(page, 'library');
await expect.poll(async () => page.locator('.library-artist-card').count()).toBeGreaterThan(0);
await page.locator('.library-artist-card').first().click();
await waitForShellRoute(page, 'artist-detail');
await expect(page).toHaveURL(/\/artist-detail$/);
await page.goBack();
await waitForShellRoute(page, 'library');
await expect(page).toHaveURL(/\/library$/);
});

30
webui/tsconfig.json Normal file
View file

@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2024",
"useDefineForClassFields": true,
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"paths": {
"@/*": ["./src/*"]
},
"strict": true,
"jsx": "react-jsx",
"allowJs": false,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node", "vitest/globals", "@testing-library/jest-dom", "react", "react-dom"]
},
"include": [
"./src",
"./tests",
"./vitest.setup.ts",
"./vite.config.ts",
"./vitest.config.ts",
"./playwright.config.ts"
],
"references": []
}

31
webui/vite.config.ts Normal file
View file

@ -0,0 +1,31 @@
import { tanstackRouter } from '@tanstack/router-plugin/vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
tanstackRouter({
target: 'react',
}),
react(),
],
base: '/static/dist/',
root: import.meta.dirname,
resolve: {
alias: [
{
find: /^@\//,
replacement: `${path.resolve(import.meta.dirname, 'src')}/`,
},
],
},
build: {
outDir: path.resolve(import.meta.dirname, 'static/dist'),
emptyOutDir: true,
manifest: true,
rolldownOptions: {
input: [path.resolve(import.meta.dirname, 'src/app/main.tsx')],
},
},
});

18
webui/vitest.config.ts Normal file
View file

@ -0,0 +1,18 @@
import { mergeConfig, defineConfig } from 'vitest/config';
import viteConfig from './vite.config';
export default mergeConfig(
viteConfig,
defineConfig({
test: {
include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts', 'src/**/*.spec.tsx'],
exclude: ['tests/**'],
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
css: true,
restoreMocks: true,
},
}),
);

21
webui/vitest.setup.ts Normal file
View file

@ -0,0 +1,21 @@
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { server } from './src/test/msw';
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
Object.defineProperty(window, 'scrollTo', {
value: vi.fn(),
writable: true,
});