commit
63260a48c0
71 changed files with 7662 additions and 3442 deletions
|
|
@ -38,8 +38,6 @@ import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from core.imports.filename import extract_track_number_from_filename
|
|
||||||
|
|
||||||
# `shutil` and `SequenceMatcher` are imported inline inside try_staging_match()
|
# `shutil` and `SequenceMatcher` are imported inline inside try_staging_match()
|
||||||
# to keep the lift byte-identical with the original web_server.py function body.
|
# to keep the lift byte-identical with the original web_server.py function body.
|
||||||
|
|
||||||
|
|
@ -61,6 +59,25 @@ def _coerce_positive_int(value: Any, default: int = 0) -> int:
|
||||||
return coerced if coerced > 0 else default
|
return coerced if coerced > 0 else default
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_explicit_track_number(filename: str) -> int:
|
||||||
|
"""Extract a track number only when the filename visibly carries one."""
|
||||||
|
basename = os.path.splitext(os.path.basename(str(filename or '')))[0].strip()
|
||||||
|
if not basename:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
match = re.match(r"^\d[\-\.](\d{1,2})\s*[\-\.]\s*", basename)
|
||||||
|
if match:
|
||||||
|
num = int(match.group(1))
|
||||||
|
return num if 1 <= num <= 99 else 0
|
||||||
|
|
||||||
|
match = re.match(r"^\(?(\d{1,3})\)?\s*[\-\.)\]]\s*", basename)
|
||||||
|
if match:
|
||||||
|
num = int(match.group(1))
|
||||||
|
return num if 1 <= num <= 999 else 0
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _staging_title_variants(title: Any, normalize: Callable[[str], str]) -> list[str]:
|
def _staging_title_variants(title: Any, normalize: Callable[[str], str]) -> list[str]:
|
||||||
"""Return conservative title variants for release-file matching.
|
"""Return conservative title variants for release-file matching.
|
||||||
|
|
||||||
|
|
@ -320,12 +337,24 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps):
|
||||||
|
|
||||||
file_track_number = (
|
file_track_number = (
|
||||||
_coerce_positive_int(best_match.get('track_number'), 0) or
|
_coerce_positive_int(best_match.get('track_number'), 0) or
|
||||||
extract_track_number_from_filename(best_match.get('full_path', ''))
|
_extract_explicit_track_number(best_match.get('full_path', ''))
|
||||||
)
|
)
|
||||||
file_disc_number = _coerce_positive_int(best_match.get('disc_number'), 1)
|
file_disc_number = _coerce_positive_int(best_match.get('disc_number'), 0)
|
||||||
if _private_album_bundle_staging:
|
if _private_album_bundle_staging:
|
||||||
track_number = file_track_number
|
track_number = (
|
||||||
disc_number = file_disc_number
|
file_track_number or
|
||||||
|
_coerce_positive_int(track_info.get('track_number'), 0) or
|
||||||
|
_coerce_positive_int(track_info.get('trackNumber'), 0) or
|
||||||
|
_coerce_positive_int(getattr(track, 'track_number', 0), 0) or
|
||||||
|
1
|
||||||
|
)
|
||||||
|
disc_number = (
|
||||||
|
file_disc_number or
|
||||||
|
_coerce_positive_int(track_info.get('disc_number'), 0) or
|
||||||
|
_coerce_positive_int(track_info.get('discNumber'), 0) or
|
||||||
|
_coerce_positive_int(getattr(track, 'disc_number', 0), 0) or
|
||||||
|
1
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
track_number = (
|
track_number = (
|
||||||
_coerce_positive_int(track_info.get('track_number'), 0) or
|
_coerce_positive_int(track_info.get('track_number'), 0) or
|
||||||
|
|
@ -337,7 +366,8 @@ def try_staging_match(task_id, batch_id, track, deps: StagingDeps):
|
||||||
_coerce_positive_int(track_info.get('disc_number'), 0) or
|
_coerce_positive_int(track_info.get('disc_number'), 0) or
|
||||||
_coerce_positive_int(track_info.get('discNumber'), 0) or
|
_coerce_positive_int(track_info.get('discNumber'), 0) or
|
||||||
_coerce_positive_int(getattr(track, 'disc_number', 0), 0) or
|
_coerce_positive_int(getattr(track, 'disc_number', 0), 0) or
|
||||||
file_disc_number
|
file_disc_number or
|
||||||
|
1
|
||||||
)
|
)
|
||||||
track_info['track_number'] = track_number
|
track_info['track_number'] = track_number
|
||||||
track_info['disc_number'] = disc_number
|
track_info['disc_number'] = disc_number
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from core.runtime_state import (
|
from core.runtime_state import (
|
||||||
|
|
@ -82,6 +83,7 @@ class StatusDeps:
|
||||||
download_orchestrator: Any = None
|
download_orchestrator: Any = None
|
||||||
run_async: Optional[Callable] = None
|
run_async: Optional[Callable] = None
|
||||||
on_download_completed: Optional[Callable[[str, str, bool], None]] = None
|
on_download_completed: Optional[Callable[[str, str, bool], None]] = None
|
||||||
|
get_persistent_download_history: Optional[Callable[[int], list[dict]]] = None
|
||||||
|
|
||||||
|
|
||||||
# Streaming sources the engine fallback applies to. Soulseek goes through
|
# Streaming sources the engine fallback applies to. Soulseek goes through
|
||||||
|
|
@ -536,6 +538,69 @@ _STATUS_PRIORITY = {
|
||||||
'not_found': 6, 'failed': 7, 'cancelled': 8,
|
'not_found': 6, 'failed': 7, 'cancelled': 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_PERSISTENT_HISTORY_TAIL_LIMIT = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_identity_part(value: Any) -> str:
|
||||||
|
return str(value or '').strip().casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _download_identity(title: Any, artist: Any, album: Any) -> tuple[str, str, str]:
|
||||||
|
return (
|
||||||
|
_normalize_identity_part(title),
|
||||||
|
_normalize_identity_part(artist),
|
||||||
|
_normalize_identity_part(album),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _history_timestamp(value: Any) -> float:
|
||||||
|
if not value:
|
||||||
|
return 0.0
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
# SQLite CURRENT_TIMESTAMP uses "YYYY-MM-DD HH:MM:SS".
|
||||||
|
return datetime.fromisoformat(text.replace('Z', '+00:00')).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(text[:19], '%Y-%m-%d %H:%M:%S').timestamp()
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _build_history_download_item(entry: dict) -> dict:
|
||||||
|
history_id = entry.get('id') or entry.get('history_id') or ''
|
||||||
|
title = entry.get('title') or entry.get('source_track_title') or ''
|
||||||
|
artist = entry.get('artist_name') or entry.get('source_artist') or ''
|
||||||
|
album = entry.get('album_name') or ''
|
||||||
|
created_at = entry.get('created_at') or entry.get('completed_at') or ''
|
||||||
|
source = entry.get('download_source') or ''
|
||||||
|
return {
|
||||||
|
'task_id': f'history-{history_id}' if history_id else f'history-{title}-{created_at}',
|
||||||
|
'title': title,
|
||||||
|
'artist': artist,
|
||||||
|
'album': album,
|
||||||
|
'artwork': entry.get('thumb_url') or '',
|
||||||
|
'status': 'completed',
|
||||||
|
'progress': 100,
|
||||||
|
'error': None,
|
||||||
|
'batch_id': '',
|
||||||
|
'batch_name': source,
|
||||||
|
'batch_source': source,
|
||||||
|
'playlist_id': '',
|
||||||
|
'track_index': 0,
|
||||||
|
'batch_total': 1,
|
||||||
|
'timestamp': _history_timestamp(created_at),
|
||||||
|
'created_at': created_at,
|
||||||
|
'priority': _STATUS_PRIORITY['completed'],
|
||||||
|
'quality': entry.get('quality') or '',
|
||||||
|
'file_path': entry.get('file_path') or '',
|
||||||
|
'is_persistent_history': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
|
def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
|
||||||
"""Flat list of every task across batches, sorted active-first then by recency.
|
"""Flat list of every task across batches, sorted active-first then by recency.
|
||||||
|
|
@ -543,6 +608,7 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
|
||||||
Powers /api/downloads/all for the centralized Downloads page.
|
Powers /api/downloads/all for the centralized Downloads page.
|
||||||
"""
|
"""
|
||||||
items = []
|
items = []
|
||||||
|
live_identities = set()
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
for task_id, task in download_tasks.items():
|
for task_id, task in download_tasks.items():
|
||||||
track_info = task.get('track_info') or {}
|
track_info = task.get('track_info') or {}
|
||||||
|
|
@ -589,6 +655,7 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
|
||||||
artwork = images[0].get('url', '') if isinstance(images[0], dict) else str(images[0])
|
artwork = images[0].get('url', '') if isinstance(images[0], dict) else str(images[0])
|
||||||
|
|
||||||
status = task.get('status', 'queued')
|
status = task.get('status', 'queued')
|
||||||
|
live_identities.add(_download_identity(title, artist, album))
|
||||||
# Determine download progress percentage
|
# Determine download progress percentage
|
||||||
progress = 0
|
progress = 0
|
||||||
if status == 'completed':
|
if status == 'completed':
|
||||||
|
|
@ -625,8 +692,29 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict:
|
||||||
'batch_total': len(batch.get('queue', [])),
|
'batch_total': len(batch.get('queue', [])),
|
||||||
'timestamp': task.get('status_change_time', 0),
|
'timestamp': task.get('status_change_time', 0),
|
||||||
'priority': _STATUS_PRIORITY.get(status, 9),
|
'priority': _STATUS_PRIORITY.get(status, 9),
|
||||||
|
'is_persistent_history': False,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if deps.get_persistent_download_history is not None and len(items) < limit:
|
||||||
|
history_limit = min(limit - len(items), _PERSISTENT_HISTORY_TAIL_LIMIT)
|
||||||
|
try:
|
||||||
|
history_entries = deps.get_persistent_download_history(history_limit) or []
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("[Downloads] persistent history lookup failed: %s", exc)
|
||||||
|
history_entries = []
|
||||||
|
|
||||||
|
appended_history = 0
|
||||||
|
for entry in history_entries:
|
||||||
|
if len(items) >= limit or appended_history >= history_limit:
|
||||||
|
break
|
||||||
|
item = _build_history_download_item(entry)
|
||||||
|
identity = _download_identity(item.get('title'), item.get('artist'), item.get('album'))
|
||||||
|
if identity in live_identities:
|
||||||
|
continue
|
||||||
|
items.append(item)
|
||||||
|
live_identities.add(identity)
|
||||||
|
appended_history += 1
|
||||||
|
|
||||||
# Sort: active first (by priority), then by timestamp desc within each group
|
# Sort: active first (by priority), then by timestamp desc within each group
|
||||||
items.sort(key=lambda x: (x['priority'], -x['timestamp']))
|
items.sort(key=lambda x: (x['priority'], -x['timestamp']))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,10 @@ def build_album_import_context(
|
||||||
"images": album.get("images") or ([] if not track_album_image else [{"url": track_album_image}]),
|
"images": album.get("images") or ([] if not track_album_image else [{"url": track_album_image}]),
|
||||||
"source": source,
|
"source": source,
|
||||||
}
|
}
|
||||||
|
for key in ("format", "country", "status", "label", "disambiguation", "release_group_id"):
|
||||||
|
value = str(album.get(key) or "").strip()
|
||||||
|
if value:
|
||||||
|
normalized_album[key] = value
|
||||||
|
|
||||||
original_search = {
|
original_search = {
|
||||||
"title": normalized_track["name"],
|
"title": normalized_track["name"],
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,12 @@ def _normalize_album_result(album: Any, source: str) -> Dict[str, Any]:
|
||||||
).strip()
|
).strip()
|
||||||
release_date = str(_extract_value(album, "release_date", "releaseDate", default="") or "").strip()
|
release_date = str(_extract_value(album, "release_date", "releaseDate", default="") or "").strip()
|
||||||
album_type = str(_extract_value(album, "album_type", "type", default="album") or "album").strip() or "album"
|
album_type = str(_extract_value(album, "album_type", "type", default="album") or "album").strip() or "album"
|
||||||
|
release_format = str(_extract_value(album, "format", "release_format", default="") or "").strip()
|
||||||
|
country = str(_extract_value(album, "country", default="") or "").strip()
|
||||||
|
status = str(_extract_value(album, "status", default="") or "").strip()
|
||||||
|
label = str(_extract_value(album, "label", default="") or "").strip()
|
||||||
|
disambiguation = str(_extract_value(album, "disambiguation", default="") or "").strip()
|
||||||
|
release_group_id = str(_extract_value(album, "release_group_id", "releaseGroupId", default="") or "").strip()
|
||||||
|
|
||||||
total_tracks = _extract_value(album, "total_tracks", "track_count", default=0)
|
total_tracks = _extract_value(album, "total_tracks", "track_count", default=0)
|
||||||
if isinstance(total_tracks, (list, tuple, set)):
|
if isinstance(total_tracks, (list, tuple, set)):
|
||||||
|
|
@ -225,7 +231,7 @@ def _normalize_album_result(album: Any, source: str) -> Dict[str, Any]:
|
||||||
else:
|
else:
|
||||||
image_url = _extract_value(first_image, "url", "image_url", "src", default="")
|
image_url = _extract_value(first_image, "url", "image_url", "src", default="")
|
||||||
|
|
||||||
return {
|
suggestion = {
|
||||||
"id": album_id or album_name or "unknown-album",
|
"id": album_id or album_name or "unknown-album",
|
||||||
"name": album_name or album_id or "Unknown Album",
|
"name": album_name or album_id or "Unknown Album",
|
||||||
"artist": artist_name or "Unknown Artist",
|
"artist": artist_name or "Unknown Artist",
|
||||||
|
|
@ -235,14 +241,30 @@ def _normalize_album_result(album: Any, source: str) -> Dict[str, Any]:
|
||||||
"album_type": album_type,
|
"album_type": album_type,
|
||||||
"source": source,
|
"source": source,
|
||||||
}
|
}
|
||||||
|
if release_format:
|
||||||
|
suggestion["format"] = release_format
|
||||||
|
if country:
|
||||||
|
suggestion["country"] = country
|
||||||
|
if status:
|
||||||
|
suggestion["status"] = status
|
||||||
|
if label:
|
||||||
|
suggestion["label"] = label
|
||||||
|
if disambiguation:
|
||||||
|
suggestion["disambiguation"] = disambiguation
|
||||||
|
if release_group_id:
|
||||||
|
suggestion["release_group_id"] = release_group_id
|
||||||
|
return suggestion
|
||||||
|
|
||||||
|
|
||||||
def _album_fingerprint(album: Dict[str, Any]) -> Tuple[str, str, str, str]:
|
def _album_fingerprint(album: Dict[str, Any]) -> Tuple[str, ...]:
|
||||||
|
if album.get("source") == "musicbrainz" and album.get("id"):
|
||||||
|
return ("musicbrainz", str(album.get("id", "") or "").strip().casefold())
|
||||||
return (
|
return (
|
||||||
str(album.get("name", "") or "").strip().casefold(),
|
str(album.get("name", "") or "").strip().casefold(),
|
||||||
str(album.get("artist", "") or "").strip().casefold(),
|
str(album.get("artist", "") or "").strip().casefold(),
|
||||||
str(album.get("release_date", "") or "").strip()[:10].casefold(),
|
str(album.get("release_date", "") or "").strip()[:10].casefold(),
|
||||||
str(album.get("album_type", "") or "").strip().casefold(),
|
str(album.get("album_type", "") or "").strip().casefold(),
|
||||||
|
str(album.get("total_tracks", "") or "").strip(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,11 @@ def _build_album_info_typed(album_data: Dict[str, Any], album_id: str,
|
||||||
if isinstance(first, dict):
|
if isinstance(first, dict):
|
||||||
ctx['image_url'] = first.get('url') or ctx.get('image_url')
|
ctx['image_url'] = first.get('url') or ctx.get('image_url')
|
||||||
|
|
||||||
|
for key in ('format', 'country', 'status', 'label', 'disambiguation', 'release_group_id'):
|
||||||
|
value = album_data.get(key)
|
||||||
|
if value:
|
||||||
|
ctx[key] = value
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -327,7 +332,7 @@ def _build_album_info_legacy(album_data: Any, album_id: str,
|
||||||
if not image_url:
|
if not image_url:
|
||||||
image_url = _extract_lookup_value(album_data, 'image_url', 'thumb_url')
|
image_url = _extract_lookup_value(album_data, 'image_url', 'thumb_url')
|
||||||
|
|
||||||
return {
|
album_info = {
|
||||||
'id': _extract_lookup_value(album_data, 'id', 'album_id', 'collectionId', 'release_id', default=album_id) or album_id,
|
'id': _extract_lookup_value(album_data, 'id', 'album_id', 'collectionId', 'release_id', default=album_id) or album_id,
|
||||||
'name': _extract_lookup_value(album_data, 'name', 'title', default=album_name or album_id) or album_name or album_id,
|
'name': _extract_lookup_value(album_data, 'name', 'title', default=album_name or album_id) or album_name or album_id,
|
||||||
'artist': resolved_artist_name or '',
|
'artist': resolved_artist_name or '',
|
||||||
|
|
@ -345,6 +350,11 @@ def _build_album_info_legacy(album_data: Any, album_id: str,
|
||||||
),
|
),
|
||||||
'total_tracks': _extract_lookup_value(album_data, 'total_tracks', 'track_count', default=0) or 0,
|
'total_tracks': _extract_lookup_value(album_data, 'total_tracks', 'track_count', default=0) or 0,
|
||||||
}
|
}
|
||||||
|
for key in ('format', 'country', 'status', 'label', 'disambiguation', 'release_group_id'):
|
||||||
|
value = _extract_lookup_value(album_data, key, default='')
|
||||||
|
if value:
|
||||||
|
album_info[key] = value
|
||||||
|
return album_info
|
||||||
|
|
||||||
|
|
||||||
def _build_album_track_entry(track_item: Any, album_info: Dict[str, Any], source: str) -> Dict[str, Any]:
|
def _build_album_track_entry(track_item: Any, album_info: Dict[str, Any], source: str) -> Dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,41 @@ class MusicBrainzClient:
|
||||||
logger.error(f"Error browsing release-groups for artist {artist_mbid}: {e}")
|
logger.error(f"Error browsing release-groups for artist {artist_mbid}: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@rate_limited
|
||||||
|
def browse_release_group_releases(self, release_group_mbid: str,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0) -> List[Dict[str, Any]]:
|
||||||
|
"""Browse concrete releases that belong to a release-group.
|
||||||
|
|
||||||
|
Release-groups identify the logical album; releases identify the
|
||||||
|
actual edition the user may own (country, format, explicit/clean
|
||||||
|
disambiguation, bonus tracks, track count). Manual import needs the
|
||||||
|
latter so users can choose the matching tracklist.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
'release-group': release_group_mbid,
|
||||||
|
'fmt': 'json',
|
||||||
|
'limit': min(limit, 100),
|
||||||
|
'offset': offset,
|
||||||
|
'inc': 'artist-credits+media+labels+release-groups',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.session.get(
|
||||||
|
f"{self.BASE_URL}/release",
|
||||||
|
params=params,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
releases = data.get('releases', [])
|
||||||
|
logger.debug(f"Browsed {len(releases)} releases for release-group {release_group_mbid}")
|
||||||
|
return releases
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error browsing releases for release-group {release_group_mbid}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
@rate_limited
|
@rate_limited
|
||||||
def search_recordings_by_artist_mbid(self, artist_mbid: str,
|
def search_recordings_by_artist_mbid(self, artist_mbid: str,
|
||||||
limit: int = 100) -> List[Dict[str, Any]]:
|
limit: int = 100) -> List[Dict[str, Any]]:
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,12 @@ class Album:
|
||||||
album_type: str
|
album_type: str
|
||||||
image_url: Optional[str] = None
|
image_url: Optional[str] = None
|
||||||
external_urls: Optional[Dict[str, str]] = None
|
external_urls: Optional[Dict[str, str]] = None
|
||||||
|
format: Optional[str] = None
|
||||||
|
country: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
label: Optional[str] = None
|
||||||
|
disambiguation: Optional[str] = None
|
||||||
|
release_group_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def _cover_art_url(mbid: str, scope: str = 'release') -> Optional[str]:
|
def _cover_art_url(mbid: str, scope: str = 'release') -> Optional[str]:
|
||||||
|
|
@ -316,8 +322,102 @@ class MusicBrainzSearchClient:
|
||||||
album_type=album_type,
|
album_type=album_type,
|
||||||
image_url=image_url,
|
image_url=image_url,
|
||||||
external_urls={'musicbrainz': f'https://musicbrainz.org/release-group/{rg_mbid}'} if rg_mbid else {},
|
external_urls={'musicbrainz': f'https://musicbrainz.org/release-group/{rg_mbid}'} if rg_mbid else {},
|
||||||
|
disambiguation=rg.get('disambiguation') or None,
|
||||||
|
release_group_id=rg_mbid or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _release_total_tracks(self, release: Dict[str, Any]) -> int:
|
||||||
|
total_tracks = 0
|
||||||
|
for medium in release.get('media', []) or []:
|
||||||
|
try:
|
||||||
|
total_tracks += int(medium.get('track-count') or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return total_tracks
|
||||||
|
|
||||||
|
def _release_formats(self, release: Dict[str, Any]) -> str:
|
||||||
|
formats = []
|
||||||
|
for medium in release.get('media', []) or []:
|
||||||
|
fmt = (medium.get('format') or '').strip()
|
||||||
|
if fmt and fmt not in formats:
|
||||||
|
formats.append(fmt)
|
||||||
|
return ', '.join(formats)
|
||||||
|
|
||||||
|
def _release_label(self, release: Dict[str, Any]) -> str:
|
||||||
|
for info in release.get('label-info', []) or []:
|
||||||
|
label = (info.get('label') or {}) if isinstance(info, dict) else {}
|
||||||
|
name = (label.get('name') or '').strip()
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def _release_to_album(self, release: Dict[str, Any],
|
||||||
|
fallback_artist_name: Optional[str] = None) -> Optional[Album]:
|
||||||
|
"""Project a concrete MusicBrainz release into our Album dataclass."""
|
||||||
|
mbid = release.get('id', '')
|
||||||
|
title = release.get('title', '') or ''
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
|
||||||
|
artists = _extract_artist_credit(release.get('artist-credit', []))
|
||||||
|
if not artists and fallback_artist_name:
|
||||||
|
artists = [fallback_artist_name]
|
||||||
|
|
||||||
|
rg = release.get('release-group', {}) or {}
|
||||||
|
primary_type = rg.get('primary-type', '') or ''
|
||||||
|
secondary_types = rg.get('secondary-types', []) or []
|
||||||
|
album_type = _map_release_type(primary_type, secondary_types)
|
||||||
|
rg_mbid = rg.get('id', '') or release.get('release-group-id', '')
|
||||||
|
image_url = self._cached_art(mbid, rg_mbid)
|
||||||
|
|
||||||
|
return Album(
|
||||||
|
id=mbid,
|
||||||
|
name=title,
|
||||||
|
artists=artists if artists else ['Unknown Artist'],
|
||||||
|
release_date=release.get('date', '') or '',
|
||||||
|
total_tracks=self._release_total_tracks(release),
|
||||||
|
album_type=album_type,
|
||||||
|
image_url=image_url,
|
||||||
|
external_urls={'musicbrainz': f'https://musicbrainz.org/release/{mbid}'} if mbid else {},
|
||||||
|
format=self._release_formats(release) or None,
|
||||||
|
country=(release.get('country') or '').strip() or None,
|
||||||
|
status=(release.get('status') or '').strip() or None,
|
||||||
|
label=self._release_label(release) or None,
|
||||||
|
disambiguation=(release.get('disambiguation') or '').strip() or None,
|
||||||
|
release_group_id=rg_mbid or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _release_variant_key(self, album: Album):
|
||||||
|
status_rank = 0 if (album.status or '').lower() == 'official' else 1
|
||||||
|
date = (album.release_date or '9999-99-99')[:10] or '9999-99-99'
|
||||||
|
track_rank = album.total_tracks or 9999
|
||||||
|
country_rank = 0 if (album.country or '') in ('XW', 'US', 'GB') else 1
|
||||||
|
return (
|
||||||
|
status_rank,
|
||||||
|
date,
|
||||||
|
country_rank,
|
||||||
|
track_rank,
|
||||||
|
album.format or '',
|
||||||
|
album.disambiguation or '',
|
||||||
|
album.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _release_group_releases_to_albums(self, rg: Dict[str, Any], artist_name: str,
|
||||||
|
limit: int) -> List[Album]:
|
||||||
|
rg_mbid = rg.get('id', '')
|
||||||
|
if not rg_mbid:
|
||||||
|
return []
|
||||||
|
|
||||||
|
releases = self._client.browse_release_group_releases(rg_mbid, limit=max(limit, 25))
|
||||||
|
albums = []
|
||||||
|
for release in releases:
|
||||||
|
release.setdefault('release-group', rg)
|
||||||
|
album = self._release_to_album(release, fallback_artist_name=artist_name)
|
||||||
|
if album:
|
||||||
|
albums.append(album)
|
||||||
|
albums.sort(key=self._release_variant_key)
|
||||||
|
return albums[:limit]
|
||||||
|
|
||||||
def search_albums(self, query: str, limit: int = 10) -> List[Album]:
|
def search_albums(self, query: str, limit: int = 10) -> List[Album]:
|
||||||
"""Search MusicBrainz for releases (albums).
|
"""Search MusicBrainz for releases (albums).
|
||||||
|
|
||||||
|
|
@ -400,6 +500,13 @@ class MusicBrainzSearchClient:
|
||||||
matched = [rg for rg in rgs if hint_lower in (rg.get('title') or '').lower()]
|
matched = [rg for rg in rgs if hint_lower in (rg.get('title') or '').lower()]
|
||||||
if matched:
|
if matched:
|
||||||
rgs = matched
|
rgs = matched
|
||||||
|
expanded = []
|
||||||
|
for rg in rgs:
|
||||||
|
expanded.extend(self._release_group_releases_to_albums(rg, tname, limit))
|
||||||
|
if len(expanded) >= limit:
|
||||||
|
break
|
||||||
|
if expanded:
|
||||||
|
return expanded[:limit]
|
||||||
else:
|
else:
|
||||||
fallback = self._search_albums_text(title_hint, tname, limit)
|
fallback = self._search_albums_text(title_hint, tname, limit)
|
||||||
if fallback:
|
if fallback:
|
||||||
|
|
@ -436,63 +543,24 @@ class MusicBrainzSearchClient:
|
||||||
|
|
||||||
albums = []
|
albums = []
|
||||||
for r in results:
|
for r in results:
|
||||||
mbid = r.get('id', '')
|
album = self._release_to_album(r)
|
||||||
title = r.get('title', '')
|
if album:
|
||||||
if not title:
|
albums.append(album)
|
||||||
continue
|
|
||||||
|
|
||||||
artists = _extract_artist_credit(r.get('artist-credit', []))
|
# Keep distinct MusicBrainz releases. The same title/artist/date
|
||||||
release_date = r.get('date', '') or ''
|
# can represent explicit, clean, regional, format, or bonus-track
|
||||||
|
# variants with different tracklists, which manual import must let
|
||||||
# Track count from media
|
# the user choose.
|
||||||
total_tracks = 0
|
seen_ids = set()
|
||||||
media = r.get('media', [])
|
unique = []
|
||||||
for m in media:
|
|
||||||
total_tracks += m.get('track-count', 0)
|
|
||||||
|
|
||||||
# Release type
|
|
||||||
rg = r.get('release-group', {})
|
|
||||||
primary_type = rg.get('primary-type', '') or ''
|
|
||||||
secondary_types = rg.get('secondary-types', []) or []
|
|
||||||
album_type = _map_release_type(primary_type, secondary_types)
|
|
||||||
|
|
||||||
# Cover art (non-blocking — skip if slow)
|
|
||||||
rg_mbid = rg.get('id', '')
|
|
||||||
image_url = self._cached_art(mbid, rg_mbid)
|
|
||||||
|
|
||||||
external_urls = {'musicbrainz': f'https://musicbrainz.org/release/{mbid}'} if mbid else {}
|
|
||||||
|
|
||||||
albums.append(Album(
|
|
||||||
id=mbid,
|
|
||||||
name=title,
|
|
||||||
artists=artists if artists else ['Unknown Artist'],
|
|
||||||
release_date=release_date,
|
|
||||||
total_tracks=total_tracks,
|
|
||||||
album_type=album_type,
|
|
||||||
image_url=image_url,
|
|
||||||
external_urls=external_urls,
|
|
||||||
))
|
|
||||||
# Deduplicate: keep best version of each title+artist combo
|
|
||||||
# (prefer ones with release dates and cover art)
|
|
||||||
seen = {}
|
|
||||||
deduped = []
|
|
||||||
for album in albums:
|
for album in albums:
|
||||||
key = (album.name.lower().strip(), ', '.join(album.artists).lower().strip())
|
if album.id and album.id in seen_ids:
|
||||||
if key not in seen:
|
continue
|
||||||
seen[key] = album
|
if album.id:
|
||||||
deduped.append(album)
|
seen_ids.add(album.id)
|
||||||
else:
|
unique.append(album)
|
||||||
existing = seen[key]
|
unique.sort(key=self._release_variant_key)
|
||||||
# Prefer: has date > no date, has art > no art
|
return unique[:limit]
|
||||||
better = False
|
|
||||||
if not existing.release_date and album.release_date:
|
|
||||||
better = True
|
|
||||||
elif not existing.image_url and album.image_url:
|
|
||||||
better = True
|
|
||||||
if better:
|
|
||||||
deduped[deduped.index(existing)] = album
|
|
||||||
seen[key] = album
|
|
||||||
return deduped
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"MusicBrainz album search failed: {e}")
|
logger.warning(f"MusicBrainz album search failed: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
@ -1030,6 +1098,12 @@ class MusicBrainzSearchClient:
|
||||||
'images': images,
|
'images': images,
|
||||||
'tracks': tracks,
|
'tracks': tracks,
|
||||||
'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'},
|
'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'},
|
||||||
|
'format': self._release_formats(release),
|
||||||
|
'country': release.get('country') or '',
|
||||||
|
'status': release.get('status') or '',
|
||||||
|
'label': self._release_label(release),
|
||||||
|
'disambiguation': release.get('disambiguation') or '',
|
||||||
|
'release_group_id': rg_mbid,
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single', limit: int = 200) -> List:
|
def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single', limit: int = 200) -> List:
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,12 @@ def search_kind(client, query: str, kind: str, source_name: Optional[str] = None
|
||||||
"release_date": album.release_date,
|
"release_date": album.release_date,
|
||||||
"total_tracks": album.total_tracks,
|
"total_tracks": album.total_tracks,
|
||||||
"album_type": album.album_type,
|
"album_type": album.album_type,
|
||||||
|
"format": getattr(album, "format", None),
|
||||||
|
"country": getattr(album, "country", None),
|
||||||
|
"status": getattr(album, "status", None),
|
||||||
|
"label": getattr(album, "label", None),
|
||||||
|
"disambiguation": getattr(album, "disambiguation", None),
|
||||||
|
"release_group_id": getattr(album, "release_group_id", None),
|
||||||
"external_urls": album.external_urls or {},
|
"external_urls": album.external_urls or {},
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
|
|
||||||
# Initialize production matching engine for parity with Soulseek
|
# Initialize production matching engine for parity with Soulseek
|
||||||
self.matching_engine = MusicMatchingEngine()
|
self.matching_engine = MusicMatchingEngine()
|
||||||
|
|
||||||
logger.info("Initialized production MusicMatchingEngine")
|
logger.info("Initialized production MusicMatchingEngine")
|
||||||
|
|
||||||
# NOTE: deliberately don't call `_check_ffmpeg()` here. That call
|
# NOTE: deliberately don't call `_check_ffmpeg()` here. That call
|
||||||
|
|
@ -216,6 +217,23 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
# Optional progress callback for UI updates
|
# Optional progress callback for UI updates
|
||||||
self.progress_callback = None
|
self.progress_callback = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _escape_ytsearch_query(query: str) -> str:
|
||||||
|
"""Escape yt-dlp search terms that begin with a dash.
|
||||||
|
|
||||||
|
YouTube video IDs may start with ``-``. When passed through
|
||||||
|
``ytsearchN:<query>``, yt-dlp treats that leading dash as search
|
||||||
|
syntax unless it is escaped. Preserve already-escaped input so
|
||||||
|
users who worked around the issue manually keep the same result.
|
||||||
|
"""
|
||||||
|
if not isinstance(query, str):
|
||||||
|
return query
|
||||||
|
stripped = query.lstrip()
|
||||||
|
leading_ws_len = len(query) - len(stripped)
|
||||||
|
if stripped.startswith('-'):
|
||||||
|
return f"{query[:leading_ws_len]}\\{stripped}"
|
||||||
|
return query
|
||||||
|
|
||||||
def is_available(self) -> bool:
|
def is_available(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if YouTube client is available (yt-dlp installed and ffmpeg available).
|
Check if YouTube client is available (yt-dlp installed and ffmpeg available).
|
||||||
|
|
@ -698,8 +716,9 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
if cookies_browser:
|
if cookies_browser:
|
||||||
ydl_opts['cookiesfrombrowser'] = (cookies_browser,)
|
ydl_opts['cookiesfrombrowser'] = (cookies_browser,)
|
||||||
|
|
||||||
|
search_query = self._escape_ytsearch_query(query)
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
data = ydl.extract_info(f"ytsearch{max_results}:{query}", download=False)
|
data = ydl.extract_info(f"ytsearch{max_results}:{search_query}", download=False)
|
||||||
if not data or 'entries' not in data:
|
if not data or 'entries' not in data:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
@ -777,9 +796,10 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
if cookies_browser:
|
if cookies_browser:
|
||||||
ydl_opts['cookiesfrombrowser'] = (cookies_browser,)
|
ydl_opts['cookiesfrombrowser'] = (cookies_browser,)
|
||||||
|
|
||||||
|
search_query = self._escape_ytsearch_query(query)
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
# Search YouTube (max 50 results)
|
# Search YouTube (max 50 results)
|
||||||
search_results = ydl.extract_info(f"ytsearch50:{query}", download=False)
|
search_results = ydl.extract_info(f"ytsearch50:{search_query}", download=False)
|
||||||
|
|
||||||
if not search_results or 'entries' not in search_results:
|
if not search_results or 'entries' not in search_results:
|
||||||
return []
|
return []
|
||||||
|
|
|
||||||
|
|
@ -357,6 +357,49 @@ def test_private_album_bundle_staging_overrides_default_track_info_number(tmp_pa
|
||||||
assert ctx['original_search_result']['filename'] == str(src_file)
|
assert ctx['original_search_result']['filename'] == str(src_file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_album_bundle_staging_keeps_task_number_when_file_has_no_number(tmp_path):
|
||||||
|
"""Private release staging must not turn every unnumbered release file into track 1."""
|
||||||
|
src_file = tmp_path / 'staging' / 'Katy Perry - Firework.flac'
|
||||||
|
src_file.parent.mkdir()
|
||||||
|
src_file.touch()
|
||||||
|
|
||||||
|
def get_batch_field(_batch_id, field):
|
||||||
|
if field == 'album_bundle_source':
|
||||||
|
return 'soulseek'
|
||||||
|
if field == 'album_bundle_private_staging':
|
||||||
|
return True
|
||||||
|
return None
|
||||||
|
|
||||||
|
deps = _build_deps(
|
||||||
|
transfer_path=str(tmp_path / 'transfer'),
|
||||||
|
staging_files=[
|
||||||
|
{
|
||||||
|
'full_path': str(src_file),
|
||||||
|
'title': 'Firework',
|
||||||
|
'artist': 'Katy Perry',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
get_batch_field=get_batch_field,
|
||||||
|
)
|
||||||
|
_seed_task('t6d', track_info={
|
||||||
|
'_is_explicit_album_download': True,
|
||||||
|
'_explicit_album_context': {'id': 'alb', 'name': 'Teenage Dream: The Complete Confection'},
|
||||||
|
'_explicit_artist_context': {'id': 'art', 'name': 'Katy Perry'},
|
||||||
|
'track_number': 4,
|
||||||
|
'disc_number': 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
ds.try_staging_match(
|
||||||
|
't6d', 'b1',
|
||||||
|
_Track(name='Firework', artists=['Katy Perry']),
|
||||||
|
deps,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = matched_downloads_context['staging_t6d']
|
||||||
|
assert ctx['track_info']['track_number'] == 4
|
||||||
|
assert ctx['original_search_result']['track_number'] == 4
|
||||||
|
|
||||||
|
|
||||||
def test_staging_title_match_accepts_feature_suffix_from_release_file(tmp_path):
|
def test_staging_title_match_accepts_feature_suffix_from_release_file(tmp_path):
|
||||||
"""Album releases can include featured artists in filenames."""
|
"""Album releases can include featured artists in filenames."""
|
||||||
src_file = tmp_path / 'staging' / '05-kendrick_lamar-money_trees_(feat._jay_rock).flac'
|
src_file = tmp_path / 'staging' / '05-kendrick_lamar-money_trees_(feat._jay_rock).flac'
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ def _build_deps(
|
||||||
cached_transfers=None,
|
cached_transfers=None,
|
||||||
download_orchestrator=None,
|
download_orchestrator=None,
|
||||||
run_async=None,
|
run_async=None,
|
||||||
|
persistent_history=None,
|
||||||
):
|
):
|
||||||
submitted = []
|
submitted = []
|
||||||
|
|
||||||
|
|
@ -57,6 +58,7 @@ def _build_deps(
|
||||||
get_cached_transfer_data=cached_transfers or (lambda: {}),
|
get_cached_transfer_data=cached_transfers or (lambda: {}),
|
||||||
download_orchestrator=download_orchestrator,
|
download_orchestrator=download_orchestrator,
|
||||||
run_async=run_async,
|
run_async=run_async,
|
||||||
|
get_persistent_download_history=persistent_history,
|
||||||
)
|
)
|
||||||
return deps, submitted
|
return deps, submitted
|
||||||
|
|
||||||
|
|
@ -655,3 +657,88 @@ def test_unified_response_respects_limit():
|
||||||
out = st.build_unified_downloads_response(5, deps)
|
out = st.build_unified_downloads_response(5, deps)
|
||||||
assert len(out['downloads']) == 5
|
assert len(out['downloads']) == 5
|
||||||
assert out['total'] == 20 # total still reflects all
|
assert out['total'] == 20 # total still reflects all
|
||||||
|
|
||||||
|
|
||||||
|
def test_unified_response_includes_persistent_download_history():
|
||||||
|
deps, _ = _build_deps(
|
||||||
|
persistent_history=lambda limit: [
|
||||||
|
{
|
||||||
|
'id': 42,
|
||||||
|
'title': 'Persisted Track',
|
||||||
|
'artist_name': 'Deezer Artist',
|
||||||
|
'album_name': 'Persistent Album',
|
||||||
|
'thumb_url': 'http://cover.jpg',
|
||||||
|
'download_source': 'Deezer',
|
||||||
|
'quality': 'FLAC',
|
||||||
|
'created_at': '2026-05-24 12:34:56',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
out = st.build_unified_downloads_response(100, deps)
|
||||||
|
|
||||||
|
assert out['total'] == 1
|
||||||
|
item = out['downloads'][0]
|
||||||
|
assert item['task_id'] == 'history-42'
|
||||||
|
assert item['title'] == 'Persisted Track'
|
||||||
|
assert item['artist'] == 'Deezer Artist'
|
||||||
|
assert item['album'] == 'Persistent Album'
|
||||||
|
assert item['artwork'] == 'http://cover.jpg'
|
||||||
|
assert item['status'] == 'completed'
|
||||||
|
assert item['progress'] == 100
|
||||||
|
assert item['batch_name'] == 'Deezer'
|
||||||
|
assert item['is_persistent_history'] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_unified_response_dedupes_history_against_live_task():
|
||||||
|
download_tasks['live'] = {
|
||||||
|
'track_index': 0,
|
||||||
|
'status': 'completed',
|
||||||
|
'track_info': {
|
||||||
|
'name': 'Same Track',
|
||||||
|
'artist': 'Same Artist',
|
||||||
|
'album': 'Same Album',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
deps, _ = _build_deps(
|
||||||
|
persistent_history=lambda limit: [
|
||||||
|
{
|
||||||
|
'id': 7,
|
||||||
|
'title': 'Same Track',
|
||||||
|
'artist_name': 'Same Artist',
|
||||||
|
'album_name': 'Same Album',
|
||||||
|
'created_at': '2026-05-24 12:34:56',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
out = st.build_unified_downloads_response(100, deps)
|
||||||
|
|
||||||
|
assert len(out['downloads']) == 1
|
||||||
|
assert out['downloads'][0]['task_id'] == 'live'
|
||||||
|
assert out['downloads'][0]['is_persistent_history'] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_unified_response_caps_persistent_history_tail():
|
||||||
|
requested_limits = []
|
||||||
|
|
||||||
|
def _history(limit):
|
||||||
|
requested_limits.append(limit)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id': i,
|
||||||
|
'title': f'Track {i}',
|
||||||
|
'artist_name': 'Artist',
|
||||||
|
'album_name': 'Album',
|
||||||
|
'created_at': '2026-05-24 12:34:56',
|
||||||
|
}
|
||||||
|
for i in range(limit + 10)
|
||||||
|
]
|
||||||
|
|
||||||
|
deps, _ = _build_deps(persistent_history=_history)
|
||||||
|
|
||||||
|
out = st.build_unified_downloads_response(300, deps)
|
||||||
|
|
||||||
|
assert requested_limits == [50]
|
||||||
|
assert len(out['downloads']) == 50
|
||||||
|
assert out['total'] == 50
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,55 @@ def test_search_import_albums_falls_back_when_primary_has_no_results(monkeypatch
|
||||||
assert spotify_client.calls == [("Album Two", {"limit": 2, "allow_fallback": False})]
|
assert spotify_client.calls == [("Album Two", {"limit": 2, "allow_fallback": False})]
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_import_albums_preserves_musicbrainz_release_variants(monkeypatch):
|
||||||
|
musicbrainz_client = FakeClient([
|
||||||
|
SimpleNamespace(
|
||||||
|
id="rel-clean",
|
||||||
|
name="Shock Value",
|
||||||
|
artists=["Timbaland"],
|
||||||
|
release_date="2007-04-03",
|
||||||
|
total_tracks=17,
|
||||||
|
image_url="",
|
||||||
|
album_type="album",
|
||||||
|
format="CD",
|
||||||
|
country="US",
|
||||||
|
status="Official",
|
||||||
|
disambiguation="clean",
|
||||||
|
release_group_id="rg-shock",
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
id="rel-explicit",
|
||||||
|
name="Shock Value",
|
||||||
|
artists=["Timbaland"],
|
||||||
|
release_date="2007-04-03",
|
||||||
|
total_tracks=18,
|
||||||
|
image_url="",
|
||||||
|
album_type="album",
|
||||||
|
format="CD",
|
||||||
|
country="US",
|
||||||
|
status="Official",
|
||||||
|
disambiguation="explicit",
|
||||||
|
release_group_id="rg-shock",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
monkeypatch.setattr(import_staging, "get_primary_source", lambda: "musicbrainz")
|
||||||
|
monkeypatch.setattr(import_staging, "get_source_priority", lambda primary: [primary])
|
||||||
|
monkeypatch.setattr(import_staging, "get_client_for_source", lambda source: musicbrainz_client)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
import_staging,
|
||||||
|
"_search_albums_for_source",
|
||||||
|
lambda source, client, query, limit=5: client.search_albums(query, limit=limit),
|
||||||
|
)
|
||||||
|
|
||||||
|
results = import_staging.search_import_albums("Timbaland Shock Value", limit=12)
|
||||||
|
|
||||||
|
assert [result["id"] for result in results] == ["rel-clean", "rel-explicit"]
|
||||||
|
assert [result["total_tracks"] for result in results] == [17, 18]
|
||||||
|
assert results[1]["disambiguation"] == "explicit"
|
||||||
|
assert results[1]["release_group_id"] == "rg-shock"
|
||||||
|
|
||||||
|
|
||||||
def test_search_import_tracks_prefers_primary_source(monkeypatch):
|
def test_search_import_tracks_prefers_primary_source(monkeypatch):
|
||||||
deezer_client = FakeClient([
|
deezer_client = FakeClient([
|
||||||
SimpleNamespace(
|
SimpleNamespace(
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,58 @@ def test_search_albums_text_path_filters_by_score():
|
||||||
assert 'Bad' not in titles
|
assert 'Bad' not in titles
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_albums_text_path_keeps_release_variants():
|
||||||
|
client = MusicBrainzSearchClient()
|
||||||
|
client._client = MagicMock()
|
||||||
|
client._client.search_release.return_value = [
|
||||||
|
{'id': 'rel-clean', 'title': 'Shock Value', 'score': 100,
|
||||||
|
'date': '2007-04-03', 'country': 'US', 'status': 'Official',
|
||||||
|
'disambiguation': 'clean',
|
||||||
|
'media': [{'format': 'CD', 'track-count': 17}],
|
||||||
|
'release-group': {'id': 'rg-shock', 'primary-type': 'Album'},
|
||||||
|
'artist-credit': [{'name': 'Timbaland'}]},
|
||||||
|
{'id': 'rel-explicit', 'title': 'Shock Value', 'score': 100,
|
||||||
|
'date': '2007-04-03', 'country': 'US', 'status': 'Official',
|
||||||
|
'disambiguation': 'explicit',
|
||||||
|
'media': [{'format': 'CD', 'track-count': 18}],
|
||||||
|
'release-group': {'id': 'rg-shock', 'primary-type': 'Album'},
|
||||||
|
'artist-credit': [{'name': 'Timbaland'}]},
|
||||||
|
]
|
||||||
|
|
||||||
|
albums = client.search_albums('Timbaland - Shock Value', limit=10)
|
||||||
|
|
||||||
|
assert [a.id for a in albums] == ['rel-clean', 'rel-explicit']
|
||||||
|
assert [a.total_tracks for a in albums] == [17, 18]
|
||||||
|
assert albums[1].disambiguation == 'explicit'
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_albums_title_hint_expands_release_group_to_releases():
|
||||||
|
client = MusicBrainzSearchClient()
|
||||||
|
client._client = MagicMock()
|
||||||
|
client._client.search_artist.return_value = [_mk_artist('Spiderbait', 'artist-spiderbait', score=100)]
|
||||||
|
client._client.browse_artist_release_groups.return_value = [
|
||||||
|
{'id': 'rg-tonight', 'title': 'Tonight Alright', 'primary-type': 'Album',
|
||||||
|
'first-release-date': '2004-03-29', 'secondary-types': []},
|
||||||
|
]
|
||||||
|
client._client.browse_release_group_releases.return_value = [
|
||||||
|
{'id': 'rel-cd', 'title': 'Tonight Alright', 'date': '2004-03-29',
|
||||||
|
'country': 'AU', 'status': 'Official',
|
||||||
|
'media': [{'format': 'CD', 'track-count': 12}],
|
||||||
|
'artist-credit': [{'name': 'Spiderbait'}]},
|
||||||
|
{'id': 'rel-vinyl', 'title': 'Tonight Alright', 'date': '2024-07-26',
|
||||||
|
'country': 'AU', 'status': 'Official',
|
||||||
|
'media': [{'format': '12\" Vinyl', 'track-count': 13}],
|
||||||
|
'artist-credit': [{'name': 'Spiderbait'}]},
|
||||||
|
]
|
||||||
|
|
||||||
|
albums = client.search_albums('Spiderbait Tonight Alright', limit=10)
|
||||||
|
|
||||||
|
client._client.browse_release_group_releases.assert_called_once_with('rg-tonight', limit=25)
|
||||||
|
assert [a.id for a in albums] == ['rel-cd', 'rel-vinyl']
|
||||||
|
assert [a.total_tracks for a in albums] == [12, 13]
|
||||||
|
assert albums[0].format == 'CD'
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Track search — routing
|
# Track search — routing
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ class _Artist:
|
||||||
|
|
||||||
class _Album:
|
class _Album:
|
||||||
def __init__(self, id_, name, artists=None, image_url=None, release_date=None,
|
def __init__(self, id_, name, artists=None, image_url=None, release_date=None,
|
||||||
total_tracks=10, album_type='album', external_urls=None):
|
total_tracks=10, album_type='album', external_urls=None, format=None,
|
||||||
|
country=None, status=None, label=None, disambiguation=None,
|
||||||
|
release_group_id=None):
|
||||||
self.id = id_
|
self.id = id_
|
||||||
self.name = name
|
self.name = name
|
||||||
self.artists = artists or []
|
self.artists = artists or []
|
||||||
|
|
@ -28,6 +30,12 @@ class _Album:
|
||||||
self.total_tracks = total_tracks
|
self.total_tracks = total_tracks
|
||||||
self.album_type = album_type
|
self.album_type = album_type
|
||||||
self.external_urls = external_urls
|
self.external_urls = external_urls
|
||||||
|
self.format = format
|
||||||
|
self.country = country
|
||||||
|
self.status = status
|
||||||
|
self.label = label
|
||||||
|
self.disambiguation = disambiguation
|
||||||
|
self.release_group_id = release_group_id
|
||||||
|
|
||||||
|
|
||||||
class _Track:
|
class _Track:
|
||||||
|
|
@ -99,6 +107,27 @@ def test_search_kind_albums_handles_no_artists():
|
||||||
assert result[0]['artist'] == 'Unknown Artist'
|
assert result[0]['artist'] == 'Unknown Artist'
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_kind_albums_passthrough_release_metadata():
|
||||||
|
client = _Client(albums=[_Album(
|
||||||
|
'a1',
|
||||||
|
'Variant',
|
||||||
|
artists=['Artist'],
|
||||||
|
format='CD',
|
||||||
|
country='US',
|
||||||
|
status='Official',
|
||||||
|
label='Fixture Records',
|
||||||
|
disambiguation='clean',
|
||||||
|
release_group_id='rg-1',
|
||||||
|
)])
|
||||||
|
result = sources.search_kind(client, 'v', 'albums')
|
||||||
|
assert result[0]['format'] == 'CD'
|
||||||
|
assert result[0]['country'] == 'US'
|
||||||
|
assert result[0]['status'] == 'Official'
|
||||||
|
assert result[0]['label'] == 'Fixture Records'
|
||||||
|
assert result[0]['disambiguation'] == 'clean'
|
||||||
|
assert result[0]['release_group_id'] == 'rg-1'
|
||||||
|
|
||||||
|
|
||||||
def test_search_kind_tracks_returns_full_shape():
|
def test_search_kind_tracks_returns_full_shape():
|
||||||
client = _Client(tracks=[_Track('t1', 'Money', artists=['Pink Floyd'], album='DSOTM',
|
client = _Client(tracks=[_Track('t1', 'Money', artists=['Pink Floyd'], album='DSOTM',
|
||||||
duration_ms=383000, image_url='m.jpg',
|
duration_ms=383000, image_url='m.jpg',
|
||||||
|
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
"""Pin the import-page album-lookup cache pattern in
|
|
||||||
``webui/static/stats-automations.js`` — github issue #524 regression
|
|
||||||
guard at the source-text level.
|
|
||||||
|
|
||||||
Why a structural test instead of a behavioral JS test:
|
|
||||||
|
|
||||||
``stats-automations.js`` is a ~7k-line file with a lot of global state
|
|
||||||
+ inline DOM rendering. Loading it into a sandboxed Node `vm` context
|
|
||||||
(the pattern used in `tests/static/test_discover_section_controller.mjs`)
|
|
||||||
would require stubbing dozens of unrelated dependencies. The file
|
|
||||||
needs to be modularized before behavioral tests are practical for
|
|
||||||
arbitrary functions in it.
|
|
||||||
|
|
||||||
Until then, this test fails the suite if the critical pattern from
|
|
||||||
the #524 fix gets removed:
|
|
||||||
|
|
||||||
1. The album cache (``_albumLookup`` field on ``importPageState``)
|
|
||||||
2. Card renderers populating the cache before emitting the onclick
|
|
||||||
3. The match-POST builder reading source/name/artist from the cache
|
|
||||||
|
|
||||||
If anyone deletes the cache, the click handler, or the cache writes,
|
|
||||||
this test catches it before the regression ships.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
_SOURCE = _REPO_ROOT / "webui" / "static" / "stats-automations.js"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
|
||||||
def js_source() -> str:
|
|
||||||
return _SOURCE.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def test_album_lookup_cache_field_exists_on_state(js_source: str):
|
|
||||||
"""importPageState must have an `_albumLookup` field. Without it,
|
|
||||||
card renderers have nowhere to stash source/name/artist for the
|
|
||||||
click handler to read."""
|
|
||||||
assert "_albumLookup:" in js_source, (
|
|
||||||
"importPageState._albumLookup field missing — the album cache "
|
|
||||||
"that backs the source-routing fix for issue #524 has been "
|
|
||||||
"removed. The click handler will fall back to passing only "
|
|
||||||
"album_id and the backend will silently misroute lookups again."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_select_album_handler_reads_cache(js_source: str):
|
|
||||||
"""importPageSelectAlbum must read source / name / artist from
|
|
||||||
the cache and include them in the match POST body. The whole
|
|
||||||
point of the fix."""
|
|
||||||
# Find the function body
|
|
||||||
match = re.search(
|
|
||||||
r"async function importPageSelectAlbum\([^)]*\) \{(.*?)^\}",
|
|
||||||
js_source, re.DOTALL | re.MULTILINE,
|
|
||||||
)
|
|
||||||
assert match, "importPageSelectAlbum function not found"
|
|
||||||
body = match.group(1)
|
|
||||||
|
|
||||||
# Must read from the lookup cache
|
|
||||||
assert "_albumLookup[" in body, (
|
|
||||||
"importPageSelectAlbum no longer reads from "
|
|
||||||
"importPageState._albumLookup — match POST will drop source "
|
|
||||||
"again, see issue #524."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Must build a matchBody that includes source + album_name + album_artist
|
|
||||||
for required_field in ("source:", "album_name:", "album_artist:"):
|
|
||||||
assert required_field in body, (
|
|
||||||
f"matchBody missing required field {required_field!r}. "
|
|
||||||
"Backend's get_artist_album_tracks needs source to route "
|
|
||||||
"the lookup to the correct metadata client. Without it, "
|
|
||||||
"cross-source album_ids fall through to the failure-fallback "
|
|
||||||
"dict (Unknown Artist / album_id-as-title / 0 tracks). "
|
|
||||||
"See issue #524 for the original symptom."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_card_renderer_populates_cache_before_onclick(js_source: str):
|
|
||||||
"""The shared card-renderer ``_renderSuggestionCard`` must write to
|
|
||||||
``_albumLookup`` before emitting the onclick — otherwise the click
|
|
||||||
handler reads an empty cache for newly-displayed albums.
|
|
||||||
|
|
||||||
Originally this test required >=2 cache writes (one per inline
|
|
||||||
renderer), but the search-results inline render was consolidated
|
|
||||||
into a single ``_renderSuggestionCard`` call as part of the #681
|
|
||||||
fix. The invariant now is: the shared renderer populates the cache,
|
|
||||||
and every render call site goes through it (no inline duplicates)."""
|
|
||||||
# 1. The shared renderer must contain the cache write.
|
|
||||||
match = re.search(
|
|
||||||
r"function _renderSuggestionCard\([^)]*\) \{(.*?)^\}",
|
|
||||||
js_source, re.DOTALL | re.MULTILINE,
|
|
||||||
)
|
|
||||||
assert match, "_renderSuggestionCard function not found"
|
|
||||||
body = match.group(1)
|
|
||||||
assert re.search(r"_albumLookup\[a\.id\]\s*=\s*\{", body), (
|
|
||||||
"_renderSuggestionCard no longer writes to _albumLookup before "
|
|
||||||
"emitting the onclick — every card rendered through this helper "
|
|
||||||
"would have an empty cache on click, regressing issue #524."
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. No inline card render allowed outside the shared helper.
|
|
||||||
# A second `_albumLookup[a.id] = {` write means a caller is
|
|
||||||
# re-implementing the renderer instead of calling the helper —
|
|
||||||
# that's exactly the duplication the #524 fix consolidated away.
|
|
||||||
cache_writes = re.findall(r"_albumLookup\[a\.id\]\s*=\s*\{", js_source)
|
|
||||||
assert len(cache_writes) == 1, (
|
|
||||||
f"Expected exactly 1 _albumLookup write (inside _renderSuggestionCard), "
|
|
||||||
f"found {len(cache_writes)}. A new inline card-render site has "
|
|
||||||
"duplicated the cache-write logic — route the new caller through "
|
|
||||||
"_renderSuggestionCard(a, primarySource) instead."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_cache_entry_carries_source_field(js_source: str):
|
|
||||||
"""The cache must store `source:` per entry — not just id/name/artist."""
|
|
||||||
write_blocks = re.findall(
|
|
||||||
r"_albumLookup\[a\.id\]\s*=\s*\{[^}]*\}",
|
|
||||||
js_source,
|
|
||||||
)
|
|
||||||
assert write_blocks, "no _albumLookup writes found"
|
|
||||||
assert any("source:" in block for block in write_blocks), (
|
|
||||||
"_albumLookup cache entries must include `source` — that's the "
|
|
||||||
"field the click handler forwards to /api/import/album/match "
|
|
||||||
"to route the lookup to the correct provider."
|
|
||||||
)
|
|
||||||
87
tests/test_youtube_search_dash_query.py
Normal file
87
tests/test_youtube_search_dash_query.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""Regression tests for YouTube searches whose query starts with ``-``.
|
||||||
|
|
||||||
|
YouTube video IDs can start with a dash. yt-dlp's ``ytsearchN:`` parser
|
||||||
|
interprets a leading dash as search syntax unless escaped, so manual
|
||||||
|
searches for those IDs used to fan out into unrelated results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from core import youtube_client
|
||||||
|
from core.youtube_client import YouTubeClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_escape_ytsearch_query_handles_leading_dash():
|
||||||
|
assert YouTubeClient._escape_ytsearch_query("-4WUHJRhvrM") == r"\-4WUHJRhvrM"
|
||||||
|
assert YouTubeClient._escape_ytsearch_query(r"\-4WUHJRhvrM") == r"\-4WUHJRhvrM"
|
||||||
|
assert YouTubeClient._escape_ytsearch_query("Yo-Yo Ma") == "Yo-Yo Ma"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_escapes_leading_dash_before_yt_dlp(monkeypatch):
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
class _FakeYoutubeDL:
|
||||||
|
def __init__(self, opts):
|
||||||
|
self.opts = opts
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def extract_info(self, search_query, download=False):
|
||||||
|
captured.append(search_query)
|
||||||
|
return {"entries": [{"id": "-4WUHJRhvrM", "title": "Unaccompanied Cello"}]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(youtube_client.yt_dlp, "YoutubeDL", _FakeYoutubeDL)
|
||||||
|
|
||||||
|
client = YouTubeClient.__new__(YouTubeClient)
|
||||||
|
monkeypatch.setattr(client, "_get_best_audio_format", lambda formats: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
client,
|
||||||
|
"_youtube_to_track_result",
|
||||||
|
lambda entry, best_audio: SimpleNamespace(filename=entry["title"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
tracks, albums = asyncio.run(client.search("-4WUHJRhvrM"))
|
||||||
|
|
||||||
|
assert captured == [r"ytsearch50:\-4WUHJRhvrM"]
|
||||||
|
assert len(tracks) == 1
|
||||||
|
assert albums == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_videos_escapes_leading_dash_before_yt_dlp(monkeypatch):
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
class _FakeYoutubeDL:
|
||||||
|
def __init__(self, opts):
|
||||||
|
self.opts = opts
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def extract_info(self, search_query, download=False):
|
||||||
|
captured.append(search_query)
|
||||||
|
return {
|
||||||
|
"entries": [{
|
||||||
|
"id": "-4WUHJRhvrM",
|
||||||
|
"title": "Unaccompanied Cello",
|
||||||
|
"duration": 152,
|
||||||
|
"uploader": "Yo-Yo Ma",
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(youtube_client.yt_dlp, "YoutubeDL", _FakeYoutubeDL)
|
||||||
|
client = YouTubeClient.__new__(YouTubeClient)
|
||||||
|
|
||||||
|
results = asyncio.run(client.search_videos("-4WUHJRhvrM", max_results=8))
|
||||||
|
|
||||||
|
assert captured == [r"ytsearch8:\-4WUHJRhvrM"]
|
||||||
|
assert [r.video_id for r in results] == ["-4WUHJRhvrM"]
|
||||||
|
|
@ -1068,6 +1068,13 @@ def _get_batch_max_concurrent(is_album=False, source=None):
|
||||||
mode = config_manager.get('download_source.mode', 'soulseek')
|
mode = config_manager.get('download_source.mode', 'soulseek')
|
||||||
if mode == 'soulseek':
|
if mode == 'soulseek':
|
||||||
return 1
|
return 1
|
||||||
|
if mode == 'hybrid':
|
||||||
|
hybrid_order = config_manager.get('download_source.hybrid_order', []) or []
|
||||||
|
if isinstance(hybrid_order, str):
|
||||||
|
hybrid_order = [hybrid_order]
|
||||||
|
first_source = next((str(s).strip().lower() for s in hybrid_order if str(s).strip()), '')
|
||||||
|
if first_source == 'soulseek':
|
||||||
|
return 1
|
||||||
return _get_max_concurrent()
|
return _get_max_concurrent()
|
||||||
|
|
||||||
# --- Session Download Statistics ---
|
# --- Session Download Statistics ---
|
||||||
|
|
@ -2557,9 +2564,16 @@ def _build_system_stats():
|
||||||
active_downloads = len([batch_id for batch_id, batch_data in download_batches.items()
|
active_downloads = len([batch_id for batch_id, batch_data in download_batches.items()
|
||||||
if batch_data.get('phase') == 'downloading'])
|
if batch_data.get('phase') == 'downloading'])
|
||||||
|
|
||||||
# Count finished downloads (completed this session) - use session counter like dashboard.py
|
# Count finished downloads from persistent history so the dashboard
|
||||||
with session_stats_lock:
|
# survives Docker/container restarts and streaming downloads that leave
|
||||||
finished_downloads = session_completed_downloads
|
# the in-memory task tracker after post-processing.
|
||||||
|
try:
|
||||||
|
persistent_finished = int(get_database().get_library_history_stats().get('downloads', 0) or 0)
|
||||||
|
with session_stats_lock:
|
||||||
|
finished_downloads = max(persistent_finished, session_completed_downloads)
|
||||||
|
except Exception:
|
||||||
|
with session_stats_lock:
|
||||||
|
finished_downloads = session_completed_downloads
|
||||||
|
|
||||||
# Calculate total download speed from active soulseek transfers
|
# Calculate total download speed from active soulseek transfers
|
||||||
# Skip the slskd API call entirely when Soulseek is not the active download
|
# Skip the slskd API call entirely when Soulseek is not the active download
|
||||||
|
|
@ -16524,6 +16538,8 @@ def _get_staging_file_cache(batch_id):
|
||||||
'title': meta['title'] or '',
|
'title': meta['title'] or '',
|
||||||
'artist': meta['albumartist'] or meta['artist'] or '',
|
'artist': meta['albumartist'] or meta['artist'] or '',
|
||||||
'album': meta['album'] or '',
|
'album': meta['album'] or '',
|
||||||
|
'track_number': meta.get('track_number'),
|
||||||
|
'disc_number': meta.get('disc_number'),
|
||||||
'extension': ext,
|
'extension': ext,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -16928,6 +16944,11 @@ def _build_status_deps():
|
||||||
download_orchestrator=download_orchestrator,
|
download_orchestrator=download_orchestrator,
|
||||||
run_async=run_async,
|
run_async=run_async,
|
||||||
on_download_completed=_on_download_completed,
|
on_download_completed=_on_download_completed,
|
||||||
|
get_persistent_download_history=lambda limit: get_database().get_library_history(
|
||||||
|
event_type='download',
|
||||||
|
page=1,
|
||||||
|
limit=limit,
|
||||||
|
)[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ This folder is the home for React migration planning work inside `webui`.
|
||||||
- cross-route risk assessment
|
- cross-route risk assessment
|
||||||
- [stats-migration-plan.md](./stats-migration-plan.md)
|
- [stats-migration-plan.md](./stats-migration-plan.md)
|
||||||
- route-specific migration plan for `stats`
|
- route-specific migration plan for `stats`
|
||||||
|
- [import-migration-plan.md](./import-migration-plan.md)
|
||||||
|
- route-specific migration plan for `import`
|
||||||
|
- implementation status and follow-up cleanup notes
|
||||||
|
|
||||||
## Naming Guidance
|
## Naming Guidance
|
||||||
|
|
||||||
|
|
|
||||||
350
webui/docs/migration/import-migration-plan.md
Normal file
350
webui/docs/migration/import-migration-plan.md
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
# WebUI Import Migration Plan
|
||||||
|
|
||||||
|
Snapshot date: 2026-05-24
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- Initial implementation completed on 2026-05-15.
|
||||||
|
- `import` is now React-owned in the shell route manifest.
|
||||||
|
- The legacy import page DOM has been removed from `webui/index.html`.
|
||||||
|
- Legacy import activation has been removed from `webui/static/init.js`.
|
||||||
|
- A React route subtree now owns import rendering, nested route state, album matching, singles matching, auto-import controls, and the client-side processing queue.
|
||||||
|
- The old `tab=` URL contract has been replaced by `/import/album`, `/import/singles`, and `/import/auto`, with `/import` redirecting to `/import/album`.
|
||||||
|
- Route-local workflow state lives in `webui/src/routes/import/-import.store.ts`, which keeps draft matching, selection, and queue state alive while navigating within the import route.
|
||||||
|
- The old import-page-specific functions have already been removed from `webui/static/stats-automations.js`; any remaining `import` references there belong to the broader automation feature set, not this page migration.
|
||||||
|
- Backend routes are already grouped around `/api/import/*` and `/api/auto-import/*`.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
- Migrate `import` into a React-owned route without changing the user workflow.
|
||||||
|
- Preserve manual album matching, singles matching, auto-import review, and the processing queue.
|
||||||
|
- Keep staging-folder and import-processing behavior backed by the existing API routes.
|
||||||
|
- Use the completed `issues` and `stats` routes as the structural reference for route slices, API helpers, shell gating, and tests.
|
||||||
|
|
||||||
|
## Why `import` Was The Right Next Route
|
||||||
|
|
||||||
|
- It is the safest remaining route after excluding `help` and `hydrabase`.
|
||||||
|
- It has real workflows, so it gives the migration program more signal than another mostly-static page.
|
||||||
|
- The backend API boundary is already clearer than the broad dashboard, library, discover, sync, or settings surfaces.
|
||||||
|
- The page is important enough to validate mutation, polling, and route-local reducer patterns before larger operational pages.
|
||||||
|
- It does not need a visual redesign or a new shell abstraction to migrate cleanly.
|
||||||
|
|
||||||
|
## Current Legacy Shape
|
||||||
|
|
||||||
|
Page surface in `webui/index.html`:
|
||||||
|
|
||||||
|
- Header
|
||||||
|
- import folder path
|
||||||
|
- file count and total size
|
||||||
|
- refresh action
|
||||||
|
- Processing queue
|
||||||
|
- per-job progress
|
||||||
|
- partial error display
|
||||||
|
- clear-finished action
|
||||||
|
- Tabs
|
||||||
|
- auto-import
|
||||||
|
- albums
|
||||||
|
- singles
|
||||||
|
- Auto-import tab
|
||||||
|
- enable toggle
|
||||||
|
- status text
|
||||||
|
- confidence and interval settings
|
||||||
|
- scan-now action
|
||||||
|
- live scan progress
|
||||||
|
- result filters
|
||||||
|
- approve, reject, approve-all, and clear-completed actions
|
||||||
|
- Albums tab
|
||||||
|
- auto-group suggestions from staging
|
||||||
|
- album search
|
||||||
|
- album result cards
|
||||||
|
- track matching view
|
||||||
|
- drag/drop and tap-to-assign overrides
|
||||||
|
- unmatched file pool
|
||||||
|
- process album action
|
||||||
|
- Singles tab
|
||||||
|
- staging file list
|
||||||
|
- select all / per-file selection
|
||||||
|
- per-file track search
|
||||||
|
- manual match selection
|
||||||
|
- process selected action
|
||||||
|
|
||||||
|
Legacy JS responsibilities in `webui/static/stats-automations.js`:
|
||||||
|
|
||||||
|
- `initializeImportPage`
|
||||||
|
- staging fetch and refresh
|
||||||
|
- tab switching
|
||||||
|
- auto-import polling
|
||||||
|
- auto-import mutations
|
||||||
|
- staging group and suggestion rendering
|
||||||
|
- album search and match POSTs
|
||||||
|
- drag/drop assignment state
|
||||||
|
- singles search and selection state
|
||||||
|
- client-side processing queue
|
||||||
|
- sequential album/singles processing requests
|
||||||
|
|
||||||
|
Backend endpoints already available:
|
||||||
|
|
||||||
|
- `GET /api/import/staging/files`
|
||||||
|
- `GET /api/import/staging/groups`
|
||||||
|
- `GET /api/import/staging/hints`
|
||||||
|
- `GET /api/import/staging/suggestions`
|
||||||
|
- `GET /api/import/search/albums`
|
||||||
|
- `POST /api/import/album/match`
|
||||||
|
- `POST /api/import/album/process`
|
||||||
|
- `GET /api/import/search/tracks`
|
||||||
|
- `POST /api/import/singles/process`
|
||||||
|
- `GET /api/auto-import/status`
|
||||||
|
- `POST /api/auto-import/toggle`
|
||||||
|
- `GET /api/auto-import/settings`
|
||||||
|
- `POST /api/auto-import/settings`
|
||||||
|
- `GET /api/auto-import/results`
|
||||||
|
- `POST /api/auto-import/approve/:id`
|
||||||
|
- `POST /api/auto-import/reject/:id`
|
||||||
|
- `POST /api/auto-import/scan-now`
|
||||||
|
- `POST /api/auto-import/approve-all`
|
||||||
|
- `POST /api/auto-import/clear-completed`
|
||||||
|
|
||||||
|
## Implemented Route Slice
|
||||||
|
|
||||||
|
```text
|
||||||
|
webui/src/routes/import/
|
||||||
|
route.tsx
|
||||||
|
index.tsx
|
||||||
|
album.tsx
|
||||||
|
auto.tsx
|
||||||
|
singles.tsx
|
||||||
|
-import.types.ts
|
||||||
|
-import.api.ts
|
||||||
|
-import.helpers.ts
|
||||||
|
-import.store.ts
|
||||||
|
-route.test.tsx
|
||||||
|
-ui/
|
||||||
|
import-page.tsx
|
||||||
|
album-import-tab.tsx
|
||||||
|
auto-import-tab.tsx
|
||||||
|
singles-import-tab.tsx
|
||||||
|
import-shared.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implemented Route Responsibilities
|
||||||
|
|
||||||
|
`route.tsx`
|
||||||
|
|
||||||
|
- declare `/import`
|
||||||
|
- gate route through `bridge.isPageAllowed('import')`
|
||||||
|
- preload shell context
|
||||||
|
- prefetch the staging-files query without blocking on transient fetch failure
|
||||||
|
|
||||||
|
`index.tsx`
|
||||||
|
|
||||||
|
- redirect `/import` to `/import/album`
|
||||||
|
|
||||||
|
`album.tsx`
|
||||||
|
|
||||||
|
- prefetch album staging groups and suggestions
|
||||||
|
|
||||||
|
`auto.tsx`
|
||||||
|
|
||||||
|
- validate the `autoFilter` search param
|
||||||
|
- keep route-driven filter changes in the URL while leaving the rest of the workflow state local
|
||||||
|
|
||||||
|
`singles.tsx`
|
||||||
|
|
||||||
|
- mount the singles import tab without extra route-level loader work
|
||||||
|
|
||||||
|
`-import.types.ts`
|
||||||
|
|
||||||
|
- search param schema
|
||||||
|
- API response types
|
||||||
|
- staging file, staging group, album result, track result, match, auto-import result, and queue item types
|
||||||
|
|
||||||
|
`-import.api.ts`
|
||||||
|
|
||||||
|
- query options for staging, groups, suggestions, auto-import status, auto-import settings, auto-import results, album search, and track search
|
||||||
|
- mutation helpers for album match, album process, singles process, auto-import actions, and settings writes
|
||||||
|
- invalidation helpers for broad route refreshes, staging-only refreshes, and auto-import-only refreshes
|
||||||
|
|
||||||
|
`-import.helpers.ts`
|
||||||
|
|
||||||
|
- byte-size formatting
|
||||||
|
- album and track display labels
|
||||||
|
- confidence class/label mapping
|
||||||
|
- staging match normalization
|
||||||
|
- auto-import result filtering and counters
|
||||||
|
|
||||||
|
`-import.store.ts`
|
||||||
|
|
||||||
|
- album search state, selected album, auto-group file paths, and match overrides
|
||||||
|
- selected single-file state and manual matches
|
||||||
|
- queue job state and queue entry updates
|
||||||
|
- single-search draft state
|
||||||
|
- draft state survival across nested route remounts
|
||||||
|
|
||||||
|
`-ui/import-page.tsx`
|
||||||
|
|
||||||
|
- page chrome and nested route navigation
|
||||||
|
- queue summary and queue-item rendering
|
||||||
|
- nested route outlet for album, singles, and auto views
|
||||||
|
|
||||||
|
## Search Params
|
||||||
|
|
||||||
|
Use nested route paths for durable, shareable tab state:
|
||||||
|
|
||||||
|
- `/import/album`
|
||||||
|
- default landing route
|
||||||
|
- `/import/singles`
|
||||||
|
- `/import/auto`
|
||||||
|
- `autoFilter`
|
||||||
|
- values: `all`, `pending`, `imported`, `failed`
|
||||||
|
- default: `all`
|
||||||
|
|
||||||
|
Keep these local to React state:
|
||||||
|
|
||||||
|
- album search text
|
||||||
|
- track search text
|
||||||
|
- selected album
|
||||||
|
- match overrides
|
||||||
|
- selected singles
|
||||||
|
- processing queue jobs
|
||||||
|
|
||||||
|
Reasoning:
|
||||||
|
|
||||||
|
- The tab choice belongs in the path, which keeps deep links simple and avoids an extra `tab=` query param.
|
||||||
|
- The auto-import filter is still useful after reloads.
|
||||||
|
- The matching workflow is ephemeral and should not create fragile URLs with file indexes or local staging paths.
|
||||||
|
|
||||||
|
## Query Model
|
||||||
|
|
||||||
|
Critical route-loader data:
|
||||||
|
|
||||||
|
- `importStagingFilesQueryOptions()`
|
||||||
|
|
||||||
|
Useful prefetch data:
|
||||||
|
|
||||||
|
- `importStagingGroupsQueryOptions()`
|
||||||
|
- `importStagingSuggestionsQueryOptions()`
|
||||||
|
|
||||||
|
Nested-route data:
|
||||||
|
|
||||||
|
- `autoImportStatusQueryOptions()`
|
||||||
|
- `autoImportSettingsQueryOptions()`
|
||||||
|
- `autoImportResultsQueryOptions(autoFilter)`
|
||||||
|
|
||||||
|
Lazy search data:
|
||||||
|
|
||||||
|
- `importAlbumSearchQueryOptions(query)`
|
||||||
|
- `importTrackSearchQueryOptions(query)`
|
||||||
|
|
||||||
|
Mutation-style actions:
|
||||||
|
|
||||||
|
- album match draft
|
||||||
|
- process one album track
|
||||||
|
- process one single file
|
||||||
|
- toggle auto-import
|
||||||
|
- save auto-import settings
|
||||||
|
- scan now
|
||||||
|
- approve/reject auto-import result
|
||||||
|
- approve all
|
||||||
|
- clear completed
|
||||||
|
|
||||||
|
Invalidation rules:
|
||||||
|
|
||||||
|
- Processing album or singles files invalidates staging files, staging groups, staging suggestions, auto-import results, and any route-local queue completion summary.
|
||||||
|
- Auto-import actions invalidate auto-import status and results.
|
||||||
|
- Auto-import settings writes invalidate settings and status.
|
||||||
|
- Refresh invalidates staging files, groups, and suggestions.
|
||||||
|
|
||||||
|
## Incremental Migration Order
|
||||||
|
|
||||||
|
Recommended order:
|
||||||
|
|
||||||
|
1. Add route slice, types, API helpers, reducer, and helper tests.
|
||||||
|
2. Build the React route shell with header, tabs, and staging summary.
|
||||||
|
3. Port the Albums tab search and suggestions, but keep processing disabled until match rendering is covered.
|
||||||
|
4. Port the album matching view, including drag/drop and tap assignment.
|
||||||
|
5. Port the processing queue and album/singles process mutations.
|
||||||
|
6. Port the Singles tab.
|
||||||
|
7. Port the Auto tab and polling behavior.
|
||||||
|
8. Flip `import` from `legacy` to `react` in the shell route manifest.
|
||||||
|
9. Remove the legacy `import-page` DOM from `webui/index.html`.
|
||||||
|
10. Remove import-specific legacy functions from `webui/static/stats-automations.js`.
|
||||||
|
|
||||||
|
This order gives us a visible React page early while delaying the highest-risk file-processing actions until the state model is tested.
|
||||||
|
|
||||||
|
The implemented route keeps the same overall migration shape, but the final URL contract uses nested route paths instead of a `tab=` search param.
|
||||||
|
|
||||||
|
## Testing Sketch
|
||||||
|
|
||||||
|
Unit tests:
|
||||||
|
|
||||||
|
- route path and filter defaults
|
||||||
|
- staging summary formatting
|
||||||
|
- auto-import counters
|
||||||
|
- confidence labels
|
||||||
|
- reducer assignment behavior
|
||||||
|
- reducer queue transitions
|
||||||
|
|
||||||
|
API tests:
|
||||||
|
|
||||||
|
- staging files success and error
|
||||||
|
- staging groups success and error
|
||||||
|
- album search success and empty result
|
||||||
|
- track search success and empty result
|
||||||
|
- album match success and failure
|
||||||
|
- album process success and partial error
|
||||||
|
- singles process success and partial error
|
||||||
|
- auto-import status/results/settings/actions
|
||||||
|
|
||||||
|
Route / component tests:
|
||||||
|
|
||||||
|
- unauthorized users redirect to profile home
|
||||||
|
- default route redirects to `/import/album`
|
||||||
|
- `/import/singles` renders the Singles tab
|
||||||
|
- `/import/auto?autoFilter=pending` renders pending auto-import results
|
||||||
|
- refresh invalidates staging queries
|
||||||
|
- album selection opens the match view
|
||||||
|
- drag/drop and tap assignment update track matches
|
||||||
|
- processing queue advances and refreshes staging on completion
|
||||||
|
- client workflow drafts survive page remounts
|
||||||
|
|
||||||
|
Playwright can wait until after route ownership flips.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- The processing queue is client-side and long-running.
|
||||||
|
- Auto-import polling must stop when leaving the auto subroute or route.
|
||||||
|
- File indexes can become stale after staging refreshes.
|
||||||
|
- Album matching depends on preserving source, album name, and album artist from search results.
|
||||||
|
- The page currently shares a large legacy module with stats and automations code, so cleanup should be careful and incremental.
|
||||||
|
|
||||||
|
## Decisions To Keep Simple
|
||||||
|
|
||||||
|
- Keep the current visual language.
|
||||||
|
- Keep the existing backend endpoints.
|
||||||
|
- Keep the processing queue client-side for the first migration.
|
||||||
|
- Keep file matching state local to the route.
|
||||||
|
- Do not extract shared workflow primitives until a second migrated route needs them.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- The route now serves as the first React-owned workflow migration.
|
||||||
|
- The implementation uses nested route paths plus a validated `autoFilter` search param.
|
||||||
|
- The route uses TanStack Query for staging data, suggestions, auto-import polling, mutations, and invalidation.
|
||||||
|
- Tests cover shell ownership, nested route state, album match payload preservation, and auto-import rendering.
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Treat remaining work as cleanup and hardening rather than route selection.
|
||||||
|
|
||||||
|
Follow-up work should optimize for:
|
||||||
|
|
||||||
|
- shrinking `stats-automations.js` after cutover
|
||||||
|
- adding E2E coverage around full album and singles processing
|
||||||
|
- considering route-level code splitting once more large React routes land
|
||||||
|
|
||||||
|
It should not optimize for:
|
||||||
|
|
||||||
|
- redesign
|
||||||
|
- backend reshaping
|
||||||
|
- shared queue abstractions
|
||||||
|
- migrating `automations` at the same time
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# WebUI Page Migration Overview
|
# WebUI Page Migration Overview
|
||||||
|
|
||||||
Snapshot date: 2026-05-14
|
Snapshot date: 2026-05-15
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
- The shell route manifest now has 18 page ids.
|
- The shell route manifest now has 18 page ids.
|
||||||
- `issues` and `stats` are now React-owned routes.
|
- `issues`, `stats`, and `import` are now React-owned routes.
|
||||||
- Since the last snapshot, the biggest changes are:
|
- Since the last snapshot, the biggest changes are:
|
||||||
- `downloads` was renamed into `search`.
|
- `downloads` was renamed into `search`.
|
||||||
- The live queue became `active-downloads`.
|
- The live queue became `active-downloads`.
|
||||||
|
|
@ -78,9 +78,9 @@ Rollups:
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed |
|
| `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed |
|
||||||
| `stats` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed |
|
| `stats` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed |
|
||||||
|
| `import` | React | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Completed |
|
||||||
| `help` | Legacy | 3 / 2 / 1 / 1 / 2 | Low | Low | Wave 1 |
|
| `help` | Legacy | 3 / 2 / 1 / 1 / 2 | Low | Low | Wave 1 |
|
||||||
| `hydrabase` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 |
|
| `hydrabase` | 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 |
|
| `search` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 2 |
|
||||||
| `watchlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
|
| `watchlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
|
||||||
| `wishlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
|
| `wishlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
|
||||||
|
|
@ -130,11 +130,13 @@ Rollups:
|
||||||
- Recommendation: low-risk route with a narrow surface.
|
- Recommendation: low-risk route with a narrow surface.
|
||||||
|
|
||||||
#### `import`
|
#### `import`
|
||||||
- Current owner: Legacy.
|
- Current owner: React.
|
||||||
- Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`.
|
- Primary files: `webui/src/routes/import/*`, `webui/src/platform/shell/route-manifest.ts`.
|
||||||
- Main surface: staging files, album and singles matching, suggestion cards, processing queue.
|
- Main surface: staging files, album and singles matching, suggestion cards, processing queue.
|
||||||
- Key coupling: settings-derived staging path assumptions and downstream library state.
|
- 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 `hydrabase`.
|
- Recommendation: completed as the next migration after `stats`. The import subtree now uses nested route paths, with `/import` redirecting to `/import/album` and `autoFilter` remaining in the search string; any remaining `import` references in `webui/static/stats-automations.js` belong to the broader automation feature set, not this page migration.
|
||||||
|
- Route-local workflow state lives in `webui/src/routes/import/-import.store.ts`, which keeps drafts and queue state alive while moving between album, singles, and auto views.
|
||||||
|
- Route plan: `webui/docs/migration/import-migration-plan.md`.
|
||||||
|
|
||||||
### Wave 2: Search split
|
### Wave 2: Search split
|
||||||
|
|
||||||
|
|
@ -264,9 +266,10 @@ Rollups:
|
||||||
- Waves 6-10 defer the broadest, most coupled, or most orchestration-heavy surfaces until the team has the most leverage.
|
- Waves 6-10 defer the broadest, most coupled, or most orchestration-heavy surfaces until the team has the most leverage.
|
||||||
|
|
||||||
## Final Recommendation
|
## Final Recommendation
|
||||||
- Keep `issues` and `stats` as the current React reference implementations, and preserve the explicit bridge contract between React routes and legacy shell behavior.
|
- Keep `issues`, `stats`, and `import` as the current React reference implementations, and preserve the explicit bridge contract between React routes and legacy shell behavior.
|
||||||
- Treat `search`, `watchlist`, `wishlist`, `active-downloads`, and `tools` as the current route ids, and keep `downloads` and `artists` only as compatibility history.
|
- Treat `search`, `watchlist`, `wishlist`, `active-downloads`, and `tools` as the current route ids, and keep `downloads` and `artists` only as compatibility history.
|
||||||
- Migrate the remaining safe legacy routes first: `help`, `hydrabase`, and `import`.
|
- Migrate the remaining safe legacy routes first: `help` and `hydrabase`.
|
||||||
|
- `import` has already been migrated and should be treated as the first React-owned workflow route.
|
||||||
- During each migration, actively look for small reuse opportunities across route slices and shared UI primitives, but only extract once the overlap is clearly real.
|
- During each migration, actively look for small reuse opportunities across route slices and shared UI primitives, but only extract once the overlap is clearly real.
|
||||||
- Use `search` as the next meaningful proving ground now that the download queue has been split out.
|
- Use `search` as the next larger proving ground after `import`, 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.
|
- 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.
|
||||||
|
|
|
||||||
179
webui/index.html
179
webui/index.html
|
|
@ -2435,10 +2435,6 @@
|
||||||
<span>← Back</span>
|
<span>← Back</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="library-artist-watchlist-btn" id="library-artist-watchlist-btn">
|
|
||||||
<span class="watchlist-icon">👁️</span>
|
|
||||||
<span class="watchlist-text">Add to Watchlist</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Artist Hero Section -->
|
<!-- Artist Hero Section -->
|
||||||
|
|
@ -2466,6 +2462,17 @@
|
||||||
<span class="radio-icon">📻</span>
|
<span class="radio-icon">📻</span>
|
||||||
<span class="radio-text">Artist Radio</span>
|
<span class="radio-text">Artist Radio</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="library-artist-watchlist-btn" id="library-artist-watchlist-btn">
|
||||||
|
<span class="watchlist-icon">👁️</span>
|
||||||
|
<span class="watchlist-text">Add to Watchlist</span>
|
||||||
|
</button>
|
||||||
|
<div class="discog-download-wrap" id="discog-download-wrap" style="display:none;">
|
||||||
|
<button class="discog-download-btn discog-btn-compact" id="discog-download-btn" onclick="openDiscographyModal()">
|
||||||
|
<span class="discog-btn-icon">⬇</span>
|
||||||
|
<span class="discog-btn-text">Download Discography</span>
|
||||||
|
<span class="discog-btn-shimmer"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button class="library-artist-enhance-btn hidden" id="library-artist-enhance-btn"
|
<button class="library-artist-enhance-btn hidden" id="library-artist-enhance-btn"
|
||||||
onclick="openEnhanceQualityModal()">
|
onclick="openEnhanceQualityModal()">
|
||||||
<span class="enhance-icon">⚡</span>
|
<span class="enhance-icon">⚡</span>
|
||||||
|
|
@ -2485,13 +2492,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="discog-download-wrap" id="discog-download-wrap" style="display:none;">
|
|
||||||
<button class="discog-download-btn discog-btn-compact" id="discog-download-btn" onclick="openDiscographyModal()">
|
|
||||||
<span class="discog-btn-icon">⬇</span>
|
|
||||||
<span class="discog-btn-text">Download Discography</span>
|
|
||||||
<span class="discog-btn-shimmer"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="collection-overview">
|
<div class="collection-overview">
|
||||||
<div class="collection-category">
|
<div class="collection-category">
|
||||||
|
|
@ -6199,159 +6199,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import Page -->
|
|
||||||
<div class="page" id="import-page">
|
|
||||||
<div class="import-page-container">
|
|
||||||
<!-- Header with staging info -->
|
|
||||||
<div class="import-page-header">
|
|
||||||
<div class="import-page-title-row">
|
|
||||||
<h1 class="import-page-title"><img src="/static/import.png" class="page-header-icon" alt=""><span>Import Music</span></h1>
|
|
||||||
<button class="import-page-refresh-btn" onclick="importPageRefreshStaging()" title="Re-scan import folder">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M13.65 2.35A8 8 0 1 0 16 8h-2a6 6 0 1 1-1.76-4.24L10 6h6V0l-2.35 2.35z"/></svg>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="import-page-staging-bar" id="import-staging-bar">
|
|
||||||
<span class="import-staging-path" id="import-page-staging-path">Import folder: loading...</span>
|
|
||||||
<span class="import-staging-stats" id="import-page-staging-stats"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Processing Queue -->
|
|
||||||
<div class="import-page-queue hidden" id="import-page-queue">
|
|
||||||
<div class="import-page-queue-header">
|
|
||||||
<span class="import-page-queue-title">Processing</span>
|
|
||||||
<button class="import-page-queue-clear" id="import-page-queue-clear" onclick="importPageClearFinishedJobs()">Clear finished</button>
|
|
||||||
</div>
|
|
||||||
<div class="import-page-queue-list" id="import-page-queue-list"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab Bar -->
|
|
||||||
<div class="import-page-tab-bar">
|
|
||||||
<button class="import-page-tab" id="import-page-tab-auto" onclick="importPageSwitchTab('auto')">Auto</button>
|
|
||||||
<button class="import-page-tab active" id="import-page-tab-album" onclick="importPageSwitchTab('album')">Albums</button>
|
|
||||||
<button class="import-page-tab" id="import-page-tab-singles" onclick="importPageSwitchTab('singles')">Singles</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auto Import Tab -->
|
|
||||||
<div class="import-page-tab-content" id="import-page-auto-content">
|
|
||||||
<div class="auto-import-controls">
|
|
||||||
<div class="auto-import-toggle-row">
|
|
||||||
<label class="auto-import-toggle-label">
|
|
||||||
<input type="checkbox" id="auto-import-enabled" onchange="_autoImportToggle(this.checked)">
|
|
||||||
<span class="repair-toggle-slider"></span>
|
|
||||||
<span>Auto-Import</span>
|
|
||||||
</label>
|
|
||||||
<span class="auto-import-status" id="auto-import-status-text">Disabled</span>
|
|
||||||
<button class="auto-import-scan-now-btn" id="auto-import-scan-now" onclick="_autoImportScanNow()" title="Scan import folder now" style="display:none">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M13.65 2.35A8 8 0 1 0 16 8h-2a6 6 0 1 1-1.76-4.24L10 6h6V0l-2.35 2.35z"/></svg>
|
|
||||||
Scan Now
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="auto-import-settings-row" id="auto-import-settings-row" style="display:none;">
|
|
||||||
<label>Confidence: <input type="range" id="auto-import-confidence" min="50" max="100" value="90" oninput="document.getElementById('auto-import-conf-val').textContent=this.value+'%'"> <span id="auto-import-conf-val">90%</span></label>
|
|
||||||
<label>Interval: <select id="auto-import-interval" onchange="_autoImportSaveSettings()">
|
|
||||||
<option value="30">30s</option>
|
|
||||||
<option value="60" selected>60s</option>
|
|
||||||
<option value="120">2m</option>
|
|
||||||
<option value="300">5m</option>
|
|
||||||
</select></label>
|
|
||||||
<button class="watchlist-action-btn watchlist-action-secondary" onclick="_autoImportSaveSettings()">Save</button>
|
|
||||||
</div>
|
|
||||||
<!-- Live scan progress -->
|
|
||||||
<div class="auto-import-progress" id="auto-import-progress" style="display:none">
|
|
||||||
<div class="auto-import-progress-text" id="auto-import-progress-text">Scanning...</div>
|
|
||||||
<div class="auto-import-progress-bar"><div class="auto-import-progress-fill" id="auto-import-progress-fill"></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Stats summary -->
|
|
||||||
<div class="auto-import-stats" id="auto-import-stats" style="display:none">
|
|
||||||
<span class="auto-import-stat" id="auto-import-stat-imported">0 imported</span>
|
|
||||||
<span class="auto-import-stat auto-import-stat-review" id="auto-import-stat-review">0 review</span>
|
|
||||||
<span class="auto-import-stat auto-import-stat-failed" id="auto-import-stat-failed">0 failed</span>
|
|
||||||
</div>
|
|
||||||
<!-- Filter pills -->
|
|
||||||
<div class="auto-import-filters" id="auto-import-filters" style="display:none">
|
|
||||||
<button class="adl-pill active" data-filter="all" onclick="_autoImportSetFilter('all')">All</button>
|
|
||||||
<button class="adl-pill" data-filter="pending" onclick="_autoImportSetFilter('pending')">Needs Review</button>
|
|
||||||
<button class="adl-pill" data-filter="imported" onclick="_autoImportSetFilter('imported')">Imported</button>
|
|
||||||
<button class="adl-pill" data-filter="failed" onclick="_autoImportSetFilter('failed')">Failed</button>
|
|
||||||
<div style="flex:1"></div>
|
|
||||||
<button class="auto-import-batch-btn" id="auto-import-approve-all" onclick="_autoImportApproveAll()" style="display:none">Approve All</button>
|
|
||||||
<button class="auto-import-batch-btn auto-import-clear-btn" id="auto-import-clear-completed" onclick="_autoImportClearCompleted()" style="display:none">Clear History</button>
|
|
||||||
</div>
|
|
||||||
<div class="auto-import-results" id="auto-import-results">
|
|
||||||
<div class="auto-import-empty">
|
|
||||||
<p>Enable auto-import to watch your import folder for new music.</p>
|
|
||||||
<p style="opacity:0.5;font-size:12px;">Drop album folders or single tracks into your import folder and SoulSync will identify, match, and import them automatically.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Album Tab -->
|
|
||||||
<div class="import-page-tab-content active" id="import-page-album-content">
|
|
||||||
<!-- Search state -->
|
|
||||||
<div id="import-page-album-search-section">
|
|
||||||
<div class="import-page-suggestions" id="import-page-suggestions">
|
|
||||||
<div class="import-page-section-label">Suggested from your import folder</div>
|
|
||||||
<div class="import-page-album-grid" id="import-page-suggestions-grid"></div>
|
|
||||||
</div>
|
|
||||||
<div class="import-page-search-bar">
|
|
||||||
<input type="text" id="import-page-album-search-input" class="import-page-search-input"
|
|
||||||
placeholder="Search for an album..." onkeydown="if(event.key==='Enter')importPageSearchAlbum()">
|
|
||||||
<button class="import-page-search-btn" onclick="importPageSearchAlbum()">Search</button>
|
|
||||||
<button class="import-page-clear-btn hidden" id="import-page-album-clear-btn"
|
|
||||||
onclick="importPageResetAlbumSearch()" title="Clear search">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="import-page-album-grid" id="import-page-album-results"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Match state (hidden initially) -->
|
|
||||||
<div id="import-page-album-match-section" class="hidden">
|
|
||||||
<div class="import-page-album-hero" id="import-page-album-hero"></div>
|
|
||||||
<div class="import-page-match-header">
|
|
||||||
<h3>Track Matching</h3>
|
|
||||||
<div class="import-page-match-actions">
|
|
||||||
<button class="import-page-secondary-btn" onclick="importPageAutoRematch()">Re-match Automatically</button>
|
|
||||||
<button class="import-page-back-btn" onclick="importPageResetAlbumSearch()">Back to Search</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="import-page-match-list" id="import-page-match-list"></div>
|
|
||||||
|
|
||||||
<!-- Unmatched file pool for drag-drop -->
|
|
||||||
<div class="import-page-unmatched-pool" id="import-page-unmatched-pool">
|
|
||||||
<div class="import-page-pool-label">Unmatched Files (<span id="import-page-unmatched-count">0</span>)</div>
|
|
||||||
<div class="import-page-pool-chips" id="import-page-pool-chips"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="import-page-match-footer">
|
|
||||||
<div class="import-page-match-stats" id="import-page-match-stats"></div>
|
|
||||||
<button class="import-page-process-btn" id="import-page-album-process-btn"
|
|
||||||
onclick="importPageProcessAlbum()">Process Album</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Singles Tab -->
|
|
||||||
<div class="import-page-tab-content" id="import-page-singles-content">
|
|
||||||
<div class="import-page-singles-header">
|
|
||||||
<div class="import-page-singles-actions">
|
|
||||||
<button class="import-page-secondary-btn" onclick="importPageSelectAllSingles()">
|
|
||||||
<span id="import-page-select-all-text">Select All</span>
|
|
||||||
</button>
|
|
||||||
<button class="import-page-process-btn" id="import-page-singles-process-btn"
|
|
||||||
onclick="importPageProcessSingles()" disabled>Process Selected (0)</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="import-page-singles-list" id="import-page-singles-list">
|
|
||||||
<div class="import-page-empty-state">Navigate to this page to scan your import folder for audio files.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Help & Docs Page -->
|
<!-- Help & Docs Page -->
|
||||||
<div class="page" id="help-page">
|
<div class="page" id="help-page">
|
||||||
<div class="docs-layout">
|
<div class="docs-layout">
|
||||||
|
|
@ -7174,8 +7021,10 @@
|
||||||
<span class="np-queue-count" id="np-queue-count"></span>
|
<span class="np-queue-count" id="np-queue-count"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="np-queue-header-actions">
|
<div class="np-queue-header-actions">
|
||||||
<button class="np-radio-btn" id="np-radio-btn" title="Radio mode - auto-add similar tracks">
|
<button class="np-radio-btn" id="np-radio-btn" title="Radio mode - auto-add similar tracks" aria-pressed="false">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h.01"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/><path d="M5 12.859a10 10 0 0 1 14 0"/><path d="M1.5 9.289a15 15 0 0 1 21 0"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h.01"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/><path d="M5 12.859a10 10 0 0 1 14 0"/><path d="M1.5 9.289a15 15 0 0 1 21 0"/></svg>
|
||||||
|
<span class="np-radio-label">Radio</span>
|
||||||
|
<span class="np-radio-pulse"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="np-queue-clear-btn" id="np-queue-clear" title="Clear queue">Clear</button>
|
<button class="np-queue-clear-btn" id="np-queue-clear" title="Clear queue">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
32
webui/package-lock.json
generated
32
webui/package-lock.json
generated
|
|
@ -15,7 +15,8 @@
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"zod": "^4.4.2"
|
"zod": "^4.4.2",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
|
@ -5391,6 +5392,35 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zustand": {
|
||||||
|
"version": "5.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
|
||||||
|
"integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18.0.0",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=18.0.0",
|
||||||
|
"use-sync-external-store": ">=1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"use-sync-external-store": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"zod": "^4.4.2"
|
"zod": "^4.4.2",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@
|
||||||
.select {
|
.select {
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 130px;
|
min-width: 130px;
|
||||||
|
box-sizing: border-box;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background:
|
background:
|
||||||
|
|
@ -95,6 +96,7 @@
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
min-height: 36px;
|
||||||
padding: 8px 32px 8px 12px;
|
padding: 8px 32px 8px 12px;
|
||||||
transition:
|
transition:
|
||||||
border-color 0.18s ease,
|
border-color 0.18s ease,
|
||||||
|
|
@ -105,28 +107,204 @@
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
&: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);
|
||||||
|
}
|
||||||
|
|
||||||
|
&: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);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size='sm'] {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 6px 30px 6px 10px;
|
||||||
|
background-position:
|
||||||
|
0 0,
|
||||||
|
0 0,
|
||||||
|
right 10px center;
|
||||||
|
}
|
||||||
|
|
||||||
|
& option,
|
||||||
|
& optgroup {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.select:hover {
|
.checkbox {
|
||||||
border-color: rgba(255, 255, 255, 0.16);
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #000;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
color-scheme: dark;
|
||||||
|
&[data-checked] {
|
||||||
|
border-color: rgb(var(--accent-light-rgb));
|
||||||
|
background: rgb(var(--accent-light-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-focused] {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-checked] .checkboxIndicator {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxIndicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxIcon {
|
||||||
|
color: #000;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-0.5px) scaleX(1.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 3px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 999px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.045)),
|
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04)),
|
||||||
rgba(255, 255, 255, 0.06);
|
rgba(255, 255, 255, 0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
color-scheme: dark;
|
||||||
|
&[data-checked] {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.55);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(var(--accent-light-rgb), 0.55), rgba(var(--accent-rgb), 0.8)),
|
||||||
|
rgba(var(--accent-rgb), 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-focused] {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-checked] .switchThumb {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.select:focus {
|
.switchThumb {
|
||||||
outline: none;
|
display: block;
|
||||||
border-color: rgba(var(--accent-light-rgb), 0.55);
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeRoot {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 160px;
|
||||||
|
width: 160px;
|
||||||
|
min-width: 160px;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeControl {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeTrack {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeIndicator {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(var(--accent-rgb), 0.95),
|
||||||
|
rgba(var(--accent-light-rgb), 0.95)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangeThumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||||
|
border-radius: 50%;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.05)),
|
radial-gradient(circle at 30% 28%, rgba(255, 255, 255, 0.3), transparent 46%),
|
||||||
rgba(255, 255, 255, 0.07);
|
rgb(var(--accent-light-rgb));
|
||||||
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
|
||||||
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select option,
|
.rangeThumb:hover {
|
||||||
.select optgroup {
|
transform: scale(1.04);
|
||||||
background: #1a1a2e;
|
}
|
||||||
color: #fff;
|
|
||||||
|
.rangeThumb:focus-visible {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px rgba(var(--accent-light-rgb), 0.14),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.optionCardGroup {
|
.optionCardGroup {
|
||||||
|
|
@ -154,25 +332,24 @@
|
||||||
border-color 0.18s ease,
|
border-color 0.18s ease,
|
||||||
box-shadow 0.18s ease,
|
box-shadow 0.18s ease,
|
||||||
background 0.18s ease;
|
background 0.18s ease;
|
||||||
}
|
&: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);
|
||||||
|
}
|
||||||
|
|
||||||
.optionCard:hover {
|
&[data-selected='true'] {
|
||||||
transform: translateY(-1px);
|
border-color: rgba(var(--accent-light-rgb), 0.45);
|
||||||
border-color: rgba(255, 255, 255, 0.14);
|
background:
|
||||||
background:
|
linear-gradient(180deg, rgba(var(--accent-rgb), 0.18), rgba(255, 255, 255, 0.04)),
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.04)),
|
rgba(255, 255, 255, 0.05);
|
||||||
rgba(255, 255, 255, 0.04);
|
box-shadow:
|
||||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
|
0 0 0 1px rgba(var(--accent-light-rgb), 0.1),
|
||||||
}
|
0 14px 32px rgba(0, 0, 0, 0.26);
|
||||||
|
}
|
||||||
.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 {
|
.optionCardIcon {
|
||||||
|
|
@ -206,9 +383,22 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
&[data-size='sm'] {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size='sm'] .optionButton {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.optionButton {
|
.optionButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
|
@ -218,24 +408,45 @@
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
padding: 10px 14px;
|
padding: 8px 16px;
|
||||||
transition:
|
transition:
|
||||||
transform 0.18s ease,
|
transform 0.18s ease,
|
||||||
border-color 0.18s ease,
|
border-color 0.18s ease,
|
||||||
box-shadow 0.18s ease,
|
box-shadow 0.18s ease,
|
||||||
background 0.18s ease;
|
background 0.18s ease;
|
||||||
}
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.optionButton:hover {
|
&[data-variant='ghost'] {
|
||||||
transform: translateY(-1px);
|
border-color: transparent;
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
background: transparent;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
color: rgba(255, 255, 255, 0.58);
|
||||||
}
|
}
|
||||||
|
|
||||||
.optionButtonSelected {
|
&[data-variant='ghost']:hover:not(:disabled) {
|
||||||
border-color: rgba(var(--accent-light-rgb), 0.5);
|
transform: none;
|
||||||
background: rgba(var(--accent-rgb), 0.18);
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.08);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-selected='true'] {
|
||||||
|
color: #fff;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-selected='true']:hover {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.62);
|
||||||
|
background: rgba(var(--accent-rgb), 0.26);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(var(--accent-light-rgb), 0.12),
|
||||||
|
0 10px 24px rgba(0, 0, 0, 0.14);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
|
|
@ -260,24 +471,118 @@
|
||||||
box-shadow 0.18s ease,
|
box-shadow 0.18s ease,
|
||||||
background 0.18s ease,
|
background 0.18s ease,
|
||||||
color 0.18s ease;
|
color 0.18s ease;
|
||||||
}
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.button:hover:not(:disabled) {
|
&:focus-visible {
|
||||||
transform: translateY(-1px);
|
outline: none;
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
border-color: rgba(var(--accent-light-rgb), 0.55);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:focus-visible {
|
&:disabled {
|
||||||
outline: none;
|
opacity: 0.55;
|
||||||
border-color: rgba(var(--accent-light-rgb), 0.55);
|
cursor: not-allowed;
|
||||||
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button:disabled {
|
&[data-size='sm'] {
|
||||||
opacity: 0.55;
|
min-height: 32px;
|
||||||
cursor: not-allowed;
|
padding: 6px 10px;
|
||||||
transform: none;
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size='lg'] {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-size='icon'] {
|
||||||
|
width: var(--button-icon-size, 36px);
|
||||||
|
height: var(--button-icon-size, 36px);
|
||||||
|
min-height: var(--button-icon-size, 36px);
|
||||||
|
min-width: var(--button-icon-size, 36px);
|
||||||
|
padding: 0;
|
||||||
|
font-size: var(--button-icon-font-size, 15px);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='primary'] {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.45);
|
||||||
|
background: rgb(var(--accent-light-rgb));
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='primary']:hover:not(:disabled) {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.55);
|
||||||
|
background: rgba(var(--accent-light-rgb), 0.95);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='primary']:focus-visible {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.8);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='primary']:disabled {
|
||||||
|
opacity: 0.62;
|
||||||
|
background: rgba(var(--accent-light-rgb), 0.45);
|
||||||
|
color: rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='primary'] [data-slot='badge'] {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
border-color: rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='secondary'] {
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='secondary']:hover:not(:disabled) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='secondary']:focus-visible {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.45);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='secondary']:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='ghost'] {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='ghost']:hover:not(:disabled) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.14);
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='ghost']:focus-visible {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.42);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-variant='ghost']:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.formError {
|
.formError {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
FormActions,
|
FormActions,
|
||||||
FormError,
|
FormError,
|
||||||
FormField,
|
FormField,
|
||||||
|
|
@ -11,7 +12,9 @@ import {
|
||||||
OptionButtonGroup,
|
OptionButtonGroup,
|
||||||
OptionCard,
|
OptionCard,
|
||||||
OptionCardGroup,
|
OptionCardGroup,
|
||||||
|
RangeInput,
|
||||||
Select,
|
Select,
|
||||||
|
Switch,
|
||||||
TextArea,
|
TextArea,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from './form';
|
} from './form';
|
||||||
|
|
@ -22,6 +25,9 @@ function FormDemo() {
|
||||||
const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover');
|
const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover');
|
||||||
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
|
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
|
||||||
const [status, setStatus] = useState('open');
|
const [status, setStatus] = useState('open');
|
||||||
|
const [archive, setArchive] = useState(false);
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [confidence, setConfidence] = useState(90);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form>
|
<form>
|
||||||
|
|
@ -90,11 +96,31 @@ function FormDemo() {
|
||||||
</Select>
|
</Select>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Archive" helperText="Shared checkbox primitive">
|
||||||
|
<Checkbox checked={archive} onCheckedChange={setArchive} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Enabled" helperText="Shared switch primitive">
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Confidence" helperText="Shared range primitive">
|
||||||
|
<RangeInput
|
||||||
|
label="Confidence"
|
||||||
|
min={50}
|
||||||
|
max={100}
|
||||||
|
value={confidence}
|
||||||
|
onValueChange={setConfidence}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<FormError message="Validation failed" />
|
<FormError message="Validation failed" />
|
||||||
|
|
||||||
<FormActions>
|
<FormActions>
|
||||||
<Button type="button">Cancel</Button>
|
<Button type="button">Cancel</Button>
|
||||||
<Button type="submit">Save</Button>
|
<Button type="submit" variant="primary">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|
@ -109,9 +135,25 @@ describe('form primitives', () => {
|
||||||
expect(screen.getByText('Short summary')).toBeInTheDocument();
|
expect(screen.getByText('Short summary')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('alert')).toHaveTextContent('Validation failed');
|
expect(screen.getByRole('alert')).toHaveTextContent('Validation failed');
|
||||||
expect(screen.getByLabelText('Status')).toHaveValue('open');
|
expect(screen.getByLabelText('Status')).toHaveValue('open');
|
||||||
|
expect(screen.getByLabelText('Status')).toHaveAttribute('data-size', 'md');
|
||||||
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } });
|
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } });
|
||||||
expect(screen.getByLabelText('Status')).toHaveValue('resolved');
|
expect(screen.getByLabelText('Status')).toHaveValue('resolved');
|
||||||
|
|
||||||
|
const archiveCheckbox = screen.getByRole('checkbox', { name: 'Archive' });
|
||||||
|
expect(archiveCheckbox).not.toBeChecked();
|
||||||
|
fireEvent.click(archiveCheckbox);
|
||||||
|
expect(archiveCheckbox).toBeChecked();
|
||||||
|
|
||||||
|
const enabledSwitch = screen.getByRole('switch', { name: 'Enabled' });
|
||||||
|
expect(enabledSwitch).toBeChecked();
|
||||||
|
fireEvent.click(enabledSwitch);
|
||||||
|
expect(enabledSwitch).not.toBeChecked();
|
||||||
|
|
||||||
|
const confidenceSlider = screen.getByLabelText('Confidence', { selector: 'input' });
|
||||||
|
expect(confidenceSlider).toHaveValue('90');
|
||||||
|
fireEvent.change(confidenceSlider, { target: { value: '75' } });
|
||||||
|
expect(confidenceSlider).toHaveValue('75');
|
||||||
|
|
||||||
const wrongCover = screen.getByRole('button', { name: /wrong cover/i });
|
const wrongCover = screen.getByRole('button', { name: /wrong cover/i });
|
||||||
const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
|
const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
|
||||||
expect(wrongCover).toHaveAttribute('aria-pressed', 'true');
|
expect(wrongCover).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
@ -126,6 +168,32 @@ describe('form primitives', () => {
|
||||||
expect(highPriority).toHaveAttribute('aria-pressed', 'true');
|
expect(highPriority).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('data-variant', 'primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports compact option button groups', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<OptionButtonGroup size="sm">
|
||||||
|
<OptionButton selected>All</OptionButton>
|
||||||
|
<OptionButton variant="ghost">Pending</OptionButton>
|
||||||
|
</OptionButtonGroup>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-size="sm"]')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Pending' })).toHaveAttribute(
|
||||||
|
'data-variant',
|
||||||
|
'ghost',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports compact select sizing', () => {
|
||||||
|
render(
|
||||||
|
<Select aria-label="Compact" defaultValue="one" size="sm">
|
||||||
|
<option value="one">One</option>
|
||||||
|
<option value="two">Two</option>
|
||||||
|
</Select>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Compact')).toHaveAttribute('data-size', 'sm');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { Button as BaseButton } from '@base-ui/react/button';
|
import { Button as BaseButton } from '@base-ui/react/button';
|
||||||
|
import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
|
||||||
import { Field } from '@base-ui/react/field';
|
import { Field } from '@base-ui/react/field';
|
||||||
import { Input as BaseInput } from '@base-ui/react/input';
|
import { Input as BaseInput } from '@base-ui/react/input';
|
||||||
|
import { Slider } from '@base-ui/react/slider';
|
||||||
|
import { Switch as BaseSwitch } from '@base-ui/react/switch';
|
||||||
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
|
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
|
type CSSProperties,
|
||||||
type ComponentPropsWithoutRef,
|
type ComponentPropsWithoutRef,
|
||||||
type ButtonHTMLAttributes,
|
type ButtonHTMLAttributes,
|
||||||
type SelectHTMLAttributes,
|
type SelectHTMLAttributes,
|
||||||
|
|
@ -73,13 +77,118 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function
|
||||||
return <textarea ref={ref} className={clsx(styles.textArea, className)} {...props} />;
|
return <textarea ref={ref} className={clsx(styles.textArea, className)} {...props} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
|
export type SelectSize = 'sm' | 'md';
|
||||||
|
|
||||||
|
export type SelectProps = Omit<SelectHTMLAttributes<HTMLSelectElement>, 'className' | 'size'> & {
|
||||||
|
className?: string;
|
||||||
|
size?: SelectSize;
|
||||||
|
};
|
||||||
|
|
||||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
||||||
|
{ className, size = 'md', ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<select ref={ref} className={clsx(styles.select, className)} data-size={size} {...props} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
type BaseCheckboxProps = ComponentPropsWithoutRef<typeof BaseCheckbox.Root>;
|
||||||
|
|
||||||
|
export type CheckboxProps = Omit<BaseCheckboxProps, 'className' | 'children'> & {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Checkbox = forwardRef<HTMLElement, CheckboxProps>(function Checkbox(
|
||||||
{ className, ...props },
|
{ className, ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
return <select ref={ref} className={clsx(styles.select, className)} {...props} />;
|
return (
|
||||||
|
<BaseCheckbox.Root ref={ref} className={clsx(styles.checkbox, className)} {...props}>
|
||||||
|
<BaseCheckbox.Indicator className={styles.checkboxIndicator}>
|
||||||
|
<span className={styles.checkboxIcon} aria-hidden="true">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
</BaseCheckbox.Indicator>
|
||||||
|
</BaseCheckbox.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
type BaseSwitchProps = ComponentPropsWithoutRef<typeof BaseSwitch.Root>;
|
||||||
|
|
||||||
|
export type SwitchProps = Omit<BaseSwitchProps, 'className' | 'children'> & {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Switch = forwardRef<HTMLElement, SwitchProps>(function Switch(
|
||||||
|
{ className, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<BaseSwitch.Root ref={ref} className={clsx(styles.switch, className)} {...props}>
|
||||||
|
<BaseSwitch.Thumb className={styles.switchThumb} />
|
||||||
|
</BaseSwitch.Root>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface RangeInputProps {
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
defaultValue?: number;
|
||||||
|
label?: ReactNode;
|
||||||
|
max?: number;
|
||||||
|
min?: number;
|
||||||
|
name?: string;
|
||||||
|
step?: number;
|
||||||
|
style?: CSSProperties;
|
||||||
|
value?: number;
|
||||||
|
onValueChange?: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RangeInput = forwardRef<HTMLDivElement, RangeInputProps>(function RangeInput(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
max = 100,
|
||||||
|
min = 0,
|
||||||
|
name,
|
||||||
|
onValueChange,
|
||||||
|
step = 1,
|
||||||
|
style,
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Slider.Root
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(styles.rangeRoot, className)}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
thumbAlignment="edge"
|
||||||
|
style={style}
|
||||||
|
disabled={disabled}
|
||||||
|
name={name}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
onValueChange={(nextValue) => {
|
||||||
|
onValueChange?.(Array.isArray(nextValue) ? nextValue[0] : nextValue);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Slider.Control className={styles.rangeControl}>
|
||||||
|
<Slider.Track className={styles.rangeTrack}>
|
||||||
|
<Slider.Indicator className={styles.rangeIndicator} />
|
||||||
|
<Slider.Thumb
|
||||||
|
aria-label={typeof label === 'string' ? label : undefined}
|
||||||
|
className={styles.rangeThumb}
|
||||||
|
/>
|
||||||
|
</Slider.Track>
|
||||||
|
</Slider.Control>
|
||||||
|
</Slider.Root>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export function OptionCardGroup({
|
export function OptionCardGroup({
|
||||||
|
|
@ -112,7 +221,8 @@ export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(functio
|
||||||
<BaseToggle
|
<BaseToggle
|
||||||
ref={ref}
|
ref={ref}
|
||||||
pressed={selected}
|
pressed={selected}
|
||||||
className={clsx(styles.optionCard, selected && styles.optionCardSelected, className)}
|
className={clsx(styles.optionCard, className)}
|
||||||
|
data-selected={selected ? 'true' : undefined}
|
||||||
type={type}
|
type={type}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|
@ -129,31 +239,42 @@ export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(functio
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export function OptionButtonGroup({
|
export type OptionButtonGroupSize = 'sm' | 'md';
|
||||||
className,
|
|
||||||
children,
|
export interface OptionButtonGroupProps {
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
size?: OptionButtonGroupSize;
|
||||||
return <div className={clsx(styles.optionButtonGroup, className)}>{children}</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function OptionButtonGroup({ className, children, size = 'md' }: OptionButtonGroupProps) {
|
||||||
|
return (
|
||||||
|
<div className={clsx(styles.optionButtonGroup, className)} data-size={size}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OptionButtonVariant = 'default' | 'ghost';
|
||||||
|
|
||||||
export interface OptionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {
|
export interface OptionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {
|
||||||
className?: string;
|
className?: string;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
|
variant?: OptionButtonVariant;
|
||||||
value?: string;
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(function OptionButton(
|
export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(function OptionButton(
|
||||||
{ className, children, selected = false, type = 'button', ...props },
|
{ className, children, selected = false, type = 'button', variant = 'default', ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<BaseToggle
|
<BaseToggle
|
||||||
ref={ref}
|
ref={ref}
|
||||||
pressed={selected}
|
pressed={selected}
|
||||||
className={clsx(styles.optionButton, selected && styles.optionButtonSelected, className)}
|
className={clsx(styles.optionButton, className)}
|
||||||
|
data-selected={selected ? 'true' : undefined}
|
||||||
|
data-variant={variant}
|
||||||
type={type}
|
type={type}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
|
@ -166,13 +287,24 @@ type BaseButtonProps = ComponentPropsWithoutRef<typeof BaseButton>;
|
||||||
|
|
||||||
export type ButtonProps = Omit<BaseButtonProps, 'className'> & {
|
export type ButtonProps = Omit<BaseButtonProps, 'className'> & {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'icon';
|
||||||
|
variant?: 'default' | 'primary' | 'secondary' | 'ghost';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||||
{ className, type = 'button', ...props },
|
{ className, size = 'md', type = 'button', variant = 'default', ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
return <BaseButton ref={ref} className={clsx(styles.button, className)} type={type} {...props} />;
|
return (
|
||||||
|
<BaseButton
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(styles.button, className)}
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
type={type}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
|
export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
export { Show } from './show';
|
export * from './primitives';
|
||||||
|
|
|
||||||
87
webui/src/components/primitives/primitives.module.css
Normal file
87
webui/src/components/primitives/primitives.module.css
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
.notice {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice[data-tone='neutral'] {
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice[data-tone='info'] {
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.24);
|
||||||
|
background: rgba(var(--accent-light-rgb), 0.08);
|
||||||
|
color: rgba(255, 232, 188, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice[data-tone='success'] {
|
||||||
|
border-color: rgba(110, 220, 150, 0.26);
|
||||||
|
background: rgba(110, 220, 150, 0.08);
|
||||||
|
color: rgba(210, 255, 228, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice[data-tone='warning'] {
|
||||||
|
border-color: rgba(255, 200, 100, 0.26);
|
||||||
|
background: rgba(255, 200, 100, 0.08);
|
||||||
|
color: rgba(255, 232, 188, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice[data-tone='danger'] {
|
||||||
|
border-color: rgba(255, 120, 120, 0.26);
|
||||||
|
background: rgba(255, 120, 120, 0.08);
|
||||||
|
color: rgba(255, 214, 214, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2ch;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge[data-tone='info'] {
|
||||||
|
color: rgb(var(--accent-light-rgb));
|
||||||
|
background: rgba(var(--accent-rgb), 0.12);
|
||||||
|
border-color: rgba(var(--accent-light-rgb), 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge[data-tone='success'] {
|
||||||
|
color: #4ade80;
|
||||||
|
background: rgba(74, 222, 128, 0.12);
|
||||||
|
border-color: rgba(74, 222, 128, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge[data-tone='warning'] {
|
||||||
|
color: #fbbf24;
|
||||||
|
background: rgba(251, 191, 36, 0.12);
|
||||||
|
border-color: rgba(251, 191, 36, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge[data-tone='danger'] {
|
||||||
|
color: #f87171;
|
||||||
|
background: rgba(248, 113, 113, 0.12);
|
||||||
|
border-color: rgba(248, 113, 113, 0.12);
|
||||||
|
}
|
||||||
67
webui/src/components/primitives/primitives.test.tsx
Normal file
67
webui/src/components/primitives/primitives.test.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { Badge, Notice, Show } from './primitives';
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Notice', () => {
|
||||||
|
it('renders as a note by default', () => {
|
||||||
|
render(<Notice>Fallback message</Notice>);
|
||||||
|
|
||||||
|
expect(screen.getByText('Fallback message')).toHaveAttribute('role', 'note');
|
||||||
|
expect(screen.getByText('Fallback message')).toHaveAttribute('data-tone', 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports tone overrides', () => {
|
||||||
|
render(
|
||||||
|
<Notice tone="warning">
|
||||||
|
<span>Provider fallback</span>
|
||||||
|
</Notice>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('note')).toHaveAttribute('data-tone', 'warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Badge', () => {
|
||||||
|
it('renders with neutral styling by default', () => {
|
||||||
|
render(<Badge>12</Badge>);
|
||||||
|
|
||||||
|
expect(screen.getByText('12')).toHaveAttribute('data-slot', 'badge');
|
||||||
|
expect(screen.getByText('12')).toHaveAttribute('data-tone', 'neutral');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports tone overrides', () => {
|
||||||
|
render(<Badge tone="warning">12</Badge>);
|
||||||
|
|
||||||
|
expect(screen.getByText('12')).toHaveAttribute('data-tone', 'warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
70
webui/src/components/primitives/primitives.tsx
Normal file
70
webui/src/components/primitives/primitives.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import styles from './primitives.module.css';
|
||||||
|
|
||||||
|
type ShowChildren<T> = ReactNode | ((value: NonNullable<T>) => ReactNode);
|
||||||
|
|
||||||
|
export interface ShowProps<T> {
|
||||||
|
children: ShowChildren<T>;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
when: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Show<T>({ fallback = null, children, when }: ShowProps<T>) {
|
||||||
|
if (!when) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof children === 'function') {
|
||||||
|
return <>{(children as (value: NonNullable<T>) => ReactNode)(when as NonNullable<T>)}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseBadgeProps = ComponentPropsWithoutRef<'span'>;
|
||||||
|
|
||||||
|
export type BadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
||||||
|
|
||||||
|
export type BadgeProps = Omit<BaseBadgeProps, 'className'> & {
|
||||||
|
className?: string;
|
||||||
|
tone?: BadgeTone;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(function Badge(
|
||||||
|
{ className, tone = 'neutral', ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(styles.badge, className)}
|
||||||
|
data-slot="badge"
|
||||||
|
data-tone={tone}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export type NoticeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
||||||
|
|
||||||
|
export type NoticeProps = Omit<ComponentPropsWithoutRef<'div'>, 'className'> & {
|
||||||
|
className?: string;
|
||||||
|
tone?: NoticeTone;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Notice = forwardRef<HTMLDivElement, NoticeProps>(function Notice(
|
||||||
|
{ className, tone = 'info', role = 'note', ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={clsx(styles.notice, className)}
|
||||||
|
data-tone={tone}
|
||||||
|
role={role}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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}</>;
|
|
||||||
}
|
|
||||||
7
webui/src/platform/shell/globals.d.ts
vendored
7
webui/src/platform/shell/globals.d.ts
vendored
|
|
@ -9,6 +9,13 @@ import type { ShellProfileContext, ShellRouteDefinition, ShellPageId } from './b
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
showToast?: (message: string, type?: string, durationOrContext?: number | string) => void;
|
showToast?: (message: string, type?: string, durationOrContext?: number | string) => void;
|
||||||
|
showConfirmDialog?: (options?: {
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
}) => Promise<boolean>;
|
||||||
SoulSyncIssueDomain?: IssueDomainBridge;
|
SoulSyncIssueDomain?: IssueDomainBridge;
|
||||||
SoulSyncWorkflowActions?: {
|
SoulSyncWorkflowActions?: {
|
||||||
openDownloadMissingAlbum: (input: DownloadMissingAlbumWorkflowInput) => void | Promise<void>;
|
openDownloadMissingAlbum: (input: DownloadMissingAlbumWorkflowInput) => void | Promise<void>;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ describe('shellRouteManifest', () => {
|
||||||
expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist');
|
expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist');
|
||||||
expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads');
|
expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads');
|
||||||
expect(resolveShellPageFromPath('/artist-detail')).toBeNull();
|
expect(resolveShellPageFromPath('/artist-detail')).toBeNull();
|
||||||
expect(resolveShellPageFromPath('/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg')).toBe('artist-detail');
|
expect(resolveShellPageFromPath('/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg')).toBe(
|
||||||
|
'artist-detail',
|
||||||
|
);
|
||||||
expect(resolveShellPageFromPath('/artists')).toBeNull();
|
expect(resolveShellPageFromPath('/artists')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -31,6 +33,13 @@ describe('shellRouteManifest', () => {
|
||||||
expect(resolveShellPageFromPath('/issues/')).toBe('issues');
|
expect(resolveShellPageFromPath('/issues/')).toBe('issues');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves nested React route paths to their shell page', () => {
|
||||||
|
expect(resolveShellPageFromPath('/import/album')).toBe('import');
|
||||||
|
expect(resolveShellPageFromPath('/import/auto')).toBe('import');
|
||||||
|
expect(resolveShellPageFromPath('/import/singles')).toBe('import');
|
||||||
|
expect(resolveLegacyShellPageFromPath('/import/album')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps a route entry for every manifest page id', () => {
|
it('keeps a route entry for every manifest page id', () => {
|
||||||
expect(shellRouteManifest).not.toHaveLength(0);
|
expect(shellRouteManifest).not.toHaveLength(0);
|
||||||
expect(getShellRouteByPageId('dashboard')?.path).toBe('/dashboard');
|
expect(getShellRouteByPageId('dashboard')?.path).toBe('/dashboard');
|
||||||
|
|
@ -43,8 +52,9 @@ describe('shellRouteManifest', () => {
|
||||||
it('tracks whether a route is rendered by React or the legacy shell', () => {
|
it('tracks whether a route is rendered by React or the legacy shell', () => {
|
||||||
expect(getShellRouteByPageId('issues')?.kind).toBe('react');
|
expect(getShellRouteByPageId('issues')?.kind).toBe('react');
|
||||||
expect(getShellRouteByPageId('stats')?.kind).toBe('react');
|
expect(getShellRouteByPageId('stats')?.kind).toBe('react');
|
||||||
|
expect(getShellRouteByPageId('import')?.kind).toBe('react');
|
||||||
expect(getShellRouteByPageId('discover')?.kind).toBe('legacy');
|
expect(getShellRouteByPageId('discover')?.kind).toBe('legacy');
|
||||||
expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['stats', 'issues']);
|
expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['import', 'stats', 'issues']);
|
||||||
expect(legacyShellRoutes.some((route) => route.pageId === 'dashboard')).toBe(true);
|
expect(legacyShellRoutes.some((route) => route.pageId === 'dashboard')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export const shellRouteManifest: readonly ShellRouteDefinition[] = [
|
||||||
{ pageId: 'wishlist', path: '/wishlist', kind: 'legacy' },
|
{ pageId: 'wishlist', path: '/wishlist', kind: 'legacy' },
|
||||||
{ pageId: 'automations', path: '/automations', kind: 'legacy' },
|
{ pageId: 'automations', path: '/automations', kind: 'legacy' },
|
||||||
{ pageId: 'active-downloads', path: '/active-downloads', kind: 'legacy' },
|
{ pageId: 'active-downloads', path: '/active-downloads', kind: 'legacy' },
|
||||||
{ pageId: 'import', path: '/import', kind: 'legacy' },
|
{ pageId: 'import', path: '/import', kind: 'react' },
|
||||||
{ pageId: 'library', path: '/library', kind: 'legacy' },
|
{ pageId: 'library', path: '/library', kind: 'legacy' },
|
||||||
{ pageId: 'tools', path: '/tools', kind: 'legacy' },
|
{ pageId: 'tools', path: '/tools', kind: 'legacy' },
|
||||||
{ pageId: 'artist-detail', path: '/artist-detail', kind: 'legacy' },
|
{ pageId: 'artist-detail', path: '/artist-detail', kind: 'legacy' },
|
||||||
|
|
@ -67,7 +67,11 @@ export function getShellRouteByPageId(pageId: ShellPageId): ShellRouteDefinition
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getShellRouteByPath(pathname: string): ShellRouteDefinition | undefined {
|
export function getShellRouteByPath(pathname: string): ShellRouteDefinition | undefined {
|
||||||
return routeByPath.get(normalizeShellPath(pathname) as `/${string}`);
|
const normalized = normalizeShellPath(pathname);
|
||||||
|
const exactRoute = routeByPath.get(normalized as `/${string}`);
|
||||||
|
if (exactRoute) return exactRoute;
|
||||||
|
|
||||||
|
return reactShellRoutes.find((route) => normalized.startsWith(`${route.path}/`));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveShellPageFromPath(pathname: string): ShellPageId | null {
|
export function resolveShellPageFromPath(pathname: string): ShellPageId | null {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,12 @@ import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as SplatRouteImport } from './routes/$'
|
import { Route as SplatRouteImport } from './routes/$'
|
||||||
import { Route as StatsRouteRouteImport } from './routes/stats/route'
|
import { Route as StatsRouteRouteImport } from './routes/stats/route'
|
||||||
import { Route as IssuesRouteRouteImport } from './routes/issues/route'
|
import { Route as IssuesRouteRouteImport } from './routes/issues/route'
|
||||||
|
import { Route as ImportRouteRouteImport } from './routes/import/route'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as ImportIndexRouteImport } from './routes/import/index'
|
||||||
|
import { Route as ImportSinglesRouteImport } from './routes/import/singles'
|
||||||
|
import { Route as ImportAutoRouteImport } from './routes/import/auto'
|
||||||
|
import { Route as ImportAlbumRouteImport } from './routes/import/album'
|
||||||
import { Route as ArtistDetailSourceIdRouteImport } from './routes/artist-detail/$source/$id'
|
import { Route as ArtistDetailSourceIdRouteImport } from './routes/artist-detail/$source/$id'
|
||||||
|
|
||||||
const SplatRoute = SplatRouteImport.update({
|
const SplatRoute = SplatRouteImport.update({
|
||||||
|
|
@ -30,11 +35,36 @@ const IssuesRouteRoute = IssuesRouteRouteImport.update({
|
||||||
path: '/issues',
|
path: '/issues',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ImportRouteRoute = ImportRouteRouteImport.update({
|
||||||
|
id: '/import',
|
||||||
|
path: '/import',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ImportIndexRoute = ImportIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => ImportRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const ImportSinglesRoute = ImportSinglesRouteImport.update({
|
||||||
|
id: '/singles',
|
||||||
|
path: '/singles',
|
||||||
|
getParentRoute: () => ImportRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const ImportAutoRoute = ImportAutoRouteImport.update({
|
||||||
|
id: '/auto',
|
||||||
|
path: '/auto',
|
||||||
|
getParentRoute: () => ImportRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const ImportAlbumRoute = ImportAlbumRouteImport.update({
|
||||||
|
id: '/album',
|
||||||
|
path: '/album',
|
||||||
|
getParentRoute: () => ImportRouteRoute,
|
||||||
|
} as any)
|
||||||
const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({
|
const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({
|
||||||
id: '/artist-detail/$source/$id',
|
id: '/artist-detail/$source/$id',
|
||||||
path: '/artist-detail/$source/$id',
|
path: '/artist-detail/$source/$id',
|
||||||
|
|
@ -43,9 +73,14 @@ const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/import': typeof ImportRouteRouteWithChildren
|
||||||
'/issues': typeof IssuesRouteRoute
|
'/issues': typeof IssuesRouteRoute
|
||||||
'/stats': typeof StatsRouteRoute
|
'/stats': typeof StatsRouteRoute
|
||||||
'/$': typeof SplatRoute
|
'/$': typeof SplatRoute
|
||||||
|
'/import/album': typeof ImportAlbumRoute
|
||||||
|
'/import/auto': typeof ImportAutoRoute
|
||||||
|
'/import/singles': typeof ImportSinglesRoute
|
||||||
|
'/import/': typeof ImportIndexRoute
|
||||||
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
|
|
@ -53,32 +88,66 @@ export interface FileRoutesByTo {
|
||||||
'/issues': typeof IssuesRouteRoute
|
'/issues': typeof IssuesRouteRoute
|
||||||
'/stats': typeof StatsRouteRoute
|
'/stats': typeof StatsRouteRoute
|
||||||
'/$': typeof SplatRoute
|
'/$': typeof SplatRoute
|
||||||
|
'/import/album': typeof ImportAlbumRoute
|
||||||
|
'/import/auto': typeof ImportAutoRoute
|
||||||
|
'/import/singles': typeof ImportSinglesRoute
|
||||||
|
'/import': typeof ImportIndexRoute
|
||||||
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/import': typeof ImportRouteRouteWithChildren
|
||||||
'/issues': typeof IssuesRouteRoute
|
'/issues': typeof IssuesRouteRoute
|
||||||
'/stats': typeof StatsRouteRoute
|
'/stats': typeof StatsRouteRoute
|
||||||
'/$': typeof SplatRoute
|
'/$': typeof SplatRoute
|
||||||
|
'/import/album': typeof ImportAlbumRoute
|
||||||
|
'/import/auto': typeof ImportAutoRoute
|
||||||
|
'/import/singles': typeof ImportSinglesRoute
|
||||||
|
'/import/': typeof ImportIndexRoute
|
||||||
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/import'
|
||||||
|
| '/issues'
|
||||||
|
| '/stats'
|
||||||
|
| '/$'
|
||||||
|
| '/import/album'
|
||||||
|
| '/import/auto'
|
||||||
|
| '/import/singles'
|
||||||
|
| '/import/'
|
||||||
|
| '/artist-detail/$source/$id'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id'
|
to:
|
||||||
id:
|
|
||||||
| '__root__'
|
|
||||||
| '/'
|
| '/'
|
||||||
| '/issues'
|
| '/issues'
|
||||||
| '/stats'
|
| '/stats'
|
||||||
| '/$'
|
| '/$'
|
||||||
|
| '/import/album'
|
||||||
|
| '/import/auto'
|
||||||
|
| '/import/singles'
|
||||||
|
| '/import'
|
||||||
|
| '/artist-detail/$source/$id'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/import'
|
||||||
|
| '/issues'
|
||||||
|
| '/stats'
|
||||||
|
| '/$'
|
||||||
|
| '/import/album'
|
||||||
|
| '/import/auto'
|
||||||
|
| '/import/singles'
|
||||||
|
| '/import/'
|
||||||
| '/artist-detail/$source/$id'
|
| '/artist-detail/$source/$id'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
ImportRouteRoute: typeof ImportRouteRouteWithChildren
|
||||||
IssuesRouteRoute: typeof IssuesRouteRoute
|
IssuesRouteRoute: typeof IssuesRouteRoute
|
||||||
StatsRouteRoute: typeof StatsRouteRoute
|
StatsRouteRoute: typeof StatsRouteRoute
|
||||||
SplatRoute: typeof SplatRoute
|
SplatRoute: typeof SplatRoute
|
||||||
|
|
@ -108,6 +177,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IssuesRouteRouteImport
|
preLoaderRoute: typeof IssuesRouteRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/import': {
|
||||||
|
id: '/import'
|
||||||
|
path: '/import'
|
||||||
|
fullPath: '/import'
|
||||||
|
preLoaderRoute: typeof ImportRouteRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
|
|
@ -115,6 +191,34 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/import/': {
|
||||||
|
id: '/import/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/import/'
|
||||||
|
preLoaderRoute: typeof ImportIndexRouteImport
|
||||||
|
parentRoute: typeof ImportRouteRoute
|
||||||
|
}
|
||||||
|
'/import/singles': {
|
||||||
|
id: '/import/singles'
|
||||||
|
path: '/singles'
|
||||||
|
fullPath: '/import/singles'
|
||||||
|
preLoaderRoute: typeof ImportSinglesRouteImport
|
||||||
|
parentRoute: typeof ImportRouteRoute
|
||||||
|
}
|
||||||
|
'/import/auto': {
|
||||||
|
id: '/import/auto'
|
||||||
|
path: '/auto'
|
||||||
|
fullPath: '/import/auto'
|
||||||
|
preLoaderRoute: typeof ImportAutoRouteImport
|
||||||
|
parentRoute: typeof ImportRouteRoute
|
||||||
|
}
|
||||||
|
'/import/album': {
|
||||||
|
id: '/import/album'
|
||||||
|
path: '/album'
|
||||||
|
fullPath: '/import/album'
|
||||||
|
preLoaderRoute: typeof ImportAlbumRouteImport
|
||||||
|
parentRoute: typeof ImportRouteRoute
|
||||||
|
}
|
||||||
'/artist-detail/$source/$id': {
|
'/artist-detail/$source/$id': {
|
||||||
id: '/artist-detail/$source/$id'
|
id: '/artist-detail/$source/$id'
|
||||||
path: '/artist-detail/$source/$id'
|
path: '/artist-detail/$source/$id'
|
||||||
|
|
@ -125,8 +229,27 @@ declare module '@tanstack/react-router' {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ImportRouteRouteChildren {
|
||||||
|
ImportAlbumRoute: typeof ImportAlbumRoute
|
||||||
|
ImportAutoRoute: typeof ImportAutoRoute
|
||||||
|
ImportSinglesRoute: typeof ImportSinglesRoute
|
||||||
|
ImportIndexRoute: typeof ImportIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImportRouteRouteChildren: ImportRouteRouteChildren = {
|
||||||
|
ImportAlbumRoute: ImportAlbumRoute,
|
||||||
|
ImportAutoRoute: ImportAutoRoute,
|
||||||
|
ImportSinglesRoute: ImportSinglesRoute,
|
||||||
|
ImportIndexRoute: ImportIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImportRouteRouteWithChildren = ImportRouteRoute._addFileChildren(
|
||||||
|
ImportRouteRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
ImportRouteRoute: ImportRouteRouteWithChildren,
|
||||||
IssuesRouteRoute: IssuesRouteRoute,
|
IssuesRouteRoute: IssuesRouteRoute,
|
||||||
StatsRouteRoute: StatsRouteRoute,
|
StatsRouteRoute: StatsRouteRoute,
|
||||||
SplatRoute: SplatRoute,
|
SplatRoute: SplatRoute,
|
||||||
|
|
|
||||||
29
webui/src/routes/import/-import.api.test.ts
Normal file
29
webui/src/routes/import/-import.api.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { HttpResponse, http, server } from '@/test/msw';
|
||||||
|
|
||||||
|
import { approveAutoImportResult, rejectAutoImportResult } from './-import.api';
|
||||||
|
|
||||||
|
const softFailureMessage = 'Item not found or not pending review';
|
||||||
|
|
||||||
|
describe('import api', () => {
|
||||||
|
it('surfaces soft failures from auto-import approval endpoints', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('/api/auto-import/approve/17', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: softFailureMessage,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.post('/api/auto-import/reject/18', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: softFailureMessage,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(approveAutoImportResult(17)).rejects.toThrow(softFailureMessage);
|
||||||
|
await expect(rejectAutoImportResult(18)).rejects.toThrow(softFailureMessage);
|
||||||
|
});
|
||||||
|
});
|
||||||
235
webui/src/routes/import/-import.api.ts
Normal file
235
webui/src/routes/import/-import.api.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { queryOptions, type QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { apiClient, readJson } from '@/app/api-client';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ImportAlbum,
|
||||||
|
ImportAlbumMatch,
|
||||||
|
ImportAlbumMatchPayload,
|
||||||
|
ImportAlbumSearchPayload,
|
||||||
|
ImportAutoImportResultsPayload,
|
||||||
|
ImportAutoImportSettingsPayload,
|
||||||
|
ImportAutoImportStatusPayload,
|
||||||
|
ImportProcessPayload,
|
||||||
|
ImportStagingFilesPayload,
|
||||||
|
ImportStagingGroupsPayload,
|
||||||
|
ImportTrackSearchPayload,
|
||||||
|
} from './-import.types';
|
||||||
|
|
||||||
|
export const IMPORT_QUERY_KEY = ['import'] as const;
|
||||||
|
|
||||||
|
export async function fetchImportStagingFiles(): Promise<ImportStagingFilesPayload> {
|
||||||
|
return readJson<ImportStagingFilesPayload>(apiClient.get('import/staging/files'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImportStagingGroups(): Promise<ImportStagingGroupsPayload> {
|
||||||
|
return readJson<ImportStagingGroupsPayload>(apiClient.get('import/staging/groups'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImportStagingSuggestions(): Promise<ImportAlbumSearchPayload> {
|
||||||
|
return readJson<ImportAlbumSearchPayload>(apiClient.get('import/staging/suggestions'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchImportAlbums(query: string): Promise<ImportAlbumSearchPayload> {
|
||||||
|
return readJson<ImportAlbumSearchPayload>(
|
||||||
|
apiClient.get('import/search/albums', {
|
||||||
|
searchParams: {
|
||||||
|
q: query,
|
||||||
|
limit: '12',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function matchImportAlbum(input: {
|
||||||
|
albumId: string;
|
||||||
|
source?: string | null;
|
||||||
|
albumName?: string | null;
|
||||||
|
albumArtist?: string | null;
|
||||||
|
filePaths?: string[] | null;
|
||||||
|
}): Promise<ImportAlbumMatchPayload> {
|
||||||
|
return readJson<ImportAlbumMatchPayload>(
|
||||||
|
apiClient.post('import/album/match', {
|
||||||
|
json: {
|
||||||
|
album_id: input.albumId,
|
||||||
|
source: input.source || '',
|
||||||
|
album_name: input.albumName || '',
|
||||||
|
album_artist: input.albumArtist || '',
|
||||||
|
...(input.filePaths?.length ? { file_paths: input.filePaths } : {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processImportAlbumTrack(input: {
|
||||||
|
album: ImportAlbum;
|
||||||
|
match: ImportAlbumMatch;
|
||||||
|
}): Promise<ImportProcessPayload> {
|
||||||
|
return readJson<ImportProcessPayload>(
|
||||||
|
apiClient.post('import/album/process', {
|
||||||
|
json: {
|
||||||
|
album: input.album,
|
||||||
|
matches: [input.match],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchImportTracks(query: string): Promise<ImportTrackSearchPayload> {
|
||||||
|
return readJson<ImportTrackSearchPayload>(
|
||||||
|
apiClient.get('import/search/tracks', {
|
||||||
|
searchParams: {
|
||||||
|
q: query,
|
||||||
|
limit: '6',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processImportSingleFile(file: unknown): Promise<ImportProcessPayload> {
|
||||||
|
return readJson<ImportProcessPayload>(
|
||||||
|
apiClient.post('import/singles/process', {
|
||||||
|
json: {
|
||||||
|
files: [file],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAutoImportStatus(): Promise<ImportAutoImportStatusPayload> {
|
||||||
|
return readJson<ImportAutoImportStatusPayload>(apiClient.get('auto-import/status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAutoImportSettings(): Promise<ImportAutoImportSettingsPayload> {
|
||||||
|
return readJson<ImportAutoImportSettingsPayload>(apiClient.get('auto-import/settings'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAutoImportSettings(input: {
|
||||||
|
confidenceThreshold: number;
|
||||||
|
scanInterval: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
await readJson<{ success: boolean; error?: string }>(
|
||||||
|
apiClient.post('auto-import/settings', {
|
||||||
|
json: {
|
||||||
|
confidence_threshold: input.confidenceThreshold,
|
||||||
|
scan_interval: input.scanInterval,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAutoImportResults(): Promise<ImportAutoImportResultsPayload> {
|
||||||
|
return readJson<ImportAutoImportResultsPayload>(
|
||||||
|
apiClient.get('auto-import/results', {
|
||||||
|
searchParams: {
|
||||||
|
limit: '100',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleAutoImport(enabled: boolean): Promise<void> {
|
||||||
|
await readJson<{ success: boolean; error?: string }>(
|
||||||
|
apiClient.post('auto-import/toggle', {
|
||||||
|
json: { enabled },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerAutoImportScan(): Promise<void> {
|
||||||
|
await readJson<{ success: boolean; error?: string }>(apiClient.post('auto-import/scan-now'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveAutoImportResult(id: number): Promise<void> {
|
||||||
|
const payload = await readJson<{ success: boolean; error?: string }>(
|
||||||
|
apiClient.post(`auto-import/approve/${id}`),
|
||||||
|
);
|
||||||
|
if (!payload.success) {
|
||||||
|
throw new Error(payload.error || 'Failed to approve import');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectAutoImportResult(id: number): Promise<void> {
|
||||||
|
const payload = await readJson<{ success: boolean; error?: string }>(
|
||||||
|
apiClient.post(`auto-import/reject/${id}`),
|
||||||
|
);
|
||||||
|
if (!payload.success) {
|
||||||
|
throw new Error(payload.error || 'Failed to dismiss import');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveAllAutoImportResults(): Promise<number> {
|
||||||
|
const payload = await readJson<{ success: boolean; count?: number; error?: string }>(
|
||||||
|
apiClient.post('auto-import/approve-all'),
|
||||||
|
);
|
||||||
|
return payload.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearCompletedAutoImportResults(): Promise<number> {
|
||||||
|
const payload = await readJson<{ success: boolean; count?: number; error?: string }>(
|
||||||
|
apiClient.post('auto-import/clear-completed'),
|
||||||
|
);
|
||||||
|
return payload.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importStagingFilesQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: [...IMPORT_QUERY_KEY, 'staging-files'],
|
||||||
|
queryFn: fetchImportStagingFiles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importStagingGroupsQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: [...IMPORT_QUERY_KEY, 'staging-groups'],
|
||||||
|
queryFn: fetchImportStagingGroups,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importStagingSuggestionsQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: [...IMPORT_QUERY_KEY, 'staging-suggestions'],
|
||||||
|
queryFn: fetchImportStagingSuggestions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoImportStatusQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: [...IMPORT_QUERY_KEY, 'auto-import-status'],
|
||||||
|
queryFn: fetchAutoImportStatus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoImportSettingsQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: [...IMPORT_QUERY_KEY, 'auto-import-settings'],
|
||||||
|
queryFn: fetchAutoImportSettings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoImportResultsQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: [...IMPORT_QUERY_KEY, 'auto-import-results'],
|
||||||
|
queryFn: fetchAutoImportResults,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateImportQueries(queryClient: QueryClient) {
|
||||||
|
return queryClient.invalidateQueries({ queryKey: IMPORT_QUERY_KEY });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateImportStagingQueries(queryClient: QueryClient) {
|
||||||
|
return Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'staging-files'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'staging-groups'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'staging-suggestions'] }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateAutoImportQueries(queryClient: QueryClient) {
|
||||||
|
return Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-status'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-settings'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-results'] }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
344
webui/src/routes/import/-import.helpers.ts
Normal file
344
webui/src/routes/import/-import.helpers.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
import type {
|
||||||
|
ImportAlbumMatch,
|
||||||
|
ImportAutoFilter,
|
||||||
|
ImportAutoImportActiveItem,
|
||||||
|
ImportAutoImportMatchData,
|
||||||
|
ImportAutoImportResult,
|
||||||
|
ImportAutoImportStatusPayload,
|
||||||
|
ImportQueueEntry,
|
||||||
|
ImportStagingFile,
|
||||||
|
} from './-import.types';
|
||||||
|
|
||||||
|
export const IMPORT_PLACEHOLDER_IMAGE = '/static/placeholder.png';
|
||||||
|
|
||||||
|
const IMPORT_SOURCE_LABELS: Record<string, string> = {
|
||||||
|
amazon: 'Amazon Music',
|
||||||
|
deezer: 'Deezer',
|
||||||
|
discogs: 'Discogs',
|
||||||
|
hydrabase: 'Hydrabase',
|
||||||
|
itunes: 'Apple Music',
|
||||||
|
musicbrainz: 'MusicBrainz',
|
||||||
|
playlist: 'Playlist',
|
||||||
|
soulseek: 'Basic Search',
|
||||||
|
spotify: 'Spotify',
|
||||||
|
youtube_videos: 'Music Videos',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStagingFileKey(file: ImportStagingFile): string {
|
||||||
|
return file.full_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatImportBytes(bytes: number): string {
|
||||||
|
if (bytes > 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
|
||||||
|
if (bytes > 1_048_576) return `${(bytes / 1_048_576).toFixed(0)} MB`;
|
||||||
|
return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStagingStatsText(files: ImportStagingFile[]): string {
|
||||||
|
const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0);
|
||||||
|
const fileLabel = `${files.length} file${files.length === 1 ? '' : 's'}`;
|
||||||
|
return totalSize ? `${fileLabel} - ${formatImportBytes(totalSize)}` : fileLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImportSourceLabel(source: string | null | undefined): string {
|
||||||
|
if (!source) return '';
|
||||||
|
return IMPORT_SOURCE_LABELS[source.toLowerCase()] || source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label a fallback result row so it is obvious which provider actually returned it.
|
||||||
|
*/
|
||||||
|
export function getImportSourceBadgeText(
|
||||||
|
resultSource: string | null | undefined,
|
||||||
|
lookupSource: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (!resultSource || !lookupSource) return '';
|
||||||
|
if (resultSource.toLowerCase() === lookupSource.toLowerCase()) return '';
|
||||||
|
return `via ${getImportSourceLabel(resultSource)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner for a whole result set that came from a fallback provider rather than the lookup source.
|
||||||
|
*/
|
||||||
|
export function getImportSourceFallbackBanner(
|
||||||
|
results: Array<{ source: string }> | null | undefined,
|
||||||
|
lookupSource: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (!lookupSource || !results?.length) return '';
|
||||||
|
const normalizedLookupSource = lookupSource.toLowerCase();
|
||||||
|
if (
|
||||||
|
!results.every(
|
||||||
|
(result) => result.source && result.source.toLowerCase() !== normalizedLookupSource,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultSource = results[0]?.source;
|
||||||
|
if (!resultSource) return '';
|
||||||
|
|
||||||
|
return `Showing ${getImportSourceLabel(resultSource)} results - not from your primary source (${getImportSourceLabel(lookupSource)}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTrackDisplayInfo(match: ImportAlbumMatch, index: number) {
|
||||||
|
const track = match.track || match.spotify_track || {};
|
||||||
|
const rawTrackNumber = track.track_number ?? track.trackNumber ?? null;
|
||||||
|
const trackNumber =
|
||||||
|
rawTrackNumber === null || rawTrackNumber === undefined || rawTrackNumber === ''
|
||||||
|
? null
|
||||||
|
: String(rawTrackNumber).split('/')[0]?.trim() || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
track,
|
||||||
|
name: track.name || track.title || `Track ${index + 1}`,
|
||||||
|
trackNumber,
|
||||||
|
displayTrackNumber: trackNumber || String(index + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEffectiveAlbumMatches(
|
||||||
|
matches: ImportAlbumMatch[],
|
||||||
|
stagingFiles: ImportStagingFile[],
|
||||||
|
overrides: Record<number, number>,
|
||||||
|
): ImportAlbumMatch[] {
|
||||||
|
return matches.flatMap((match, index) => {
|
||||||
|
if (Object.hasOwn(overrides, index)) {
|
||||||
|
const override = overrides[index];
|
||||||
|
if (override === -1) return [];
|
||||||
|
const stagingFile = stagingFiles[override];
|
||||||
|
return stagingFile ? [{ ...match, staging_file: stagingFile, confidence: 1 }] : [];
|
||||||
|
}
|
||||||
|
return match.staging_file ? [match] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayedMatchFile(
|
||||||
|
match: ImportAlbumMatch,
|
||||||
|
index: number,
|
||||||
|
stagingFiles: ImportStagingFile[],
|
||||||
|
overrides: Record<number, number>,
|
||||||
|
): { file: ImportStagingFile | null; confidence: number; isOverride: boolean } {
|
||||||
|
if (Object.hasOwn(overrides, index)) {
|
||||||
|
const override = overrides[index];
|
||||||
|
if (override === -1) return { file: null, confidence: match.confidence, isOverride: false };
|
||||||
|
return {
|
||||||
|
file: stagingFiles[override] ?? null,
|
||||||
|
confidence: 1,
|
||||||
|
isOverride: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match.staging_file) {
|
||||||
|
return { file: null, confidence: match.confidence, isOverride: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoFileName = match.staging_file.filename;
|
||||||
|
const reassigned = Object.entries(overrides).some(([trackIndex, stagingFileIndex]) => {
|
||||||
|
const file = stagingFiles[stagingFileIndex];
|
||||||
|
return file && file.filename === autoFileName && Number(trackIndex) !== index;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: reassigned ? null : match.staging_file,
|
||||||
|
confidence: match.confidence,
|
||||||
|
isOverride: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnmatchedStagingFiles(
|
||||||
|
matches: ImportAlbumMatch[],
|
||||||
|
stagingFiles: ImportStagingFile[],
|
||||||
|
overrides: Record<number, number>,
|
||||||
|
): Array<{ file: ImportStagingFile; index: number }> {
|
||||||
|
return stagingFiles.flatMap((file, index) => {
|
||||||
|
if (Object.values(overrides).includes(index)) return [];
|
||||||
|
|
||||||
|
const autoUsed = matches.some((match, matchIndex) => {
|
||||||
|
if (Object.hasOwn(overrides, matchIndex)) return false;
|
||||||
|
return match.staging_file?.filename === file.filename;
|
||||||
|
});
|
||||||
|
|
||||||
|
return autoUsed ? [] : [{ file, index }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAutoImportCounts(results: ImportAutoImportResult[]) {
|
||||||
|
return {
|
||||||
|
imported: results.filter(
|
||||||
|
(result) => result.status === 'completed' || result.status === 'approved',
|
||||||
|
).length,
|
||||||
|
review: results.filter((result) => result.status === 'pending_review').length,
|
||||||
|
failed: results.filter(
|
||||||
|
(result) => result.status === 'failed' || result.status === 'needs_identification',
|
||||||
|
).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterAutoImportResults(
|
||||||
|
results: ImportAutoImportResult[],
|
||||||
|
filter: ImportAutoFilter,
|
||||||
|
): ImportAutoImportResult[] {
|
||||||
|
if (filter === 'pending') return results.filter((result) => result.status === 'pending_review');
|
||||||
|
if (filter === 'imported') {
|
||||||
|
return results.filter(
|
||||||
|
(result) => result.status === 'completed' || result.status === 'approved',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filter === 'failed') {
|
||||||
|
return results.filter(
|
||||||
|
(result) => result.status === 'failed' || result.status === 'needs_identification',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAutoImportStatusText(status: ImportAutoImportStatusPayload | undefined): string {
|
||||||
|
if (!status) return 'Loading...';
|
||||||
|
if (status.paused) return 'Paused';
|
||||||
|
if (status.current_status === 'processing') return 'Processing...';
|
||||||
|
if (status.current_status === 'scanning') return 'Scanning...';
|
||||||
|
if (!status.running) return 'Disabled';
|
||||||
|
|
||||||
|
if (status.last_scan_time) {
|
||||||
|
const lastScan = new Date(status.last_scan_time);
|
||||||
|
const diffSeconds = Math.floor((Date.now() - lastScan.getTime()) / 1000);
|
||||||
|
if (Number.isFinite(diffSeconds) && diffSeconds >= 0 && diffSeconds < 60) {
|
||||||
|
return `Watching (scanned ${diffSeconds}s ago)`;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(diffSeconds) && diffSeconds < 3600) {
|
||||||
|
return `Watching (scanned ${Math.floor(diffSeconds / 60)}m ago)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Watching';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AutoImportStatusTone = 'neutral' | 'info' | 'success';
|
||||||
|
|
||||||
|
export function getAutoImportStatusTone(
|
||||||
|
status: ImportAutoImportStatusPayload | undefined,
|
||||||
|
): AutoImportStatusTone {
|
||||||
|
if (!status?.running || status.paused) return 'neutral';
|
||||||
|
if (status.current_status === 'scanning' || status.current_status === 'processing') return 'info';
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveImportLines(status: ImportAutoImportStatusPayload | undefined): string[] {
|
||||||
|
const active = Array.isArray(status?.active_imports) ? status.active_imports : [];
|
||||||
|
if (active.length > 0) return active.map(getActiveImportLine);
|
||||||
|
if (status?.current_status === 'scanning') {
|
||||||
|
return [`Scanning... (${status.stats?.scanned || 0} processed)`];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveImportLine(item: ImportAutoImportActiveItem): string {
|
||||||
|
const folder = item.folder_name || '...';
|
||||||
|
const trackIndex = item.track_index || 0;
|
||||||
|
const trackTotal = item.track_total || 0;
|
||||||
|
const trackName = item.track_name || '';
|
||||||
|
if (item.status === 'processing' && trackTotal > 0) {
|
||||||
|
return `${folder} - track ${trackIndex}/${trackTotal}: ${trackName}`;
|
||||||
|
}
|
||||||
|
if (item.status === 'matching') return `${folder} - matching tracks...`;
|
||||||
|
if (item.status === 'identifying') return `${folder} - identifying...`;
|
||||||
|
return `${folder} - queued`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAutoImportMatchData(
|
||||||
|
matchData: ImportAutoImportResult['match_data'],
|
||||||
|
): ImportAutoImportMatchData {
|
||||||
|
if (!matchData) return {};
|
||||||
|
if (typeof matchData === 'object') return matchData;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(matchData) as ImportAutoImportMatchData;
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAutoImportTimeAgo(createdAt: string | null | undefined): string {
|
||||||
|
if (!createdAt) return '';
|
||||||
|
const created = new Date(createdAt);
|
||||||
|
const diffMinutes = Math.floor((Date.now() - created.getTime()) / 60_000);
|
||||||
|
if (!Number.isFinite(diffMinutes) || diffMinutes < 0) return '';
|
||||||
|
if (diffMinutes < 1) return 'just now';
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
||||||
|
if (diffMinutes < 1440) return `${Math.floor(diffMinutes / 60)}h ago`;
|
||||||
|
return `${Math.floor(diffMinutes / 1440)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfidenceClass(confidencePercent: number): 'high' | 'medium' | 'low' {
|
||||||
|
if (confidencePercent >= 90) return 'high';
|
||||||
|
if (confidencePercent >= 70) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAutoImportStatusMeta(status: string): {
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
className: 'completed' | 'review' | 'failed' | 'processing' | 'neutral';
|
||||||
|
} {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
completed: 'Imported',
|
||||||
|
pending_review: 'Needs Review',
|
||||||
|
needs_identification: 'Unidentified',
|
||||||
|
failed: 'Failed',
|
||||||
|
scanning: 'Scanning...',
|
||||||
|
matched: 'Matched',
|
||||||
|
rejected: 'Dismissed',
|
||||||
|
approved: 'Approved',
|
||||||
|
processing: 'Processing',
|
||||||
|
};
|
||||||
|
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
completed: '✓',
|
||||||
|
pending_review: '⚠',
|
||||||
|
needs_identification: '✗',
|
||||||
|
failed: '✗',
|
||||||
|
scanning: '⌛',
|
||||||
|
matched: '✓',
|
||||||
|
rejected: '✕',
|
||||||
|
approved: '✓',
|
||||||
|
processing: '⧗',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: labels[status] || status,
|
||||||
|
icon: icons[status] || '',
|
||||||
|
className:
|
||||||
|
status === 'completed'
|
||||||
|
? 'completed'
|
||||||
|
: status === 'pending_review'
|
||||||
|
? 'review'
|
||||||
|
: status === 'failed' || status === 'needs_identification'
|
||||||
|
? 'failed'
|
||||||
|
: status === 'processing'
|
||||||
|
? 'processing'
|
||||||
|
: 'neutral',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQueueProgressPercent(entry: ImportQueueEntry): number {
|
||||||
|
if (entry.status === 'done' || entry.status === 'error') return 100;
|
||||||
|
if (entry.total <= 0) return 0;
|
||||||
|
return Math.round((entry.processed / entry.total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQueueStatusText(entry: ImportQueueEntry): string {
|
||||||
|
if (entry.status === 'running') return `${entry.processed}/${entry.total}`;
|
||||||
|
if (entry.status === 'done') {
|
||||||
|
return entry.errors.length > 0
|
||||||
|
? `${entry.processed}/${entry.total} (${entry.errors.length} err)`
|
||||||
|
: 'Done';
|
||||||
|
}
|
||||||
|
return 'Failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(durationMs: number | null | undefined): string {
|
||||||
|
if (!durationMs) return '';
|
||||||
|
const minutes = Math.floor(durationMs / 60_000);
|
||||||
|
const seconds = String(Math.floor((durationMs % 60_000) / 1000)).padStart(2, '0');
|
||||||
|
return `${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
267
webui/src/routes/import/-import.store.ts
Normal file
267
webui/src/routes/import/-import.store.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { combine } from 'zustand/middleware';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ImportAlbumMatchPayload,
|
||||||
|
ImportAlbumResult,
|
||||||
|
ImportQueueEntry,
|
||||||
|
ImportQueueJob,
|
||||||
|
ImportStagingFile,
|
||||||
|
ImportTrackResult,
|
||||||
|
} from './-import.types';
|
||||||
|
|
||||||
|
import { getStagingFileKey } from './-import.helpers';
|
||||||
|
|
||||||
|
export type SingleSearchState = {
|
||||||
|
query: string;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
results: ImportTrackResult[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type StateUpdater<T> = T | ((current: T) => T);
|
||||||
|
|
||||||
|
function resolveState<T>(current: T, updater: StateUpdater<T>) {
|
||||||
|
return typeof updater === 'function' ? (updater as (value: T) => T)(current) : updater;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInitialWorkflowState() {
|
||||||
|
return {
|
||||||
|
queue: [] as ImportQueueEntry[],
|
||||||
|
nextQueueId: 0,
|
||||||
|
albumQuery: '',
|
||||||
|
albumResults: null as ImportAlbumResult[] | null,
|
||||||
|
albumSearchError: null as string | null,
|
||||||
|
albumSearchLoading: false,
|
||||||
|
// Lookup source used to seed the album search. Result rows still carry their own `source`.
|
||||||
|
albumSearchLookupSource: null as string | null,
|
||||||
|
autoGroupFilePaths: null as string[] | null,
|
||||||
|
selectedAlbum: null as ImportAlbumResult | null,
|
||||||
|
albumMatch: null as ImportAlbumMatchPayload | null,
|
||||||
|
albumMatchError: null as string | null,
|
||||||
|
albumMatchLoading: false,
|
||||||
|
matchOverrides: {} as Record<number, number>,
|
||||||
|
selectedSingles: new Set<string>(),
|
||||||
|
singlesManualMatches: {} as Record<string, ImportTrackResult>,
|
||||||
|
openSingleSearch: null as string | null,
|
||||||
|
singleSearches: {} as Record<string, SingleSearchState>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useImportWorkflowStore = create(
|
||||||
|
combine(createInitialWorkflowState(), (set, get) => ({
|
||||||
|
clearFinishedJobs: () => {
|
||||||
|
set((state) => ({ queue: state.queue.filter((entry) => entry.status === 'running') }));
|
||||||
|
},
|
||||||
|
enqueueQueueJob: (job: ImportQueueJob) => {
|
||||||
|
const id = get().nextQueueId + 1;
|
||||||
|
const entry: ImportQueueEntry = {
|
||||||
|
id,
|
||||||
|
type: job.type,
|
||||||
|
label: job.label,
|
||||||
|
sublabel: job.sublabel,
|
||||||
|
imageUrl: job.imageUrl,
|
||||||
|
status: 'running',
|
||||||
|
processed: 0,
|
||||||
|
total: job.items.length,
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
nextQueueId: id,
|
||||||
|
queue: [...state.queue, entry],
|
||||||
|
}));
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
updateQueueEntry: (entryId: number, patch: Partial<ImportQueueEntry>) => {
|
||||||
|
set((state) => ({
|
||||||
|
queue: state.queue.map((entry) => (entry.id === entryId ? { ...entry, ...patch } : entry)),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
resetAlbumSearch: () => {
|
||||||
|
set({
|
||||||
|
albumQuery: '',
|
||||||
|
albumResults: null,
|
||||||
|
albumSearchError: null,
|
||||||
|
albumSearchLoading: false,
|
||||||
|
albumSearchLookupSource: null,
|
||||||
|
autoGroupFilePaths: null,
|
||||||
|
selectedAlbum: null,
|
||||||
|
albumMatch: null,
|
||||||
|
albumMatchError: null,
|
||||||
|
albumMatchLoading: false,
|
||||||
|
matchOverrides: {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setAlbumQuery: (albumQuery: string) => set({ albumQuery }),
|
||||||
|
setAlbumResults: (albumResults: ImportAlbumResult[] | null) => set({ albumResults }),
|
||||||
|
setAlbumSearchError: (albumSearchError: string | null) => set({ albumSearchError }),
|
||||||
|
setAlbumSearchLoading: (albumSearchLoading: boolean) => set({ albumSearchLoading }),
|
||||||
|
setAlbumSearchLookupSource: (albumSearchLookupSource: string | null) =>
|
||||||
|
set({ albumSearchLookupSource }),
|
||||||
|
setAlbumSearchContext: (albumQuery: string, autoGroupFilePaths: string[] | null) => {
|
||||||
|
set({
|
||||||
|
albumQuery,
|
||||||
|
albumSearchLoading: true,
|
||||||
|
albumSearchError: null,
|
||||||
|
albumResults: null,
|
||||||
|
albumSearchLookupSource: null,
|
||||||
|
selectedAlbum: null,
|
||||||
|
albumMatch: null,
|
||||||
|
autoGroupFilePaths,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setSelectedAlbum: (selectedAlbum: ImportAlbumResult | null) => set({ selectedAlbum }),
|
||||||
|
setAlbumMatch: (albumMatch: ImportAlbumMatchPayload | null) => set({ albumMatch }),
|
||||||
|
setAlbumMatchError: (albumMatchError: string | null) => set({ albumMatchError }),
|
||||||
|
setAlbumMatchLoading: (albumMatchLoading: boolean) => set({ albumMatchLoading }),
|
||||||
|
clearAutoGroupFilePaths: () => set({ autoGroupFilePaths: null }),
|
||||||
|
setMatchOverrides: (updater: StateUpdater<Record<number, number>>) => {
|
||||||
|
set((state) => ({ matchOverrides: resolveState(state.matchOverrides, updater) }));
|
||||||
|
},
|
||||||
|
toggleSingle: (fileKey: string) => {
|
||||||
|
set((state) => {
|
||||||
|
const selectedSingles = new Set(state.selectedSingles);
|
||||||
|
if (selectedSingles.has(fileKey)) selectedSingles.delete(fileKey);
|
||||||
|
else selectedSingles.add(fileKey);
|
||||||
|
return { selectedSingles };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
toggleAllSingles: (stagingFiles: ImportStagingFile[]) => {
|
||||||
|
set((state) => ({
|
||||||
|
selectedSingles: (() => {
|
||||||
|
const fileKeys = stagingFiles.map(getStagingFileKey);
|
||||||
|
return state.selectedSingles.size === fileKeys.length &&
|
||||||
|
fileKeys.every((key) => state.selectedSingles.has(key))
|
||||||
|
? new Set<string>()
|
||||||
|
: new Set(fileKeys);
|
||||||
|
})(),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
clearSinglesSelection: () => {
|
||||||
|
set({
|
||||||
|
selectedSingles: new Set<string>(),
|
||||||
|
singlesManualMatches: {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
syncSinglesWorkflow: (stagingFiles: ImportStagingFile[]) => {
|
||||||
|
const validKeys = new Set(stagingFiles.map(getStagingFileKey));
|
||||||
|
set((state) => ({
|
||||||
|
selectedSingles: new Set([...state.selectedSingles].filter((key) => validKeys.has(key))),
|
||||||
|
singlesManualMatches: Object.fromEntries(
|
||||||
|
Object.entries(state.singlesManualMatches).filter(([key]) => validKeys.has(key)),
|
||||||
|
),
|
||||||
|
openSingleSearch:
|
||||||
|
state.openSingleSearch && validKeys.has(state.openSingleSearch)
|
||||||
|
? state.openSingleSearch
|
||||||
|
: null,
|
||||||
|
singleSearches: Object.fromEntries(
|
||||||
|
Object.entries(state.singleSearches).filter(([key]) => validKeys.has(key)),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setOpenSingleSearch: (openSingleSearch: string | null) => set({ openSingleSearch }),
|
||||||
|
ensureSingleSearch: (fileKey: string, query: string) => {
|
||||||
|
set((state) => ({
|
||||||
|
singleSearches: {
|
||||||
|
...state.singleSearches,
|
||||||
|
[fileKey]: state.singleSearches[fileKey] ?? {
|
||||||
|
query,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
results: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setSingleSearch: (fileKey: string, updater: StateUpdater<SingleSearchState>) => {
|
||||||
|
set((state) => {
|
||||||
|
const current = state.singleSearches[fileKey] ?? {
|
||||||
|
query: '',
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
singleSearches: {
|
||||||
|
...state.singleSearches,
|
||||||
|
[fileKey]: resolveState(current, updater),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
selectSingleMatch: (fileKey: string, track: ImportTrackResult) => {
|
||||||
|
set((state) => ({
|
||||||
|
singlesManualMatches: { ...state.singlesManualMatches, [fileKey]: track },
|
||||||
|
selectedSingles: new Set(state.selectedSingles).add(fileKey),
|
||||||
|
openSingleSearch: null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function resetImportWorkflowStore() {
|
||||||
|
useImportWorkflowStore.setState(createInitialWorkflowState());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImportQueueWorkflow() {
|
||||||
|
return useImportWorkflowStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
clearFinishedJobs: state.clearFinishedJobs,
|
||||||
|
enqueueQueueJob: state.enqueueQueueJob,
|
||||||
|
queue: state.queue,
|
||||||
|
updateQueueEntry: state.updateQueueEntry,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAlbumImportWorkflow() {
|
||||||
|
return useImportWorkflowStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
albumMatch: state.albumMatch,
|
||||||
|
albumMatchError: state.albumMatchError,
|
||||||
|
albumMatchLoading: state.albumMatchLoading,
|
||||||
|
albumQuery: state.albumQuery,
|
||||||
|
albumResults: state.albumResults,
|
||||||
|
albumSearchError: state.albumSearchError,
|
||||||
|
albumSearchLoading: state.albumSearchLoading,
|
||||||
|
albumSearchLookupSource: state.albumSearchLookupSource,
|
||||||
|
autoGroupFilePaths: state.autoGroupFilePaths,
|
||||||
|
clearAutoGroupFilePaths: state.clearAutoGroupFilePaths,
|
||||||
|
matchOverrides: state.matchOverrides,
|
||||||
|
resetAlbumWorkflow: state.resetAlbumSearch,
|
||||||
|
selectedAlbum: state.selectedAlbum,
|
||||||
|
setAlbumMatch: state.setAlbumMatch,
|
||||||
|
setAlbumMatchError: state.setAlbumMatchError,
|
||||||
|
setAlbumMatchLoading: state.setAlbumMatchLoading,
|
||||||
|
setAlbumQuery: state.setAlbumQuery,
|
||||||
|
setAlbumResults: state.setAlbumResults,
|
||||||
|
setAlbumSearchContext: state.setAlbumSearchContext,
|
||||||
|
setAlbumSearchError: state.setAlbumSearchError,
|
||||||
|
setAlbumSearchLoading: state.setAlbumSearchLoading,
|
||||||
|
setAlbumSearchLookupSource: state.setAlbumSearchLookupSource,
|
||||||
|
setMatchOverrides: state.setMatchOverrides,
|
||||||
|
setSelectedAlbum: state.setSelectedAlbum,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSinglesImportWorkflow() {
|
||||||
|
return useImportWorkflowStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
clearSinglesSelection: state.clearSinglesSelection,
|
||||||
|
ensureSingleSearch: state.ensureSingleSearch,
|
||||||
|
openSingleSearch: state.openSingleSearch,
|
||||||
|
selectedSingles: state.selectedSingles,
|
||||||
|
selectSingleMatchInStore: state.selectSingleMatch,
|
||||||
|
setOpenSingleSearch: state.setOpenSingleSearch,
|
||||||
|
setSingleSearch: state.setSingleSearch,
|
||||||
|
singleSearches: state.singleSearches,
|
||||||
|
singlesManualMatches: state.singlesManualMatches,
|
||||||
|
syncSinglesWorkflow: state.syncSinglesWorkflow,
|
||||||
|
toggleAllSingles: state.toggleAllSingles,
|
||||||
|
toggleSingleInStore: state.toggleSingle,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
243
webui/src/routes/import/-import.types.ts
Normal file
243
webui/src/routes/import/-import.types.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const IMPORT_AUTO_FILTER_VALUES = ['all', 'pending', 'imported', 'failed'] as const;
|
||||||
|
export type ImportAutoFilter = (typeof IMPORT_AUTO_FILTER_VALUES)[number];
|
||||||
|
|
||||||
|
export const importAutoSearchSchema = z.object({
|
||||||
|
autoFilter: z.enum(IMPORT_AUTO_FILTER_VALUES).default('all').catch('all'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ImportAutoSearch = z.infer<typeof importAutoSearchSchema>;
|
||||||
|
|
||||||
|
export interface ImportStagingFile {
|
||||||
|
filename: string;
|
||||||
|
rel_path?: string;
|
||||||
|
full_path: string;
|
||||||
|
title?: string | null;
|
||||||
|
artist?: string | null;
|
||||||
|
album?: string | null;
|
||||||
|
track_number?: string | number | null;
|
||||||
|
disc_number?: string | number | null;
|
||||||
|
extension?: string | null;
|
||||||
|
size?: number | null;
|
||||||
|
manual_match?: ImportTrackResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportStagingFilesPayload {
|
||||||
|
success: boolean;
|
||||||
|
files?: ImportStagingFile[];
|
||||||
|
staging_path?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportStagingGroup {
|
||||||
|
album: string;
|
||||||
|
artist: string;
|
||||||
|
file_count: number;
|
||||||
|
files?: Array<{
|
||||||
|
filename: string;
|
||||||
|
full_path: string;
|
||||||
|
title?: string | null;
|
||||||
|
track_number?: string | number | null;
|
||||||
|
}>;
|
||||||
|
file_paths: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportStagingGroupsPayload {
|
||||||
|
success: boolean;
|
||||||
|
groups?: ImportStagingGroup[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAlbumResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
/** Provider that returned this result row. */
|
||||||
|
source: string;
|
||||||
|
image_url?: string | null;
|
||||||
|
total_tracks?: number | null;
|
||||||
|
release_date?: string | null;
|
||||||
|
format?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
disambiguation?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAlbumSearchPayload {
|
||||||
|
success: boolean;
|
||||||
|
albums?: ImportAlbumResult[];
|
||||||
|
suggestions?: ImportAlbumResult[];
|
||||||
|
/** Provider used to seed the lookup chain for this response. */
|
||||||
|
primary_source?: string | null;
|
||||||
|
ready?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportTrackResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
album?: string | null;
|
||||||
|
/** Provider that returned this result row. */
|
||||||
|
source: string;
|
||||||
|
image_url?: string | null;
|
||||||
|
duration_ms?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportTrackSearchPayload {
|
||||||
|
success: boolean;
|
||||||
|
tracks?: ImportTrackResult[];
|
||||||
|
/** Provider used to seed the lookup chain for this response. */
|
||||||
|
primary_source?: string | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAlbum {
|
||||||
|
id?: string | number | null;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
/** Provider used to resolve this selected album. */
|
||||||
|
source: string;
|
||||||
|
image_url?: string | null;
|
||||||
|
total_tracks?: number | null;
|
||||||
|
release_date?: string | null;
|
||||||
|
format?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
disambiguation?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAlbumTrack {
|
||||||
|
id?: string | number | null;
|
||||||
|
name?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
track_number?: string | number | null;
|
||||||
|
trackNumber?: string | number | null;
|
||||||
|
disc_number?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAlbumMatch {
|
||||||
|
track?: ImportAlbumTrack | null;
|
||||||
|
spotify_track?: ImportAlbumTrack | null;
|
||||||
|
staging_file?: ImportStagingFile | null;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAlbumMatchPayload {
|
||||||
|
success: boolean;
|
||||||
|
album?: ImportAlbum;
|
||||||
|
matches?: ImportAlbumMatch[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportProcessPayload {
|
||||||
|
success: boolean;
|
||||||
|
processed?: number;
|
||||||
|
total?: number;
|
||||||
|
errors?: string[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAutoImportActiveItem {
|
||||||
|
folder_hash?: string | null;
|
||||||
|
folder_name?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
track_index?: number | null;
|
||||||
|
track_total?: number | null;
|
||||||
|
track_name?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAutoImportStatusPayload {
|
||||||
|
success: boolean;
|
||||||
|
running?: boolean;
|
||||||
|
paused?: boolean;
|
||||||
|
current_status?: string | null;
|
||||||
|
last_scan_time?: string | null;
|
||||||
|
active_imports?: ImportAutoImportActiveItem[];
|
||||||
|
stats?: {
|
||||||
|
scanned?: number;
|
||||||
|
auto_processed?: number;
|
||||||
|
pending_review?: number;
|
||||||
|
failed?: number;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAutoImportSettingsPayload {
|
||||||
|
success: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
scan_interval?: number;
|
||||||
|
confidence_threshold?: number;
|
||||||
|
auto_process?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAutoImportMatchData {
|
||||||
|
matched_count?: number;
|
||||||
|
total_tracks?: number;
|
||||||
|
matches?: Array<{
|
||||||
|
track_name?: string | null;
|
||||||
|
track?: { name?: string | null };
|
||||||
|
file?: string | null;
|
||||||
|
confidence?: number | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAutoImportResult {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
folder_hash?: string | null;
|
||||||
|
folder_name: string;
|
||||||
|
album_name?: string | null;
|
||||||
|
artist_name?: string | null;
|
||||||
|
image_url?: string | null;
|
||||||
|
confidence?: number | null;
|
||||||
|
total_files?: number | null;
|
||||||
|
identification_method?: string | null;
|
||||||
|
match_data?: string | ImportAutoImportMatchData | null;
|
||||||
|
error_message?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAutoImportResultsPayload {
|
||||||
|
success: boolean;
|
||||||
|
results?: ImportAutoImportResult[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportQueueStatus = 'running' | 'done' | 'error';
|
||||||
|
export type ImportQueueJobType = 'album' | 'singles';
|
||||||
|
|
||||||
|
export interface ImportQueueEntry {
|
||||||
|
id: number;
|
||||||
|
type: ImportQueueJobType;
|
||||||
|
label: string;
|
||||||
|
sublabel: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
status: ImportQueueStatus;
|
||||||
|
processed: number;
|
||||||
|
total: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportAlbumQueueJob {
|
||||||
|
type: 'album';
|
||||||
|
label: string;
|
||||||
|
sublabel: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
items: ImportAlbumMatch[];
|
||||||
|
albumData: ImportAlbum;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportSinglesQueueJob {
|
||||||
|
type: 'singles';
|
||||||
|
label: string;
|
||||||
|
sublabel: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
items: ImportStagingFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportQueueJob = ImportAlbumQueueJob | ImportSinglesQueueJob;
|
||||||
414
webui/src/routes/import/-route.test.tsx
Normal file
414
webui/src/routes/import/-route.test.tsx
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
import { createMemoryHistory } from '@tanstack/react-router';
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createAppQueryClient } from '@/app/query-client';
|
||||||
|
import { AppRouterProvider, createAppRouter } from '@/app/router';
|
||||||
|
import { HttpResponse, http, server } from '@/test/msw';
|
||||||
|
import { createShellBridge } from '@/test/shell-bridge';
|
||||||
|
|
||||||
|
import type { ImportStagingFile } from './-import.types';
|
||||||
|
|
||||||
|
import { autoImportResultsQueryOptions, autoImportStatusQueryOptions } from './-import.api';
|
||||||
|
import { resetImportWorkflowStore } from './-import.store';
|
||||||
|
|
||||||
|
function renderImportRoute(initialEntries = ['/import']) {
|
||||||
|
const queryClient = createAppQueryClient();
|
||||||
|
const history = createMemoryHistory({ initialEntries });
|
||||||
|
const router = createAppRouter({ history, queryClient });
|
||||||
|
|
||||||
|
return {
|
||||||
|
history,
|
||||||
|
router,
|
||||||
|
queryClient,
|
||||||
|
...render(<AppRouterProvider router={router} queryClient={queryClient} />),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFetchUrls() {
|
||||||
|
return vi
|
||||||
|
.mocked(fetch)
|
||||||
|
.mock.calls.map(([input]) => (input instanceof Request ? input.url : String(input)));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('import route', () => {
|
||||||
|
let albumMatchBodies: Record<string, unknown>[];
|
||||||
|
let stagingFilesPayload: ImportStagingFile[];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
albumMatchBodies = [];
|
||||||
|
stagingFilesPayload = [
|
||||||
|
{
|
||||||
|
filename: '01-track.flac',
|
||||||
|
rel_path: 'Album/01-track.flac',
|
||||||
|
full_path: '/music/Staging/Album/01-track.flac',
|
||||||
|
title: 'Track One',
|
||||||
|
artist: 'Artist A',
|
||||||
|
album: 'Album A',
|
||||||
|
extension: '.flac',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: '02-track.flac',
|
||||||
|
rel_path: 'Album/02-track.flac',
|
||||||
|
full_path: '/music/Staging/Album/02-track.flac',
|
||||||
|
title: 'Track Two',
|
||||||
|
artist: 'Artist A',
|
||||||
|
album: 'Album A',
|
||||||
|
extension: '.flac',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
resetImportWorkflowStore();
|
||||||
|
window.SoulSyncWebShellBridge = createShellBridge();
|
||||||
|
window.showToast = vi.fn();
|
||||||
|
window.showConfirmDialog = vi.fn(async () => true);
|
||||||
|
vi.spyOn(globalThis, 'fetch');
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/import/staging/files', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
staging_path: '/music/Staging',
|
||||||
|
files: stagingFilesPayload,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/import/staging/groups', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
album: 'Album A',
|
||||||
|
artist: 'Artist A',
|
||||||
|
file_count: 2,
|
||||||
|
file_paths: ['/music/Staging/Album/01-track.flac'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/import/staging/suggestions', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
ready: true,
|
||||||
|
primary_source: 'spotify',
|
||||||
|
suggestions: [
|
||||||
|
{
|
||||||
|
id: 'album-1',
|
||||||
|
name: 'Album A',
|
||||||
|
artist: 'Artist A',
|
||||||
|
source: 'deezer',
|
||||||
|
total_tracks: 1,
|
||||||
|
release_date: '2026-01-01',
|
||||||
|
format: 'CD',
|
||||||
|
country: 'US',
|
||||||
|
disambiguation: '25th Anniversary Edition',
|
||||||
|
status: 'official',
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/import/search/albums', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
primary_source: 'spotify',
|
||||||
|
albums: [
|
||||||
|
{
|
||||||
|
id: 'album-1',
|
||||||
|
name: 'Album A',
|
||||||
|
artist: 'Artist A',
|
||||||
|
source: 'deezer',
|
||||||
|
total_tracks: 1,
|
||||||
|
release_date: '2026-01-01',
|
||||||
|
format: 'CD',
|
||||||
|
country: 'US',
|
||||||
|
disambiguation: '25th Anniversary Edition',
|
||||||
|
status: 'official',
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.post('/api/import/album/match', async ({ request }) => {
|
||||||
|
const body = (await request.json()) as Record<string, unknown>;
|
||||||
|
albumMatchBodies.push(body);
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
received: body,
|
||||||
|
album: {
|
||||||
|
id: 'album-1',
|
||||||
|
name: 'Album A',
|
||||||
|
artist: 'Artist A',
|
||||||
|
source: 'deezer',
|
||||||
|
total_tracks: 1,
|
||||||
|
release_date: '2026-01-01',
|
||||||
|
format: 'CD',
|
||||||
|
country: 'US',
|
||||||
|
disambiguation: '25th Anniversary Edition',
|
||||||
|
status: 'official',
|
||||||
|
label: 'MusicBrainz',
|
||||||
|
},
|
||||||
|
matches: [
|
||||||
|
{
|
||||||
|
track: { name: 'Track One', track_number: 1 },
|
||||||
|
staging_file: {
|
||||||
|
filename: '01-track.flac',
|
||||||
|
full_path: '/music/Staging/Album/01-track.flac',
|
||||||
|
},
|
||||||
|
confidence: 0.95,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/auto-import/status', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
running: true,
|
||||||
|
current_status: 'idle',
|
||||||
|
active_imports: [],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/auto-import/settings', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
scan_interval: 60,
|
||||||
|
confidence_threshold: 0.9,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/auto-import/results', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
status: 'pending_review',
|
||||||
|
folder_hash: 'hash-1',
|
||||||
|
folder_name: 'Album A',
|
||||||
|
album_name: 'Album A',
|
||||||
|
artist_name: 'Artist A',
|
||||||
|
confidence: 0.82,
|
||||||
|
total_files: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
http.get('/api/issues/counts', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
counts: {
|
||||||
|
open: 0,
|
||||||
|
in_progress: 0,
|
||||||
|
resolved: 0,
|
||||||
|
dismissed: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the import page through the app router', async () => {
|
||||||
|
const { history } = renderImportRoute();
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId('import-page')).toBeInTheDocument());
|
||||||
|
expect(await screen.findByText('Import Music')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Import: /music/Staging')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText('1 tracks · 2026 · CD · US · 25th Anniversary Edition'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('official · MusicBrainz')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Showing Deezer results - not from your primary source (Spotify).'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('via Deezer')).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(history.location.pathname).toBe('/import/album'));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/groups'))).toBe(true),
|
||||||
|
);
|
||||||
|
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(window.SoulSyncWebShellBridge?.showReactHost).toHaveBeenCalledWith('import');
|
||||||
|
expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('import');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the import page rendering when staging files fail to load', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/import/staging/files', () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Import folder unavailable',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderImportRoute();
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('import-page')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Import Music')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Import folder: error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the active tab in nested route paths', async () => {
|
||||||
|
const { history } = renderImportRoute();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('link', { name: 'Singles' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(history.location.pathname).toBe('/import/singles'));
|
||||||
|
expect(screen.getByRole('button', { name: /Process Selected\s*0/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps client workflow drafts across page remounts', async () => {
|
||||||
|
const view = renderImportRoute();
|
||||||
|
|
||||||
|
const searchInput = await screen.findByPlaceholderText('Search for an album...');
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'half matched album' } });
|
||||||
|
view.unmount();
|
||||||
|
|
||||||
|
renderImportRoute();
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue('half matched album')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps singles selection tied to file identity across refreshes', async () => {
|
||||||
|
renderImportRoute(['/import/singles']);
|
||||||
|
|
||||||
|
const secondTrack = await screen.findByLabelText('Select 02-track.flac');
|
||||||
|
fireEvent.click(secondTrack);
|
||||||
|
|
||||||
|
stagingFilesPayload = [
|
||||||
|
{
|
||||||
|
filename: '00-intro.flac',
|
||||||
|
rel_path: 'Album/00-intro.flac',
|
||||||
|
full_path: '/music/Staging/Album/00-intro.flac',
|
||||||
|
title: 'Intro',
|
||||||
|
artist: 'Artist A',
|
||||||
|
album: 'Album A',
|
||||||
|
extension: '.flac',
|
||||||
|
},
|
||||||
|
...stagingFilesPayload,
|
||||||
|
];
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('checkbox', { name: 'Select 02-track.flac' })).toBeChecked(),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('checkbox', { name: 'Select 01-track.flac' })).not.toBeChecked();
|
||||||
|
expect(screen.getByRole('button', { name: /Process Selected\s*1/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves album source details when matching an album', async () => {
|
||||||
|
renderImportRoute();
|
||||||
|
|
||||||
|
const albumButtons = await screen.findAllByRole('button', { name: /Album A/ });
|
||||||
|
fireEvent.click(albumButtons[albumButtons.length - 1]);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Track Matching')).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(albumMatchBodies.at(-1)).toMatchObject({
|
||||||
|
source: 'deezer',
|
||||||
|
album_name: 'Album A',
|
||||||
|
album_artist: 'Artist A',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the served source when album search falls back', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/import/search/albums', () => {
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
primary_source: 'spotify',
|
||||||
|
albums: [
|
||||||
|
{
|
||||||
|
id: 'album-2',
|
||||||
|
name: 'Album A',
|
||||||
|
artist: 'Artist A',
|
||||||
|
source: 'musicbrainz',
|
||||||
|
total_tracks: 1,
|
||||||
|
release_date: '2026-01-01',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderImportRoute();
|
||||||
|
|
||||||
|
const searchInput = await screen.findByPlaceholderText('Search for an album...');
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'Album A' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Search' }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
'Showing MusicBrainz results - not from your primary source (Spotify).',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('via MusicBrainz')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders auto-import results from route search state', async () => {
|
||||||
|
renderImportRoute(['/import/auto?autoFilter=pending']);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Album A').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText('Watching')).toHaveAttribute('data-tone', 'success');
|
||||||
|
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/groups'))).toBe(false);
|
||||||
|
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps cached auto-import status visible when a refetch fails', async () => {
|
||||||
|
const { queryClient } = renderImportRoute(['/import/auto']);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Watching')).toBeInTheDocument();
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/auto-import/status', () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Auto-import unavailable',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: autoImportStatusQueryOptions().queryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Watching')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Auto-import is unavailable:/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps cached auto-import results visible when a refetch fails', async () => {
|
||||||
|
const { queryClient } = renderImportRoute(['/import/auto?autoFilter=pending']);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Album A').length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/auto-import/results', () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Auto-import results unavailable',
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: autoImportResultsQueryOptions().queryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Album A').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText(/Failed to load imports:/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
644
webui/src/routes/import/-ui/album-import-tab.tsx
Normal file
644
webui/src/routes/import/-ui/album-import-tab.tsx
Normal file
|
|
@ -0,0 +1,644 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { type DragEvent, type KeyboardEvent, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button, TextInput } from '@/components/form/form';
|
||||||
|
import { Notice } from '@/components/primitives';
|
||||||
|
|
||||||
|
import type { ImportAlbumResult } from '../-import.types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
importStagingGroupsQueryOptions,
|
||||||
|
importStagingSuggestionsQueryOptions,
|
||||||
|
matchImportAlbum,
|
||||||
|
searchImportAlbums,
|
||||||
|
} from '../-import.api';
|
||||||
|
import {
|
||||||
|
getDisplayedMatchFile,
|
||||||
|
getEffectiveAlbumMatches,
|
||||||
|
getImportSourceBadgeText,
|
||||||
|
getImportSourceFallbackBanner,
|
||||||
|
getTrackDisplayInfo,
|
||||||
|
getUnmatchedStagingFiles,
|
||||||
|
IMPORT_PLACEHOLDER_IMAGE,
|
||||||
|
} from '../-import.helpers';
|
||||||
|
import { useAlbumImportWorkflow } from '../-import.store';
|
||||||
|
import styles from './import-page.module.css';
|
||||||
|
import {
|
||||||
|
fallbackImage,
|
||||||
|
getErrorMessage,
|
||||||
|
useImportQueueActions,
|
||||||
|
useImportStaging,
|
||||||
|
} from './import-shared';
|
||||||
|
|
||||||
|
function useAlbumImportViewModel() {
|
||||||
|
const { refreshStaging, stagingFiles } = useImportStaging();
|
||||||
|
const [dragOverTrack, setDragOverTrack] = useState<number | null>(null);
|
||||||
|
const [tapSelectedChip, setTapSelectedChip] = useState<number | null>(null);
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
...importStagingGroupsQueryOptions(),
|
||||||
|
});
|
||||||
|
const suggestionsQuery = useQuery({
|
||||||
|
...importStagingSuggestionsQueryOptions(),
|
||||||
|
});
|
||||||
|
const { addQueueJob } = useImportQueueActions();
|
||||||
|
const {
|
||||||
|
albumMatch,
|
||||||
|
albumMatchError,
|
||||||
|
albumMatchLoading,
|
||||||
|
albumQuery,
|
||||||
|
albumResults,
|
||||||
|
albumSearchError,
|
||||||
|
albumSearchLoading,
|
||||||
|
albumSearchLookupSource,
|
||||||
|
autoGroupFilePaths,
|
||||||
|
clearAutoGroupFilePaths,
|
||||||
|
matchOverrides,
|
||||||
|
resetAlbumWorkflow,
|
||||||
|
selectedAlbum,
|
||||||
|
setAlbumMatch,
|
||||||
|
setAlbumMatchError,
|
||||||
|
setAlbumMatchLoading,
|
||||||
|
setAlbumQuery,
|
||||||
|
setAlbumResults,
|
||||||
|
setAlbumSearchContext,
|
||||||
|
setAlbumSearchError,
|
||||||
|
setAlbumSearchLoading,
|
||||||
|
setAlbumSearchLookupSource,
|
||||||
|
setMatchOverrides,
|
||||||
|
setSelectedAlbum,
|
||||||
|
} = useAlbumImportWorkflow();
|
||||||
|
|
||||||
|
const resetAlbumSearch = () => {
|
||||||
|
setDragOverTrack(null);
|
||||||
|
setTapSelectedChip(null);
|
||||||
|
resetAlbumWorkflow();
|
||||||
|
void refreshStaging();
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAlbumSearch = async (query: string, filePaths: string[] | null = null) => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
setAlbumSearchContext(trimmed, filePaths);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await searchImportAlbums(trimmed);
|
||||||
|
setAlbumResults(payload.albums ?? []);
|
||||||
|
setAlbumSearchLookupSource(payload.primary_source ?? null);
|
||||||
|
} catch (error) {
|
||||||
|
setAlbumSearchError(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setAlbumSearchLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectAlbum = async (album: ImportAlbumResult) => {
|
||||||
|
setSelectedAlbum(album);
|
||||||
|
setAlbumMatch(null);
|
||||||
|
setAlbumMatchError(null);
|
||||||
|
setAlbumMatchLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pass the source that returned this result row so matching keeps using the
|
||||||
|
// same provider even if the search fell back from the lookup source.
|
||||||
|
const payload = await matchImportAlbum({
|
||||||
|
albumId: album.id,
|
||||||
|
source: album.source,
|
||||||
|
albumName: album.name,
|
||||||
|
albumArtist: album.artist,
|
||||||
|
filePaths: autoGroupFilePaths,
|
||||||
|
});
|
||||||
|
setAlbumMatch(payload);
|
||||||
|
setMatchOverrides({});
|
||||||
|
setTapSelectedChip(null);
|
||||||
|
setDragOverTrack(null);
|
||||||
|
} catch (error) {
|
||||||
|
setAlbumMatchError(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
clearAutoGroupFilePaths();
|
||||||
|
setAlbumMatchLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignMatchFile = (trackIndex: number, stagingFileIndex: number) => {
|
||||||
|
setMatchOverrides((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
for (const [key, value] of Object.entries(next)) {
|
||||||
|
if (value === stagingFileIndex) {
|
||||||
|
delete next[Number(key)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next[trackIndex] = stagingFileIndex;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setTapSelectedChip(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unmatchTrack = (trackIndex: number) => {
|
||||||
|
setMatchOverrides((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[trackIndex];
|
||||||
|
if (albumMatch?.matches?.[trackIndex]?.staging_file) {
|
||||||
|
next[trackIndex] = -1;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const processAlbum = () => {
|
||||||
|
const album = albumMatch?.album;
|
||||||
|
const matches = albumMatch?.matches ?? [];
|
||||||
|
if (!album || matches.length === 0) return;
|
||||||
|
|
||||||
|
const effectiveMatches = getEffectiveAlbumMatches(matches, stagingFiles, matchOverrides);
|
||||||
|
if (effectiveMatches.length === 0) return;
|
||||||
|
|
||||||
|
addQueueJob({
|
||||||
|
type: 'album',
|
||||||
|
label: album.name,
|
||||||
|
sublabel: `${album.artist} - ${effectiveMatches.length} tracks`,
|
||||||
|
imageUrl: album.image_url,
|
||||||
|
items: effectiveMatches,
|
||||||
|
albumData: album,
|
||||||
|
});
|
||||||
|
resetAlbumSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
albumMatch,
|
||||||
|
albumMatchError,
|
||||||
|
albumMatchLoading,
|
||||||
|
albumQuery,
|
||||||
|
albumResults,
|
||||||
|
albumSearchError,
|
||||||
|
albumSearchLoading,
|
||||||
|
albumSearchLookupSource,
|
||||||
|
dragOverTrack,
|
||||||
|
groups: groupsQuery.data?.groups ?? [],
|
||||||
|
matchOverrides,
|
||||||
|
onAlbumQueryChange: setAlbumQuery,
|
||||||
|
onAutoRematch: () => {
|
||||||
|
setMatchOverrides({});
|
||||||
|
setTapSelectedChip(null);
|
||||||
|
setDragOverTrack(null);
|
||||||
|
},
|
||||||
|
onBackToSearch: resetAlbumSearch,
|
||||||
|
onDragOverTrack: setDragOverTrack,
|
||||||
|
onProcessAlbum: processAlbum,
|
||||||
|
onRunGroupSearch: (group: {
|
||||||
|
album: string;
|
||||||
|
artist: string;
|
||||||
|
file_count: number;
|
||||||
|
file_paths: string[];
|
||||||
|
}) => {
|
||||||
|
void runAlbumSearch(`${group.artist} ${group.album}`, group.file_paths);
|
||||||
|
},
|
||||||
|
onRunSearch: () => {
|
||||||
|
void runAlbumSearch(albumQuery);
|
||||||
|
},
|
||||||
|
onSelectAlbum: (album: ImportAlbumResult) => {
|
||||||
|
void selectAlbum(album);
|
||||||
|
},
|
||||||
|
onTapAssign: assignMatchFile,
|
||||||
|
onTapSelectChip: (index: number) => {
|
||||||
|
setTapSelectedChip((current) => (current === index ? null : index));
|
||||||
|
},
|
||||||
|
onUnmatchTrack: unmatchTrack,
|
||||||
|
selectedAlbum,
|
||||||
|
stagingFiles,
|
||||||
|
suggestions: suggestionsQuery.data?.suggestions ?? [],
|
||||||
|
suggestionsReady: suggestionsQuery.data?.ready ?? true,
|
||||||
|
suggestionsLookupSource: suggestionsQuery.data?.primary_source ?? null,
|
||||||
|
tapSelectedChip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlbumImportViewModel = ReturnType<typeof useAlbumImportViewModel>;
|
||||||
|
|
||||||
|
type AlbumMetaFields = {
|
||||||
|
total_tracks?: number | null;
|
||||||
|
release_date?: string | null;
|
||||||
|
format?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
disambiguation?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AlbumImportTab() {
|
||||||
|
const viewModel = useAlbumImportViewModel();
|
||||||
|
|
||||||
|
return <AlbumImportPanelContent viewModel={viewModel} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewModel }) {
|
||||||
|
const {
|
||||||
|
albumMatch,
|
||||||
|
albumMatchError,
|
||||||
|
albumMatchLoading,
|
||||||
|
albumQuery,
|
||||||
|
albumResults,
|
||||||
|
albumSearchError,
|
||||||
|
albumSearchLoading,
|
||||||
|
albumSearchLookupSource,
|
||||||
|
groups,
|
||||||
|
onAlbumQueryChange,
|
||||||
|
onBackToSearch,
|
||||||
|
onRunGroupSearch,
|
||||||
|
onRunSearch,
|
||||||
|
onSelectAlbum,
|
||||||
|
selectedAlbum,
|
||||||
|
suggestions,
|
||||||
|
suggestionsReady,
|
||||||
|
suggestionsLookupSource,
|
||||||
|
} = viewModel;
|
||||||
|
|
||||||
|
const showingMatch = selectedAlbum || albumMatchLoading || albumMatchError || albumMatch;
|
||||||
|
const suggestionsFallbackBanner = getImportSourceFallbackBanner(
|
||||||
|
suggestions,
|
||||||
|
suggestionsLookupSource,
|
||||||
|
);
|
||||||
|
const albumResultsFallbackBanner = getImportSourceFallbackBanner(
|
||||||
|
albumResults,
|
||||||
|
albumSearchLookupSource,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
id="import-page-album-search-section"
|
||||||
|
className={clsx({ [styles.hidden]: showingMatch })}
|
||||||
|
>
|
||||||
|
{albumResults === null && (
|
||||||
|
<>
|
||||||
|
{groups.length > 0 && (
|
||||||
|
<div id="import-page-auto-groups" className={styles.importPageAutoGroups}>
|
||||||
|
<div className={styles.importPageSectionLabel}>Auto-Detected Albums</div>
|
||||||
|
<div className={styles.importPageAlbumGrid}>
|
||||||
|
{groups.map((group, index) => (
|
||||||
|
<button
|
||||||
|
key={`${group.artist}-${group.album}-${index}`}
|
||||||
|
className={clsx(styles.importPageAlbumCard, styles.importPageAutoGroupCard)}
|
||||||
|
onClick={() => onRunGroupSearch(group)}
|
||||||
|
>
|
||||||
|
<div className={styles.importPageAutoGroupCount}>{group.file_count}</div>
|
||||||
|
<div className={styles.importPageAutoGroupInfo}>
|
||||||
|
<div className={styles.importPageAlbumCardTitle} title={group.album}>
|
||||||
|
{group.album}
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageAlbumCardArtist} title={group.artist}>
|
||||||
|
{group.artist} · {group.file_count} tracks
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.importPageSuggestions} id="import-page-suggestions">
|
||||||
|
<div className={styles.importPageSectionLabel}>Suggested from your import folder</div>
|
||||||
|
{suggestionsFallbackBanner ? (
|
||||||
|
<Notice tone="warning">{suggestionsFallbackBanner}</Notice>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.importPageAlbumGrid} id="import-page-suggestions-grid">
|
||||||
|
{suggestions.length > 0 ? (
|
||||||
|
suggestions.map((album) => (
|
||||||
|
<AlbumCard
|
||||||
|
key={`${album.source || 'source'}-${album.id}`}
|
||||||
|
album={album}
|
||||||
|
lookupSource={suggestionsLookupSource}
|
||||||
|
onSelect={onSelectAlbum}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : suggestionsReady ? null : (
|
||||||
|
<div className={styles.importPageEmptyState}>Loading suggestions...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.importPageSearchBar}>
|
||||||
|
<TextInput
|
||||||
|
type="text"
|
||||||
|
id="import-page-album-search-input"
|
||||||
|
className={styles.importPageSearchInput}
|
||||||
|
placeholder="Search for an album..."
|
||||||
|
value={albumQuery}
|
||||||
|
onChange={(event) => onAlbumQueryChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') onRunSearch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={clsx({ [styles.hidden]: albumResults === null })}
|
||||||
|
id="import-page-album-clear-btn"
|
||||||
|
title="Clear search"
|
||||||
|
onClick={onBackToSearch}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={onRunSearch}>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{albumResultsFallbackBanner ? (
|
||||||
|
<Notice tone="warning">{albumResultsFallbackBanner}</Notice>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.importPageAlbumGrid} id="import-page-album-results">
|
||||||
|
{albumSearchLoading ? (
|
||||||
|
<div className={styles.importPageEmptyState}>Searching...</div>
|
||||||
|
) : albumSearchError ? (
|
||||||
|
<Notice tone="danger" role="alert">
|
||||||
|
Error: {albumSearchError}
|
||||||
|
</Notice>
|
||||||
|
) : albumResults?.length === 0 ? (
|
||||||
|
<div className={styles.importPageEmptyState}>No albums found</div>
|
||||||
|
) : (
|
||||||
|
albumResults?.map((album) => (
|
||||||
|
<AlbumCard
|
||||||
|
key={`${album.source || 'source'}-${album.id}`}
|
||||||
|
album={album}
|
||||||
|
lookupSource={albumSearchLookupSource}
|
||||||
|
onSelect={onSelectAlbum}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="import-page-album-match-section"
|
||||||
|
className={clsx({ [styles.hidden]: !showingMatch })}
|
||||||
|
>
|
||||||
|
{albumMatchLoading ? (
|
||||||
|
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
|
||||||
|
) : albumMatchError ? (
|
||||||
|
<Notice tone="danger" role="alert">
|
||||||
|
Error: {albumMatchError}
|
||||||
|
</Notice>
|
||||||
|
) : albumMatch?.album ? (
|
||||||
|
<AlbumMatchPanel viewModel={viewModel} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.importPageEmptyState}>
|
||||||
|
Select an album to start matching files.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlbumCard({
|
||||||
|
album,
|
||||||
|
lookupSource,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
album: ImportAlbumResult;
|
||||||
|
lookupSource: string | null;
|
||||||
|
onSelect: (album: ImportAlbumResult) => void;
|
||||||
|
}) {
|
||||||
|
const resultSourceBadge = getImportSourceBadgeText(album.source, lookupSource);
|
||||||
|
const metaParts = getAlbumMetaParts(album);
|
||||||
|
const detailParts = getAlbumDetailParts(album);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" className={styles.importPageAlbumCard} onClick={() => onSelect(album)}>
|
||||||
|
<img
|
||||||
|
src={album.image_url || IMPORT_PLACEHOLDER_IMAGE}
|
||||||
|
alt={album.name}
|
||||||
|
loading="lazy"
|
||||||
|
onError={fallbackImage}
|
||||||
|
/>
|
||||||
|
<div className={styles.importPageAlbumCardTitle} title={album.name}>
|
||||||
|
{album.name}
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageAlbumCardArtist} title={album.artist}>
|
||||||
|
{album.artist}
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageAlbumCardMeta}>{metaParts.join(' · ')}</div>
|
||||||
|
{detailParts.length > 0 ? (
|
||||||
|
<div className={styles.importPageAlbumCardDetail}>{detailParts.join(' · ')}</div>
|
||||||
|
) : null}
|
||||||
|
{resultSourceBadge ? (
|
||||||
|
<div className={styles.importPageAlbumCardSource}>{resultSourceBadge}</div>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
|
||||||
|
const {
|
||||||
|
albumMatch,
|
||||||
|
albumMatchError,
|
||||||
|
albumMatchLoading,
|
||||||
|
dragOverTrack,
|
||||||
|
matchOverrides,
|
||||||
|
onAutoRematch,
|
||||||
|
onBackToSearch,
|
||||||
|
onDragOverTrack,
|
||||||
|
onProcessAlbum,
|
||||||
|
onTapAssign,
|
||||||
|
onTapSelectChip,
|
||||||
|
onUnmatchTrack,
|
||||||
|
stagingFiles,
|
||||||
|
tapSelectedChip,
|
||||||
|
} = viewModel;
|
||||||
|
|
||||||
|
const effectiveMatches = getEffectiveAlbumMatches(
|
||||||
|
albumMatch?.matches ?? [],
|
||||||
|
stagingFiles,
|
||||||
|
matchOverrides,
|
||||||
|
);
|
||||||
|
const unmatchedFiles = getUnmatchedStagingFiles(
|
||||||
|
albumMatch?.matches ?? [],
|
||||||
|
stagingFiles,
|
||||||
|
matchOverrides,
|
||||||
|
);
|
||||||
|
const matchedCount = effectiveMatches.length;
|
||||||
|
const heroMetaParts = albumMatch?.album ? getAlbumMetaParts(albumMatch.album) : [];
|
||||||
|
|
||||||
|
return albumMatchLoading ? (
|
||||||
|
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
|
||||||
|
) : albumMatchError ? (
|
||||||
|
<Notice tone="danger" role="alert">
|
||||||
|
Error: {albumMatchError}
|
||||||
|
</Notice>
|
||||||
|
) : albumMatch?.album ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.importPageAlbumHero} id="import-page-album-hero">
|
||||||
|
<img
|
||||||
|
src={albumMatch.album.image_url || IMPORT_PLACEHOLDER_IMAGE}
|
||||||
|
alt={albumMatch.album.name}
|
||||||
|
loading="lazy"
|
||||||
|
onError={fallbackImage}
|
||||||
|
/>
|
||||||
|
<div className={styles.importPageAlbumHeroInfo}>
|
||||||
|
<div className={styles.importPageAlbumHeroTitle}>{albumMatch.album.name}</div>
|
||||||
|
<div className={styles.importPageAlbumHeroArtist}>{albumMatch.album.artist}</div>
|
||||||
|
<div className={styles.importPageAlbumHeroMeta}>{heroMetaParts.join(' · ')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.importPageMatchHeader}>
|
||||||
|
<h3>Track Matching</h3>
|
||||||
|
<div className={styles.importPageMatchActions}>
|
||||||
|
<Button variant="secondary" onClick={onAutoRematch}>
|
||||||
|
Re-match Automatically
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={onBackToSearch}>
|
||||||
|
Back to Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.importPageMatchList} id="import-page-match-list">
|
||||||
|
{(albumMatch.matches ?? []).map((match, index) => {
|
||||||
|
const trackInfo = getTrackDisplayInfo(match, index);
|
||||||
|
const { confidence, file } = getDisplayedMatchFile(
|
||||||
|
match,
|
||||||
|
index,
|
||||||
|
stagingFiles,
|
||||||
|
matchOverrides,
|
||||||
|
);
|
||||||
|
const confidencePercent = Math.round(confidence * 100);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${trackInfo.displayTrackNumber}-${trackInfo.name}-${index}`}
|
||||||
|
className={clsx(styles.importPageMatchRow, {
|
||||||
|
[styles.matched]: file,
|
||||||
|
[styles.dragOver]: dragOverTrack === index,
|
||||||
|
})}
|
||||||
|
onClick={() => {
|
||||||
|
if (tapSelectedChip !== null) onTapAssign(index, tapSelectedChip);
|
||||||
|
}}
|
||||||
|
onDragOver={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
onDragOverTrack(index);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => onDragOverTrack(null)}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onDragOverTrack(null);
|
||||||
|
const stagingFileIndex = Number(event.dataTransfer.getData('text/plain'));
|
||||||
|
if (Number.isFinite(stagingFileIndex)) onTapAssign(index, stagingFileIndex);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className={styles.importPageMatchNum}>{trackInfo.displayTrackNumber}</span>
|
||||||
|
<span className={styles.importPageMatchTrack}>{trackInfo.name}</span>
|
||||||
|
<span
|
||||||
|
className={clsx(styles.importPageMatchFile, {
|
||||||
|
[styles.hasFile]: file,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{file ? (
|
||||||
|
<>
|
||||||
|
<span className={styles.importPageMatchFileName}>{file.filename}</span>
|
||||||
|
<span
|
||||||
|
className={clsx(styles.importPageMatchConfidence, {
|
||||||
|
[styles.low]: confidence < 0.7,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{confidencePercent}%
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className={styles.importPageMatchDropZone}>Drop a file here</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{file ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onUnmatchTrack(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.importPageUnmatchedPool} id="import-page-unmatched-pool">
|
||||||
|
<div className={styles.importPagePoolLabel}>
|
||||||
|
Unmatched Files (<span id="import-page-unmatched-count">{unmatchedFiles.length}</span>)
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPagePoolChips} id="import-page-pool-chips">
|
||||||
|
{unmatchedFiles.length === 0 ? (
|
||||||
|
<span className={styles.importPagePoolEmpty}>All files matched</span>
|
||||||
|
) : (
|
||||||
|
unmatchedFiles.map(({ file, index }) => (
|
||||||
|
<span
|
||||||
|
key={`${file.full_path}-${index}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={clsx(styles.importPageFileChip, {
|
||||||
|
[styles.selected]: tapSelectedChip === index,
|
||||||
|
})}
|
||||||
|
draggable
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onTapSelectChip(index);
|
||||||
|
}}
|
||||||
|
onDragStart={(event: DragEvent<HTMLSpanElement>) => {
|
||||||
|
event.dataTransfer.setData('text/plain', String(index));
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
}}
|
||||||
|
onKeyDown={(event: KeyboardEvent<HTMLSpanElement>) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
onTapSelectChip(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file.filename}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.importPageMatchFooter}>
|
||||||
|
<div className={styles.importPageMatchStats} id="import-page-match-stats">
|
||||||
|
{matchedCount} of {albumMatch.matches?.length ?? 0} tracks matched
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
id="import-page-album-process-btn"
|
||||||
|
disabled={matchedCount === 0}
|
||||||
|
onClick={onProcessAlbum}
|
||||||
|
>
|
||||||
|
Process {matchedCount} Track{matchedCount === 1 ? '' : 's'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={styles.importPageEmptyState}>Select an album to start matching files.</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlbumMetaParts(album: AlbumMetaFields) {
|
||||||
|
return [
|
||||||
|
`${album.total_tracks || 0} tracks`,
|
||||||
|
album.release_date?.substring(0, 4) || '',
|
||||||
|
album.format || '',
|
||||||
|
album.country || '',
|
||||||
|
album.disambiguation || '',
|
||||||
|
].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlbumDetailParts(album: AlbumMetaFields) {
|
||||||
|
return [album.status || '', album.label || ''].filter(Boolean);
|
||||||
|
}
|
||||||
614
webui/src/routes/import/-ui/auto-import-tab.tsx
Normal file
614
webui/src/routes/import/-ui/auto-import-tab.tsx
Normal file
|
|
@ -0,0 +1,614 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
OptionButton,
|
||||||
|
OptionButtonGroup,
|
||||||
|
RangeInput,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
} from '@/components/form/form';
|
||||||
|
import { Badge, Notice } from '@/components/primitives';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ImportAutoFilter,
|
||||||
|
ImportAutoImportResult,
|
||||||
|
ImportAutoImportStatusPayload,
|
||||||
|
} from '../-import.types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
approveAllAutoImportResults,
|
||||||
|
approveAutoImportResult,
|
||||||
|
autoImportResultsQueryOptions,
|
||||||
|
autoImportSettingsQueryOptions,
|
||||||
|
autoImportStatusQueryOptions,
|
||||||
|
clearCompletedAutoImportResults,
|
||||||
|
invalidateAutoImportQueries,
|
||||||
|
rejectAutoImportResult,
|
||||||
|
saveAutoImportSettings,
|
||||||
|
toggleAutoImport,
|
||||||
|
triggerAutoImportScan,
|
||||||
|
} from '../-import.api';
|
||||||
|
import {
|
||||||
|
filterAutoImportResults,
|
||||||
|
getActiveImportLines,
|
||||||
|
getAutoImportCounts,
|
||||||
|
getAutoImportStatusMeta,
|
||||||
|
getAutoImportStatusText,
|
||||||
|
getAutoImportStatusTone,
|
||||||
|
getAutoImportTimeAgo,
|
||||||
|
getConfidenceClass,
|
||||||
|
parseAutoImportMatchData,
|
||||||
|
} from '../-import.helpers';
|
||||||
|
import styles from './import-page.module.css';
|
||||||
|
import { fallbackImage, getErrorMessage, RefreshIcon } from './import-shared';
|
||||||
|
|
||||||
|
export function AutoImportPanel({
|
||||||
|
autoFilter,
|
||||||
|
onFilterChange,
|
||||||
|
}: {
|
||||||
|
autoFilter: ImportAutoFilter;
|
||||||
|
onFilterChange: (filter: ImportAutoFilter) => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [confidence, setConfidence] = useState(90);
|
||||||
|
const [interval, setInterval] = useState(60);
|
||||||
|
const [expandedRows, setExpandedRows] = useState<Set<number>>(() => new Set());
|
||||||
|
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
...autoImportStatusQueryOptions(),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
const settingsQuery = useQuery({
|
||||||
|
...autoImportSettingsQueryOptions(),
|
||||||
|
});
|
||||||
|
const resultsQuery = useQuery({
|
||||||
|
...autoImportResultsQueryOptions(),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const settings = settingsQuery.data;
|
||||||
|
if (!settings) return;
|
||||||
|
setConfidence(Math.round((settings.confidence_threshold ?? 0.9) * 100));
|
||||||
|
setInterval(settings.scan_interval ?? 60);
|
||||||
|
}, [settingsQuery.data]);
|
||||||
|
|
||||||
|
const invalidateAutoImport = () => {
|
||||||
|
void invalidateAutoImportQueries(queryClient);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: toggleAutoImport,
|
||||||
|
onSuccess: (_, enabled) => {
|
||||||
|
window.showToast?.(enabled ? 'Auto-import enabled' : 'Auto-import disabled', 'success');
|
||||||
|
invalidateAutoImport();
|
||||||
|
},
|
||||||
|
onError: showMutationError,
|
||||||
|
});
|
||||||
|
const saveSettingsMutation = useMutation({
|
||||||
|
mutationFn: saveAutoImportSettings,
|
||||||
|
onSuccess: () => {
|
||||||
|
window.showToast?.('Settings saved', 'success');
|
||||||
|
invalidateAutoImport();
|
||||||
|
},
|
||||||
|
onError: showMutationError,
|
||||||
|
});
|
||||||
|
const scanMutation = useMutation({
|
||||||
|
mutationFn: triggerAutoImportScan,
|
||||||
|
onSuccess: () => {
|
||||||
|
window.showToast?.('Scan triggered', 'success');
|
||||||
|
invalidateAutoImport();
|
||||||
|
},
|
||||||
|
onError: showMutationError,
|
||||||
|
});
|
||||||
|
const approveMutation = useMutation({
|
||||||
|
mutationFn: approveAutoImportResult,
|
||||||
|
onSuccess: () => {
|
||||||
|
window.showToast?.('Approved', 'success');
|
||||||
|
invalidateAutoImport();
|
||||||
|
},
|
||||||
|
onError: showMutationError,
|
||||||
|
});
|
||||||
|
const rejectMutation = useMutation({
|
||||||
|
mutationFn: rejectAutoImportResult,
|
||||||
|
onSuccess: () => {
|
||||||
|
window.showToast?.('Dismissed', 'success');
|
||||||
|
invalidateAutoImport();
|
||||||
|
},
|
||||||
|
onError: showMutationError,
|
||||||
|
});
|
||||||
|
const approveAllMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const confirmed = await confirmAction({
|
||||||
|
title: 'Approve All',
|
||||||
|
message: 'Approve and import all pending review items?',
|
||||||
|
confirmText: 'Approve All',
|
||||||
|
});
|
||||||
|
if (!confirmed) return null;
|
||||||
|
return await approveAllAutoImportResults();
|
||||||
|
},
|
||||||
|
onSuccess: (count) => {
|
||||||
|
if (count === null) return;
|
||||||
|
window.showToast?.(`Approved ${count} items`, 'success');
|
||||||
|
invalidateAutoImport();
|
||||||
|
},
|
||||||
|
onError: showMutationError,
|
||||||
|
});
|
||||||
|
const clearMutation = useMutation({
|
||||||
|
mutationFn: clearCompletedAutoImportResults,
|
||||||
|
onSuccess: (count) => {
|
||||||
|
window.showToast?.(`Cleared ${count} imported items`, 'success');
|
||||||
|
invalidateAutoImport();
|
||||||
|
},
|
||||||
|
onError: showMutationError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allResults = resultsQuery.data?.results ?? [];
|
||||||
|
const results = filterAutoImportResults(allResults, autoFilter);
|
||||||
|
const counts = getAutoImportCounts(allResults);
|
||||||
|
const activeLines = getActiveImportLines(statusQuery.data);
|
||||||
|
const statusTone = getAutoImportStatusTone(statusQuery.data);
|
||||||
|
const statusError =
|
||||||
|
statusQuery.error && !statusQuery.data ? getErrorMessage(statusQuery.error) : '';
|
||||||
|
const resultsError =
|
||||||
|
resultsQuery.error && !resultsQuery.data ? getErrorMessage(resultsQuery.error) : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{statusError ? (
|
||||||
|
<Notice tone="danger" role="alert">
|
||||||
|
Auto-import is unavailable: {statusError}
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.autoImportControls}>
|
||||||
|
<div className={styles.autoImportToggleRow}>
|
||||||
|
<div className={styles.autoImportToggleLabel}>
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(statusQuery.data?.running)}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
aria-labelledby="auto-import-toggle-label"
|
||||||
|
id="auto-import-enabled"
|
||||||
|
onCheckedChange={(checked) => toggleMutation.mutate(checked)}
|
||||||
|
/>
|
||||||
|
<span id="auto-import-toggle-label">Auto-Import</span>
|
||||||
|
</div>
|
||||||
|
<Badge id="auto-import-status-text" tone={statusTone}>
|
||||||
|
{getAutoImportStatusText(statusQuery.data)}
|
||||||
|
</Badge>
|
||||||
|
<div className={styles.importPageFlexSpacer} />
|
||||||
|
{statusQuery.data?.running ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
id="auto-import-scan-now"
|
||||||
|
title="Scan import folder now"
|
||||||
|
disabled={scanMutation.isPending}
|
||||||
|
onClick={() => scanMutation.mutate()}
|
||||||
|
>
|
||||||
|
<RefreshIcon />
|
||||||
|
Scan Now
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{statusQuery.data?.running ? (
|
||||||
|
<div className={styles.autoImportSettingsRow} id="auto-import-settings-row">
|
||||||
|
<div className={styles.autoImportSetting}>
|
||||||
|
<span>Confidence:</span>
|
||||||
|
<RangeInput
|
||||||
|
label="Confidence"
|
||||||
|
min={50}
|
||||||
|
max={100}
|
||||||
|
value={confidence}
|
||||||
|
onValueChange={setConfidence}
|
||||||
|
/>
|
||||||
|
<span className={styles.autoImportConfidenceValue} id="auto-import-conf-val">
|
||||||
|
{confidence}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportSetting}>
|
||||||
|
<span>Interval:</span>
|
||||||
|
<Select
|
||||||
|
id="auto-import-interval"
|
||||||
|
size="sm"
|
||||||
|
value={interval}
|
||||||
|
onChange={(event) => setInterval(Number(event.target.value))}
|
||||||
|
>
|
||||||
|
<option value="30">30s</option>
|
||||||
|
<option value="60">60s</option>
|
||||||
|
<option value="120">2m</option>
|
||||||
|
<option value="300">5m</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
disabled={saveSettingsMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
saveSettingsMutation.mutate({
|
||||||
|
confidenceThreshold: confidence / 100,
|
||||||
|
scanInterval: interval,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{activeLines.length > 0 ? (
|
||||||
|
<div className={styles.autoImportProgress} id="auto-import-progress">
|
||||||
|
<div className={styles.autoImportProgressText} id="auto-import-progress-text">
|
||||||
|
{activeLines.length === 1
|
||||||
|
? `Processing ${activeLines[0]}`
|
||||||
|
: `Processing ${activeLines.length} imports:`}
|
||||||
|
{activeLines.length > 1
|
||||||
|
? activeLines.map((line) => <div key={line}>{line}</div>)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportProgressBar}>
|
||||||
|
<div className={styles.autoImportProgressFill} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allResults.length > 0 ? (
|
||||||
|
<OptionButtonGroup size="sm" className={styles.autoImportFilters}>
|
||||||
|
{(['all', 'pending', 'imported', 'failed'] as const).map((filter) => (
|
||||||
|
<OptionButton
|
||||||
|
key={filter}
|
||||||
|
selected={autoFilter === filter}
|
||||||
|
variant={autoFilter === filter ? 'default' : 'ghost'}
|
||||||
|
onClick={() => onFilterChange(filter)}
|
||||||
|
>
|
||||||
|
<span>{getAutoImportFilterLabel(filter)}</span>
|
||||||
|
<Badge tone={getAutoImportFilterTone(filter)}>
|
||||||
|
{getAutoImportFilterCount(filter, counts, allResults.length)}
|
||||||
|
</Badge>
|
||||||
|
</OptionButton>
|
||||||
|
))}
|
||||||
|
<div className={styles.importPageFlexSpacer} />
|
||||||
|
{counts.review > 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
id="auto-import-approve-all"
|
||||||
|
disabled={approveAllMutation.isPending}
|
||||||
|
onClick={() => approveAllMutation.mutate()}
|
||||||
|
>
|
||||||
|
Approve All
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{counts.imported + counts.failed > 0 ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
id="auto-import-clear-completed"
|
||||||
|
disabled={clearMutation.isPending}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => clearMutation.mutate()}
|
||||||
|
>
|
||||||
|
Clear History
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</OptionButtonGroup>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={styles.autoImportResults} id="auto-import-results">
|
||||||
|
{resultsError ? (
|
||||||
|
<Notice tone="danger" role="alert">
|
||||||
|
Failed to load imports: {resultsError}
|
||||||
|
</Notice>
|
||||||
|
) : allResults.length === 0 ? (
|
||||||
|
<div className={styles.autoImportEmpty}>
|
||||||
|
<p>No imports yet. Drop album folders or single tracks into your import folder.</p>
|
||||||
|
</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div className={styles.autoImportEmpty}>
|
||||||
|
<p>No {autoFilter === 'pending' ? 'pending review' : autoFilter} items.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
results.map((result, index) => (
|
||||||
|
<AutoImportResultCard
|
||||||
|
key={result.id}
|
||||||
|
expanded={expandedRows.has(result.id)}
|
||||||
|
index={index}
|
||||||
|
approvePending={approveMutation.isPending}
|
||||||
|
rejectPending={rejectMutation.isPending}
|
||||||
|
result={result}
|
||||||
|
status={statusQuery.data}
|
||||||
|
onApprove={() => approveMutation.mutate(result.id)}
|
||||||
|
onReject={() => rejectMutation.mutate(result.id)}
|
||||||
|
onToggle={() => {
|
||||||
|
setExpandedRows((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(result.id)) next.delete(result.id);
|
||||||
|
else next.add(result.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutoImportResultCard({
|
||||||
|
approvePending,
|
||||||
|
expanded,
|
||||||
|
index,
|
||||||
|
rejectPending,
|
||||||
|
result,
|
||||||
|
status,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
approvePending: boolean;
|
||||||
|
expanded: boolean;
|
||||||
|
index: number;
|
||||||
|
rejectPending: boolean;
|
||||||
|
result: ImportAutoImportResult;
|
||||||
|
status: ImportAutoImportStatusPayload | undefined;
|
||||||
|
onApprove: () => void;
|
||||||
|
onReject: () => void;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const confidencePercent = Math.round((result.confidence || 0) * 100);
|
||||||
|
const confidenceClass = getConfidenceClass(confidencePercent);
|
||||||
|
const statusMeta = getAutoImportStatusMeta(result.status);
|
||||||
|
const liveActive = status?.active_imports?.find(
|
||||||
|
(item) => item.folder_hash === result.folder_hash,
|
||||||
|
);
|
||||||
|
const isLiveProcessing = result.status === 'processing' && liveActive?.status === 'processing';
|
||||||
|
const liveTrackIndex = isLiveProcessing ? liveActive?.track_index || 0 : 0;
|
||||||
|
const liveTrackTotal = isLiveProcessing ? liveActive?.track_total || 0 : 0;
|
||||||
|
const liveTrackName = isLiveProcessing ? liveActive?.track_name || '' : '';
|
||||||
|
const matchData = parseAutoImportMatchData(result.match_data);
|
||||||
|
const trackDetails =
|
||||||
|
matchData.matches?.map((match) => ({
|
||||||
|
name: match.track_name || match.track?.name || 'Unknown',
|
||||||
|
file: match.file ? match.file.split(/[/\\]/).pop() || '?' : '?',
|
||||||
|
confidence: Math.round((match.confidence || 0) * 100),
|
||||||
|
})) ?? [];
|
||||||
|
const matchSummary =
|
||||||
|
isLiveProcessing && liveTrackTotal > 0
|
||||||
|
? `track ${liveTrackIndex}/${liveTrackTotal}: ${liveTrackName}`
|
||||||
|
: matchData.total_tracks && matchData.total_tracks > 0
|
||||||
|
? `${matchData.matched_count || 0}/${matchData.total_tracks} tracks`
|
||||||
|
: `${result.total_files || 0} files`;
|
||||||
|
const methodLabel = getMethodLabel(result.identification_method);
|
||||||
|
const timeAgo = getAutoImportTimeAgo(result.created_at);
|
||||||
|
const statusCardClass = getAutoImportCardClass(statusMeta.className);
|
||||||
|
const statusBadgeClass = getAutoImportBadgeClass(statusMeta.className);
|
||||||
|
const confidenceFillClass = getAutoImportConfidenceClass(confidenceClass);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.autoImportCard, statusCardClass)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onToggle}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
onToggle();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={styles.autoImportCardTop}>
|
||||||
|
<div className={styles.autoImportCardLeft}>
|
||||||
|
{result.image_url ? (
|
||||||
|
<img
|
||||||
|
className={styles.autoImportCardArt}
|
||||||
|
src={result.image_url}
|
||||||
|
alt=""
|
||||||
|
onError={fallbackImage}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={styles.autoImportCardArtFallback}>💿</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportCardCenter}>
|
||||||
|
<div className={styles.autoImportCardAlbum}>
|
||||||
|
{result.album_name || result.folder_name}
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportCardArtist}>
|
||||||
|
{result.artist_name || 'Unknown Artist'}
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportCardMeta}>
|
||||||
|
<span>{matchSummary}</span>
|
||||||
|
{methodLabel ? (
|
||||||
|
<span className={styles.autoImportMethodBadge}>{methodLabel}</span>
|
||||||
|
) : null}
|
||||||
|
{timeAgo ? <span>{timeAgo}</span> : null}
|
||||||
|
</div>
|
||||||
|
{result.error_message ? (
|
||||||
|
<div className={styles.autoImportCardError}>{result.error_message}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportCardRight}>
|
||||||
|
<div className={clsx(styles.autoImportStatusBadge, statusBadgeClass)}>
|
||||||
|
{statusMeta.icon} {statusMeta.label}
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportConfidenceBar}>
|
||||||
|
<div
|
||||||
|
className={clsx(styles.autoImportConfidenceFill, confidenceFillClass)}
|
||||||
|
style={{ width: `${confidencePercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportConfidenceText}>{confidencePercent}% confidence</div>
|
||||||
|
{result.status === 'pending_review' ? (
|
||||||
|
<div className={styles.autoImportActions}>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={approvePending}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onApprove();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Approve & Import
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={rejectPending}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onReject();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.autoImportCardFolderPath}>{result.folder_name}</div>
|
||||||
|
{trackDetails.length > 0 ? (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.autoImportTrackList, {
|
||||||
|
[styles.expanded]: expanded,
|
||||||
|
})}
|
||||||
|
id={`auto-import-tracks-${index}`}
|
||||||
|
>
|
||||||
|
<div className={styles.autoImportTrackListHeader}>
|
||||||
|
<span>Track</span>
|
||||||
|
<span>Matched File</span>
|
||||||
|
<span>Conf</span>
|
||||||
|
</div>
|
||||||
|
{trackDetails.map((track, trackIndex) => {
|
||||||
|
const rowClassName = clsx(styles.autoImportTrackRow, {
|
||||||
|
[styles.autoImportTrackRowActive]:
|
||||||
|
isLiveProcessing && liveTrackIndex > 0 && trackIndex + 1 === liveTrackIndex,
|
||||||
|
[styles.autoImportTrackRowDone]:
|
||||||
|
isLiveProcessing && liveTrackIndex > 0 && trackIndex + 1 < liveTrackIndex,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div key={`${track.name}-${track.file}-${trackIndex}`} className={rowClassName}>
|
||||||
|
<span className={styles.autoImportTrackName}>{track.name}</span>
|
||||||
|
<span className={styles.autoImportTrackFile}>{track.file}</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
styles.autoImportTrackConf,
|
||||||
|
getAutoImportConfidenceClass(getConfidenceClass(track.confidence)),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{track.confidence}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMutationError(error: unknown) {
|
||||||
|
window.showToast?.(getErrorMessage(error), 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoImportCardClass(status: string): string {
|
||||||
|
const classes: Record<string, string> = {
|
||||||
|
completed: styles.autoImportCompleted,
|
||||||
|
review: styles.autoImportReview,
|
||||||
|
failed: styles.autoImportFailed,
|
||||||
|
processing: styles.autoImportProcessing,
|
||||||
|
};
|
||||||
|
|
||||||
|
return classes[status] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoImportBadgeClass(status: string): string {
|
||||||
|
const classes: Record<string, string> = {
|
||||||
|
completed: styles.autoImportBadgeCompleted,
|
||||||
|
review: styles.autoImportBadgeReview,
|
||||||
|
failed: styles.autoImportBadgeFailed,
|
||||||
|
neutral: styles.autoImportBadgeNeutral,
|
||||||
|
processing: styles.autoImportBadgeProcessing,
|
||||||
|
};
|
||||||
|
|
||||||
|
return classes[status] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoImportConfidenceClass(status: string): string {
|
||||||
|
const classes: Record<string, string> = {
|
||||||
|
high: styles.autoImportConfHigh,
|
||||||
|
medium: styles.autoImportConfMedium,
|
||||||
|
low: styles.autoImportConfLow,
|
||||||
|
};
|
||||||
|
|
||||||
|
return classes[status] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoImportFilterLabel(filter: ImportAutoFilter): string {
|
||||||
|
switch (filter) {
|
||||||
|
case 'all':
|
||||||
|
return 'All';
|
||||||
|
case 'pending':
|
||||||
|
return 'Needs Review';
|
||||||
|
case 'imported':
|
||||||
|
return 'Imported';
|
||||||
|
case 'failed':
|
||||||
|
return 'Failed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoImportFilterCount(
|
||||||
|
filter: ImportAutoFilter,
|
||||||
|
counts: ReturnType<typeof getAutoImportCounts>,
|
||||||
|
totalCount: number,
|
||||||
|
): number {
|
||||||
|
switch (filter) {
|
||||||
|
case 'all':
|
||||||
|
return totalCount;
|
||||||
|
case 'pending':
|
||||||
|
return counts.review;
|
||||||
|
case 'imported':
|
||||||
|
return counts.imported;
|
||||||
|
case 'failed':
|
||||||
|
return counts.failed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoImportFilterTone(
|
||||||
|
filter: ImportAutoFilter,
|
||||||
|
): 'neutral' | 'warning' | 'success' | 'danger' {
|
||||||
|
switch (filter) {
|
||||||
|
case 'pending':
|
||||||
|
return 'warning';
|
||||||
|
case 'imported':
|
||||||
|
return 'success';
|
||||||
|
case 'failed':
|
||||||
|
return 'danger';
|
||||||
|
case 'all':
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMethodLabel(method: string | null | undefined): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
tags: 'Tags',
|
||||||
|
folder_name: 'Folder Name',
|
||||||
|
acoustid: 'AcoustID',
|
||||||
|
filename: 'Filename',
|
||||||
|
};
|
||||||
|
return method ? labels[method] || method : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAction({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
if (window.showConfirmDialog) {
|
||||||
|
return await window.showConfirmDialog({ title, message, confirmText });
|
||||||
|
}
|
||||||
|
return window.confirm(message);
|
||||||
|
}
|
||||||
1825
webui/src/routes/import/-ui/import-page.module.css
Normal file
1825
webui/src/routes/import/-ui/import-page.module.css
Normal file
File diff suppressed because it is too large
Load diff
208
webui/src/routes/import/-ui/import-page.tsx
Normal file
208
webui/src/routes/import/-ui/import-page.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { Link, Outlet } from '@tanstack/react-router';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import { Button } from '@/components/form/form';
|
||||||
|
import { Show } from '@/components/primitives';
|
||||||
|
import { useReactPageShell } from '@/platform/shell/route-controllers';
|
||||||
|
|
||||||
|
import type { ImportQueueEntry } from '../-import.types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getQueueProgressPercent,
|
||||||
|
getQueueStatusText,
|
||||||
|
getStagingStatsText,
|
||||||
|
} from '../-import.helpers';
|
||||||
|
import { useImportQueueWorkflow } from '../-import.store';
|
||||||
|
import styles from './import-page.module.css';
|
||||||
|
import { fallbackImage, RefreshIcon, useImportStaging } from './import-shared';
|
||||||
|
|
||||||
|
export function ImportPage() {
|
||||||
|
useReactPageShell('import');
|
||||||
|
|
||||||
|
const { refreshStaging, stagingFiles, stagingPath, stagingQuery } = useImportStaging();
|
||||||
|
const isRefreshing = stagingQuery.isRefetching;
|
||||||
|
const lastRefreshedAt =
|
||||||
|
stagingQuery.dataUpdatedAt > 0 ? formatShortTime(stagingQuery.dataUpdatedAt) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="import-page" data-testid="import-page">
|
||||||
|
<div className={styles.importPageContainer}>
|
||||||
|
<ImportHeader
|
||||||
|
error={stagingQuery.error}
|
||||||
|
fileCountText={getStagingStatsText(stagingFiles)}
|
||||||
|
loading={stagingQuery.isLoading}
|
||||||
|
stagingPath={stagingPath}
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
lastRefreshedAt={lastRefreshedAt}
|
||||||
|
onRefresh={refreshStaging}
|
||||||
|
/>
|
||||||
|
<ImportProcessingQueue />
|
||||||
|
<ImportTabNav />
|
||||||
|
<section className={clsx(styles.importPageTabContent, styles.active)}>
|
||||||
|
<Outlet />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortTime(timestamp: number) {
|
||||||
|
return new Date(timestamp).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportHeader({
|
||||||
|
error,
|
||||||
|
fileCountText,
|
||||||
|
loading,
|
||||||
|
stagingPath,
|
||||||
|
refreshing,
|
||||||
|
lastRefreshedAt,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
error: unknown;
|
||||||
|
fileCountText: string;
|
||||||
|
loading: boolean;
|
||||||
|
stagingPath: string;
|
||||||
|
refreshing: boolean;
|
||||||
|
lastRefreshedAt: string | null;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className={styles.importPageHeader}>
|
||||||
|
<div className={styles.importPageTitleRow}>
|
||||||
|
<h1 className={styles.importPageTitle}>
|
||||||
|
<img src="/static/import.png" className="page-header-icon" alt="" />
|
||||||
|
<span>Import Music</span>
|
||||||
|
</h1>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
title="Re-scan import folder"
|
||||||
|
aria-busy={refreshing}
|
||||||
|
disabled={refreshing}
|
||||||
|
onClick={onRefresh}
|
||||||
|
>
|
||||||
|
<RefreshIcon />
|
||||||
|
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageStagingBar} id="import-staging-bar">
|
||||||
|
<span className={styles.importStagingPath} id="import-page-staging-path">
|
||||||
|
{error ? 'Import folder: error' : `Import: ${stagingPath}`}
|
||||||
|
</span>
|
||||||
|
<Show when={lastRefreshedAt != null}>
|
||||||
|
<span className={styles.importStagingRefreshAt}>
|
||||||
|
{lastRefreshedAt ? `Last refreshed: ${lastRefreshedAt}` : null}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
<span className={styles.importStagingStats} id="import-page-staging-stats">
|
||||||
|
{loading ? 'loading...' : fileCountText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportProcessingQueue() {
|
||||||
|
const { clearFinishedJobs, queue } = useImportQueueWorkflow();
|
||||||
|
const hasFinished = queue.some((entry) => entry.status !== 'running');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={clsx(styles.importPageQueue, {
|
||||||
|
[styles.hidden]: queue.length === 0,
|
||||||
|
})}
|
||||||
|
id="import-page-queue"
|
||||||
|
>
|
||||||
|
<div className={styles.importPageQueueHeader}>
|
||||||
|
<span className={styles.importPageQueueTitle}>Processing</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
id="import-page-queue-clear"
|
||||||
|
style={{ display: hasFinished ? undefined : 'none' }}
|
||||||
|
onClick={clearFinishedJobs}
|
||||||
|
>
|
||||||
|
Clear finished
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageQueueList} id="import-page-queue-list">
|
||||||
|
{queue.map((entry) => (
|
||||||
|
<ImportQueueItem key={entry.id} entry={entry} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportQueueItem({ entry }: { entry: ImportQueueEntry }) {
|
||||||
|
const statusText = getQueueStatusText(entry);
|
||||||
|
const statusClass = clsx({
|
||||||
|
[styles.error]:
|
||||||
|
entry.status === 'error' || (entry.status === 'done' && entry.errors.length > 0),
|
||||||
|
[styles.done]: entry.status === 'done',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.importPageQueueItem}>
|
||||||
|
{entry.imageUrl ? (
|
||||||
|
<img
|
||||||
|
className={styles.importPageQueueArt}
|
||||||
|
src={entry.imageUrl}
|
||||||
|
alt=""
|
||||||
|
onError={fallbackImage}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={clsx(styles.importPageQueueArt, styles.importPageQueueArtEmpty)}>♪</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.importPageQueueInfo}>
|
||||||
|
<div className={styles.importPageQueueName}>{entry.label}</div>
|
||||||
|
<div className={styles.importPageQueueDetail}>{entry.sublabel}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageQueueProgress}>
|
||||||
|
<div className={styles.importPageQueueBar}>
|
||||||
|
<div
|
||||||
|
className={clsx(styles.importPageQueueFill, {
|
||||||
|
[styles.error]: entry.status === 'error',
|
||||||
|
})}
|
||||||
|
style={{ width: `${getQueueProgressPercent(entry)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={clsx(styles.importPageQueueStatus, statusClass)}>{statusText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportTabNav() {
|
||||||
|
return (
|
||||||
|
<nav className={styles.importPageTabBar} aria-label="Import modes">
|
||||||
|
<Link
|
||||||
|
to="/import/auto"
|
||||||
|
className={styles.importPageTab}
|
||||||
|
activeProps={{ className: clsx(styles.importPageTab, styles.active) }}
|
||||||
|
id="import-page-tab-auto"
|
||||||
|
>
|
||||||
|
Auto
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/import/album"
|
||||||
|
className={styles.importPageTab}
|
||||||
|
activeProps={{ className: clsx(styles.importPageTab, styles.active) }}
|
||||||
|
id="import-page-tab-album"
|
||||||
|
>
|
||||||
|
Albums
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/import/singles"
|
||||||
|
className={styles.importPageTab}
|
||||||
|
activeProps={{ className: clsx(styles.importPageTab, styles.active) }}
|
||||||
|
id="import-page-tab-singles"
|
||||||
|
>
|
||||||
|
Singles
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
webui/src/routes/import/-ui/import-shared.tsx
Normal file
109
webui/src/routes/import/-ui/import-shared.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import type { ImportQueueJob, ImportStagingFile } from '../-import.types';
|
||||||
|
|
||||||
|
import {
|
||||||
|
importStagingFilesQueryOptions,
|
||||||
|
invalidateImportStagingQueries,
|
||||||
|
processImportAlbumTrack,
|
||||||
|
processImportSingleFile,
|
||||||
|
} from '../-import.api';
|
||||||
|
import { getTrackDisplayInfo, IMPORT_PLACEHOLDER_IMAGE } from '../-import.helpers';
|
||||||
|
import { useImportQueueWorkflow, useImportWorkflowStore } from '../-import.store';
|
||||||
|
|
||||||
|
const EMPTY_STAGING_FILES: ImportStagingFile[] = [];
|
||||||
|
|
||||||
|
export function useImportStaging() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const clearFinishedJobs = useImportWorkflowStore((state) => state.clearFinishedJobs);
|
||||||
|
const stagingQuery = useQuery({
|
||||||
|
...importStagingFilesQueryOptions(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
refreshStaging: async () => {
|
||||||
|
clearFinishedJobs();
|
||||||
|
await invalidateImportStagingQueries(queryClient);
|
||||||
|
},
|
||||||
|
// Keep the empty fallback stable so staging-driven effects do not loop while loading.
|
||||||
|
stagingFiles: stagingQuery.data?.files ?? EMPTY_STAGING_FILES,
|
||||||
|
stagingPath: stagingQuery.data?.staging_path || 'Not configured',
|
||||||
|
stagingQuery,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useImportQueueActions() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { enqueueQueueJob, updateQueueEntry } = useImportQueueWorkflow();
|
||||||
|
|
||||||
|
const runQueueJob = async (entryId: number, job: ImportQueueJob) => {
|
||||||
|
let processed = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < job.items.length; index += 1) {
|
||||||
|
const itemName =
|
||||||
|
job.type === 'album'
|
||||||
|
? getTrackDisplayInfo(job.items[index], index).name
|
||||||
|
: job.items[index].title || job.items[index].filename || `File ${index + 1}`;
|
||||||
|
|
||||||
|
updateQueueEntry(entryId, {
|
||||||
|
sublabel: `Processing ${index + 1}/${job.items.length}: ${itemName}`,
|
||||||
|
processed,
|
||||||
|
errors: [...errors],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload =
|
||||||
|
job.type === 'album'
|
||||||
|
? await processImportAlbumTrack({
|
||||||
|
album: job.albumData,
|
||||||
|
match: job.items[index],
|
||||||
|
})
|
||||||
|
: await processImportSingleFile(job.items[index]);
|
||||||
|
|
||||||
|
processed += payload.processed || 0;
|
||||||
|
if (payload.errors?.length) {
|
||||||
|
errors.push(...payload.errors);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`${itemName}: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateQueueEntry(entryId, {
|
||||||
|
processed,
|
||||||
|
errors: [...errors],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateQueueEntry(entryId, {
|
||||||
|
status: errors.length > 0 && processed === 0 ? 'error' : 'done',
|
||||||
|
processed,
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
void invalidateImportStagingQueries(queryClient);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
addQueueJob: (job: ImportQueueJob) => {
|
||||||
|
const id = enqueueQueueJob(job);
|
||||||
|
void runQueueJob(id, job);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RefreshIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M13.65 2.35A8 8 0 1 0 16 8h-2a6 6 0 1 1-1.76-4.24L10 6h6V0l-2.35 2.35z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fallbackImage(event: { currentTarget: HTMLImageElement }) {
|
||||||
|
if (event.currentTarget.src.endsWith(IMPORT_PLACEHOLDER_IMAGE)) return;
|
||||||
|
event.currentTarget.src = IMPORT_PLACEHOLDER_IMAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
}
|
||||||
336
webui/src/routes/import/-ui/singles-import-tab.tsx
Normal file
336
webui/src/routes/import/-ui/singles-import-tab.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { Button, Checkbox, TextInput } from '@/components/form/form';
|
||||||
|
import { Badge, Notice } from '@/components/primitives';
|
||||||
|
|
||||||
|
import type { SingleSearchState } from '../-import.store';
|
||||||
|
import type { ImportTrackResult } from '../-import.types';
|
||||||
|
import type { ImportStagingFile } from '../-import.types';
|
||||||
|
|
||||||
|
import { searchImportTracks } from '../-import.api';
|
||||||
|
import { formatDuration, getStagingFileKey } from '../-import.helpers';
|
||||||
|
import { useSinglesImportWorkflow } from '../-import.store';
|
||||||
|
import styles from './import-page.module.css';
|
||||||
|
import {
|
||||||
|
fallbackImage,
|
||||||
|
getErrorMessage,
|
||||||
|
useImportQueueActions,
|
||||||
|
useImportStaging,
|
||||||
|
} from './import-shared';
|
||||||
|
|
||||||
|
export function SinglesImportTab() {
|
||||||
|
const { refreshStaging, stagingFiles } = useImportStaging();
|
||||||
|
const { addQueueJob } = useImportQueueActions();
|
||||||
|
const {
|
||||||
|
clearSinglesSelection,
|
||||||
|
ensureSingleSearch,
|
||||||
|
openSingleSearch,
|
||||||
|
selectedSingles,
|
||||||
|
selectSingleMatchInStore,
|
||||||
|
setOpenSingleSearch,
|
||||||
|
setSingleSearch,
|
||||||
|
singleSearches,
|
||||||
|
singlesManualMatches,
|
||||||
|
syncSinglesWorkflow,
|
||||||
|
toggleAllSingles,
|
||||||
|
toggleSingleInStore,
|
||||||
|
} = useSinglesImportWorkflow();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
syncSinglesWorkflow(stagingFiles);
|
||||||
|
}, [stagingFiles, syncSinglesWorkflow]);
|
||||||
|
|
||||||
|
const openSingleSearchPanel = (file: ImportStagingFile) => {
|
||||||
|
const fileKey = getStagingFileKey(file);
|
||||||
|
if (openSingleSearch === fileKey) {
|
||||||
|
setOpenSingleSearch(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpenSingleSearch(fileKey);
|
||||||
|
const defaultQuery =
|
||||||
|
[file?.artist, file?.title].filter(Boolean).join(' ') ||
|
||||||
|
(file?.filename || '').replace(/\.[^.]+$/, '');
|
||||||
|
ensureSingleSearch(fileKey, defaultQuery);
|
||||||
|
if (defaultQuery && !singleSearches[fileKey]?.results.length) {
|
||||||
|
void runSingleSearch(fileKey, defaultQuery);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSingleSearch = async (fileKey: string, query: string) => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
setSingleSearch(fileKey, (current) => ({
|
||||||
|
query: trimmed,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
results: current.results,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await searchImportTracks(trimmed);
|
||||||
|
setSingleSearch(fileKey, {
|
||||||
|
query: trimmed,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
results: payload.tracks ?? [],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setSingleSearch(fileKey, {
|
||||||
|
query: trimmed,
|
||||||
|
loading: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
results: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSingleMatch = (fileKey: string, track: ImportTrackResult) => {
|
||||||
|
selectSingleMatchInStore(fileKey, track);
|
||||||
|
};
|
||||||
|
|
||||||
|
const processSingles = () => {
|
||||||
|
const filesToProcess = stagingFiles.flatMap((file) => {
|
||||||
|
const fileKey = getStagingFileKey(file);
|
||||||
|
if (!selectedSingles.has(fileKey)) return [];
|
||||||
|
const manualMatch = singlesManualMatches[fileKey];
|
||||||
|
return manualMatch ? [{ ...file, manual_match: manualMatch }] : [file];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filesToProcess.length === 0) return;
|
||||||
|
|
||||||
|
addQueueJob({
|
||||||
|
type: 'singles',
|
||||||
|
label: `${filesToProcess.length} Single${filesToProcess.length === 1 ? '' : 's'}`,
|
||||||
|
sublabel:
|
||||||
|
filesToProcess
|
||||||
|
.map((file) => file.title || file.filename)
|
||||||
|
.slice(0, 3)
|
||||||
|
.join(', ') + (filesToProcess.length > 3 ? '...' : ''),
|
||||||
|
imageUrl: null,
|
||||||
|
items: filesToProcess,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearSinglesSelection();
|
||||||
|
void refreshStaging();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SinglesImportPanel
|
||||||
|
files={stagingFiles}
|
||||||
|
manualMatches={singlesManualMatches}
|
||||||
|
openSearchKey={openSingleSearch}
|
||||||
|
searchStates={singleSearches}
|
||||||
|
selected={selectedSingles}
|
||||||
|
onOpenSearch={openSingleSearchPanel}
|
||||||
|
onProcessSingles={processSingles}
|
||||||
|
onRunSearch={runSingleSearch}
|
||||||
|
onSearchQueryChange={(fileKey, query) => {
|
||||||
|
setSingleSearch(fileKey, (current) => ({
|
||||||
|
query,
|
||||||
|
loading: current.loading,
|
||||||
|
error: current.error,
|
||||||
|
results: current.results,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
onSelectAll={() => toggleAllSingles(stagingFiles)}
|
||||||
|
onSelectMatch={selectSingleMatch}
|
||||||
|
onToggleSingle={toggleSingleInStore}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SinglesImportPanel({
|
||||||
|
files,
|
||||||
|
manualMatches,
|
||||||
|
openSearchKey,
|
||||||
|
searchStates,
|
||||||
|
selected,
|
||||||
|
onOpenSearch,
|
||||||
|
onProcessSingles,
|
||||||
|
onRunSearch,
|
||||||
|
onSearchQueryChange,
|
||||||
|
onSelectAll,
|
||||||
|
onSelectMatch,
|
||||||
|
onToggleSingle,
|
||||||
|
}: {
|
||||||
|
files: ImportStagingFile[];
|
||||||
|
manualMatches: Record<string, ImportTrackResult>;
|
||||||
|
openSearchKey: string | null;
|
||||||
|
searchStates: Record<string, SingleSearchState>;
|
||||||
|
selected: Set<string>;
|
||||||
|
onOpenSearch: (file: ImportStagingFile) => void;
|
||||||
|
onProcessSingles: () => void;
|
||||||
|
onRunSearch: (fileKey: string, query: string) => void;
|
||||||
|
onSearchQueryChange: (fileKey: string, query: string) => void;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onSelectMatch: (fileKey: string, track: ImportTrackResult) => void;
|
||||||
|
onToggleSingle: (fileKey: string) => void;
|
||||||
|
}) {
|
||||||
|
const selectedCount = files.filter((file) => selected.has(getStagingFileKey(file))).length;
|
||||||
|
const allSelected = files.length > 0 && selectedCount === files.length;
|
||||||
|
const processVariant = selectedCount > 0 ? 'primary' : 'secondary';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.importPageSinglesHeader}>
|
||||||
|
<div className={styles.importPageSinglesActions}>
|
||||||
|
<Button variant="secondary" onClick={onSelectAll}>
|
||||||
|
<span id="import-page-select-all-text">
|
||||||
|
{allSelected ? 'Deselect All' : 'Select All'}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={processVariant}
|
||||||
|
id="import-page-singles-process-btn"
|
||||||
|
disabled={selectedCount === 0}
|
||||||
|
onClick={onProcessSingles}
|
||||||
|
>
|
||||||
|
<span>Process Selected</span>
|
||||||
|
<Badge>{selectedCount}</Badge>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageSinglesList} id="import-page-singles-list">
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<div className={styles.importPageEmptyState}>No audio files found in import folder</div>
|
||||||
|
) : (
|
||||||
|
files.map((file) => {
|
||||||
|
const fileKey = getStagingFileKey(file);
|
||||||
|
const manualMatch = manualMatches[fileKey];
|
||||||
|
const isSelected = selected.has(fileKey);
|
||||||
|
const searchState = searchStates[fileKey];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={fileKey}
|
||||||
|
className={clsx(styles.importPageSingleItem, {
|
||||||
|
[styles.matched]: manualMatch,
|
||||||
|
})}
|
||||||
|
data-single-key={fileKey}
|
||||||
|
>
|
||||||
|
<label className={styles.importPageSingleCheckboxWrap}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
aria-label={`Select ${file.filename}`}
|
||||||
|
onCheckedChange={() => onToggleSingle(fileKey)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className={styles.importPageSingleInfo}>
|
||||||
|
<div className={styles.importPageSingleFilename}>{file.filename}</div>
|
||||||
|
<div className={styles.importPageSingleMeta}>
|
||||||
|
{file.title ? <span>{file.title}</span> : null}
|
||||||
|
{file.artist ? <span>{file.artist}</span> : null}
|
||||||
|
{file.extension ? <span>{file.extension}</span> : null}
|
||||||
|
</div>
|
||||||
|
{manualMatch ? (
|
||||||
|
<div className={styles.importPageSingleMatchedInfo}>
|
||||||
|
✓ {manualMatch.name} - {manualMatch.artist}
|
||||||
|
<button
|
||||||
|
className={styles.importPageSingleMatchedChange}
|
||||||
|
data-import-page-inline-action
|
||||||
|
onClick={() => onOpenSearch(file)}
|
||||||
|
>
|
||||||
|
change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageSingleActions}>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onOpenSearch(file)}>
|
||||||
|
🔍 Identify
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{openSearchKey === fileKey ? (
|
||||||
|
<SingleSearchPanel
|
||||||
|
fileKey={fileKey}
|
||||||
|
searchState={searchState}
|
||||||
|
onQueryChange={onSearchQueryChange}
|
||||||
|
onRunSearch={onRunSearch}
|
||||||
|
onSelectMatch={onSelectMatch}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SingleSearchPanel({
|
||||||
|
fileKey,
|
||||||
|
searchState,
|
||||||
|
onQueryChange,
|
||||||
|
onRunSearch,
|
||||||
|
onSelectMatch,
|
||||||
|
}: {
|
||||||
|
fileKey: string;
|
||||||
|
searchState: SingleSearchState | undefined;
|
||||||
|
onQueryChange: (fileKey: string, query: string) => void;
|
||||||
|
onRunSearch: (fileKey: string, query: string) => void;
|
||||||
|
onSelectMatch: (fileKey: string, track: ImportTrackResult) => void;
|
||||||
|
}) {
|
||||||
|
const query = searchState?.query ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.importPageSingleSearchPanel}>
|
||||||
|
<div className={styles.importPageSingleSearchBar}>
|
||||||
|
<TextInput
|
||||||
|
type="text"
|
||||||
|
className={styles.importPageSingleSearchInput}
|
||||||
|
value={query}
|
||||||
|
placeholder="Search artist - title..."
|
||||||
|
onChange={(event) => onQueryChange(fileKey, event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') onRunSearch(fileKey, query);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant="primary" onClick={() => onRunSearch(fileKey, query)}>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageSingleSearchResults}>
|
||||||
|
{searchState?.loading ? (
|
||||||
|
<div className={styles.importPageEmptyState}>Searching...</div>
|
||||||
|
) : searchState?.error ? (
|
||||||
|
<Notice tone="danger" role="alert">
|
||||||
|
Error: {searchState.error}
|
||||||
|
</Notice>
|
||||||
|
) : searchState?.results.length === 0 ? (
|
||||||
|
<div className={styles.importPageEmptyState}>No results found</div>
|
||||||
|
) : (
|
||||||
|
searchState?.results.map((track, index) => (
|
||||||
|
<button
|
||||||
|
key={`${track.source || 'source'}-${track.id}-${index}`}
|
||||||
|
className={styles.importPageSingleResultItem}
|
||||||
|
data-import-page-result-row
|
||||||
|
onClick={() => onSelectMatch(fileKey, track)}
|
||||||
|
>
|
||||||
|
{track.image_url ? (
|
||||||
|
<img
|
||||||
|
className={styles.importPageSingleResultImg}
|
||||||
|
src={track.image_url}
|
||||||
|
alt=""
|
||||||
|
onError={fallbackImage}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.importPageSingleResultInfo}>
|
||||||
|
<div className={styles.importPageSingleResultName}>
|
||||||
|
{track.name} - {track.artist}
|
||||||
|
</div>
|
||||||
|
<div className={styles.importPageSingleResultDetail}>
|
||||||
|
{track.album}
|
||||||
|
{track.duration_ms ? ` - ${formatDuration(track.duration_ms)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={styles.importPageSingleResultSelect}>Select</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
webui/src/routes/import/album.tsx
Normal file
15
webui/src/routes/import/album.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
import {
|
||||||
|
importStagingGroupsQueryOptions,
|
||||||
|
importStagingSuggestionsQueryOptions,
|
||||||
|
} from './-import.api';
|
||||||
|
import { AlbumImportTab } from './-ui/album-import-tab';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/import/album')({
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
void context.queryClient.prefetchQuery(importStagingGroupsQueryOptions());
|
||||||
|
void context.queryClient.prefetchQuery(importStagingSuggestionsQueryOptions());
|
||||||
|
},
|
||||||
|
component: AlbumImportTab,
|
||||||
|
});
|
||||||
27
webui/src/routes/import/auto.tsx
Normal file
27
webui/src/routes/import/auto.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
import type { ImportAutoFilter } from './-import.types';
|
||||||
|
|
||||||
|
import { importAutoSearchSchema } from './-import.types';
|
||||||
|
import { AutoImportPanel } from './-ui/auto-import-tab';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/import/auto')({
|
||||||
|
validateSearch: importAutoSearchSchema,
|
||||||
|
component: AutoImportRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
function AutoImportRoute() {
|
||||||
|
const navigate = useNavigate({ from: Route.fullPath });
|
||||||
|
const { autoFilter } = Route.useSearch();
|
||||||
|
|
||||||
|
const setAutoFilter = (nextFilter: ImportAutoFilter) => {
|
||||||
|
void navigate({
|
||||||
|
to: Route.fullPath,
|
||||||
|
search: (prev) => ({ ...prev, autoFilter: nextFilter }),
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AutoImportPanel autoFilter={autoFilter} onFilterChange={setAutoFilter} />;
|
||||||
|
}
|
||||||
7
webui/src/routes/import/index.tsx
Normal file
7
webui/src/routes/import/index.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/import/')({
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: '/import/album', replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
22
webui/src/routes/import/route.tsx
Normal file
22
webui/src/routes/import/route.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
import { getProfileHomePath } from '@/platform/shell/bridge';
|
||||||
|
|
||||||
|
import { importStagingFilesQueryOptions } from './-import.api';
|
||||||
|
import { ImportPage } from './-ui/import-page';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/import')({
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
const { bridge } = context.shell;
|
||||||
|
|
||||||
|
if (!bridge.isPageAllowed('import')) {
|
||||||
|
throw redirect({ href: getProfileHomePath(bridge), replace: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loader: ({ context }) => {
|
||||||
|
// Warm the staging query if possible, but never block the route on a transient fetch
|
||||||
|
// failure. The page owns the in-place error state for that case.
|
||||||
|
void context.queryClient.prefetchQuery(importStagingFilesQueryOptions());
|
||||||
|
},
|
||||||
|
component: ImportPage,
|
||||||
|
});
|
||||||
7
webui/src/routes/import/singles.tsx
Normal file
7
webui/src/routes/import/singles.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
import { SinglesImportTab } from './-ui/singles-import-tab';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/import/singles')({
|
||||||
|
component: SinglesImportTab,
|
||||||
|
});
|
||||||
|
|
@ -136,7 +136,11 @@ describe('issues route', () => {
|
||||||
it('renders stats and list items through the app router', async () => {
|
it('renders stats and list items through the app router', async () => {
|
||||||
renderIssuesRoute();
|
renderIssuesRoute();
|
||||||
await waitFor(() => expect(screen.getByTestId('issue-counts')).toHaveTextContent('2'));
|
await waitFor(() => expect(screen.getByTestId('issue-counts')).toHaveTextContent('2'));
|
||||||
expect(await screen.findByTestId('issue-card-7')).toHaveTextContent('Bad tags');
|
const issueCard = await screen.findByRole('link', { name: /Bad tags/i });
|
||||||
|
expect(issueCard).toHaveAttribute('href', expect.stringContaining('/issues?'));
|
||||||
|
expect(issueCard).toHaveAttribute('href', expect.stringContaining('status=open'));
|
||||||
|
expect(issueCard).toHaveAttribute('href', expect.stringContaining('category=all'));
|
||||||
|
expect(issueCard).toHaveAttribute('href', expect.stringContaining('issueId=7'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads the detail modal from the route search state', async () => {
|
it('loads the detail modal from the route search state', async () => {
|
||||||
|
|
@ -157,17 +161,22 @@ describe('issues route', () => {
|
||||||
|
|
||||||
it('opens and closes the detail modal', async () => {
|
it('opens and closes the detail modal', async () => {
|
||||||
const { history } = renderIssuesRoute();
|
const { history } = renderIssuesRoute();
|
||||||
fireEvent.click(await screen.findByTestId('issue-card-7'));
|
fireEvent.click(await screen.findByRole('link', { name: /Bad tags/i }));
|
||||||
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
|
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
|
||||||
await waitFor(() => expect(history.location.search).toContain('issueId=7'));
|
await waitFor(() => expect(history.location.search).toContain('issueId=7'));
|
||||||
fireEvent.click(screen.getByRole('button', { name: /close issue detail/i }));
|
const closeLink = screen.getByRole('link', { name: /^close$/i });
|
||||||
|
expect(closeLink).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
expect.stringContaining('/issues?status=open&category=all'),
|
||||||
|
);
|
||||||
|
fireEvent.click(closeLink);
|
||||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||||
await waitFor(() => expect(history.location.search).toBe('?status=open&category=all'));
|
await waitFor(() => expect(history.location.search).toBe('?status=open&category=all'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes the detail modal with Escape', async () => {
|
it('closes the detail modal with Escape', async () => {
|
||||||
const { history } = renderIssuesRoute();
|
const { history } = renderIssuesRoute();
|
||||||
fireEvent.click(await screen.findByTestId('issue-card-7'));
|
fireEvent.click(await screen.findByRole('link', { name: /Bad tags/i }));
|
||||||
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
|
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
|
||||||
await waitFor(() => expect(history.location.search).toContain('issueId=7'));
|
await waitFor(() => expect(history.location.search).toContain('issueId=7'));
|
||||||
|
|
||||||
|
|
@ -179,7 +188,7 @@ describe('issues route', () => {
|
||||||
|
|
||||||
it('focuses the detail modal close button on open', async () => {
|
it('focuses the detail modal close button on open', async () => {
|
||||||
renderIssuesRoute();
|
renderIssuesRoute();
|
||||||
fireEvent.click(await screen.findByTestId('issue-card-7'));
|
fireEvent.click(await screen.findByRole('link', { name: /Bad tags/i }));
|
||||||
|
|
||||||
const closeButton = await screen.findByRole('button', {
|
const closeButton = await screen.findByRole('button', {
|
||||||
name: /close issue detail/i,
|
name: /close issue detail/i,
|
||||||
|
|
@ -190,7 +199,7 @@ describe('issues route', () => {
|
||||||
|
|
||||||
it('invokes the shared workflow adapter for admin downloads', async () => {
|
it('invokes the shared workflow adapter for admin downloads', async () => {
|
||||||
renderIssuesRoute();
|
renderIssuesRoute();
|
||||||
fireEvent.click(await screen.findByTestId('issue-card-7'));
|
fireEvent.click(await screen.findByRole('link', { name: /Bad tags/i }));
|
||||||
fireEvent.click(await screen.findByRole('button', { name: /download album/i }));
|
fireEvent.click(await screen.findByRole('button', { name: /download album/i }));
|
||||||
await waitFor(() => expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalled());
|
await waitFor(() => expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalled());
|
||||||
expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalledWith(
|
expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalledWith(
|
||||||
|
|
|
||||||
|
|
@ -878,6 +878,43 @@
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modalLinkButton {
|
||||||
|
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;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
transform 0.18s ease,
|
||||||
|
border-color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
background 0.18s ease,
|
||||||
|
color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalLinkButton:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalLinkButton: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);
|
||||||
|
}
|
||||||
|
|
||||||
.modalButtonSecondary {
|
.modalButtonSecondary {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog';
|
import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog';
|
||||||
|
|
@ -22,6 +23,7 @@ import {
|
||||||
ISSUE_CATEGORY_META,
|
ISSUE_CATEGORY_META,
|
||||||
parseSnapshot,
|
parseSnapshot,
|
||||||
} from '../-issues.helpers';
|
} from '../-issues.helpers';
|
||||||
|
import { Route } from '../route';
|
||||||
import styles from './issue-detail-modal.module.css';
|
import styles from './issue-detail-modal.module.css';
|
||||||
|
|
||||||
export function IssueDetailModal({
|
export function IssueDetailModal({
|
||||||
|
|
@ -213,9 +215,14 @@ export function IssueDetailModal({
|
||||||
/>
|
/>
|
||||||
<DialogBody>{renderIssueDetailContent()}</DialogBody>
|
<DialogBody>{renderIssueDetailContent()}</DialogBody>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button className={styles.modalButtonSecondary} type="button" onClick={onClose}>
|
<Link
|
||||||
|
className={`${styles.modalLinkButton} ${styles.modalButtonSecondary}`}
|
||||||
|
replace
|
||||||
|
search={(prev) => ({ ...prev, issueId: undefined })}
|
||||||
|
to={Route.fullPath}
|
||||||
|
>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Link>
|
||||||
{issue && (
|
{issue && (
|
||||||
<>
|
<>
|
||||||
{statusButtons}
|
{statusButtons}
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
text-decoration: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
import { Select } from '@/components/form';
|
import { Select } from '@/components/form';
|
||||||
import { Show } from '@/components/primitives';
|
import { Show } from '@/components/primitives';
|
||||||
|
|
@ -73,13 +73,6 @@ function IssueBoard() {
|
||||||
...issueListQueryOptions(profileId, params),
|
...issueListQueryOptions(profileId, params),
|
||||||
});
|
});
|
||||||
|
|
||||||
const openIssue = (issueId: number) => {
|
|
||||||
void navigate({
|
|
||||||
to: Route.fullPath,
|
|
||||||
search: (prev) => ({ ...prev, issueId }),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCategoryChange = (category: IssuesSearch['category']) => {
|
const onCategoryChange = (category: IssuesSearch['category']) => {
|
||||||
void navigate({
|
void navigate({
|
||||||
to: Route.fullPath,
|
to: Route.fullPath,
|
||||||
|
|
@ -112,7 +105,6 @@ function IssueBoard() {
|
||||||
issuesError={issuesQuery.error}
|
issuesError={issuesQuery.error}
|
||||||
issuesLoading={issuesQuery.isLoading}
|
issuesLoading={issuesQuery.isLoading}
|
||||||
showReporterName={isAdmin}
|
showReporterName={isAdmin}
|
||||||
onIssueSelect={openIssue}
|
|
||||||
statusFilter={params.status}
|
statusFilter={params.status}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -225,7 +217,6 @@ function IssueBoardList({
|
||||||
issues,
|
issues,
|
||||||
issuesError,
|
issuesError,
|
||||||
issuesLoading,
|
issuesLoading,
|
||||||
onIssueSelect,
|
|
||||||
showReporterName,
|
showReporterName,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -233,7 +224,6 @@ function IssueBoardList({
|
||||||
issues: IssueRecord[];
|
issues: IssueRecord[];
|
||||||
issuesError: unknown;
|
issuesError: unknown;
|
||||||
issuesLoading: boolean;
|
issuesLoading: boolean;
|
||||||
onIssueSelect: (issueId: number) => void;
|
|
||||||
showReporterName: boolean;
|
showReporterName: boolean;
|
||||||
statusFilter: IssuesSearch['status'];
|
statusFilter: IssuesSearch['status'];
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -285,7 +275,6 @@ function IssueBoardList({
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
issue={issue}
|
issue={issue}
|
||||||
showReporterName={showReporterName}
|
showReporterName={showReporterName}
|
||||||
onIssueSelect={onIssueSelect}
|
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -294,11 +283,9 @@ function IssueBoardList({
|
||||||
function IssueBoardCard({
|
function IssueBoardCard({
|
||||||
issue,
|
issue,
|
||||||
showReporterName,
|
showReporterName,
|
||||||
onIssueSelect,
|
|
||||||
}: {
|
}: {
|
||||||
issue: IssueRecord;
|
issue: IssueRecord;
|
||||||
showReporterName: boolean;
|
showReporterName: boolean;
|
||||||
onIssueSelect: (issueId: number) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const snapshot = parseSnapshot(issue.snapshot_data);
|
const snapshot = parseSnapshot(issue.snapshot_data);
|
||||||
const artwork = getIssueArtwork(snapshot);
|
const artwork = getIssueArtwork(snapshot);
|
||||||
|
|
@ -311,11 +298,11 @@ function IssueBoardCard({
|
||||||
const createdDate = formatIssueDate(issue.created_at);
|
const createdDate = formatIssueDate(issue.created_at);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Link
|
||||||
className={styles.issueCard}
|
className={styles.issueCard}
|
||||||
type="button"
|
|
||||||
data-testid={`issue-card-${issue.id}`}
|
data-testid={`issue-card-${issue.id}`}
|
||||||
onClick={() => onIssueSelect(issue.id)}
|
to={Route.fullPath}
|
||||||
|
search={(prev) => ({ ...prev, issueId: issue.id })}
|
||||||
>
|
>
|
||||||
<div className={styles.issueCardLeft}>
|
<div className={styles.issueCardLeft}>
|
||||||
{artwork ? (
|
{artwork ? (
|
||||||
|
|
@ -360,7 +347,7 @@ function IssueBoardCard({
|
||||||
title={`${issue.priority} priority`}
|
title={`${issue.priority} priority`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,14 +117,29 @@ describe('stats route', () => {
|
||||||
await waitFor(() => expect(history.location.search).toContain('range=30d'));
|
await waitFor(() => expect(history.location.search).toContain('range=30d'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hands artist detail navigation directly to the shell bridge', async () => {
|
it('links artist names to the artist-detail route', async () => {
|
||||||
renderStatsRoute();
|
const { history } = renderStatsRoute();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: 'Artist A' }));
|
const bubbleLink = await screen.findByRole('link', {
|
||||||
|
name: 'Open artist detail for Artist A',
|
||||||
|
});
|
||||||
|
expect(bubbleLink).toHaveAttribute('href', '/artist-detail/library/7');
|
||||||
|
|
||||||
expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith(
|
const rankedLink = screen.getByRole('link', { name: 'Artist A' });
|
||||||
7,
|
expect(rankedLink).toHaveAttribute('href', '/artist-detail/library/7');
|
||||||
'Artist A',
|
|
||||||
|
fireEvent.click(bubbleLink);
|
||||||
|
|
||||||
|
await waitFor(() => expect(history.location.pathname).toBe('/artist-detail/library/7'));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith(
|
||||||
|
'7',
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
skipRouteChange: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -364,10 +364,13 @@
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font: inherit;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statsArtistBubble:disabled {
|
.statsArtistBubbleDisabled {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -375,6 +378,10 @@
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statsArtistBubbleDisabled:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.statsBubbleImage {
|
.statsBubbleImage {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
import { type ComponentPropsWithoutRef, type ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
|
|
@ -72,6 +72,8 @@ const STATS_CHART_CURSOR = {
|
||||||
fill: 'rgba(var(--accent-rgb), 0.12)',
|
fill: 'rgba(var(--accent-rgb), 0.12)',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const ARTIST_DETAIL_SOURCE = 'library' as const;
|
||||||
|
|
||||||
export function StatsPage() {
|
export function StatsPage() {
|
||||||
const bridge = useReactPageShell('stats');
|
const bridge = useReactPageShell('stats');
|
||||||
|
|
||||||
|
|
@ -136,10 +138,6 @@ export function StatsPage() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const openArtistDetail = (artistId: string | number, artistName: string) => {
|
|
||||||
bridge.navigateToArtistDetail(artistId, artistName);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="stats-container" className={styles.statsContainer} data-testid="stats-page">
|
<div id="stats-container" className={styles.statsContainer} data-testid="stats-page">
|
||||||
<header className={styles.statsHeader}>
|
<header className={styles.statsHeader}>
|
||||||
|
|
@ -229,25 +227,15 @@ export function StatsPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statsRightCol}>
|
<div className={styles.statsRightCol}>
|
||||||
<StatsSectionCard title="Top Artists">
|
<StatsSectionCard title="Top Artists">
|
||||||
<TopArtistsVisual
|
<TopArtistsVisual artists={cachedStats?.top_artists ?? []} />
|
||||||
artists={cachedStats?.top_artists ?? []}
|
<StatsRankedArtists artists={cachedStats?.top_artists ?? []} />
|
||||||
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
|
|
||||||
/>
|
|
||||||
<StatsRankedArtists
|
|
||||||
artists={cachedStats?.top_artists ?? []}
|
|
||||||
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
|
|
||||||
/>
|
|
||||||
</StatsSectionCard>
|
</StatsSectionCard>
|
||||||
<StatsSectionCard title="Top Albums">
|
<StatsSectionCard title="Top Albums">
|
||||||
<StatsRankedAlbums
|
<StatsRankedAlbums albums={cachedStats?.top_albums ?? []} />
|
||||||
albums={cachedStats?.top_albums ?? []}
|
|
||||||
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
|
|
||||||
/>
|
|
||||||
</StatsSectionCard>
|
</StatsSectionCard>
|
||||||
<StatsSectionCard title="Top Tracks">
|
<StatsSectionCard title="Top Tracks">
|
||||||
<StatsRankedTracks
|
<StatsRankedTracks
|
||||||
tracks={cachedStats?.top_tracks ?? []}
|
tracks={cachedStats?.top_tracks ?? []}
|
||||||
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
|
|
||||||
onPlay={(track) => playStatsTrack(bridge, track)}
|
onPlay={(track) => playStatsTrack(bridge, track)}
|
||||||
/>
|
/>
|
||||||
</StatsSectionCard>
|
</StatsSectionCard>
|
||||||
|
|
@ -405,13 +393,7 @@ function StatsGenreLegend({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TopArtistsVisual({
|
function TopArtistsVisual({ artists }: { artists: StatsArtistRow[] }) {
|
||||||
artists,
|
|
||||||
onArtistSelect,
|
|
||||||
}: {
|
|
||||||
artists: StatsArtistRow[];
|
|
||||||
onArtistSelect: (artistId: string | number, artistName: string) => void;
|
|
||||||
}) {
|
|
||||||
const topArtists = getTopArtistBubbles(artists);
|
const topArtists = getTopArtistBubbles(artists);
|
||||||
if (topArtists.length === 0) return null;
|
if (topArtists.length === 0) return null;
|
||||||
|
|
||||||
|
|
@ -420,18 +402,8 @@ function TopArtistsVisual({
|
||||||
<div className={styles.statsArtistBubbles}>
|
<div className={styles.statsArtistBubbles}>
|
||||||
{topArtists.map(({ artist, percent, size }) => {
|
{topArtists.map(({ artist, percent, size }) => {
|
||||||
const isClickable = artist.id !== null && artist.id !== undefined;
|
const isClickable = artist.id !== null && artist.id !== undefined;
|
||||||
return (
|
const bubbleContent = (
|
||||||
<button
|
<>
|
||||||
key={`${artist.name}-${artist.id ?? 'unknown'}`}
|
|
||||||
type="button"
|
|
||||||
className={styles.statsArtistBubble}
|
|
||||||
onClick={() => {
|
|
||||||
if (isClickable) {
|
|
||||||
onArtistSelect(artist.id as string | number, artist.name);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!isClickable}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={styles.statsBubbleImage}
|
className={styles.statsBubbleImage}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -451,7 +423,25 @@ function TopArtistsVisual({
|
||||||
<div className={styles.statsBubbleCount}>
|
<div className={styles.statsBubbleCount}>
|
||||||
{formatCompactNumber(artist.play_count)}
|
{formatCompactNumber(artist.play_count)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</>
|
||||||
|
);
|
||||||
|
return isClickable ? (
|
||||||
|
<ArtistDetailLink
|
||||||
|
key={`${artist.name}-${artist.id ?? 'unknown'}`}
|
||||||
|
artistId={artist.id}
|
||||||
|
className={styles.statsArtistBubble}
|
||||||
|
aria-label={`Open artist detail for ${artist.name}`}
|
||||||
|
>
|
||||||
|
{bubbleContent}
|
||||||
|
</ArtistDetailLink>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={`${artist.name}-${artist.id ?? 'unknown'}`}
|
||||||
|
className={`${styles.statsArtistBubble} ${styles.statsArtistBubbleDisabled}`}
|
||||||
|
aria-disabled="true"
|
||||||
|
>
|
||||||
|
{bubbleContent}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -459,13 +449,30 @@ function TopArtistsVisual({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatsRankedArtists({
|
function ArtistDetailLink({
|
||||||
artists,
|
artistId,
|
||||||
onArtistSelect,
|
children,
|
||||||
|
...linkProps
|
||||||
}: {
|
}: {
|
||||||
artists: StatsArtistRow[];
|
artistId: string | number | null | undefined;
|
||||||
onArtistSelect: (artistId: string | number, artistName: string) => void;
|
children: ReactNode;
|
||||||
}) {
|
} & Omit<ComponentPropsWithoutRef<'a'>, 'children' | 'href'>) {
|
||||||
|
if (artistId == null) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/artist-detail/$source/$id"
|
||||||
|
params={{ source: ARTIST_DETAIL_SOURCE, id: String(artistId) }}
|
||||||
|
{...linkProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsRankedArtists({ artists }: { artists: StatsArtistRow[] }) {
|
||||||
return (
|
return (
|
||||||
<div id="stats-top-artists" className={styles.statsRankedList}>
|
<div id="stats-top-artists" className={styles.statsRankedList}>
|
||||||
{artists.length === 0 ? <EmptyListState message="No data yet" /> : null}
|
{artists.length === 0 ? <EmptyListState message="No data yet" /> : null}
|
||||||
|
|
@ -479,17 +486,9 @@ function StatsRankedArtists({
|
||||||
)}
|
)}
|
||||||
<div className={styles.statsRankedInfo}>
|
<div className={styles.statsRankedInfo}>
|
||||||
<div className={styles.statsRankedName}>
|
<div className={styles.statsRankedName}>
|
||||||
{artist.id ? (
|
<ArtistDetailLink artistId={artist.id} className={styles.statsArtistLink}>
|
||||||
<button
|
{artist.name}
|
||||||
type="button"
|
</ArtistDetailLink>
|
||||||
className={styles.statsArtistLink}
|
|
||||||
onClick={() => onArtistSelect(artist.id as string | number, artist.name)}
|
|
||||||
>
|
|
||||||
{artist.name}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
artist.name
|
|
||||||
)}
|
|
||||||
{artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_') ? (
|
{artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_') ? (
|
||||||
<img src="/static/trans2.png" className={styles.statsSoulIdBadge} alt="SoulID" />
|
<img src="/static/trans2.png" className={styles.statsSoulIdBadge} alt="SoulID" />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -509,13 +508,7 @@ function StatsRankedArtists({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatsRankedAlbums({
|
function StatsRankedAlbums({ albums }: { albums: StatsAlbumRow[] }) {
|
||||||
albums,
|
|
||||||
onArtistSelect,
|
|
||||||
}: {
|
|
||||||
albums: StatsAlbumRow[];
|
|
||||||
onArtistSelect: (artistId: string | number, artistName: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div id="stats-top-albums" className={styles.statsRankedList}>
|
<div id="stats-top-albums" className={styles.statsRankedList}>
|
||||||
{albums.length === 0 ? <EmptyListState message="No data yet" /> : null}
|
{albums.length === 0 ? <EmptyListState message="No data yet" /> : null}
|
||||||
|
|
@ -530,19 +523,9 @@ function StatsRankedAlbums({
|
||||||
<div className={styles.statsRankedInfo}>
|
<div className={styles.statsRankedInfo}>
|
||||||
<div className={styles.statsRankedName}>{album.name}</div>
|
<div className={styles.statsRankedName}>{album.name}</div>
|
||||||
<div className={styles.statsRankedMeta}>
|
<div className={styles.statsRankedMeta}>
|
||||||
{album.artist_id ? (
|
<ArtistDetailLink artistId={album.artist_id} className={styles.statsArtistLink}>
|
||||||
<button
|
{album.artist || ''}
|
||||||
type="button"
|
</ArtistDetailLink>
|
||||||
className={styles.statsArtistLink}
|
|
||||||
onClick={() =>
|
|
||||||
onArtistSelect(album.artist_id as string | number, album.artist || '')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{album.artist || ''}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
album.artist || ''
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.statsRankedCount}>
|
<span className={styles.statsRankedCount}>
|
||||||
|
|
@ -556,11 +539,9 @@ function StatsRankedAlbums({
|
||||||
|
|
||||||
function StatsRankedTracks({
|
function StatsRankedTracks({
|
||||||
tracks,
|
tracks,
|
||||||
onArtistSelect,
|
|
||||||
onPlay,
|
onPlay,
|
||||||
}: {
|
}: {
|
||||||
tracks: StatsTrackRow[];
|
tracks: StatsTrackRow[];
|
||||||
onArtistSelect: (artistId: string | number, artistName: string) => void;
|
|
||||||
onPlay: (track: { title: string; artist: string; album: string }) => Promise<void>;
|
onPlay: (track: { title: string; artist: string; album: string }) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -577,19 +558,9 @@ function StatsRankedTracks({
|
||||||
<div className={styles.statsRankedInfo}>
|
<div className={styles.statsRankedInfo}>
|
||||||
<div className={styles.statsRankedName}>{track.name}</div>
|
<div className={styles.statsRankedName}>{track.name}</div>
|
||||||
<div className={styles.statsRankedMeta}>
|
<div className={styles.statsRankedMeta}>
|
||||||
{track.artist_id ? (
|
<ArtistDetailLink artistId={track.artist_id} className={styles.statsArtistLink}>
|
||||||
<button
|
{track.artist || ''}
|
||||||
type="button"
|
</ArtistDetailLink>
|
||||||
className={styles.statsArtistLink}
|
|
||||||
onClick={() =>
|
|
||||||
onArtistSelect(track.artist_id as string | number, track.artist || '')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{track.artist || ''}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
track.artist || ''
|
|
||||||
)}
|
|
||||||
{track.album ? ` · ${track.album}` : ''}
|
{track.album ? ` · ${track.album}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -568,7 +568,7 @@ async function fetchAndUpdateSystemStats() {
|
||||||
|
|
||||||
// Update all stat cards
|
// Update all stat cards
|
||||||
updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading');
|
updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading');
|
||||||
updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed this session');
|
updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed downloads');
|
||||||
updateStatCard('download-speed-card', data.download_speed, 'Combined speed');
|
updateStatCard('download-speed-card', data.download_speed, 'Combined speed');
|
||||||
updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing');
|
updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing');
|
||||||
updateStatCard('uptime-card', data.uptime, 'Application runtime');
|
updateStatCard('uptime-card', data.uptime, 'Application runtime');
|
||||||
|
|
|
||||||
|
|
@ -628,7 +628,7 @@ function unsubscribeFromDownloadBatch(batchId) {
|
||||||
function handleDashboardStats(data) {
|
function handleDashboardStats(data) {
|
||||||
// Same logic as fetchAndUpdateSystemStats response handler
|
// Same logic as fetchAndUpdateSystemStats response handler
|
||||||
updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading');
|
updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading');
|
||||||
updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed this session');
|
updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed downloads');
|
||||||
updateStatCard('download-speed-card', data.download_speed, 'Combined speed');
|
updateStatCard('download-speed-card', data.download_speed, 'Combined speed');
|
||||||
updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing');
|
updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing');
|
||||||
updateStatCard('uptime-card', data.uptime, 'Application runtime');
|
updateStatCard('uptime-card', data.uptime, 'Application runtime');
|
||||||
|
|
|
||||||
|
|
@ -2420,9 +2420,6 @@ async function loadPageData(pageId) {
|
||||||
loadApiKeys();
|
loadApiKeys();
|
||||||
loadBlacklistCount();
|
loadBlacklistCount();
|
||||||
break;
|
break;
|
||||||
case 'import':
|
|
||||||
initializeImportPage();
|
|
||||||
break;
|
|
||||||
case 'hydrabase':
|
case 'hydrabase':
|
||||||
// Check connection status and pre-fill saved credentials
|
// Check connection status and pre-fill saved credentials
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1383,6 +1383,54 @@ let npMediaSource = null;
|
||||||
let npVizAnimFrame = null;
|
let npVizAnimFrame = null;
|
||||||
let npVizInitialized = false;
|
let npVizInitialized = false;
|
||||||
|
|
||||||
|
function npQueueHasNext() {
|
||||||
|
if (npQueue.length === 0) return false;
|
||||||
|
return npShuffleOn
|
||||||
|
? npQueue.length > 1
|
||||||
|
: (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all');
|
||||||
|
}
|
||||||
|
|
||||||
|
function npEnsureCurrentTrackInQueue() {
|
||||||
|
if (!currentTrack || !currentTrack.is_library || npQueue.length > 0) return;
|
||||||
|
npQueue.push({
|
||||||
|
title: currentTrack.title,
|
||||||
|
artist: currentTrack.artist,
|
||||||
|
album: currentTrack.album,
|
||||||
|
file_path: currentTrack.filename || currentTrack.file_path,
|
||||||
|
filename: currentTrack.filename || currentTrack.file_path,
|
||||||
|
is_library: true,
|
||||||
|
image_url: currentTrack.image_url,
|
||||||
|
id: currentTrack.id,
|
||||||
|
artist_id: currentTrack.artist_id,
|
||||||
|
album_id: currentTrack.album_id,
|
||||||
|
bitrate: currentTrack.bitrate,
|
||||||
|
sample_rate: currentTrack.sample_rate
|
||||||
|
});
|
||||||
|
npQueueIndex = 0;
|
||||||
|
renderNpQueue();
|
||||||
|
updateNpPrevNextButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function npSetRadioMode(enabled, options = {}) {
|
||||||
|
const { toast = true, fetchIfNeeded = false } = options;
|
||||||
|
npRadioMode = Boolean(enabled);
|
||||||
|
const radioBtn = document.getElementById('np-radio-btn');
|
||||||
|
if (radioBtn) {
|
||||||
|
radioBtn.classList.toggle('active', npRadioMode);
|
||||||
|
radioBtn.setAttribute('aria-pressed', npRadioMode ? 'true' : 'false');
|
||||||
|
radioBtn.title = npRadioMode
|
||||||
|
? 'Radio mode on - similar tracks will auto-queue'
|
||||||
|
: 'Radio mode - auto-add similar tracks';
|
||||||
|
}
|
||||||
|
if (toast) {
|
||||||
|
showToast(npRadioMode ? 'Radio mode on - similar tracks will auto-queue' : 'Radio mode off', 'success');
|
||||||
|
}
|
||||||
|
if (npRadioMode && fetchIfNeeded && currentTrack && currentTrack.id && !npLoadingQueueItem && !npQueueHasNext()) {
|
||||||
|
npEnsureCurrentTrackInQueue();
|
||||||
|
npFetchRadioTracks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initExpandedPlayer() {
|
function initExpandedPlayer() {
|
||||||
const closeBtn = document.getElementById('np-close-btn');
|
const closeBtn = document.getElementById('np-close-btn');
|
||||||
const overlay = document.getElementById('np-modal-overlay');
|
const overlay = document.getElementById('np-modal-overlay');
|
||||||
|
|
@ -1462,40 +1510,9 @@ function initExpandedPlayer() {
|
||||||
const radioBtn = document.getElementById('np-radio-btn');
|
const radioBtn = document.getElementById('np-radio-btn');
|
||||||
if (radioBtn) {
|
if (radioBtn) {
|
||||||
radioBtn.addEventListener('click', () => {
|
radioBtn.addEventListener('click', () => {
|
||||||
npRadioMode = !npRadioMode;
|
npSetRadioMode(!npRadioMode, { fetchIfNeeded: true });
|
||||||
radioBtn.classList.toggle('active', npRadioMode);
|
|
||||||
showToast(npRadioMode ? 'Radio mode on — similar tracks will auto-queue' : 'Radio mode off', 'success');
|
|
||||||
// Immediately fetch radio tracks if turned on while playing with empty/exhausted queue
|
|
||||||
if (npRadioMode && currentTrack && currentTrack.id && !npLoadingQueueItem) {
|
|
||||||
const hasNext = npQueue.length > 0 && (npShuffleOn
|
|
||||||
? npQueue.length > 1
|
|
||||||
: (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all'));
|
|
||||||
if (!hasNext) {
|
|
||||||
// Add current track to queue first so it appears as "now playing" in context
|
|
||||||
if (npQueue.length === 0 && currentTrack.is_library) {
|
|
||||||
npQueue.push({
|
|
||||||
title: currentTrack.title,
|
|
||||||
artist: currentTrack.artist,
|
|
||||||
album: currentTrack.album,
|
|
||||||
file_path: currentTrack.filename || currentTrack.file_path,
|
|
||||||
filename: currentTrack.filename || currentTrack.file_path,
|
|
||||||
is_library: true,
|
|
||||||
image_url: currentTrack.image_url,
|
|
||||||
id: currentTrack.id,
|
|
||||||
artist_id: currentTrack.artist_id,
|
|
||||||
album_id: currentTrack.album_id,
|
|
||||||
bitrate: currentTrack.bitrate
|
|
||||||
});
|
|
||||||
npQueueIndex = 0;
|
|
||||||
renderNpQueue();
|
|
||||||
updateNpPrevNextButtons();
|
|
||||||
}
|
|
||||||
npFetchRadioTracks();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action link (Go to Artist)
|
// Action link (Go to Artist)
|
||||||
const gotoArtistBtn = document.getElementById('np-goto-artist');
|
const gotoArtistBtn = document.getElementById('np-goto-artist');
|
||||||
if (gotoArtistBtn) {
|
if (gotoArtistBtn) {
|
||||||
|
|
|
||||||
|
|
@ -1834,7 +1834,7 @@
|
||||||
/* --- Phase 9: Touch & Hover Adaptations --- */
|
/* --- Phase 9: Touch & Hover Adaptations --- */
|
||||||
|
|
||||||
/* Global touch targets - only standalone/action buttons, not inline */
|
/* Global touch targets - only standalone/action buttons, not inline */
|
||||||
button:not(.watchlist-card-remove):not(.wishlist-delete-btn):not(.wishlist-delete-album-btn):not(.wishlist-delete-btn-small):not(.wishlist-back-btn):not(.alphabet-btn):not(.filter-btn):not(.playlist-modal-close) {
|
button:not(.watchlist-card-remove):not(.wishlist-delete-btn):not(.wishlist-delete-album-btn):not(.wishlist-delete-btn-small):not(.wishlist-back-btn):not(.alphabet-btn):not(.filter-btn):not(.playlist-modal-close):not([data-import-page-result-row]):not([data-import-page-inline-action]) {
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2312,86 +2312,9 @@
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transition: opacity 0.1s ease;
|
transition: opacity 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Import Page - Touch fallback */
|
|
||||||
.import-page-file-chip {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-page-match-row {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Import Page — small screen */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.import-page-container {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-page-title {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-page-album-grid {
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-page-album-hero {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-page-album-hero img {
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-page-match-row {
|
|
||||||
grid-template-columns: 28px 1fr;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-page-match-file {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
padding-left: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-page-match-unmatch {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-page-singles-header {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-page-singles-actions {
|
|
||||||
width: 100%;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-page-single-item {
|
|
||||||
grid-template-columns: 28px 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-page-single-actions {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-page-single-search-panel {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-page-staging-bar {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Profile Picker - Mobile */
|
/* Profile Picker - Mobile */
|
||||||
.profile-picker-grid {
|
.profile-picker-grid {
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
|
|
||||||
|
|
@ -2278,7 +2278,9 @@ function _adlRender() {
|
||||||
else if (_adlFilter === 'completed') filtered = filtered.filter(d => completedStatuses.includes(d.status));
|
else if (_adlFilter === 'completed') filtered = filtered.filter(d => completedStatuses.includes(d.status));
|
||||||
else if (_adlFilter === 'failed') filtered = filtered.filter(d => failedStatuses.includes(d.status));
|
else if (_adlFilter === 'failed') filtered = filtered.filter(d => failedStatuses.includes(d.status));
|
||||||
|
|
||||||
const completedN = _adlData.filter(d => [...completedStatuses, ...failedStatuses].includes(d.status)).length;
|
const completedN = _adlData.filter(d =>
|
||||||
|
[...completedStatuses, ...failedStatuses].includes(d.status) && !d.is_persistent_history
|
||||||
|
).length;
|
||||||
|
|
||||||
if (countEl) {
|
if (countEl) {
|
||||||
const activeN = _adlData.filter(d => activeStatuses.includes(d.status)).length;
|
const activeN = _adlData.filter(d => activeStatuses.includes(d.status)).length;
|
||||||
|
|
|
||||||
|
|
@ -1190,9 +1190,7 @@ async function loadInitialData() {
|
||||||
if (route?.kind === 'react') {
|
if (route?.kind === 'react') {
|
||||||
showReactHost(targetPage);
|
showReactHost(targetPage);
|
||||||
setActivePageChrome(targetPage);
|
setActivePageChrome(targetPage);
|
||||||
if (window.location.pathname !== route.path) {
|
// Keep nested react-tab URLs like /import/auto or /import/singles intact.
|
||||||
history.replaceState({ page: targetPage }, '', route.path);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -60,6 +60,10 @@ function expectedUrlPattern(path: string, pageId: ShellPageId): RegExp {
|
||||||
return /\/stats(?:\?range=7d)?$/;
|
return /\/stats(?:\?range=7d)?$/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pageId === 'import') {
|
||||||
|
return /\/import\/album$/;
|
||||||
|
}
|
||||||
|
|
||||||
return new RegExp(`${path.replace('/', '\\/')}$`);
|
return new RegExp(`${path.replace('/', '\\/')}$`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue