feat(TagEditor): add remote metadata lookup UI

Phase 5 of service integration:
- Add MusicBrainz/Discogs provider selector
- Add search functionality with results display
- Add apply-to-form button to populate fields
- Include track-level metadata application
- Collapsible panel to keep UI clean
This commit is contained in:
vicrodh committed 2026-01-26 18:48:46 -06:00
1 parent 6d20baf914
commit 9dd63e106b
1 file changed
+354
@@ -30,6 +30,44 @@
sample_rate: number;
}
// Remote metadata types
interface RemoteAlbumSearchResult {
provider: 'musicbrainz' | 'discogs';
provider_id: string;
title: string;
artist: string;
year?: number;
track_count?: number;
country?: string;
label?: string;
catalog_number?: string;
confidence?: number;
format?: string;
}
interface RemoteTrackMetadata {
disc_number?: number;
track_number?: number;
title: string;
duration_ms?: number;
}
interface RemoteAlbumMetadata {
provider: 'musicbrainz' | 'discogs';
provider_id: string;
title: string;
artist: string;
year?: number;
genres?: string[];
label?: string;
catalog_number?: string;
country?: string;
barcode?: string;
tracks?: RemoteTrackMetadata[];
disc_count?: number;
source_url?: string;
}
interface Props {
isOpen: boolean;
album: LocalAlbum | null;
@@ -41,6 +79,7 @@
let { isOpen, album, tracks, onClose, onSaved }: Props = $props();
type PersistenceMode = 'sidecar' | 'direct';
type RemoteProvider = 'musicbrainz' | 'discogs';
let albumTitle = $state('');
let albumArtist = $state('');
@@ -51,6 +90,14 @@
let persistence: PersistenceMode = $state('sidecar');
let saving = $state(false);
// Remote metadata search state
let remoteProvider: RemoteProvider = $state('musicbrainz');
let remoteSearching = $state(false);
let remoteLoading = $state(false);
let remoteResults = $state<RemoteAlbumSearchResult[]>([]);
let selectedRemoteId = $state<string | null>(null);
let showRemotePanel = $state(false);
type TrackEdit = {
id: number;
filePath: string;
@@ -91,9 +138,85 @@
$effect(() => {
if (isOpen) {
resetFromAlbum();
// Reset remote state when modal opens
remoteResults = [];
selectedRemoteId = null;
showRemotePanel = false;
}
});
// Remote metadata functions
async function searchRemoteMetadata() {
if (!albumTitle.trim() && !albumArtist.trim()) {
showToast('Enter album title or artist to search', 'error');
return;
}
remoteSearching = true;
remoteResults = [];
selectedRemoteId = null;
try {
const results = await invoke<RemoteAlbumSearchResult[]>('remote_metadata_search', {
provider: remoteProvider,
query: albumTitle.trim(),
artist: albumArtist.trim() || null,
limit: 10
});
remoteResults = results;
if (results.length === 0) {
showToast('No results found', 'info');
}
} catch (err) {
console.error('Remote search failed:', err);
showToast(`Search failed: ${err}`, 'error');
} finally {
remoteSearching = false;
}
}
async function applyRemoteMetadata() {
if (!selectedRemoteId) return;
remoteLoading = true;
try {
const metadata = await invoke<RemoteAlbumMetadata>('remote_metadata_get_album', {
provider: remoteProvider,
providerId: selectedRemoteId
});
// Apply album-level metadata
if (metadata.title) albumTitle = metadata.title;
if (metadata.artist) albumArtist = metadata.artist;
if (metadata.year) yearInput = String(metadata.year);
if (metadata.genres && metadata.genres.length > 0) {
genre = metadata.genres.slice(0, 3).join(', ');
}
if (metadata.catalog_number) catalogNumber = metadata.catalog_number;
if (metadata.disc_count) albumTotalDiscs = metadata.disc_count;
// Apply track-level metadata if available
if (metadata.tracks && metadata.tracks.length > 0) {
// Match tracks by position
const remoteTracks = metadata.tracks;
for (let i = 0; i < trackEdits.length && i < remoteTracks.length; i++) {
const remote = remoteTracks[i];
if (remote.title) trackEdits[i].title = remote.title;
if (remote.track_number !== undefined) trackEdits[i].trackNumber = remote.track_number;
if (remote.disc_number !== undefined) trackEdits[i].discNumber = remote.disc_number;
}
}
showToast(`Applied metadata from ${remoteProvider === 'musicbrainz' ? 'MusicBrainz' : 'Discogs'}`, 'success');
showRemotePanel = false;
} catch (err) {
console.error('Failed to fetch metadata:', err);
showToast(`Failed to fetch metadata: ${err}`, 'error');
} finally {
remoteLoading = false;
}
}
function parseYear(): number | null {
const trimmed = yearInput.trim();
if (!trimmed) return null;
@@ -239,6 +362,92 @@
</div>
</div>
<!-- Remote Metadata Lookup -->
<div class="remote-section">
<button
class="btn btn-ghost btn-sm remote-toggle"
onclick={() => showRemotePanel = !showRemotePanel}
type="button"
>
<svg class="icon-inline" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
{showRemotePanel ? 'Hide' : 'Fetch from'} MusicBrainz / Discogs
<svg class="icon-inline chevron" class:rotated={showRemotePanel} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{#if showRemotePanel}
<div class="remote-panel">
<div class="remote-controls">
<select
class="select-inline control-xs"
bind:value={remoteProvider}
>
<option value="musicbrainz">MusicBrainz</option>
<option value="discogs">Discogs</option>
</select>
<button
class="btn btn-secondary btn-sm"
onclick={searchRemoteMetadata}
disabled={remoteSearching}
type="button"
>
{#if remoteSearching}
<span class="spinner-inline"></span>
Searching...
{:else}
Search
{/if}
</button>
</div>
{#if remoteResults.length > 0}
<div class="remote-results">
{#each remoteResults as result (result.provider_id)}
<button
class="remote-result"
class:selected={selectedRemoteId === result.provider_id}
onclick={() => selectedRemoteId = result.provider_id}
type="button"
>
<div class="result-main">
<span class="result-title">{result.title}</span>
<span class="result-artist">{result.artist}</span>
</div>
<div class="result-meta">
{#if result.year}<span>{result.year}</span>{/if}
{#if result.country}<span>{result.country}</span>{/if}
{#if result.label}<span>{result.label}</span>{/if}
{#if result.catalog_number}<span class="mono">{result.catalog_number}</span>{/if}
{#if result.confidence}<span class="confidence">{result.confidence}%</span>{/if}
</div>
</button>
{/each}
</div>
<div class="remote-actions">
<button
class="btn btn-primary btn-sm"
onclick={applyRemoteMetadata}
disabled={!selectedRemoteId || remoteLoading}
type="button"
>
{#if remoteLoading}
<span class="spinner-inline"></span>
Applying...
{:else}
Apply Selected
{/if}
</button>
</div>
{/if}
</div>
{/if}
</div>
<div class="section">
<div class="track-table">
<div class="track-head">
@@ -583,4 +792,149 @@ input[type="number"] {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
word-break: break-all;
}
/* Remote metadata panel styles */
.remote-section {
border: 1px solid var(--bg-tertiary);
border-radius: 10px;
overflow: hidden;
}
.remote-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--bg-secondary);
border: none;
color: var(--text-muted);
font-size: 13px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.remote-toggle:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.remote-toggle .chevron {
margin-left: auto;
transition: transform 0.2s ease;
}
.remote-toggle .chevron.rotated {
transform: rotate(180deg);
}
.remote-panel {
border-top: 1px solid var(--bg-tertiary);
padding: 12px;
background: var(--bg-primary);
display: flex;
flex-direction: column;
gap: 12px;
}
.remote-controls {
display: flex;
gap: 10px;
align-items: center;
}
.remote-results {
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
padding: 4px;
}
.remote-result {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
text-align: left;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.remote-result:hover {
background: var(--bg-hover);
}
.remote-result.selected {
background: rgba(var(--accent-primary-rgb), 0.1);
border-color: var(--accent-primary);
}
.result-main {
display: flex;
gap: 8px;
align-items: baseline;
}
.result-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.result-artist {
font-size: 12px;
color: var(--text-muted);
}
.result-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
font-size: 11px;
color: var(--text-muted);
}
.result-meta span {
padding: 2px 6px;
background: var(--bg-secondary);
border-radius: 4px;
}
.result-meta .confidence {
background: rgba(var(--accent-primary-rgb), 0.15);
color: var(--accent-primary);
}
.remote-actions {
display: flex;
justify-content: flex-end;
}
.spinner-inline {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: inline-block;
margin-right: 6px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.icon-inline {
width: 16px;
height: 16px;
flex-shrink: 0;
}
</style>