Compare commits

...

2 Commits

Author SHA1 Message Date
Senko-san a37c19fd45 feat(library): surface metadata enrichment status, errors and covers
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
The mapper dropped metadata_status and hardcoded availability, so enrichment
state was invisible and a just-uploaded track never appeared to change. Map
metadata_status/metadata_error/has_cover onto Track; add MetadataStatusBadge
(pending spinner / enriched / failed-with-reason / manual) shown in TrackRow,
and serve token-bearing track covers via getTrackCoverUrl.

UploadPage now polls each uploaded track (stops once enrichment settles) so the
resolved title/artist — or a failure reason — appears live. i18n in en + ru.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:29:22 +03:00
Senko-san facc215450 chore: update/make more clear connect flow 2026-06-13 12:35:20 +03:00
11 changed files with 562 additions and 236 deletions
+15
View File
@@ -17,3 +17,18 @@ export function getCoverUrl(artUrl: string | undefined): string | undefined {
const base = getApiBaseUrl(); const base = getApiBaseUrl();
return `${base}${artUrl}`; return `${base}${artUrl}`;
} }
/**
* Cover image URL for a track, served by `GET /tracks/{id}/cover`. Like the
* audio stream, an `<img>` can't send an `Authorization` header, so the access
* token rides as `?token=`. Returns undefined when the track has no cover.
*/
export function getTrackCoverUrl(
trackId: string,
token: string,
hasCover: boolean,
): string | undefined {
if (!hasCover) return undefined;
const base = getApiBaseUrl();
return `${base}/tracks/${trackId}/cover?token=${encodeURIComponent(token)}`;
}
+24 -2
View File
@@ -14,12 +14,27 @@
import type { import type {
Album, Album,
Artist, Artist,
MetadataStatus,
PaginatedResponse, PaginatedResponse,
Playlist, Playlist,
Track, Track,
User, User,
} from './types'; } from './types';
const METADATA_STATUSES: readonly MetadataStatus[] = [
'pending',
'enriched',
'failed',
'manual',
];
/** Map the backend's free-form status string onto the UI union, defaulting any
* unknown value to `pending` (a safe "not yet identified" state). */
const toMetadataStatus = (raw: string): MetadataStatus =>
(METADATA_STATUSES as readonly string[]).includes(raw)
? (raw as MetadataStatus)
: 'pending';
// ---- raw wire shapes (snake_case, exactly as the backend emits) ---- // ---- raw wire shapes (snake_case, exactly as the backend emits) ----
export interface RawPaged<T> { export interface RawPaged<T> {
@@ -49,6 +64,9 @@ export interface RawTrack {
file_format: string; file_format: string;
file_size: number; file_size: number;
metadata_status: string; metadata_status: string;
metadata_error: string | null;
enriched_at: string | null;
has_cover: boolean;
source: string; source: string;
created_at: string; created_at: string;
} }
@@ -98,13 +116,17 @@ export const toTrack = (r: RawTrack): Track => ({
artistName: r.artist_name, artistName: r.artist_name,
albumId: r.album_id ?? '', albumId: r.album_id ?? '',
albumTitle: r.album_title ?? '', albumTitle: r.album_title ?? '',
// Cover endpoints aren't wired on the backend yet — leave art undefined so the // `has_cover` says a cover exists; the actual URL (which needs a `?token=`) is
// UI renders generated tile art instead of a broken image. // built in the component from the track id — see `getTrackCoverUrl`. Keep
// `albumArtUrl` undefined so callers fall back to generated tile art.
albumArtUrl: undefined, albumArtUrl: undefined,
hasCover: r.has_cover,
durationMs: (r.duration_seconds ?? 0) * 1000, durationMs: (r.duration_seconds ?? 0) * 1000,
// The lean TrackOut carries no availability/like state: a track returned by // The lean TrackOut carries no availability/like state: a track returned by
// the library is on the server, and per-track like state comes from /likes. // the library is on the server, and per-track like state comes from /likes.
availability: 'server', availability: 'server',
metadataStatus: toMetadataStatus(r.metadata_status),
metadataError: r.metadata_error ?? undefined,
liked: false, liked: false,
format: r.file_format, format: r.file_format,
fileSize: r.file_size, fileSize: r.file_size,
+12
View File
@@ -1,5 +1,13 @@
export type TrackAvailability = 'server' | 'downloading' | 'error' | 'missing'; export type TrackAvailability = 'server' | 'downloading' | 'error' | 'missing';
/**
* Metadata-enrichment state, distinct from file `availability`. `pending` = the
* worker hasn't finished (or hasn't started); `enriched` = identity found;
* `failed` = no match / a worker error (see `metadataError`); `manual` = user-
* edited and never auto-overwritten.
*/
export type MetadataStatus = 'pending' | 'enriched' | 'failed' | 'manual';
export interface Track { export interface Track {
id: string; id: string;
title: string; title: string;
@@ -8,12 +16,16 @@ export interface Track {
albumId: string; albumId: string;
albumTitle: string; albumTitle: string;
albumArtUrl?: string; albumArtUrl?: string;
hasCover: boolean;
durationMs: number; durationMs: number;
trackNumber?: number; trackNumber?: number;
discNumber?: number; discNumber?: number;
year?: number; year?: number;
genre?: string; genre?: string;
availability: TrackAvailability; availability: TrackAvailability;
metadataStatus: MetadataStatus;
/** Human-readable reason the last enrichment run set `failed`; else undefined. */
metadataError?: string;
fileSize?: number; fileSize?: number;
format?: string; format?: string;
bitrate?: number; bitrate?: number;
@@ -0,0 +1,47 @@
import { Badge, Spinner, Tooltip } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import type { MetadataStatus } from '../../api/types';
interface Props {
status: MetadataStatus;
/** Reason shown in the tooltip for a `failed` status. */
error?: string;
/** When true, render nothing for the normal `enriched` state (keeps dense
* track lists quiet; the upload screen sets this false to confirm success). */
hideWhenEnriched?: boolean;
}
type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
const VARIANT: Record<MetadataStatus, Variant> = {
pending: 'neutral',
enriched: 'lime',
failed: 'ember',
manual: 'outline',
};
/**
* Shows a track's metadata-enrichment state (distinct from file availability).
* `pending` carries a spinner; `failed` exposes the backend reason on hover.
*/
export function MetadataStatusBadge({
status,
error,
hideWhenEnriched = true,
}: Props) {
const { t } = useTranslation();
if (status === 'enriched' && hideWhenEnriched) return null;
const label = t(`metadata.status.${status}`);
const tooltip =
status === 'failed' && error ? error : t(`metadata.statusHint.${status}`);
return (
<Tooltip content={tooltip}>
<Badge variant={VARIANT[status]} dot={status !== 'pending'}>
{status === 'pending' ? <Spinner size="sm" /> : null}
{label}
</Badge>
</Tooltip>
);
}
+15 -3
View File
@@ -1,11 +1,12 @@
import { Row } from '@olly/modern-sk'; import { Row } from '@olly/modern-sk';
import { TrackContextMenu } from './TrackContextMenu'; import { TrackContextMenu } from './TrackContextMenu';
import { AvailabilityBadge } from './AvailabilityBadge'; import { AvailabilityBadge } from './AvailabilityBadge';
import { MetadataStatusBadge } from './MetadataStatusBadge';
import { formatDuration } from '../../lib/format'; import { formatDuration } from '../../lib/format';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch'; import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { play } from '../../store/slices/player'; import { play } from '../../store/slices/player';
import type { Track } from '../../api/types'; import type { Track } from '../../api/types';
import { getCoverUrl } from '../../api/endpoints/streaming'; import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
interface Props { interface Props {
track: Track; track: Track;
@@ -27,8 +28,13 @@ export function TrackRow({
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const currentTrackId = useAppSelector((s) => s.player.currentTrackId); const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
const isPlaying = useAppSelector((s) => s.player.isPlaying); const isPlaying = useAppSelector((s) => s.player.isPlaying);
const token = useAppSelector((s) => s.auth.accessToken);
const isActive = currentTrackId === track.id; const isActive = currentTrackId === track.id;
const artUrl = getCoverUrl(track.albumArtUrl); // Prefer an explicit album art URL; otherwise serve the track's own cover
// (needs the token in the query string — `<img>` can't send a header).
const artUrl =
getCoverUrl(track.albumArtUrl) ??
(token ? getTrackCoverUrl(track.id, token, track.hasCover) : undefined);
return ( return (
<Row <Row
@@ -95,7 +101,13 @@ export function TrackRow({
{showAlbum && ` · ${track.albumTitle}`} {showAlbum && ` · ${track.albumTitle}`}
</div> </div>
</div> </div>
<AvailabilityBadge availability={track.availability} /> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<MetadataStatusBadge
status={track.metadataStatus}
error={track.metadataError}
/>
<AvailabilityBadge availability={track.availability} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span <span
style={{ style={{
+5
View File
@@ -98,6 +98,11 @@ export function upsertInstance(url: string, name?: string): Instance {
return inst; return inst;
} }
/** Clear a backend's stored session without forgetting the instance itself. */
export function clearInstanceAuth(id: string): void {
localStorage.removeItem(scopedKey('auth', id));
}
/** Remove a backend and wipe every scoped key it owns. */ /** Remove a backend and wipe every scoped key it owns. */
export function removeInstance(id: string): void { export function removeInstance(id: string): void {
writeRegistry(readRegistry().filter((i) => i.id !== id)); writeRegistry(readRegistry().filter((i) => i.id !== id));
+307 -197
View File
@@ -2,11 +2,11 @@ import { useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk'; import { Card, TextField, Button, Callout, Badge, Dialog } from '@olly/modern-sk';
import { Icon } from '../../components/common/Icon'; import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch'; import { useAppDispatch } from '../../hooks/useAppDispatch';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { setTokens, setUser } from '../../store/slices/auth'; import { setTokens, setUser } from '../../store/slices/auth';
import { setApiBaseUrl } from '../../config/runtime-config';
import { import {
useLoginMutation, useLoginMutation,
useRegisterMutation, useRegisterMutation,
@@ -17,10 +17,27 @@ import {
getActiveInstanceId, getActiveInstanceId,
setActiveInstanceId, setActiveInstanceId,
removeInstance, removeInstance,
clearInstanceAuth,
upsertInstance,
type Instance,
} from '../../config/instances'; } from '../../config/instances';
type Mode = 'login' | 'register'; type Mode = 'login' | 'register';
const HEALTH_VARIANTS = {
connected: 'lime',
connecting: 'neutral',
disconnected: 'ember',
error: 'ember',
} as const;
const HEALTH_KEY = {
connected: 'conn.connected',
connecting: 'conn.connecting',
disconnected: 'conn.disconnected',
error: 'conn.error',
} as const;
/** Map an RTKQ login failure to a user-facing i18n key. */ /** Map an RTKQ login failure to a user-facing i18n key. */
function resolveLoginError(err: unknown): string { function resolveLoginError(err: unknown): string {
const e = err as FetchBaseQueryError | undefined; const e = err as FetchBaseQueryError | undefined;
@@ -43,6 +60,121 @@ function resolveRegisterError(err: unknown): string {
return 'connect.errors.registerFailed'; return 'connect.errors.registerFailed';
} }
function InstanceRow({
inst,
selected,
onSelect,
onLogout,
onRemove,
}: {
inst: Instance;
selected: boolean;
onSelect: () => void;
onLogout: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const status = useConnectionStatus(inst.baseUrl);
const [dialogOpen, setDialogOpen] = useState(false);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.625rem',
padding: '0.375rem 0',
}}
>
<Badge variant={HEALTH_VARIANTS[status]} dot>
{t(HEALTH_KEY[status])}
</Badge>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--color-text-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.name}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.baseUrl}
</div>
</div>
{selected ? (
<Badge variant="outline">{t('connect.domains.selected')}</Badge>
) : (
<Button variant="ghost" size="sm" onClick={onSelect}>
{t('connect.domains.use')}
</Button>
)}
<Dialog
open={dialogOpen}
onOpenChange={setDialogOpen}
title={t('connect.removeDialog.title')}
description={t('connect.removeDialog.description', {
name: inst.name,
})}
trigger={
<button
type="button"
className="iconbtn sm"
title={t('connect.domains.forgetTitle')}
>
<Icon name="trash" />
</button>
}
footer={
<div
style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}
>
<Button
variant="ghost"
size="sm"
onClick={() => setDialogOpen(false)}
>
{t('connect.removeDialog.cancel')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setDialogOpen(false);
onLogout();
}}
>
{t('connect.removeDialog.logout')}
</Button>
<Button
variant="ember"
size="sm"
onClick={() => {
setDialogOpen(false);
onRemove();
}}
>
{t('connect.removeDialog.removeAndLogout')}
</Button>
</div>
}
/>
</div>
);
}
export function ConnectPage() { export function ConnectPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -50,10 +182,15 @@ export function ConnectPage() {
const [rev, setRev] = useState(0); const [rev, setRev] = useState(0);
const instances = listInstances(); const instances = listInstances();
const activeId = getActiveInstanceId();
const [selectedId, setSelectedId] = useState<string | null>(() =>
getActiveInstanceId(),
);
const selectedInstance = instances.find((i) => i.id === selectedId) ?? null;
const [addUrl, setAddUrl] = useState('https://');
const [mode, setMode] = useState<Mode>('login'); const [mode, setMode] = useState<Mode>('login');
const [apiUrl, setApiUrl] = useState('https://');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -67,23 +204,40 @@ export function ConnectPage() {
setError(null); setError(null);
}; };
const switchTo = (id: string) => { // Switching the active instance and reloading lets the app pick the saved
// session for that instance back up (if any); if it has none, ProtectedRoute
// bounces back here and `selectedId` defaults to it, surfacing the login card.
const selectInstance = (id: string) => {
setActiveInstanceId(id); setActiveInstanceId(id);
window.location.assign('/'); window.location.assign('/');
}; };
const forget = (id: string) => { const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
const url = addUrl.trim();
if (!url || url === 'https://') return;
const inst = upsertInstance(url);
setActiveInstanceId(inst.id);
setAddUrl('https://');
setSelectedId(inst.id);
setRev((r) => r + 1);
};
const handleLogout = (id: string) => {
clearInstanceAuth(id);
setRev((r) => r + 1);
};
const handleRemove = (id: string) => {
removeInstance(id); removeInstance(id);
if (selectedId === id) setSelectedId(getActiveInstanceId());
setRev((r) => r + 1); setRev((r) => r + 1);
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!selectedInstance) return;
setError(null); setError(null);
// Point the API layer at this backend *before* logging in — baseQuery reads
// the active instance's URL at request time. Auth tokens then persist under
// that instance's namespace, never bleeding across servers.
setApiBaseUrl(apiUrl);
try { try {
const action = const action =
@@ -145,204 +299,160 @@ export function ConnectPage() {
<Icon name="vinyl-record" fill /> MCMA <Icon name="vinyl-record" fill /> MCMA
</h1> </h1>
{instances.length > 0 && (
<Card>
<div
style={{
padding: '1.25rem 1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
{t('connect.savedInstances')}
</span>
{instances.map((inst) => (
<div
key={inst.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.625rem',
padding: '0.375rem 0',
}}
>
<span
className={`led ${inst.id === activeId ? 'online' : 'offline'}`}
style={{
width: 8,
height: 8,
borderRadius: '50%',
background:
inst.id === activeId ? 'var(--lime)' : 'var(--fg-3)',
boxShadow:
inst.id === activeId ? '0 0 6px var(--lime)' : 'none',
flexShrink: 0,
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--color-text-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.name}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.baseUrl}
</div>
</div>
{inst.id === activeId ? (
<Badge variant="lime">{t('connect.active')}</Badge>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => switchTo(inst.id)}
>
{t('connect.use')}
</Button>
)}
<button
type="button"
className="iconbtn sm"
onClick={() => forget(inst.id)}
title={t('connect.forgetTitle')}
>
<Icon name="trash" />
</button>
</div>
))}
</div>
</Card>
)}
<Card> <Card>
<form <div
onSubmit={handleSubmit}
style={{ style={{
padding: '1.25rem 1.5rem',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '1rem', gap: '0.5rem',
padding: '1.5rem',
}} }}
> >
<span className="msk-label"> {instances.length > 0 && (
{mode === 'register' <>
? t('connect.form.registerTitle') <span className="msk-label" style={{ marginBottom: '0.25rem' }}>
: t('connect.form.title')} {t('connect.domains.title')}
</span>
<div>
<label style={labelStyle}>{t('connect.form.serverUrl')}</label>
<TextField
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
placeholder="https://your-server.example.com"
required
/>
</div>
<div>
<label style={labelStyle}>{t('connect.form.username')}</label>
<TextField
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="username"
autoComplete="username"
required
/>
</div>
<div>
<label style={labelStyle}>{t('connect.form.password')}</label>
<TextField
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="password"
autoComplete={
mode === 'register' ? 'new-password' : 'current-password'
}
required
/>
{mode === 'register' && (
<span
style={{
display: 'block',
fontSize: '0.75rem',
color: 'var(--color-text-3)',
marginTop: '0.375rem',
}}
>
{t('connect.form.passwordHint')}
</span> </span>
)} {instances.map((inst) => (
</div> <InstanceRow
{error && <Callout variant="danger">{t(error)}</Callout>} key={inst.id}
<Button inst={inst}
type="submit" selected={inst.id === selectedId}
variant="primary" onSelect={() => selectInstance(inst.id)}
disabled={isLoading} onLogout={() => handleLogout(inst.id)}
style={{ marginTop: '0.5rem' }} onRemove={() => handleRemove(inst.id)}
/>
))}
</>
)}
<form
onSubmit={handleAdd}
style={{
display: 'flex',
gap: '0.5rem',
marginTop: instances.length > 0 ? '0.5rem' : 0,
}}
> >
{isLoading <TextField
? mode === 'register' value={addUrl}
? t('connect.form.registering') onChange={(e) => setAddUrl(e.target.value)}
: t('connect.form.submitting') placeholder={t('connect.domains.addPlaceholder')}
: mode === 'register' style={{ flex: 1 }}
? t('connect.form.registerSubmit') />
: t('connect.form.submit')} <Button type="submit" variant="primary">
</Button> <Icon name="plus" /> {t('connect.domains.addButton')}
</Button>
</form>
</div>
</Card>
{REGISTRATION_ENABLED && ( {selectedInstance && (
<div <Card>
style={{ <form
textAlign: 'center', onSubmit={handleSubmit}
fontSize: '0.8125rem', style={{
color: 'var(--color-text-3)', display: 'flex',
}} flexDirection: 'column',
> gap: '1rem',
{mode === 'register' ? ( padding: '1.5rem',
<> }}
{t('connect.form.haveAccount')}{' '} >
<Button <span className="msk-label">
type="button" {mode === 'register'
variant="ghost" ? t('connect.login.registerTitle', {
size="sm" name: selectedInstance.name,
onClick={() => switchMode('login')} })
> : t('connect.login.title', { name: selectedInstance.name })}
{t('connect.form.signInLink')} </span>
</Button> <div>
</> <label style={labelStyle}>{t('connect.login.username')}</label>
) : ( <TextField
<> value={username}
{t('connect.form.noAccount')}{' '} onChange={(e) => setUsername(e.target.value)}
<Button placeholder="username"
type="button" autoComplete="username"
variant="ghost" required
size="sm" />
onClick={() => switchMode('register')} </div>
> <div>
{t('connect.form.registerLink')} <label style={labelStyle}>{t('connect.login.password')}</label>
</Button> <TextField
</> type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="password"
autoComplete={
mode === 'register' ? 'new-password' : 'current-password'
}
required
/>
{mode === 'register' && (
<span
style={{
display: 'block',
fontSize: '0.75rem',
color: 'var(--color-text-3)',
marginTop: '0.375rem',
}}
>
{t('connect.login.passwordHint')}
</span>
)} )}
</div> </div>
)} {error && <Callout variant="danger">{t(error)}</Callout>}
</form> <Button
</Card> type="submit"
variant="primary"
disabled={isLoading}
style={{ marginTop: '0.5rem' }}
>
{isLoading
? mode === 'register'
? t('connect.login.registering')
: t('connect.login.submitting')
: mode === 'register'
? t('connect.login.registerSubmit')
: t('connect.login.submit')}
</Button>
{REGISTRATION_ENABLED && (
<div
style={{
textAlign: 'center',
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
}}
>
{mode === 'register' ? (
<>
{t('connect.login.haveAccount')}{' '}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => switchMode('login')}
>
{t('connect.login.signInLink')}
</Button>
</>
) : (
<>
{t('connect.login.noAccount')}{' '}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => switchMode('register')}
>
{t('connect.login.registerLink')}
</Button>
</>
)}
</div>
)}
</form>
</Card>
)}
</div> </div>
</div> </div>
); );
+57 -6
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk'; import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk';
@@ -6,6 +6,8 @@ import {
buildUploadFormData, buildUploadFormData,
useUploadTrackMutation, useUploadTrackMutation,
} from '../../api/endpoints/upload'; } from '../../api/endpoints/upload';
import { useGetTrackQuery } from '../../api/endpoints/library';
import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge';
/** Pure client-side state — this is a transient upload queue, never server data. */ /** Pure client-side state — this is a transient upload queue, never server data. */
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error'; type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
@@ -273,11 +275,7 @@ function UploadRow({
{item.error} {item.error}
</div> </div>
)} )}
{done && ( {done && item.trackId && <EnrichmentStatus trackId={item.trackId} />}
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
{t('upload.unknownArtist')}
</div>
)}
</div> </div>
<StatusBadge status={item.status} /> <StatusBadge status={item.status} />
@@ -301,6 +299,59 @@ function UploadRow({
); );
} }
/**
* Polls a just-uploaded track until enrichment settles, then shows the outcome.
* Metadata enrichment runs asynchronously in a worker after the upload response
* returns, so without polling the row would never reflect the resolved title/
* artist or a failure reason. Polling stops (interval → 0) once the status
* leaves `pending`.
*/
function EnrichmentStatus({ trackId }: { trackId: string }) {
const { t } = useTranslation();
const [pollMs, setPollMs] = useState(2500);
const { data } = useGetTrackQuery(trackId, { pollingInterval: pollMs });
useEffect(() => {
if (data && data.metadataStatus !== 'pending') setPollMs(0);
}, [data]);
const status = data?.metadataStatus ?? 'pending';
const resolved =
data && data.metadataStatus === 'enriched'
? `${data.artistName} · ${data.title}`
: t(`metadata.statusHint.${status}`);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginTop: '0.25rem',
}}
>
<MetadataStatusBadge
status={status}
error={data?.metadataError}
hideWhenEnriched={false}
/>
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{status === 'failed' && data?.metadataError
? data.metadataError
: resolved}
</span>
</div>
);
}
function StatusBadge({ status }: { status: ItemStatus }) { function StatusBadge({ status }: { status: ItemStatus }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (status === 'uploading') { if (status === 'uploading') {
+5 -3
View File
@@ -3,7 +3,9 @@ import { getApiBaseUrl } from '../config/runtime-config';
type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error'; type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
export function useConnectionStatus() { /** Pings `${baseUrl}/health` (defaults to the active instance's base URL). */
export function useConnectionStatus(baseUrl?: string) {
const url = baseUrl ?? getApiBaseUrl();
const [status, setStatus] = useState<ConnectionStatus>('connecting'); const [status, setStatus] = useState<ConnectionStatus>('connecting');
useEffect(() => { useEffect(() => {
@@ -13,7 +15,7 @@ export function useConnectionStatus() {
if (cancelled) return; if (cancelled) return;
setStatus('connecting'); setStatus('connecting');
try { try {
const res = await fetch(`${getApiBaseUrl()}/health`, { const res = await fetch(`${url}/health`, {
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });
if (!cancelled) setStatus(res.ok ? 'connected' : 'error'); if (!cancelled) setStatus(res.ok ? 'connected' : 'error');
@@ -30,7 +32,7 @@ export function useConnectionStatus() {
cancelled = true; cancelled = true;
clearInterval(interval); clearInterval(interval);
}; };
}, []); }, [url]);
return status; return status;
} }
+38 -13
View File
@@ -25,25 +25,36 @@ const en = {
signOut: 'Sign out', signOut: 'Sign out',
}, },
connect: { connect: {
savedInstances: 'Saved instances', domains: {
active: 'active', title: 'Saved instances',
use: 'Use', addPlaceholder: 'https://your-server.example.com',
forgetTitle: 'Forget this instance', addButton: 'Add instance',
form: { selected: 'Selected',
title: 'Connect to a backend', use: 'Use',
registerTitle: 'Create an account', forgetTitle: 'Remove this instance',
serverUrl: 'Server URL', },
removeDialog: {
title: 'Remove cached data?',
description:
'This removes "{{name}}" from your saved instances and clears its cached data on this device.',
cancel: 'Cancel',
logout: 'Just log out',
removeAndLogout: 'Remove data & log out',
},
login: {
title: 'Log in to {{name}}',
registerTitle: 'Sign up for {{name}}',
username: 'Username', username: 'Username',
password: 'Password', password: 'Password',
passwordHint: 'At least 8 characters.', passwordHint: 'At least 8 characters.',
submit: 'Connect', submit: 'Log in',
submitting: 'Connecting…', submitting: 'Logging in…',
registerSubmit: 'Create account', registerSubmit: 'Sign up',
registering: 'Creating account…', registering: 'Signing up…',
noAccount: "Don't have an account?", noAccount: "Don't have an account?",
registerLink: 'Sign up', registerLink: 'Sign up',
haveAccount: 'Already have an account?', haveAccount: 'Already have an account?',
signInLink: 'Sign in', signInLink: 'Log in',
}, },
errors: { errors: {
unreachable: unreachable:
@@ -219,6 +230,20 @@ const en = {
error: 'Failed', error: 'Failed',
}, },
}, },
metadata: {
status: {
pending: 'Enriching…',
enriched: 'Enriched',
failed: 'No match',
manual: 'Manual',
},
statusHint: {
pending: 'Identifying metadata…',
enriched: 'Metadata identified',
failed: 'Metadata could not be identified',
manual: 'Edited manually — not auto-updated',
},
},
} as const; } as const;
export default en; export default en;
+37 -12
View File
@@ -27,21 +27,32 @@ const ru: Translations = {
signOut: 'Выйти', signOut: 'Выйти',
}, },
connect: { connect: {
savedInstances: 'Сохранённые серверы', domains: {
active: 'активный', title: 'Сохранённые серверы',
use: 'Выбрать', addPlaceholder: 'https://your-server.example.com',
forgetTitle: 'Забыть этот сервер', addButton: 'Добавить сервер',
form: { selected: 'Выбран',
title: 'Подключиться к серверу', use: 'Выбрать',
registerTitle: 'Создать аккаунт', forgetTitle: 'Удалить этот сервер',
serverUrl: 'URL сервера', },
removeDialog: {
title: 'Удалить локальные данные?',
description:
'Сервер «{{name}}» будет удалён из сохранённых, а его данные на этом устройстве — очищены.',
cancel: 'Отмена',
logout: 'Просто выйти',
removeAndLogout: 'Удалить данные и выйти',
},
login: {
title: 'Вход в {{name}}',
registerTitle: 'Регистрация на {{name}}',
username: 'Имя пользователя', username: 'Имя пользователя',
password: 'Пароль', password: 'Пароль',
passwordHint: 'Не менее 8 символов.', passwordHint: 'Не менее 8 символов.',
submit: 'Подключиться', submit: 'Войти',
submitting: 'Подключение…', submitting: 'Вход…',
registerSubmit: 'Создать аккаунт', registerSubmit: 'Зарегистрироваться',
registering: 'Создание аккаунта…', registering: 'Регистрация…',
noAccount: 'Нет аккаунта?', noAccount: 'Нет аккаунта?',
registerLink: 'Зарегистрироваться', registerLink: 'Зарегистрироваться',
haveAccount: 'Уже есть аккаунт?', haveAccount: 'Уже есть аккаунт?',
@@ -221,6 +232,20 @@ const ru: Translations = {
error: 'Ошибка', error: 'Ошибка',
}, },
}, },
metadata: {
status: {
pending: 'Обработка…',
enriched: 'Готово',
failed: 'Нет совпадения',
manual: 'Вручную',
},
statusHint: {
pending: 'Определяем метаданные…',
enriched: 'Метаданные определены',
failed: 'Не удалось определить метаданные',
manual: 'Изменено вручную — не обновляется автоматически',
},
},
}; };
export default ru; export default ru;