Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e45ef785cf | |||
| 9d88d70be0 | |||
| 6efbf4b68b | |||
| 87fac7a5a1 | |||
| 3eba3701d0 | |||
| 28d9e59e34 |
@@ -1,6 +1,11 @@
|
||||
import { api } from '../index';
|
||||
import { toUser, type RawUser } from '../mappers';
|
||||
import type { User } from '../types';
|
||||
import type { User, ServicesStatus, SourceInfo } from '../types';
|
||||
|
||||
interface ReindexJob {
|
||||
source: string;
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin user management. The backend models authorization as `is_superuser` /
|
||||
@@ -14,6 +19,11 @@ export const adminApi = api.injectEndpoints({
|
||||
transformResponse: (raw: RawUser[]) => raw.map(toUser),
|
||||
providesTags: ['User'],
|
||||
}),
|
||||
getUser: build.query<User, string>({
|
||||
query: (id) => `/admin/users/${id}`,
|
||||
transformResponse: (raw: RawUser) => toUser(raw),
|
||||
providesTags: (_res, _err, id) => [{ type: 'User', id }],
|
||||
}),
|
||||
createUser: build.mutation<
|
||||
User,
|
||||
{ username: string; password: string; role: 'admin' | 'user' }
|
||||
@@ -39,11 +49,34 @@ export const adminApi = api.injectEndpoints({
|
||||
},
|
||||
}),
|
||||
transformResponse: (raw: RawUser) => toUser(raw),
|
||||
invalidatesTags: ['User'],
|
||||
invalidatesTags: (_res, _err, { id }) => ['User', { type: 'User', id }],
|
||||
}),
|
||||
// Soft delete on the backend: deactivates the account and revokes its
|
||||
// sessions (see admin.py::deactivate_user), so it also refreshes the row.
|
||||
deleteUser: build.mutation<void, string>({
|
||||
query: (id) => ({ url: `/admin/users/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: ['User'],
|
||||
invalidatesTags: (_res, _err, id) => ['User', { type: 'User', id }],
|
||||
}),
|
||||
resetPassword: build.mutation<void, { id: string; newPassword: string }>({
|
||||
query: ({ id, newPassword }) => ({
|
||||
url: `/admin/users/${id}/reset-password`,
|
||||
method: 'POST',
|
||||
body: { new_password: newPassword },
|
||||
}),
|
||||
}),
|
||||
// -- instance (§A9) -------------------------------------------------------
|
||||
getServices: build.query<ServicesStatus, void>({
|
||||
query: () => '/admin/services',
|
||||
}),
|
||||
getSources: build.query<SourceInfo[], void>({
|
||||
query: () => '/admin/sources',
|
||||
}),
|
||||
reindex: build.mutation<ReindexJob[], void>({
|
||||
query: () => ({ url: '/admin/reindex', method: 'POST' }),
|
||||
transformResponse: (raw: { jobs: { source: string; job_id: string }[] }) =>
|
||||
raw.jobs.map((j) => ({ source: j.source, jobId: j.job_id })),
|
||||
// A fresh scan changes the catalogue once the worker runs.
|
||||
invalidatesTags: ['Track', 'Album', 'Artist', 'Storage'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
@@ -51,7 +84,12 @@ export const adminApi = api.injectEndpoints({
|
||||
|
||||
export const {
|
||||
useGetUsersQuery,
|
||||
useGetUserQuery,
|
||||
useCreateUserMutation,
|
||||
useUpdateUserMutation,
|
||||
useDeleteUserMutation,
|
||||
useResetPasswordMutation,
|
||||
useGetServicesQuery,
|
||||
useGetSourcesQuery,
|
||||
useReindexMutation,
|
||||
} = adminApi;
|
||||
|
||||
@@ -201,6 +201,18 @@ export const libraryApi = api.injectEndpoints({
|
||||
}),
|
||||
invalidatesTags: (_r, _e, trackId) => [{ type: 'Track', id: trackId }],
|
||||
}),
|
||||
// Enqueue a transcode to a cached Opus + HLS rendition (§6.6). Fire-and-
|
||||
// forget: the worker does the work, playback keeps using the original.
|
||||
optimizeTrack: build.mutation<
|
||||
{ status: string; job_id: string; quality: string },
|
||||
{ trackId: string; quality?: string }
|
||||
>({
|
||||
query: ({ trackId, quality = 'high' }) => ({
|
||||
url: `/tracks/${trackId}/optimize`,
|
||||
method: 'POST',
|
||||
params: { quality },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@@ -219,4 +231,5 @@ export const {
|
||||
useLazyGetMetadataMatchesQuery,
|
||||
useApplyMetadataMutation,
|
||||
useEnrichTrackMutation,
|
||||
useOptimizeTrackMutation,
|
||||
} = libraryApi;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { api } from '../index';
|
||||
import type { Lyrics } from '../types';
|
||||
|
||||
// `GET /tracks/{id}/lyrics` — cached lyrics for the Now Playing panel (§6.7).
|
||||
// A miss is a normal 200 with `status: 'not_found'`, not an error.
|
||||
|
||||
interface RawLyrics {
|
||||
track_id: string;
|
||||
status: string;
|
||||
source: string | null;
|
||||
synced: string | null;
|
||||
plain: string | null;
|
||||
synced_available: boolean;
|
||||
}
|
||||
|
||||
const toLyrics = (r: RawLyrics): Lyrics => ({
|
||||
trackId: r.track_id,
|
||||
status: r.status as Lyrics['status'],
|
||||
source: r.source ?? undefined,
|
||||
synced: r.synced ?? undefined,
|
||||
plain: r.plain ?? undefined,
|
||||
syncedAvailable: r.synced_available,
|
||||
});
|
||||
|
||||
export const lyricsApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getLyrics: build.query<Lyrics, string>({
|
||||
query: (trackId) => `/tracks/${trackId}/lyrics`,
|
||||
transformResponse: (raw: RawLyrics) => toLyrics(raw),
|
||||
}),
|
||||
refetchLyrics: build.mutation<Lyrics, string>({
|
||||
query: (trackId) => ({
|
||||
url: `/tracks/${trackId}/lyrics/refetch`,
|
||||
method: 'POST',
|
||||
}),
|
||||
transformResponse: (raw: RawLyrics) => toLyrics(raw),
|
||||
// Push the fresh result straight into the getLyrics cache for this track.
|
||||
async onQueryStarted(trackId, { dispatch, queryFulfilled }) {
|
||||
const { data } = await queryFulfilled;
|
||||
dispatch(
|
||||
lyricsApi.util.upsertQueryData('getLyrics', trackId, data),
|
||||
);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const { useGetLyricsQuery, useRefetchLyricsMutation } = lyricsApi;
|
||||
@@ -1,10 +1,32 @@
|
||||
import { api } from '../index';
|
||||
import { toStorageStats, type RawStorageStats } from '../mappers';
|
||||
import type { StorageStats } from '../types';
|
||||
import {
|
||||
toStorageStats,
|
||||
toPage,
|
||||
toTrack,
|
||||
type RawStorageStats,
|
||||
type RawPaged,
|
||||
type RawTrack,
|
||||
} from '../mappers';
|
||||
import type {
|
||||
StorageStats,
|
||||
DuplicateGroup,
|
||||
PaginatedResponse,
|
||||
Track,
|
||||
} from '../types';
|
||||
|
||||
// `GET /storage` returns library + disk statistics (§A6). The maintenance
|
||||
// routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
|
||||
// `POST /storage/cleanup`) are still backend stubs and unused by the UI.
|
||||
// routes back the Storage Maintenance screen: `/duplicates`, `/broken`,
|
||||
// `/missing-metadata` (read) and `POST /cleanup` (admin, enqueues the worker).
|
||||
|
||||
interface RawDuplicateGroup {
|
||||
fingerprint: string;
|
||||
tracks: RawTrack[];
|
||||
}
|
||||
|
||||
interface Paging {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export const storageApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
@@ -13,16 +35,32 @@ export const storageApi = api.injectEndpoints({
|
||||
transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
|
||||
providesTags: ['Storage'],
|
||||
}),
|
||||
scanStorage: build.mutation<{ jobId: string }, void>({
|
||||
query: () => ({ url: '/storage/scan', method: 'POST' }),
|
||||
invalidatesTags: ['Storage', 'Track', 'Album', 'Artist'],
|
||||
getDuplicates: build.query<DuplicateGroup[], void>({
|
||||
query: () => '/storage/duplicates',
|
||||
transformResponse: (raw: RawDuplicateGroup[]) =>
|
||||
raw.map((g) => ({
|
||||
fingerprint: g.fingerprint,
|
||||
tracks: g.tracks.map(toTrack),
|
||||
})),
|
||||
providesTags: ['Storage', 'Track'],
|
||||
}),
|
||||
deleteTrackFile: build.mutation<void, string>({
|
||||
query: (trackId) => ({
|
||||
url: `/storage/tracks/${trackId}`,
|
||||
method: 'DELETE',
|
||||
getBrokenFiles: build.query<PaginatedResponse<Track>, Paging | void>({
|
||||
query: (p) => ({ url: '/storage/broken', params: p ?? {} }),
|
||||
transformResponse: (raw: RawPaged<RawTrack>) => toPage(raw, toTrack),
|
||||
providesTags: ['Storage', 'Track'],
|
||||
}),
|
||||
getMissingMetadata: build.query<PaginatedResponse<Track>, Paging | void>({
|
||||
query: (p) => ({ url: '/storage/missing-metadata', params: p ?? {} }),
|
||||
transformResponse: (raw: RawPaged<RawTrack>) => toPage(raw, toTrack),
|
||||
providesTags: ['Storage', 'Track'],
|
||||
}),
|
||||
runCleanup: build.mutation<{ status: string; jobId: string }, void>({
|
||||
query: () => ({ url: '/storage/cleanup', method: 'POST' }),
|
||||
transformResponse: (raw: { status: string; job_id: string }) => ({
|
||||
status: raw.status,
|
||||
jobId: raw.job_id,
|
||||
}),
|
||||
invalidatesTags: ['Storage', { type: 'Track', id: undefined }],
|
||||
invalidatesTags: ['Storage', 'Track'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
@@ -30,6 +68,8 @@ export const storageApi = api.injectEndpoints({
|
||||
|
||||
export const {
|
||||
useGetStorageStatsQuery,
|
||||
useScanStorageMutation,
|
||||
useDeleteTrackFileMutation,
|
||||
useGetDuplicatesQuery,
|
||||
useGetBrokenFilesQuery,
|
||||
useGetMissingMetadataQuery,
|
||||
useRunCleanupMutation,
|
||||
} = storageApi;
|
||||
|
||||
@@ -5,9 +5,17 @@ import { getApiBaseUrl } from '../../config/runtime-config';
|
||||
* param because `<audio>` can't send an `Authorization` header; the backend
|
||||
* accepts `?token=` on `GET /stream/{id}` for exactly this reason.
|
||||
*/
|
||||
export function getStreamUrl(trackId: string, token: string): string {
|
||||
export function getStreamUrl(
|
||||
trackId: string,
|
||||
token: string,
|
||||
quality?: string,
|
||||
): string {
|
||||
const base = getApiBaseUrl();
|
||||
return `${base}/stream/${trackId}?token=${encodeURIComponent(token)}`;
|
||||
const q =
|
||||
quality && quality !== 'original'
|
||||
? `&quality=${encodeURIComponent(quality)}`
|
||||
: '';
|
||||
return `${base}/stream/${trackId}?token=${encodeURIComponent(token)}${q}`;
|
||||
}
|
||||
|
||||
export function getCoverUrl(artUrl: string | undefined): string | undefined {
|
||||
|
||||
@@ -128,6 +128,7 @@ export const toUser = (r: RawUser): User => ({
|
||||
username: r.username,
|
||||
// MVP role model: superuser → admin, everyone else → user.
|
||||
role: r.is_superuser ? 'admin' : 'user',
|
||||
isActive: r.is_active,
|
||||
createdAt: r.created_at,
|
||||
});
|
||||
|
||||
|
||||
@@ -140,6 +140,16 @@ export interface UploadResponse {
|
||||
already_exists: boolean;
|
||||
}
|
||||
|
||||
/** One backing-dependency probe result (GET /admin/services). `skipped` = the
|
||||
* dependency is optional and not configured (e.g. ML with no ML_SERVICE_URL). */
|
||||
export type ServiceStatus = 'ok' | 'down' | 'skipped';
|
||||
|
||||
export interface ServicesStatus {
|
||||
database: ServiceStatus;
|
||||
redis: ServiceStatus;
|
||||
ml: ServiceStatus;
|
||||
}
|
||||
|
||||
export interface StorageFormatBreakdown {
|
||||
fileFormat: string;
|
||||
trackCount: number;
|
||||
@@ -176,11 +186,29 @@ export interface StorageStats {
|
||||
disk?: StorageDiskUsage;
|
||||
}
|
||||
|
||||
/** A set of tracks sharing one acoustic fingerprint — de-dup candidates (§A6). */
|
||||
export interface DuplicateGroup {
|
||||
fingerprint: string;
|
||||
tracks: Track[];
|
||||
}
|
||||
|
||||
/** Cached lyrics for a track (§6.7). `not_found` is a normal state, not an
|
||||
* error. `synced` is raw LRC (client parses timestamps); `plain` is fallback. */
|
||||
export interface Lyrics {
|
||||
trackId: string;
|
||||
status: 'found' | 'not_found' | 'pending';
|
||||
source?: string;
|
||||
synced?: string;
|
||||
plain?: string;
|
||||
syncedAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
role: 'admin' | 'user';
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
lastActiveAt?: string;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Outlet } from 'react-router';
|
||||
import { Suspense } from 'react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { PersistentPlayer } from '../player/PersistentPlayer';
|
||||
import { NowPlaying } from '../player/NowPlaying';
|
||||
import { QueuePanel } from '../player/QueuePanel';
|
||||
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
|
||||
import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog';
|
||||
@@ -37,6 +38,7 @@ export function AppShell() {
|
||||
</div>
|
||||
<AddToPlaylistDialog />
|
||||
<PersistentPlayer />
|
||||
<NowPlaying />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Spinner } from '@olly/modern-sk';
|
||||
import { useGetLyricsQuery, useRefetchLyricsMutation } from '../../api/endpoints/lyrics';
|
||||
import { parseLrc, activeLineIndex } from '../../lib/lrc';
|
||||
|
||||
/**
|
||||
* Lyrics tab inside Now Playing. Synced mode highlights the active LRC line by
|
||||
* playback position (auto-scrolling; tap a line to seek to its timecode); plain
|
||||
* mode is a static scroll. Missing lyrics is a normal state with a re-fetch.
|
||||
*/
|
||||
export function LyricsPanel({
|
||||
trackId,
|
||||
position,
|
||||
onSeek,
|
||||
}: {
|
||||
trackId: string;
|
||||
position: number;
|
||||
onSeek: (seconds: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, isError, refetch } = useGetLyricsQuery(trackId);
|
||||
const [refetchLyrics, { isLoading: isRefetching }] =
|
||||
useRefetchLyricsMutation();
|
||||
|
||||
const lines = data?.synced ? parseLrc(data.synced) : [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Centered>
|
||||
<Spinner />
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Centered>
|
||||
<p style={{ color: 'var(--color-text-2)' }}>{t('common.error')}</p>
|
||||
<Button variant="ghost" size="sm" onClick={() => void refetch()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
const hasLyrics =
|
||||
data?.status === 'found' && (lines.length > 0 || Boolean(data.plain));
|
||||
|
||||
if (!hasLyrics) {
|
||||
return (
|
||||
<Centered>
|
||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
||||
{t('lyrics.none')}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isRefetching}
|
||||
onClick={() => void refetchLyrics(trackId)}
|
||||
>
|
||||
{isRefetching ? <Spinner size="sm" /> : t('lyrics.refetch')}
|
||||
</Button>
|
||||
</Centered>
|
||||
);
|
||||
}
|
||||
|
||||
if (lines.length > 0) {
|
||||
return (
|
||||
<SyncedLyrics lines={lines} position={position} onSeek={onSeek} />
|
||||
);
|
||||
}
|
||||
|
||||
// Plain fallback — no timestamps to sync against.
|
||||
return (
|
||||
<div style={{ padding: '1rem 0', maxWidth: 640, margin: '0 auto' }}>
|
||||
<pre
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '1rem',
|
||||
lineHeight: 1.7,
|
||||
color: 'var(--color-text-1)',
|
||||
margin: 0,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{data?.plain}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncedLyrics({
|
||||
lines,
|
||||
position,
|
||||
onSeek,
|
||||
}: {
|
||||
lines: { time: number; text: string }[];
|
||||
position: number;
|
||||
onSeek: (seconds: number) => void;
|
||||
}) {
|
||||
const activeIndex = activeLineIndex(lines, position);
|
||||
const activeRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// Keep the active line centred as playback advances.
|
||||
useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
padding: '30vh 0',
|
||||
maxWidth: 640,
|
||||
margin: '0 auto',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{lines.map((line, i) => {
|
||||
const active = i === activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={`${line.time}-${i}`}
|
||||
ref={active ? activeRef : undefined}
|
||||
type="button"
|
||||
onClick={() => onSeek(line.time)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
padding: '0.15rem 0.5rem',
|
||||
fontSize: active ? '1.4rem' : '1.1rem',
|
||||
fontWeight: active ? 700 : 500,
|
||||
color: active ? 'var(--color-text-1)' : 'var(--color-text-3)',
|
||||
opacity: active ? 1 : 0.6,
|
||||
transition: 'all 0.2s ease',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{line.text || '♪'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Centered({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.75rem',
|
||||
height: '100%',
|
||||
minHeight: 200,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Slider, SegmentedControl, IconButton } from '@olly/modern-sk';
|
||||
import { Icon } from '../common/Icon';
|
||||
import { ArtTile } from '../common/ArtTile';
|
||||
import { LyricsPanel } from './LyricsPanel';
|
||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||
import { pause, resume, setNowPlayingOpen } from '../../store/slices/player';
|
||||
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
||||
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
|
||||
import { formatDuration } from '../../lib/format';
|
||||
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||
|
||||
type Tab = 'artwork' | 'lyrics';
|
||||
|
||||
/**
|
||||
* Full-screen Now Playing overlay (§A1 / §A11). Expands from the persistent
|
||||
* player: big artwork or a synced Lyrics tab, plus transport + seek. Mounted in
|
||||
* AppShell; visibility is driven by `player.isNowPlayingOpen`.
|
||||
*/
|
||||
export function NowPlaying() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { seek, playNext, playPrev } = useAudioPlayer();
|
||||
const player = useAppSelector((s) => s.player);
|
||||
const queue = useAppSelector((s) => s.queue);
|
||||
const token = useAppSelector((s) => s.auth.accessToken);
|
||||
const [tab, setTab] = useState<Tab>('artwork');
|
||||
|
||||
const currentEntry = queue.entries[queue.currentIndex];
|
||||
const current = useResolvedQueueEntry(currentEntry);
|
||||
|
||||
if (!player.isNowPlayingOpen || !currentEntry || !current) return null;
|
||||
|
||||
const artUrl =
|
||||
getCoverUrl(currentEntry.albumArtUrl) ??
|
||||
(token && current.hasCover
|
||||
? getTrackCoverUrl(current.trackId, token, true)
|
||||
: undefined);
|
||||
const seedLabel = current.albumTitle ?? current.title ?? '';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
background: 'var(--color-bg)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
padding: '1rem 1.5rem',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={() => dispatch(setNowPlayingOpen(false))}
|
||||
aria-label={t('nowPlaying.collapse')}
|
||||
>
|
||||
⌄
|
||||
</IconButton>
|
||||
<div style={{ flex: 1 }} />
|
||||
<SegmentedControl
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as Tab)}
|
||||
items={[
|
||||
{ value: 'artwork', label: t('nowPlaying.artwork') },
|
||||
{ value: 'lyrics', label: t('nowPlaying.lyrics') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0, padding: '0 1.5rem' }}>
|
||||
{tab === 'artwork' ? (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<ArtTile
|
||||
seed={seedLabel}
|
||||
size={320}
|
||||
label={seedLabel}
|
||||
src={artUrl}
|
||||
/>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 700 }}>
|
||||
{current.title}
|
||||
</div>
|
||||
<div style={{ fontSize: '1rem', color: 'var(--color-text-2)' }}>
|
||||
{current.artistName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<LyricsPanel
|
||||
trackId={current.trackId}
|
||||
position={player.position}
|
||||
onSeek={seek}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transport */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: '1rem 1.5rem 1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
maxWidth: 720,
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<span className="pl-time">{formatDuration(player.position * 1000)}</span>
|
||||
<Slider
|
||||
style={{ flex: 1 }}
|
||||
min={0}
|
||||
max={player.duration || 1}
|
||||
step={1}
|
||||
value={[player.position]}
|
||||
onValueChange={([v]) => seek(v)}
|
||||
aria-label={t('player.play')}
|
||||
/>
|
||||
<span className="pl-time">{formatDuration(player.duration * 1000)}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pl-tbtn"
|
||||
onClick={playPrev}
|
||||
title={t('player.previous')}
|
||||
>
|
||||
<Icon name="skip-back" fill />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pl-play"
|
||||
onClick={() =>
|
||||
player.isPlaying ? dispatch(pause()) : dispatch(resume())
|
||||
}
|
||||
title={player.isPlaying ? t('player.pause') : t('player.play')}
|
||||
>
|
||||
<Icon name={player.isPlaying ? 'pause' : 'play'} fill />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pl-tbtn"
|
||||
onClick={playNext}
|
||||
title={t('player.next')}
|
||||
>
|
||||
<Icon name="skip-forward" fill />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
toggleMute,
|
||||
setVolume,
|
||||
toggleQueue,
|
||||
toggleNowPlaying,
|
||||
} from '../../store/slices/player';
|
||||
import { openTrackInfo } from '../../store/slices/ui';
|
||||
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
||||
@@ -145,6 +146,14 @@ export function PersistentPlayer() {
|
||||
>
|
||||
<Icon name="queue" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="iconbtn sm"
|
||||
onClick={() => dispatch(toggleNowPlaying())}
|
||||
title={t('nowPlaying.expand')}
|
||||
>
|
||||
<Icon name="vinyl-record" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Window } from '@olly/modern-sk';
|
||||
import {
|
||||
Window,
|
||||
Card,
|
||||
Button,
|
||||
TextField,
|
||||
SegmentedControl,
|
||||
Dialog,
|
||||
AlertDialog,
|
||||
Menu,
|
||||
MenuTrigger,
|
||||
MenuContent,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
Badge,
|
||||
Callout,
|
||||
} from '@olly/modern-sk';
|
||||
import { Icon } from '../../components/common/Icon';
|
||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { EmptyState } from '../../components/common/EmptyState';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import {
|
||||
useGetUsersQuery,
|
||||
useGetUserQuery,
|
||||
useCreateUserMutation,
|
||||
useUpdateUserMutation,
|
||||
useDeleteUserMutation,
|
||||
useResetPasswordMutation,
|
||||
useGetServicesQuery,
|
||||
useGetSourcesQuery,
|
||||
useReindexMutation,
|
||||
} from '../../api/endpoints/admin';
|
||||
import type { User, ServiceStatus } from '../../api/types';
|
||||
|
||||
function StubPanel({ title }: { title: string }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -12,16 +46,430 @@ function StubPanel({ title }: { title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `/admin/users` — user list (add/remove). Scaffold. */
|
||||
export function AdminUsers() {
|
||||
/** Role + active-status badges shared by the list and the detail view. */
|
||||
function UserBadges({ user }: { user: User }) {
|
||||
const { t } = useTranslation();
|
||||
return <StubPanel title={t('admin.tabs.users')} />;
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '0.375rem', alignItems: 'center' }}>
|
||||
<Badge variant={user.role === 'admin' ? 'lime' : 'neutral'}>
|
||||
{t(`admin.users.role.${user.role}`)}
|
||||
</Badge>
|
||||
{!user.isActive && (
|
||||
<Badge variant="ember">{t('admin.users.status.blocked')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/admin/users/:userId` — per-user permissions / reset password / status. Scaffold. */
|
||||
/** `/admin/users` — user list with add / role toggle / (de)activate / reset password. */
|
||||
export function AdminUsers() {
|
||||
const { t } = useTranslation();
|
||||
const { data: users, isLoading, isError, refetch } = useGetUsersQuery();
|
||||
const [createUser, { isLoading: isCreating }] = useCreateUserMutation();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState<'admin' | 'user'>('user');
|
||||
const [createError, setCreateError] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setRole('user');
|
||||
setCreateError(false);
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = username.trim();
|
||||
if (!name || !password) return;
|
||||
setCreateError(false);
|
||||
try {
|
||||
await createUser({ username: name, password, role }).unwrap();
|
||||
setCreateOpen(false);
|
||||
resetForm();
|
||||
} catch {
|
||||
setCreateError(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Window title={t('admin.tabs.users')}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpen(open);
|
||||
if (!open) resetForm();
|
||||
}}
|
||||
title={t('admin.users.newDialog.title')}
|
||||
trigger={
|
||||
<Button variant="primary">
|
||||
<Icon name="plus" /> {t('admin.users.add')}
|
||||
</Button>
|
||||
}
|
||||
footer={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" onClick={() => setCreateOpen(false)}>
|
||||
{t('admin.users.newDialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!username.trim() || !password || isCreating}
|
||||
onClick={(e) => void handleCreate(e)}
|
||||
>
|
||||
{t('admin.users.newDialog.create')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => void handleCreate(e)}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}
|
||||
>
|
||||
<TextField
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t('admin.users.newDialog.username')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<TextField
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('admin.users.newDialog.password')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={role}
|
||||
onValueChange={(v) => setRole(v === 'admin' ? 'admin' : 'user')}
|
||||
items={[
|
||||
{ value: 'user', label: t('admin.users.role.user') },
|
||||
{ value: 'admin', label: t('admin.users.role.admin') },
|
||||
]}
|
||||
/>
|
||||
{createError && (
|
||||
<Callout variant="danger">
|
||||
{t('admin.users.newDialog.error')}
|
||||
</Callout>
|
||||
)}
|
||||
</form>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{isLoading && <LoadingSkeleton rows={5} height={56} />}
|
||||
{isError && <ErrorState onRetry={refetch} />}
|
||||
{!isLoading && !isError && (users?.length ?? 0) === 0 && (
|
||||
<EmptyState icon="☺" title={t('admin.users.empty')} />
|
||||
)}
|
||||
{!isLoading && !isError && users && users.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{users.map((user) => (
|
||||
<UserRow key={user.id} user={user} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
/** One row in the user list: identity + badges + actions menu. */
|
||||
function UserRow({ user }: { user: User }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { user: currentUser } = usePermissions();
|
||||
const isSelf = currentUser?.id === user.id;
|
||||
|
||||
const [updateUser] = useUpdateUserMutation();
|
||||
const [deleteUser] = useDeleteUserMutation();
|
||||
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
||||
|
||||
const toggleRole = () =>
|
||||
void updateUser({
|
||||
id: user.id,
|
||||
role: user.role === 'admin' ? 'user' : 'admin',
|
||||
});
|
||||
const toggleActive = () =>
|
||||
void updateUser({ id: user.id, isActive: !user.isActive });
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
padding: '0.75rem 1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate(`/admin/users/${user.id}`)}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
textAlign: 'left',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
{isSelf && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-3)',
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{t('admin.users.you')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||
{t('admin.users.added', {
|
||||
date: new Date(user.createdAt).toLocaleDateString(),
|
||||
})}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<UserBadges user={user} />
|
||||
|
||||
<Menu>
|
||||
<MenuTrigger asChild>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={t('admin.users.menu.options')}
|
||||
>
|
||||
⋯
|
||||
</IconButton>
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem onSelect={toggleRole}>
|
||||
{user.role === 'admin'
|
||||
? t('admin.users.menu.makeUser')
|
||||
: t('admin.users.menu.makeAdmin')}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => setResetOpen(true)}>
|
||||
{t('admin.users.menu.resetPassword')}
|
||||
</MenuItem>
|
||||
{/* Guard against locking yourself out of the instance. */}
|
||||
{!isSelf && (
|
||||
<MenuItem
|
||||
onSelect={() =>
|
||||
user.isActive ? setDeactivateOpen(true) : toggleActive()
|
||||
}
|
||||
>
|
||||
{user.isActive
|
||||
? t('admin.users.menu.deactivate')
|
||||
: t('admin.users.menu.activate')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
|
||||
<ResetPasswordDialog
|
||||
user={user}
|
||||
open={resetOpen}
|
||||
onOpenChange={setResetOpen}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deactivateOpen}
|
||||
onOpenChange={setDeactivateOpen}
|
||||
title={t('admin.users.deactivateDialog.title')}
|
||||
description={t('admin.users.deactivateDialog.description', {
|
||||
name: user.username,
|
||||
})}
|
||||
cancelLabel={t('admin.users.deactivateDialog.cancel')}
|
||||
actionLabel={t('admin.users.deactivateDialog.confirm')}
|
||||
destructive
|
||||
onAction={() => void deleteUser(user.id)}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reset-password dialog: admin sets a new password for the account. */
|
||||
function ResetPasswordDialog({
|
||||
user,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
user: User;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [resetPassword, { isLoading }] = useResetPasswordMutation();
|
||||
const [value, setValue] = useState('');
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!value) return;
|
||||
await resetPassword({ id: user.id, newPassword: value }).unwrap();
|
||||
setValue('');
|
||||
setDone(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
onOpenChange(o);
|
||||
if (!o) {
|
||||
setValue('');
|
||||
setDone(false);
|
||||
}
|
||||
}}
|
||||
title={t('admin.users.resetDialog.title', { name: user.username })}
|
||||
footer={
|
||||
<div
|
||||
style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}
|
||||
>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t('admin.users.resetDialog.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!value || isLoading}
|
||||
onClick={(e) => void handleSubmit(e)}
|
||||
>
|
||||
{t('admin.users.resetDialog.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{done ? (
|
||||
<Callout variant="success">{t('admin.users.resetDialog.done')}</Callout>
|
||||
) : (
|
||||
<form onSubmit={(e) => void handleSubmit(e)}>
|
||||
<TextField
|
||||
autoFocus
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={t('admin.users.resetDialog.newPassword')}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/admin/users/:userId` — per-user detail: identity, role toggle, reset, status. */
|
||||
export function AdminUserDetail() {
|
||||
const { t } = useTranslation();
|
||||
return <StubPanel title={t('admin.userDetail')} />;
|
||||
const navigate = useNavigate();
|
||||
const { userId = '' } = useParams();
|
||||
const { user: currentUser } = usePermissions();
|
||||
|
||||
const { data: user, isLoading, isError, refetch } = useGetUserQuery(userId, {
|
||||
skip: !userId,
|
||||
});
|
||||
const [updateUser] = useUpdateUserMutation();
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
|
||||
const isSelf = currentUser?.id === user?.id;
|
||||
|
||||
return (
|
||||
<Window title={t('admin.userDetail')}>
|
||||
<Button variant="ghost" size="sm" onClick={() => void navigate('/admin/users')}>
|
||||
← {t('common.back')}
|
||||
</Button>
|
||||
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
{isLoading && <LoadingSkeleton rows={3} height={40} />}
|
||||
{isError && <ErrorState onRetry={refetch} />}
|
||||
{user && (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}
|
||||
>
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: '1.25rem' }}>{user.username}</h2>
|
||||
<UserBadges user={user} />
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||
{t('admin.users.added', {
|
||||
date: new Date(user.createdAt).toLocaleDateString(),
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
void updateUser({
|
||||
id: user.id,
|
||||
role: user.role === 'admin' ? 'user' : 'admin',
|
||||
})
|
||||
}
|
||||
>
|
||||
{user.role === 'admin'
|
||||
? t('admin.users.menu.makeUser')
|
||||
: t('admin.users.menu.makeAdmin')}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setResetOpen(true)}>
|
||||
{t('admin.users.menu.resetPassword')}
|
||||
</Button>
|
||||
{!isSelf && (
|
||||
<Button
|
||||
variant={user.isActive ? 'ember' : 'ghost'}
|
||||
onClick={() =>
|
||||
void updateUser({ id: user.id, isActive: !user.isActive })
|
||||
}
|
||||
>
|
||||
{user.isActive
|
||||
? t('admin.users.menu.deactivate')
|
||||
: t('admin.users.menu.activate')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ResetPasswordDialog
|
||||
user={user}
|
||||
open={resetOpen}
|
||||
onOpenChange={setResetOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
/** `/admin/sources` — pluggable source management (creds/cookies/status). Scaffold. */
|
||||
@@ -30,8 +478,174 @@ export function AdminSources() {
|
||||
return <StubPanel title={t('admin.tabs.sources')} />;
|
||||
}
|
||||
|
||||
/** `/admin/instance` — service health, ML_SERVICE_URL, reindex. Scaffold. */
|
||||
const SERVICE_VARIANT: Record<ServiceStatus, 'lime' | 'ember' | 'neutral'> = {
|
||||
ok: 'lime',
|
||||
down: 'ember',
|
||||
skipped: 'neutral',
|
||||
};
|
||||
|
||||
/** `/admin/instance` — backing-service health, source status, library reindex. */
|
||||
export function AdminInstance() {
|
||||
const { t } = useTranslation();
|
||||
return <StubPanel title={t('admin.tabs.instance')} />;
|
||||
const {
|
||||
data: servicesData,
|
||||
isLoading: servicesLoading,
|
||||
isError: servicesError,
|
||||
refetch: refetchServices,
|
||||
} = useGetServicesQuery();
|
||||
const {
|
||||
data: sourcesData,
|
||||
isLoading: sourcesLoading,
|
||||
isError: sourcesError,
|
||||
refetch: refetchSources,
|
||||
} = useGetSourcesQuery();
|
||||
const [reindex, { isLoading: isReindexing }] = useReindexMutation();
|
||||
const [reindexMsg, setReindexMsg] = useState<
|
||||
{ kind: 'success' | 'danger'; text: string } | null
|
||||
>(null);
|
||||
|
||||
const handleReindex = async () => {
|
||||
setReindexMsg(null);
|
||||
try {
|
||||
const jobs = await reindex().unwrap();
|
||||
setReindexMsg({
|
||||
kind: 'success',
|
||||
text: t('admin.instance.reindex.enqueued', { count: jobs.length }),
|
||||
});
|
||||
} catch {
|
||||
// 400 when no indexable source is configured (see admin.py::trigger_reindex).
|
||||
setReindexMsg({
|
||||
kind: 'danger',
|
||||
text: t('admin.instance.reindex.noSource'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Window title={t('admin.tabs.instance')}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
{/* ── Services ─────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<h3 style={{ margin: '0 0 0.5rem', fontSize: '0.95rem' }}>
|
||||
{t('admin.instance.services.title')}
|
||||
</h3>
|
||||
{servicesLoading && <LoadingSkeleton rows={3} height={40} />}
|
||||
{servicesError && <ErrorState onRetry={refetchServices} />}
|
||||
{servicesData && (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
||||
>
|
||||
{(['database', 'redis', 'ml'] as const).map((key) => {
|
||||
const status = servicesData[key];
|
||||
return (
|
||||
<Card
|
||||
key={key}
|
||||
style={{
|
||||
padding: '0.625rem 1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>
|
||||
{t(`admin.instance.services.${key}`)}
|
||||
</span>
|
||||
<Badge variant={SERVICE_VARIANT[status]} dot>
|
||||
{t(`admin.instance.status.${status}`)}
|
||||
</Badge>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Sources (read-only; configured via env) ──────────────── */}
|
||||
<section>
|
||||
<h3 style={{ margin: '0 0 0.25rem', fontSize: '0.95rem' }}>
|
||||
{t('admin.instance.sources.title')}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 0.5rem',
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-3)',
|
||||
}}
|
||||
>
|
||||
{t('admin.instance.sources.envNote')}
|
||||
</p>
|
||||
{sourcesLoading && <LoadingSkeleton rows={2} height={40} />}
|
||||
{sourcesError && <ErrorState onRetry={refetchSources} />}
|
||||
{sourcesData && sourcesData.length === 0 && (
|
||||
<EmptyState icon="⊘" title={t('admin.instance.sources.empty')} />
|
||||
)}
|
||||
{sourcesData && sourcesData.length > 0 && (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
|
||||
>
|
||||
{sourcesData.map((s) => (
|
||||
<Card
|
||||
key={s.name}
|
||||
style={{
|
||||
padding: '0.625rem 1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>
|
||||
{s.label}
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '0.5rem',
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-3)',
|
||||
}}
|
||||
>
|
||||
{s.kind}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant={s.available ? 'lime' : 'ember'} dot>
|
||||
{t(
|
||||
s.available
|
||||
? 'admin.instance.sources.available'
|
||||
: 'admin.instance.sources.unavailable',
|
||||
)}
|
||||
</Badge>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Reindex ──────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<h3 style={{ margin: '0 0 0.25rem', fontSize: '0.95rem' }}>
|
||||
{t('admin.instance.reindex.title')}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 0.75rem',
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-3)',
|
||||
}}
|
||||
>
|
||||
{t('admin.instance.reindex.description')}
|
||||
</p>
|
||||
{reindexMsg && (
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<Callout variant={reindexMsg.kind}>{reindexMsg.text}</Callout>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={isReindexing}
|
||||
onClick={() => void handleReindex()}
|
||||
>
|
||||
{t('admin.instance.reindex.button')}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Card,
|
||||
IconButton,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Spinner,
|
||||
TextField,
|
||||
} from '@olly/modern-sk';
|
||||
@@ -16,11 +17,17 @@ import {
|
||||
useEnrichTrackMutation,
|
||||
useGetTrackQuery,
|
||||
useLazyGetMetadataMatchesQuery,
|
||||
useOptimizeTrackMutation,
|
||||
} from '../../api/endpoints/library';
|
||||
import type { MetadataMatch } from '../../api/types';
|
||||
import {
|
||||
useGetBrokenFilesQuery,
|
||||
useGetMissingMetadataQuery,
|
||||
} from '../../api/endpoints/storage';
|
||||
import type { MetadataEdit, MetadataMatch, Track } from '../../api/types';
|
||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { EmptyState } from '../../components/common/EmptyState';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
import { TrackRow } from '../../components/track/TrackRow';
|
||||
|
||||
interface Props {
|
||||
/** Single-track editor vs. batch editor — both A7, same scaffold. */
|
||||
@@ -60,13 +67,12 @@ function fieldStyle(): React.CSSProperties {
|
||||
|
||||
/**
|
||||
* `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
|
||||
* match picker with a current-vs-proposed diff. `/metadata/batch` is deferred.
|
||||
* match picker with a current-vs-proposed diff. `/metadata/batch` operates on a
|
||||
* metadata-status bucket (§A6 → A7 flow): bulk re-enrich + apply common fields.
|
||||
*/
|
||||
export function MetadataEditorPage({ batch = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (batch) {
|
||||
return <Placeholder title={t('pages.metadataBatch')} />;
|
||||
return <BatchEditor />;
|
||||
}
|
||||
|
||||
return <SingleTrackEditor />;
|
||||
@@ -413,12 +419,67 @@ function SingleTrackEditor() {
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<OptimizeCard trackId={trackId} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Enqueue a transcode to a cached Opus + HLS rendition (§6.6 / A6). */
|
||||
function OptimizeCard({ trackId }: { trackId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [optimize, { isLoading, isSuccess, isError }] =
|
||||
useOptimizeTrackMutation();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.25rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
||||
{t('metadataEditor.optimize.title')}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||
{t('metadataEditor.optimize.hint')}
|
||||
</div>
|
||||
{isSuccess && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<Callout variant="info">
|
||||
{t('metadataEditor.optimize.enqueued')}
|
||||
</Callout>
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<Callout variant="danger">{t('common.error')}</Callout>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
onClick={() => void optimize({ trackId })}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
t('metadataEditor.optimize.button')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchRow({
|
||||
match,
|
||||
onUse,
|
||||
@@ -589,3 +650,319 @@ function DiffView({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Batch editor (§A7 / §A6 cross-flow) ─────────────────────────────────────
|
||||
|
||||
interface BatchForm {
|
||||
artistName: string;
|
||||
albumTitle: string;
|
||||
year: string;
|
||||
genre: string;
|
||||
}
|
||||
|
||||
const EMPTY_BATCH: BatchForm = {
|
||||
artistName: '',
|
||||
albumTitle: '',
|
||||
year: '',
|
||||
genre: '',
|
||||
};
|
||||
|
||||
type BatchStatus = 'pending' | 'failed';
|
||||
|
||||
/**
|
||||
* `/metadata/batch?status=pending|failed` — bulk metadata over a status bucket
|
||||
* (tracks awaiting enrichment, or ones whose enrichment failed). Two batch
|
||||
* actions: re-run auto-enrichment, or stamp common fields onto the selection.
|
||||
*/
|
||||
function BatchEditor() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const status: BatchStatus =
|
||||
params.get('status') === 'failed' ? 'failed' : 'pending';
|
||||
|
||||
const missing = useGetMissingMetadataQuery(
|
||||
{ limit: 200 },
|
||||
{ skip: status !== 'pending' },
|
||||
);
|
||||
const broken = useGetBrokenFilesQuery(
|
||||
{ limit: 200 },
|
||||
{ skip: status !== 'failed' },
|
||||
);
|
||||
const active = status === 'failed' ? broken : missing;
|
||||
const items: Track[] = active.data?.items ?? [];
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [seededFor, setSeededFor] = useState<BatchStatus | null>(null);
|
||||
|
||||
// Seed the selection (all) the first time each bucket's data arrives.
|
||||
useEffect(() => {
|
||||
if (active.data && seededFor !== status) {
|
||||
setSelected(new Set(active.data.items.map((tr) => tr.id)));
|
||||
setSeededFor(status);
|
||||
}
|
||||
}, [active.data, seededFor, status]);
|
||||
|
||||
const [form, setForm] = useState<BatchForm>(EMPTY_BATCH);
|
||||
const [enrichTrack] = useEnrichTrackMutation();
|
||||
const [applyMetadata] = useApplyMetadataMutation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState<{ ok: number; fail: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const runBulk = async (op: (id: string) => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
setResult(null);
|
||||
const ids = [...selected];
|
||||
const outcomes = await Promise.allSettled(ids.map(op));
|
||||
const ok = outcomes.filter((o) => o.status === 'fulfilled').length;
|
||||
setResult({ ok, fail: ids.length - ok });
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const reEnrich = () => void runBulk((id) => enrichTrack(id).unwrap());
|
||||
const applyCommon = () => {
|
||||
const edit: MetadataEdit = {
|
||||
artistName: form.artistName.trim() || undefined,
|
||||
albumTitle: form.albumTitle.trim() || undefined,
|
||||
year: form.year.trim() ? Number(form.year) : undefined,
|
||||
genre: form.genre.trim() || undefined,
|
||||
};
|
||||
void runBulk((id) => applyMetadata({ trackId: id, edit }).unwrap());
|
||||
};
|
||||
|
||||
const updateField = (key: keyof BatchForm) => (value: string) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
const hasCommonEdits = Object.values(form).some((v) => v.trim() !== '');
|
||||
const selectedCount = selected.size;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.25rem 1.5rem',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label={t('common.back')}
|
||||
>
|
||||
←
|
||||
</IconButton>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||
{t('pages.metadataBatch')}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.125rem 0 0',
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-3)',
|
||||
}}
|
||||
>
|
||||
{t('metadataBatch.selectedCount', {
|
||||
selected: selectedCount,
|
||||
total: items.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={status}
|
||||
onValueChange={(v) => setParams({ status: v })}
|
||||
items={[
|
||||
{ value: 'pending', label: t('metadataBatch.status.pending') },
|
||||
{ value: 'failed', label: t('metadataBatch.status.failed') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.25rem',
|
||||
maxWidth: 760,
|
||||
}}
|
||||
>
|
||||
{result && (
|
||||
<Callout variant={result.fail === 0 ? 'success' : 'danger'}>
|
||||
{t('metadataBatch.result', {
|
||||
ok: result.ok,
|
||||
fail: result.fail,
|
||||
})}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div
|
||||
style={{
|
||||
padding: '1.25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
||||
{t('metadataBatch.reEnrich.title')}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-3)',
|
||||
}}
|
||||
>
|
||||
{t('metadataBatch.reEnrich.hint')}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={reEnrich}
|
||||
disabled={busy || selectedCount === 0}
|
||||
>
|
||||
{busy ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
t('metadataBatch.reEnrich.button')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--color-border)' }} />
|
||||
|
||||
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
|
||||
{t('metadataBatch.common.title')}
|
||||
</div>
|
||||
<div
|
||||
style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}
|
||||
>
|
||||
{t('metadataBatch.common.hint')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>
|
||||
{t('metadataEditor.fields.artist')}
|
||||
</label>
|
||||
<TextField
|
||||
style={fieldStyle()}
|
||||
value={form.artistName}
|
||||
onChange={(e) => updateField('artistName')(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>
|
||||
{t('metadataEditor.fields.album')}
|
||||
</label>
|
||||
<TextField
|
||||
style={fieldStyle()}
|
||||
value={form.albumTitle}
|
||||
onChange={(e) => updateField('albumTitle')(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>
|
||||
{t('metadataEditor.fields.year')}
|
||||
</label>
|
||||
<TextField
|
||||
style={fieldStyle()}
|
||||
type="number"
|
||||
value={form.year}
|
||||
onChange={(e) => updateField('year')(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>
|
||||
{t('metadataEditor.fields.genre')}
|
||||
</label>
|
||||
<TextField
|
||||
style={fieldStyle()}
|
||||
value={form.genre}
|
||||
onChange={(e) => updateField('genre')(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={applyCommon}
|
||||
disabled={busy || selectedCount === 0 || !hasCommonEdits}
|
||||
>
|
||||
{busy ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
t('metadataBatch.common.apply')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{active.isLoading && <LoadingSkeleton rows={6} height={56} />}
|
||||
{active.isError && <ErrorState onRetry={() => active.refetch()} />}
|
||||
{!active.isLoading && !active.isError && items.length === 0 && (
|
||||
<EmptyState icon="✓" title={t('metadataBatch.empty')} />
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
}}
|
||||
>
|
||||
{items.map((track) => (
|
||||
<div
|
||||
key={track.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(track.id)}
|
||||
onChange={() => toggle(track.id)}
|
||||
aria-label={track.title}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<TrackRow track={track} showAlbum />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Window,
|
||||
@@ -12,6 +13,9 @@ import {
|
||||
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
|
||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||
import { logout } from '../../store/slices/auth';
|
||||
import { getActiveInstance } from '../../config/instances';
|
||||
import {
|
||||
useGetSettingsQuery,
|
||||
useUpdateSettingsMutation,
|
||||
@@ -316,14 +320,60 @@ export function ScrobblingSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
/** `/settings/instance` — switch/forget instance. Scaffold. */
|
||||
/** `/settings/instance` — show the active instance; switch or sign out (both
|
||||
* routes back to /connect, the full instance manager). */
|
||||
export function InstanceSettings() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const instance = getActiveInstance();
|
||||
|
||||
const handleSignOut = () => {
|
||||
dispatch(logout());
|
||||
void navigate('/connect');
|
||||
};
|
||||
|
||||
return (
|
||||
<Window title={t('settings.tabs.instance')}>
|
||||
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
|
||||
{t('common.comingSoon')}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
padding: '0.75rem 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1.25rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={labelStyle}>{t('settings.instance.current')}</span>
|
||||
{instance ? (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{instance.name}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-3)',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{instance.baseUrl}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ margin: 0, color: 'var(--color-text-2)' }}>
|
||||
{t('settings.instance.none')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<Button variant="ghost" onClick={() => void navigate('/connect')}>
|
||||
{t('settings.instance.manage')}
|
||||
</Button>
|
||||
<Button variant="ember" onClick={handleSignOut}>
|
||||
{t('settings.instance.signOut')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Window>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,243 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Placeholder } from '../../components/common/Placeholder';
|
||||
import { Window, Card, Button, SegmentedControl, Callout } from '@olly/modern-sk';
|
||||
import { TrackRow } from '../../components/track/TrackRow';
|
||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||
import { EmptyState } from '../../components/common/EmptyState';
|
||||
import { ErrorState } from '../../components/common/ErrorState';
|
||||
import { usePermissions } from '../../hooks/usePermissions';
|
||||
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||
import {
|
||||
useGetDuplicatesQuery,
|
||||
useGetBrokenFilesQuery,
|
||||
useGetMissingMetadataQuery,
|
||||
useRunCleanupMutation,
|
||||
} from '../../api/endpoints/storage';
|
||||
|
||||
/** `/storage/maintenance` — A6 maintenance (dupes, broken files, cleanup). Scaffold only. */
|
||||
const PAGE_SIZE = 50;
|
||||
type Tab = 'duplicates' | 'broken' | 'missing';
|
||||
|
||||
/** `/storage/maintenance` — A6 maintenance: duplicates, broken files, missing
|
||||
* metadata, and orphan cleanup. */
|
||||
export function StorageMaintenancePage() {
|
||||
const { t } = useTranslation();
|
||||
return <Placeholder title={t('pages.storageMaintenance')} />;
|
||||
const offline = useIsOffline();
|
||||
const [tab, setTab] = useState<Tab>('duplicates');
|
||||
|
||||
return (
|
||||
<div style={{ padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>
|
||||
<Window title={t('pages.storageMaintenance')}>
|
||||
<p style={{ color: 'var(--color-text-2)', marginTop: 0 }}>
|
||||
{t('storage.maintenance.subtitle')}
|
||||
</p>
|
||||
|
||||
{offline && (
|
||||
<Callout variant="info">{t('common.offlineBanner')}</Callout>
|
||||
)}
|
||||
|
||||
<CleanupSection />
|
||||
|
||||
<div style={{ margin: '1.25rem 0' }}>
|
||||
<SegmentedControl
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as Tab)}
|
||||
items={[
|
||||
{ value: 'duplicates', label: t('storage.maintenance.tabs.duplicates') },
|
||||
{ value: 'broken', label: t('storage.maintenance.tabs.broken') },
|
||||
{ value: 'missing', label: t('storage.maintenance.tabs.missing') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Only the active tab is mounted, so only its query fires. */}
|
||||
{tab === 'duplicates' && <DuplicatesTab />}
|
||||
{tab === 'broken' && (
|
||||
<TrackListTab useQuery={useGetBrokenFilesQuery} emptyKey="broken" />
|
||||
)}
|
||||
{tab === 'missing' && (
|
||||
<TrackListTab useQuery={useGetMissingMetadataQuery} emptyKey="missing" />
|
||||
)}
|
||||
</Window>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Orphan-file cleanup — admin only (enqueues a worker job). */
|
||||
function CleanupSection() {
|
||||
const { t } = useTranslation();
|
||||
const { isAdmin } = usePermissions();
|
||||
const [runCleanup, { isLoading }] = useRunCleanupMutation();
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const handle = async () => {
|
||||
setDone(false);
|
||||
await runCleanup().unwrap();
|
||||
setDone(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
padding: '1rem 1.25rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{t('storage.maintenance.cleanup.title')}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||
{t('storage.maintenance.cleanup.description')}
|
||||
</div>
|
||||
</div>
|
||||
{done && (
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-accent)' }}>
|
||||
{t('storage.maintenance.cleanup.enqueued')}
|
||||
</span>
|
||||
)}
|
||||
<Button variant="ember" disabled={isLoading} onClick={() => void handle()}>
|
||||
{t('storage.maintenance.cleanup.button')}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Duplicate groups (tracks sharing an acoustic fingerprint). */
|
||||
function DuplicatesTab() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isError, refetch } = useGetDuplicatesQuery();
|
||||
|
||||
if (isLoading) return <LoadingSkeleton rows={5} height={56} />;
|
||||
if (isError) return <ErrorState onRetry={refetch} />;
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<EmptyState icon="✓" title={t('storage.maintenance.duplicates.empty')} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{data.map((group) => (
|
||||
<Card key={group.fingerprint} style={{ padding: '0.75rem 1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-3)',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{t('storage.maintenance.duplicates.groupTitle', {
|
||||
count: group.tracks.length,
|
||||
})}
|
||||
</div>
|
||||
{group.tracks.map((track) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
showAlbum
|
||||
onEditMetadata={(tr) => void navigate(`/tracks/${tr.id}/metadata`)}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A paginated track list backed by a maintenance query (broken / missing). */
|
||||
function TrackListTab({
|
||||
useQuery,
|
||||
emptyKey,
|
||||
}: {
|
||||
useQuery:
|
||||
| typeof useGetBrokenFilesQuery
|
||||
| typeof useGetMissingMetadataQuery;
|
||||
emptyKey: 'broken' | 'missing';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [offset, setOffset] = useState(0);
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingSkeleton rows={6} height={56} />;
|
||||
if (isError) return <ErrorState onRetry={refetch} />;
|
||||
if (!data || data.items.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="✓"
|
||||
title={t(`storage.maintenance.${emptyKey}.empty`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const from = offset + 1;
|
||||
const to = offset + data.items.length;
|
||||
// Broken files map to the `failed` metadata bucket; missing → `pending`.
|
||||
const batchStatus = emptyKey === 'broken' ? 'failed' : 'pending';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.5rem' }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void navigate(`/metadata/batch?status=${batchStatus}`)}
|
||||
>
|
||||
{t('storage.maintenance.editBatch')}
|
||||
</Button>
|
||||
</div>
|
||||
{data.items.map((track) => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
showAlbum
|
||||
onEditMetadata={(tr) => void navigate(`/tracks/${tr.id}/metadata`)}
|
||||
/>
|
||||
))}
|
||||
{(offset > 0 || data.hasMore) && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.75rem',
|
||||
marginTop: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
|
||||
{t('storage.maintenance.pagination', {
|
||||
from,
|
||||
to,
|
||||
total: data.total,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
>
|
||||
{t('storage.maintenance.prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!data.hasMore}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
>
|
||||
{t('storage.maintenance.next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { nextTrack, prevTrack } from '../store/slices/queue';
|
||||
import { play } from '../store/slices/player';
|
||||
import { getStreamUrl, getCoverUrl } from '../api/endpoints/streaming';
|
||||
import { useGetSettingsQuery } from '../api/endpoints/settings';
|
||||
|
||||
let audioElement: HTMLAudioElement | null = null;
|
||||
|
||||
@@ -26,6 +27,10 @@ export function useAudioPlayer() {
|
||||
const player = useAppSelector((s) => s.player);
|
||||
const queue = useAppSelector((s) => s.queue);
|
||||
const accessToken = useAppSelector((s) => s.auth.accessToken);
|
||||
// Default stream quality (server-synced §A10) → the backend transcodes to it
|
||||
// on demand; a cache miss transparently falls back to the original.
|
||||
const { data: settings } = useGetSettingsQuery();
|
||||
const streamQuality = settings?.streamQuality;
|
||||
const isSetup = useRef(false);
|
||||
// `ended` is registered once below; read the latest loop flag through a ref
|
||||
// so the listener doesn't need to be re-bound on every queue change.
|
||||
@@ -63,7 +68,7 @@ export function useAudioPlayer() {
|
||||
useEffect(() => {
|
||||
if (!player.currentTrackId || !accessToken) return;
|
||||
const audio = getAudio();
|
||||
const url = getStreamUrl(player.currentTrackId, accessToken);
|
||||
const url = getStreamUrl(player.currentTrackId, accessToken, streamQuality);
|
||||
if (audio.src !== url) {
|
||||
audio.src = url;
|
||||
audio.load();
|
||||
@@ -73,7 +78,7 @@ export function useAudioPlayer() {
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}, [player.currentTrackId, player.isPlaying, accessToken]);
|
||||
}, [player.currentTrackId, player.isPlaying, accessToken, streamQuality]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = getAudio();
|
||||
|
||||
@@ -163,6 +163,16 @@ const en = {
|
||||
mute: 'Mute',
|
||||
unmute: 'Unmute',
|
||||
},
|
||||
nowPlaying: {
|
||||
expand: 'Now playing',
|
||||
collapse: 'Collapse',
|
||||
artwork: 'Artwork',
|
||||
lyrics: 'Lyrics',
|
||||
},
|
||||
lyrics: {
|
||||
none: 'No lyrics found for this track.',
|
||||
refetch: 'Look again',
|
||||
},
|
||||
queue: {
|
||||
title: 'Play queue',
|
||||
shuffle: 'Shuffle queue',
|
||||
@@ -286,6 +296,36 @@ const en = {
|
||||
},
|
||||
storage: {
|
||||
subtitle: 'Everything this instance has tucked away',
|
||||
maintenance: {
|
||||
subtitle:
|
||||
'Find duplicates, tracks that failed enrichment, and files still awaiting metadata — then reconcile orphaned rows.',
|
||||
tabs: {
|
||||
duplicates: 'Duplicates',
|
||||
broken: 'Failed',
|
||||
missing: 'No metadata',
|
||||
},
|
||||
cleanup: {
|
||||
title: 'Clean up orphaned files',
|
||||
description:
|
||||
'Scan the catalogue and remove rows whose backing file has vanished. Runs in the background.',
|
||||
button: 'Run cleanup',
|
||||
enqueued: 'Cleanup queued',
|
||||
},
|
||||
duplicates: {
|
||||
empty: 'No duplicates found.',
|
||||
groupTitle: '{{count}} tracks share a fingerprint',
|
||||
},
|
||||
broken: {
|
||||
empty: 'No failed tracks.',
|
||||
},
|
||||
missing: {
|
||||
empty: 'Every track has metadata.',
|
||||
},
|
||||
pagination: '{{from}}–{{to}} of {{total}}',
|
||||
prev: 'Previous',
|
||||
next: 'Next',
|
||||
editBatch: 'Edit in batch',
|
||||
},
|
||||
device: 'On this device',
|
||||
server: 'On the server',
|
||||
audioCache: 'Cached audio',
|
||||
@@ -361,6 +401,12 @@ const en = {
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
},
|
||||
instance: {
|
||||
current: 'Current instance',
|
||||
none: 'No instance selected.',
|
||||
manage: 'Manage instances',
|
||||
signOut: 'Sign out',
|
||||
},
|
||||
playback: {
|
||||
streamQuality: 'Streaming quality',
|
||||
streamQualityHint:
|
||||
@@ -400,6 +446,77 @@ const en = {
|
||||
sources: 'Sources',
|
||||
instance: 'Instance',
|
||||
},
|
||||
users: {
|
||||
add: 'Add user',
|
||||
empty: 'No users yet.',
|
||||
you: '(you)',
|
||||
added: 'Added {{date}}',
|
||||
role: {
|
||||
admin: 'Admin',
|
||||
user: 'User',
|
||||
},
|
||||
status: {
|
||||
blocked: 'Blocked',
|
||||
},
|
||||
newDialog: {
|
||||
title: 'Add user',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
create: 'Create',
|
||||
cancel: 'Cancel',
|
||||
error: 'Could not create the user — the name may be taken.',
|
||||
},
|
||||
menu: {
|
||||
options: 'Options',
|
||||
makeAdmin: 'Make admin',
|
||||
makeUser: 'Revoke admin',
|
||||
resetPassword: 'Reset password',
|
||||
activate: 'Activate',
|
||||
deactivate: 'Deactivate',
|
||||
},
|
||||
resetDialog: {
|
||||
title: 'Reset password — {{name}}',
|
||||
newPassword: 'New password',
|
||||
submit: 'Set password',
|
||||
cancel: 'Cancel',
|
||||
done: 'Password updated.',
|
||||
},
|
||||
deactivateDialog: {
|
||||
title: 'Deactivate user?',
|
||||
description:
|
||||
'{{name}} will be signed out and blocked from signing in. You can reactivate them later.',
|
||||
confirm: 'Deactivate',
|
||||
cancel: 'Cancel',
|
||||
},
|
||||
},
|
||||
instance: {
|
||||
status: {
|
||||
ok: 'Healthy',
|
||||
down: 'Down',
|
||||
skipped: 'Not configured',
|
||||
},
|
||||
services: {
|
||||
title: 'Services',
|
||||
database: 'Database',
|
||||
redis: 'Redis',
|
||||
ml: 'ML service',
|
||||
},
|
||||
sources: {
|
||||
title: 'Sources',
|
||||
envNote: 'Sources are configured via environment variables.',
|
||||
empty: 'No sources configured.',
|
||||
available: 'Available',
|
||||
unavailable: 'Unavailable',
|
||||
},
|
||||
reindex: {
|
||||
title: 'Reindex library',
|
||||
description:
|
||||
'Re-scan every indexable source. Runs in the background and is idempotent.',
|
||||
button: 'Reindex',
|
||||
enqueued: 'Reindex queued ({{count}} sources).',
|
||||
noSource: 'No indexable source is configured.',
|
||||
},
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Page not found',
|
||||
@@ -488,10 +605,35 @@ const en = {
|
||||
manual: 'Edited manually — not auto-updated',
|
||||
},
|
||||
},
|
||||
metadataBatch: {
|
||||
selectedCount: '{{selected}} of {{total}} selected',
|
||||
status: {
|
||||
pending: 'No metadata',
|
||||
failed: 'Failed',
|
||||
},
|
||||
result: '{{ok}} updated, {{fail}} failed',
|
||||
reEnrich: {
|
||||
title: 'Re-run auto-enrichment',
|
||||
hint: 'Queue AcoustID/MusicBrainz identification for the selected tracks.',
|
||||
button: 'Re-enrich selected',
|
||||
},
|
||||
common: {
|
||||
title: 'Apply common fields',
|
||||
hint: 'Filled fields are written to every selected track; blank fields are left unchanged.',
|
||||
apply: 'Apply to selected',
|
||||
},
|
||||
empty: 'Nothing in this bucket.',
|
||||
},
|
||||
metadataEditor: {
|
||||
error: 'Failed to load track',
|
||||
saved: 'Metadata saved.',
|
||||
saveError: 'Failed to save metadata.',
|
||||
optimize: {
|
||||
title: 'Optimize format',
|
||||
hint: 'Transcode to a compact Opus + HLS rendition in the background.',
|
||||
button: 'Optimize',
|
||||
enqueued: 'Optimization queued — it runs in the background.',
|
||||
},
|
||||
save: 'Save',
|
||||
fields: {
|
||||
title: 'Title',
|
||||
|
||||
@@ -164,6 +164,16 @@ const ru: Translations = {
|
||||
mute: 'Выключить звук',
|
||||
unmute: 'Включить звук',
|
||||
},
|
||||
nowPlaying: {
|
||||
expand: 'Сейчас играет',
|
||||
collapse: 'Свернуть',
|
||||
artwork: 'Обложка',
|
||||
lyrics: 'Текст',
|
||||
},
|
||||
lyrics: {
|
||||
none: 'Текст для этого трека не найден.',
|
||||
refetch: 'Искать снова',
|
||||
},
|
||||
queue: {
|
||||
title: 'Очередь воспроизведения',
|
||||
shuffle: 'Перемешать очередь',
|
||||
@@ -287,6 +297,36 @@ const ru: Translations = {
|
||||
},
|
||||
storage: {
|
||||
subtitle: 'Всё, что хранит этот инстанс',
|
||||
maintenance: {
|
||||
subtitle:
|
||||
'Найдите дубликаты, треки с ошибкой обогащения и файлы без метаданных — и уберите осиротевшие записи.',
|
||||
tabs: {
|
||||
duplicates: 'Дубликаты',
|
||||
broken: 'С ошибкой',
|
||||
missing: 'Без метаданных',
|
||||
},
|
||||
cleanup: {
|
||||
title: 'Очистить осиротевшие файлы',
|
||||
description:
|
||||
'Сверить каталог и удалить записи, у которых пропал файл на диске. Выполняется в фоне.',
|
||||
button: 'Запустить очистку',
|
||||
enqueued: 'Очистка поставлена в очередь',
|
||||
},
|
||||
duplicates: {
|
||||
empty: 'Дубликатов не найдено.',
|
||||
groupTitle: '{{count}} треков с одним отпечатком',
|
||||
},
|
||||
broken: {
|
||||
empty: 'Треков с ошибкой нет.',
|
||||
},
|
||||
missing: {
|
||||
empty: 'У всех треков есть метаданные.',
|
||||
},
|
||||
pagination: '{{from}}–{{to}} из {{total}}',
|
||||
prev: 'Назад',
|
||||
next: 'Вперёд',
|
||||
editBatch: 'Редактировать массово',
|
||||
},
|
||||
device: 'На этом устройстве',
|
||||
server: 'На сервере',
|
||||
audioCache: 'Кэш аудио',
|
||||
@@ -362,6 +402,12 @@ const ru: Translations = {
|
||||
save: 'Сохранить',
|
||||
saving: 'Сохранение…',
|
||||
},
|
||||
instance: {
|
||||
current: 'Текущий инстанс',
|
||||
none: 'Инстанс не выбран.',
|
||||
manage: 'Управление инстансами',
|
||||
signOut: 'Выйти',
|
||||
},
|
||||
playback: {
|
||||
streamQuality: 'Качество потока',
|
||||
streamQualityHint:
|
||||
@@ -401,6 +447,77 @@ const ru: Translations = {
|
||||
sources: 'Источники',
|
||||
instance: 'Сервер',
|
||||
},
|
||||
users: {
|
||||
add: 'Добавить',
|
||||
empty: 'Пользователей пока нет.',
|
||||
you: '(вы)',
|
||||
added: 'Добавлен {{date}}',
|
||||
role: {
|
||||
admin: 'Админ',
|
||||
user: 'Пользователь',
|
||||
},
|
||||
status: {
|
||||
blocked: 'Заблокирован',
|
||||
},
|
||||
newDialog: {
|
||||
title: 'Новый пользователь',
|
||||
username: 'Логин',
|
||||
password: 'Пароль',
|
||||
create: 'Создать',
|
||||
cancel: 'Отмена',
|
||||
error: 'Не удалось создать пользователя — возможно, логин занят.',
|
||||
},
|
||||
menu: {
|
||||
options: 'Действия',
|
||||
makeAdmin: 'Сделать админом',
|
||||
makeUser: 'Снять админа',
|
||||
resetPassword: 'Сбросить пароль',
|
||||
activate: 'Разблокировать',
|
||||
deactivate: 'Заблокировать',
|
||||
},
|
||||
resetDialog: {
|
||||
title: 'Сброс пароля — {{name}}',
|
||||
newPassword: 'Новый пароль',
|
||||
submit: 'Задать пароль',
|
||||
cancel: 'Отмена',
|
||||
done: 'Пароль обновлён.',
|
||||
},
|
||||
deactivateDialog: {
|
||||
title: 'Заблокировать пользователя?',
|
||||
description:
|
||||
'{{name}} будет разлогинен и не сможет войти. Позже можно разблокировать.',
|
||||
confirm: 'Заблокировать',
|
||||
cancel: 'Отмена',
|
||||
},
|
||||
},
|
||||
instance: {
|
||||
status: {
|
||||
ok: 'Работает',
|
||||
down: 'Недоступен',
|
||||
skipped: 'Не настроен',
|
||||
},
|
||||
services: {
|
||||
title: 'Сервисы',
|
||||
database: 'База данных',
|
||||
redis: 'Redis',
|
||||
ml: 'ML-сервис',
|
||||
},
|
||||
sources: {
|
||||
title: 'Источники',
|
||||
envNote: 'Источники настраиваются через переменные окружения.',
|
||||
empty: 'Источники не настроены.',
|
||||
available: 'Доступен',
|
||||
unavailable: 'Недоступен',
|
||||
},
|
||||
reindex: {
|
||||
title: 'Переиндексация библиотеки',
|
||||
description:
|
||||
'Пересканировать все индексируемые источники. Выполняется в фоне, идемпотентно.',
|
||||
button: 'Переиндексировать',
|
||||
enqueued: 'Переиндексация запущена ({{count}} источников).',
|
||||
noSource: 'Индексируемые источники не настроены.',
|
||||
},
|
||||
},
|
||||
},
|
||||
notFound: {
|
||||
title: 'Страница не найдена',
|
||||
@@ -490,7 +607,32 @@ const ru: Translations = {
|
||||
manual: 'Изменено вручную — не обновляется автоматически',
|
||||
},
|
||||
},
|
||||
metadataBatch: {
|
||||
selectedCount: 'Выбрано {{selected}} из {{total}}',
|
||||
status: {
|
||||
pending: 'Без метаданных',
|
||||
failed: 'С ошибкой',
|
||||
},
|
||||
result: 'Обновлено {{ok}}, ошибок {{fail}}',
|
||||
reEnrich: {
|
||||
title: 'Повторное авто-обогащение',
|
||||
hint: 'Поставить в очередь идентификацию AcoustID/MusicBrainz для выбранных треков.',
|
||||
button: 'Переобогатить выбранные',
|
||||
},
|
||||
common: {
|
||||
title: 'Применить общие поля',
|
||||
hint: 'Заполненные поля запишутся всем выбранным трекам; пустые останутся без изменений.',
|
||||
apply: 'Применить к выбранным',
|
||||
},
|
||||
empty: 'В этой категории пусто.',
|
||||
},
|
||||
metadataEditor: {
|
||||
optimize: {
|
||||
title: 'Оптимизировать формат',
|
||||
hint: 'Перекодировать в компактный Opus + HLS в фоне.',
|
||||
button: 'Оптимизировать',
|
||||
enqueued: 'Оптимизация поставлена в очередь — выполнится в фоне.',
|
||||
},
|
||||
error: 'Не удалось загрузить трек',
|
||||
saved: 'Метаданные сохранены.',
|
||||
saveError: 'Не удалось сохранить метаданные.',
|
||||
|
||||
@@ -23,6 +23,7 @@ import './api/endpoints/storage';
|
||||
import './api/endpoints/admin';
|
||||
import './api/endpoints/settings';
|
||||
import './api/endpoints/upload';
|
||||
import './api/endpoints/lyrics';
|
||||
|
||||
// Tier 3 offline: register the audio-caching service worker (no-op if the
|
||||
// browser/origin doesn't support it).
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/** One parsed LRC line: a timestamp (seconds) and its text. */
|
||||
export interface LrcLine {
|
||||
time: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const TAG = /\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/g;
|
||||
|
||||
/**
|
||||
* Parse an LRC string into time-sorted lines. A single source line may carry
|
||||
* several timestamps (repeated lyric) — each becomes its own entry. Metadata
|
||||
* tags (`[ar:…]`, `[ti:…]`) and untimed lines are skipped; empty-text lines are
|
||||
* kept so the active-line highlight advances through instrumental gaps.
|
||||
*/
|
||||
export function parseLrc(lrc: string): LrcLine[] {
|
||||
const lines: LrcLine[] = [];
|
||||
for (const raw of lrc.split(/\r?\n/)) {
|
||||
const stamps = [...raw.matchAll(TAG)];
|
||||
if (stamps.length === 0) continue;
|
||||
const text = raw.replace(/\[[^\]]*\]/g, '').trim();
|
||||
for (const m of stamps) {
|
||||
const min = Number(m[1]);
|
||||
const sec = Number(m[2]);
|
||||
const frac = m[3] ? Number(m[3].padEnd(3, '0')) / 1000 : 0;
|
||||
lines.push({ time: min * 60 + sec + frac, text });
|
||||
}
|
||||
}
|
||||
return lines.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the active line for a playback position (seconds) — the last line
|
||||
* whose timestamp has passed. Returns -1 before the first line. Assumes `lines`
|
||||
* is time-sorted (as `parseLrc` returns).
|
||||
*/
|
||||
export function activeLineIndex(lines: LrcLine[], position: number): number {
|
||||
let lo = 0;
|
||||
let hi = lines.length - 1;
|
||||
let result = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (lines[mid].time <= position) {
|
||||
result = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface PlayerState {
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
isQueueOpen: boolean;
|
||||
isNowPlayingOpen: boolean;
|
||||
}
|
||||
|
||||
export const playerInitialState: PlayerState = {
|
||||
@@ -18,6 +19,7 @@ export const playerInitialState: PlayerState = {
|
||||
volume: 0.78,
|
||||
muted: false,
|
||||
isQueueOpen: false,
|
||||
isNowPlayingOpen: false,
|
||||
};
|
||||
|
||||
export const playerSlice = createSlice({
|
||||
@@ -55,6 +57,12 @@ export const playerSlice = createSlice({
|
||||
toggleQueue(state) {
|
||||
state.isQueueOpen = !state.isQueueOpen;
|
||||
},
|
||||
toggleNowPlaying(state) {
|
||||
state.isNowPlayingOpen = !state.isNowPlayingOpen;
|
||||
},
|
||||
setNowPlayingOpen(state, action: PayloadAction<boolean>) {
|
||||
state.isNowPlayingOpen = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -68,5 +76,7 @@ export const {
|
||||
setVolume,
|
||||
toggleMute,
|
||||
toggleQueue,
|
||||
toggleNowPlaying,
|
||||
setNowPlayingOpen,
|
||||
} = playerSlice.actions;
|
||||
export default playerSlice.reducer;
|
||||
|
||||
Reference in New Issue
Block a user