11 Commits

Author SHA1 Message Date
Цвылев Александр Вадимович 22f7fd25ff perf(metadata): bound bulk re-enrich concurrency
A large batch selection fired hundreds of simultaneous requests (each also
enqueuing a worker job). Run them in batches of BULK_CONCURRENCY.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 10:53:49 +03:00
Цвылев Александр Вадимович 5b31e77b1e perf(radio): bound queue backlog and exclude list
An infinite radio appends forever: cap the queue at QUEUE_CAP, trimming only
already-played entries (keep KEEP_BEHIND for prev-nav), and send only the most
recent EXCLUDE_WINDOW ids as "don't repeat" so the request and server NOT IN
don't grow unbounded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 10:53:48 +03:00
Цвылев Александр Вадимович 7f116f0657 fix(player): clean up audio listeners + keep position on URL change
- register named listeners with cleanup so a remount (StrictMode) can't leave
  duplicates double-dispatching
- when the same track's URL changes (quality switch or token refresh), resume
  at the current position instead of restarting from 0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 10:53:48 +03:00
Цвылев Александр Вадимович ac6b76bf98 feat(radio): wire radio + similarity into the queue (A11/B6, §6.5)
"Start radio" in the track menu seeds the queue from a track; RadioController
(headless, in AppShell) tops it up via /radio/next as it drains. QueuePanel's
radio scaffolding is now live: the exploration slider drives familiar<->new and
each entry shows its reason ("why this is playing"). Adds the radio endpoints
(start/next/similar), radio context + actions on the queue slice, and the pick->
entry mapper. Degrades to metadata picks while ML is offline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 02:40:13 +03:00
Цвылев Александр Вадимович e45ef785cf feat(player): stream at the chosen quality + per-track optimize (§6.6)
The Playback quality setting now drives streaming: getStreamUrl appends
?quality= and useAudioPlayer feeds the server-synced streamQuality, so the
backend transcodes on demand (miss → original). Adds an optimizeTrack mutation
and an Optimize action in the metadata editor to warm a track's rendition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 21:22:59 +03:00
Цвылев Александр Вадимович 9d88d70be0 feat(player): Now Playing overlay + synced lyrics panel (A1/A11, §6.7)
Full-screen overlay expanded from the persistent player: big artwork or a
Lyrics tab. Synced (LRC) lyrics highlight the active line by playback position,
auto-scroll, and seek on tap; plain text is the fallback; missing lyrics offers
a re-fetch. Adds lyrics endpoint + LRC parser + isNowPlayingOpen player state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 21:04:39 +03:00
Цвылев Александр Вадимович 6efbf4b68b feat(metadata): wire batch editor over metadata-status buckets (A7)
/metadata/batch?status=pending|failed edits a whole bucket: bulk re-enrich
(AcoustID/MusicBrainz) and stamp common fields onto the selection, with a
select-list and per-track checkboxes. Storage Maintenance failed/missing tabs
link straight into it, closing the A6→A7 flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:50:04 +03:00
Цвылев Александр Вадимович 87fac7a5a1 feat(admin): wire instance health panel + settings instance (A9/A10)
AdminInstance shows backing-service health (db/redis/ml), read-only source
status (env-configured), and a library reindex with graceful handling when no
indexable source exists. InstanceSettings shows the active instance and offers
switch/sign-out to /connect. Adds getServices/getSources/reindex endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:43:27 +03:00
Цвылев Александр Вадимович 3eba3701d0 feat(storage): wire Storage Maintenance screen (A6)
Duplicates / failed / missing-metadata tabs (paginated, reusing TrackRow) plus
admin orphan-file cleanup. Adds getDuplicates/getBrokenFiles/getMissingMetadata/
runCleanup endpoints and a DuplicateGroup type; drops the dead scanStorage/
deleteTrackFile stubs that had no backend route.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:42:05 +03:00
Цвылев Александр Вадимович 28d9e59e34 feat(admin): wire Admin Users management (A9)
User list with create dialog, role toggle, (de)activate, and reset-password;
self-lockout guard. Adds getUser/resetPassword endpoints, an isActive field on
the User type, and en/ru strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 20:40:36 +03:00
Цвылев Александр Вадимович 57044dabba feat(settings): wire Playback + Scrobbling panels to the backend
Adds src/api/endpoints/settings.ts (getSettings/updateSettings,
getScrobbling/updateScrobbling) plus AppSettings/ScrobblingConfig types
and a Settings cache tag. Replaces the "coming soon" scaffolds:

- Playback: stream-quality preference (SegmentedControl), server-synced.
- Scrobbling: enable + provider (Last.fm/ListenBrainz) + username +
  write-only session key, with a linked/not-linked badge and save states.

Built with modern-sk (Window/SegmentedControl/TextField/Button/Callout/
Badge) + the shared Loading/Error states; en + ru strings added. Server
state flows through RTK Query hooks only (no fetch in components).

Also drops a pre-existing unused TextField import in LibraryPage that was
failing `tsc --noEmit`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 15:16:19 +03:00
31 changed files with 2964 additions and 76 deletions
+41 -3
View File
@@ -1,6 +1,11 @@
import { api } from '../index'; import { api } from '../index';
import { toUser, type RawUser } from '../mappers'; 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` / * 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), transformResponse: (raw: RawUser[]) => raw.map(toUser),
providesTags: ['User'], 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< createUser: build.mutation<
User, User,
{ username: string; password: string; role: 'admin' | 'user' } { username: string; password: string; role: 'admin' | 'user' }
@@ -39,11 +49,34 @@ export const adminApi = api.injectEndpoints({
}, },
}), }),
transformResponse: (raw: RawUser) => toUser(raw), 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>({ deleteUser: build.mutation<void, string>({
query: (id) => ({ url: `/admin/users/${id}`, method: 'DELETE' }), 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, overrideExisting: false,
@@ -51,7 +84,12 @@ export const adminApi = api.injectEndpoints({
export const { export const {
useGetUsersQuery, useGetUsersQuery,
useGetUserQuery,
useCreateUserMutation, useCreateUserMutation,
useUpdateUserMutation, useUpdateUserMutation,
useDeleteUserMutation, useDeleteUserMutation,
useResetPasswordMutation,
useGetServicesQuery,
useGetSourcesQuery,
useReindexMutation,
} = adminApi; } = adminApi;
+13
View File
@@ -201,6 +201,18 @@ export const libraryApi = api.injectEndpoints({
}), }),
invalidatesTags: (_r, _e, trackId) => [{ type: 'Track', id: trackId }], 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, overrideExisting: false,
}); });
@@ -219,4 +231,5 @@ export const {
useLazyGetMetadataMatchesQuery, useLazyGetMetadataMatchesQuery,
useApplyMetadataMutation, useApplyMetadataMutation,
useEnrichTrackMutation, useEnrichTrackMutation,
useOptimizeTrackMutation,
} = libraryApi; } = libraryApi;
+49
View File
@@ -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;
+67
View File
@@ -0,0 +1,67 @@
import { api } from '../index';
import { toTrack, type RawTrack } from '../mappers';
import type { RadioResult } from '../types';
// Radio + similarity (§6.5). Stateless: the client passes the seed + already-
// queued ids and pulls more as the queue drains.
interface RawRadioTrack {
track: RawTrack;
reason: string;
}
interface RawRadio {
source: string;
tracks: RawRadioTrack[];
}
interface RawSimilar {
source: string;
tracks: RawTrack[];
}
export interface RadioArgs {
seedTrackId?: string;
fromLikes?: boolean;
exploration?: number;
count?: number;
excludeIds?: string[];
}
const toBody = (a: RadioArgs) => ({
seed_track_id: a.seedTrackId ?? null,
from_likes: a.fromLikes ?? false,
exploration: a.exploration ?? 0.25,
count: a.count ?? 20,
exclude_ids: a.excludeIds ?? [],
});
const toRadio = (r: RawRadio): RadioResult => ({
source: r.source,
picks: r.tracks.map((x) => ({ track: toTrack(x.track), reason: x.reason })),
});
export const radioApi = api.injectEndpoints({
endpoints: (build) => ({
startRadio: build.mutation<RadioResult, RadioArgs>({
query: (a) => ({ url: '/radio', method: 'POST', body: toBody(a) }),
transformResponse: (r: RawRadio) => toRadio(r),
}),
radioNext: build.mutation<RadioResult, RadioArgs>({
query: (a) => ({ url: '/radio/next', method: 'POST', body: toBody(a) }),
transformResponse: (r: RawRadio) => toRadio(r),
}),
getSimilarTracks: build.query<RadioResult, string>({
query: (trackId) => `/tracks/${trackId}/similar`,
transformResponse: (r: RawSimilar) => ({
source: r.source,
picks: r.tracks.map((t) => ({ track: toTrack(t), reason: 'similar' })),
}),
}),
}),
overrideExisting: false,
});
export const {
useStartRadioMutation,
useRadioNextMutation,
useGetSimilarTracksQuery,
} = radioApi;
+90
View File
@@ -0,0 +1,90 @@
import { api } from '../index';
import type {
AppSettings,
ScrobbleProvider,
ScrobblingConfig,
StreamQuality,
ThemePref,
} from '../types';
// Wire shapes (snake_case) from the backend, mapped to the app's camelCase types.
interface RawSettings {
theme: ThemePref;
stream_quality: StreamQuality;
}
interface RawScrobbling {
enabled: boolean;
provider: ScrobbleProvider | null;
username: string | null;
configured: boolean;
}
const toSettings = (r: RawSettings): AppSettings => ({
theme: r.theme,
streamQuality: r.stream_quality,
});
const toScrobbling = (r: RawScrobbling): ScrobblingConfig => ({
enabled: r.enabled,
provider: r.provider,
username: r.username,
configured: r.configured,
});
export const settingsApi = api.injectEndpoints({
endpoints: (build) => ({
getSettings: build.query<AppSettings, void>({
query: () => '/settings',
transformResponse: (raw: RawSettings) => toSettings(raw),
providesTags: ['Settings'],
}),
updateSettings: build.mutation<
AppSettings,
{ theme?: ThemePref; streamQuality?: StreamQuality }
>({
query: ({ theme, streamQuality }) => ({
url: '/settings',
method: 'PATCH',
body: { theme, stream_quality: streamQuality },
}),
transformResponse: (raw: RawSettings) => toSettings(raw),
invalidatesTags: ['Settings'],
}),
getScrobbling: build.query<ScrobblingConfig, void>({
query: () => '/settings/scrobbling',
transformResponse: (raw: RawScrobbling) => toScrobbling(raw),
providesTags: ['Settings'],
}),
updateScrobbling: build.mutation<
ScrobblingConfig,
{
enabled: boolean;
provider: ScrobbleProvider | null;
username: string | null;
// Write-only: send only when the user typed a new key; omit to keep the
// stored one (the server never returns it).
sessionKey?: string;
}
>({
query: ({ enabled, provider, username, sessionKey }) => ({
url: '/settings/scrobbling',
method: 'PUT',
body: {
enabled,
provider,
username,
session_key: sessionKey,
},
}),
transformResponse: (raw: RawScrobbling) => toScrobbling(raw),
invalidatesTags: ['Settings'],
}),
}),
overrideExisting: false,
});
export const {
useGetSettingsQuery,
useUpdateSettingsMutation,
useGetScrobblingQuery,
useUpdateScrobblingMutation,
} = settingsApi;
+54 -14
View File
@@ -1,10 +1,32 @@
import { api } from '../index'; import { api } from '../index';
import { toStorageStats, type RawStorageStats } from '../mappers'; import {
import type { StorageStats } from '../types'; 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 // `GET /storage` returns library + disk statistics (§A6). The maintenance
// routes (`/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`, // routes back the Storage Maintenance screen: `/duplicates`, `/broken`,
// `POST /storage/cleanup`) are still backend stubs and unused by the UI. // `/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({ export const storageApi = api.injectEndpoints({
endpoints: (build) => ({ endpoints: (build) => ({
@@ -13,16 +35,32 @@ export const storageApi = api.injectEndpoints({
transformResponse: (raw: RawStorageStats) => toStorageStats(raw), transformResponse: (raw: RawStorageStats) => toStorageStats(raw),
providesTags: ['Storage'], providesTags: ['Storage'],
}), }),
scanStorage: build.mutation<{ jobId: string }, void>({ getDuplicates: build.query<DuplicateGroup[], void>({
query: () => ({ url: '/storage/scan', method: 'POST' }), query: () => '/storage/duplicates',
invalidatesTags: ['Storage', 'Track', 'Album', 'Artist'], transformResponse: (raw: RawDuplicateGroup[]) =>
raw.map((g) => ({
fingerprint: g.fingerprint,
tracks: g.tracks.map(toTrack),
})),
providesTags: ['Storage', 'Track'],
}), }),
deleteTrackFile: build.mutation<void, string>({ getBrokenFiles: build.query<PaginatedResponse<Track>, Paging | void>({
query: (trackId) => ({ query: (p) => ({ url: '/storage/broken', params: p ?? {} }),
url: `/storage/tracks/${trackId}`, transformResponse: (raw: RawPaged<RawTrack>) => toPage(raw, toTrack),
method: 'DELETE', providesTags: ['Storage', 'Track'],
}), }),
invalidatesTags: ['Storage', { type: 'Track', id: undefined }], 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', 'Track'],
}), }),
}), }),
overrideExisting: false, overrideExisting: false,
@@ -30,6 +68,8 @@ export const storageApi = api.injectEndpoints({
export const { export const {
useGetStorageStatsQuery, useGetStorageStatsQuery,
useScanStorageMutation, useGetDuplicatesQuery,
useDeleteTrackFileMutation, useGetBrokenFilesQuery,
useGetMissingMetadataQuery,
useRunCleanupMutation,
} = storageApi; } = storageApi;
+10 -2
View File
@@ -5,9 +5,17 @@ import { getApiBaseUrl } from '../../config/runtime-config';
* param because `<audio>` can't send an `Authorization` header; the backend * param because `<audio>` can't send an `Authorization` header; the backend
* accepts `?token=` on `GET /stream/{id}` for exactly this reason. * 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(); 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 { export function getCoverUrl(artUrl: string | undefined): string | undefined {
+1
View File
@@ -23,6 +23,7 @@ export const api = createApi({
'Like', 'Like',
'User', 'User',
'Storage', 'Storage',
'Settings',
], ],
// Tier 2 offline: seed the cache from the persisted snapshot dispatched at // Tier 2 offline: seed the cache from the persisted snapshot dispatched at
// startup (see `store/rtkqPersist.ts`). Returning the saved queries/mutations // startup (see `store/rtkqPersist.ts`). Returning the saved queries/mutations
+1
View File
@@ -128,6 +128,7 @@ export const toUser = (r: RawUser): User => ({
username: r.username, username: r.username,
// MVP role model: superuser → admin, everyone else → user. // MVP role model: superuser → admin, everyone else → user.
role: r.is_superuser ? 'admin' : 'user', role: r.is_superuser ? 'admin' : 'user',
isActive: r.is_active,
createdAt: r.created_at, createdAt: r.created_at,
}); });
+63
View File
@@ -140,6 +140,16 @@ export interface UploadResponse {
already_exists: boolean; 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 { export interface StorageFormatBreakdown {
fileFormat: string; fileFormat: string;
trackCount: number; trackCount: number;
@@ -176,11 +186,43 @@ export interface StorageStats {
disk?: StorageDiskUsage; disk?: StorageDiskUsage;
} }
/** A set of tracks sharing one acoustic fingerprint — de-dup candidates (§A6). */
export interface DuplicateGroup {
fingerprint: string;
tracks: Track[];
}
/** One radio/similarity pick with its reason code (§6.5). `reason`: `ml` |
* `similar` | `from_likes` | `discover` — localized client-side. */
export interface RadioPick {
track: Track;
reason: string;
}
/** Radio / similar response: where the picks came from + the picks. `source`:
* `ml` (recommender) or `metadata` (fallback heuristics). */
export interface RadioResult {
source: string;
picks: RadioPick[];
}
/** 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 { export interface User {
id: string; id: string;
username: string; username: string;
email?: string; email?: string;
role: 'admin' | 'user'; role: 'admin' | 'user';
isActive: boolean;
createdAt: string; createdAt: string;
lastActiveAt?: string; lastActiveAt?: string;
} }
@@ -258,3 +300,24 @@ export interface MetadataEdit {
genre?: string; genre?: string;
trackNumber?: number; trackNumber?: number;
} }
// -- user settings (`/settings`) ---------------------------------------------
export type ThemePref = 'system' | 'light' | 'dark';
export type StreamQuality = 'original' | 'high' | 'medium' | 'low';
/** General per-user preferences from `GET /settings`. */
export interface AppSettings {
theme: ThemePref;
streamQuality: StreamQuality;
}
export type ScrobbleProvider = 'lastfm' | 'listenbrainz';
/** Scrobbling config from `GET /settings/scrobbling`. The session key is
* write-only server-side, so it never appears here — only `configured`. */
export interface ScrobblingConfig {
enabled: boolean;
provider: ScrobbleProvider | null;
username: string | null;
configured: boolean;
}
+4
View File
@@ -2,7 +2,9 @@ import { Outlet } from 'react-router';
import { Suspense } from 'react'; import { Suspense } from 'react';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { PersistentPlayer } from '../player/PersistentPlayer'; import { PersistentPlayer } from '../player/PersistentPlayer';
import { NowPlaying } from '../player/NowPlaying';
import { QueuePanel } from '../player/QueuePanel'; import { QueuePanel } from '../player/QueuePanel';
import { RadioController } from '../player/RadioController';
import { TrackInfoDrawer } from '../track/TrackInfoDrawer'; import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog'; import { AddToPlaylistDialog } from '../track/AddToPlaylistDialog';
import { LoadingSkeleton } from '../common/LoadingSkeleton'; import { LoadingSkeleton } from '../common/LoadingSkeleton';
@@ -37,6 +39,8 @@ export function AppShell() {
</div> </div>
<AddToPlaylistDialog /> <AddToPlaylistDialog />
<PersistentPlayer /> <PersistentPlayer />
<NowPlaying />
<RadioController />
</div> </div>
); );
} }
+169
View File
@@ -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>
);
}
+188
View File
@@ -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, toggleMute,
setVolume, setVolume,
toggleQueue, toggleQueue,
toggleNowPlaying,
} from '../../store/slices/player'; } from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui'; import { openTrackInfo } from '../../store/slices/ui';
import { useAudioPlayer } from '../../hooks/useAudioPlayer'; import { useAudioPlayer } from '../../hooks/useAudioPlayer';
@@ -145,6 +146,14 @@ export function PersistentPlayer() {
> >
<Icon name="queue" /> <Icon name="queue" />
</button> </button>
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(toggleNowPlaying())}
title={t('nowPlaying.expand')}
>
<Icon name="vinyl-record" />
</button>
</div> </div>
</div> </div>
); );
+11 -1
View File
@@ -36,6 +36,7 @@ import {
clearQueue, clearQueue,
toggleShuffle, toggleShuffle,
toggleLoop, toggleLoop,
setExploration,
type QueueEntry, type QueueEntry,
} from '../../store/slices/queue'; } from '../../store/slices/queue';
import { toggleQueue } from '../../store/slices/player'; import { toggleQueue } from '../../store/slices/player';
@@ -148,7 +149,8 @@ export function QueuePanel() {
min={0} min={0}
max={100} max={100}
step={1} step={1}
defaultValue={[42]} value={[Math.round((queue.radio?.exploration ?? 0.25) * 100)]}
onValueChange={([v]) => dispatch(setExploration(v / 100))}
aria-label="Exploration" aria-label="Exploration"
/> />
<span className="lab">{t('queue.new')}</span> <span className="lab">{t('queue.new')}</span>
@@ -277,6 +279,14 @@ function QueueRow({
className="r" className="r"
text={resolved?.artistName ?? entry.artistName} text={resolved?.artistName ?? entry.artistName}
/> />
{entry.reason && (
<span
style={{ fontSize: 11, color: 'var(--color-accent)' }}
title={t('queue.whyPlaying')}
>
{t(`queue.reason.${entry.reason}`)}
</span>
)}
</div> </div>
<Menu> <Menu>
<MenuTrigger asChild> <MenuTrigger asChild>
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useRef } from 'react';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { useRadioNextMutation } from '../../api/endpoints/radio';
import { appendToQueue } from '../../store/slices/queue';
import { pickToEntry } from '../../lib/radio';
// Prefetch more radio tracks once the current position is within this many of
// the end of the queue.
const LOOKAHEAD = 3;
// Only the most recent tracks are sent as "don't repeat these" — bounding the
// request so a long radio session doesn't ship an ever-growing exclude list
// (and a huge NOT IN on the server). Recent-repeat avoidance is what matters.
const EXCLUDE_WINDOW = 150;
/**
* Headless controller: while a radio drives the queue, top it up as it drains
* (§6.5 / B6 infinite feed). Mounted once in AppShell. A failed prefetch just
* stops the feed — playback of what's already queued is unaffected.
*/
export function RadioController() {
const dispatch = useAppDispatch();
const radio = useAppSelector((s) => s.queue.radio);
const currentIndex = useAppSelector((s) => s.queue.currentIndex);
const entries = useAppSelector((s) => s.queue.entries);
const [radioNext] = useRadioNextMutation();
const loadingRef = useRef(false);
useEffect(() => {
if (!radio) return;
const nearEnd = currentIndex >= entries.length - LOOKAHEAD;
if (!nearEnd || loadingRef.current) return;
loadingRef.current = true;
void (async () => {
try {
const res = await radioNext({
seedTrackId: radio.seedTrackId ?? undefined,
fromLikes: radio.fromLikes,
exploration: radio.exploration,
count: 10,
excludeIds: entries.slice(-EXCLUDE_WINDOW).map((e) => e.trackId),
}).unwrap();
if (res.picks.length > 0) {
dispatch(appendToQueue(res.picks.map(pickToEntry)));
}
} catch {
// best-effort — leave the queue as-is
} finally {
loadingRef.current = false;
}
})();
}, [radio, currentIndex, entries, radioNext, dispatch]);
return null;
}
@@ -11,6 +11,7 @@ import { useAppDispatch } from '../../hooks/useAppDispatch';
import { addToQueue, addNextInQueue } from '../../store/slices/queue'; import { addToQueue, addNextInQueue } from '../../store/slices/queue';
import { play } from '../../store/slices/player'; import { play } from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui'; import { openTrackInfo } from '../../store/slices/ui';
import { useStartRadio } from '../../hooks/useStartRadio';
import type { Track } from '../../api/types'; import type { Track } from '../../api/types';
interface Props { interface Props {
@@ -32,6 +33,7 @@ export function TrackContextMenu({
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { start: startRadio } = useStartRadio();
const entry = { const entry = {
trackId: track.id, trackId: track.id,
@@ -75,6 +77,9 @@ export function TrackContextMenu({
> >
{t('track.menu.addToQueue')} {t('track.menu.addToQueue')}
</MenuItem> </MenuItem>
<MenuItem onSelect={() => void startRadio({ seedTrackId: track.id })}>
{t('track.menu.startRadio')}
</MenuItem>
<MenuSeparator /> <MenuSeparator />
<MenuItem <MenuItem
onSelect={() => { onSelect={() => {
+622 -8
View File
@@ -1,5 +1,39 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { useTranslation } from 'react-i18next'; 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 }) { function StubPanel({ title }: { title: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -12,16 +46,430 @@ function StubPanel({ title }: { title: string }) {
); );
} }
/** `/admin/users` — user list (add/remove). Scaffold. */ /** Role + active-status badges shared by the list and the detail view. */
export function AdminUsers() { function UserBadges({ user }: { user: User }) {
const { t } = useTranslation(); 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() { export function AdminUserDetail() {
const { t } = useTranslation(); 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. */ /** `/admin/sources` — pluggable source management (creds/cookies/status). Scaffold. */
@@ -30,8 +478,174 @@ export function AdminSources() {
return <StubPanel title={t('admin.tabs.sources')} />; 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() { export function AdminInstance() {
const { t } = useTranslation(); 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
View File
@@ -7,7 +7,6 @@ import {
TabsContent, TabsContent,
ScrollArea, ScrollArea,
Card, Card,
TextField,
Callout, Callout,
SearchField, SearchField,
} from '@olly/modern-sk'; } from '@olly/modern-sk';
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Badge, Badge,
@@ -8,6 +8,7 @@ import {
Card, Card,
IconButton, IconButton,
ScrollArea, ScrollArea,
SegmentedControl,
Spinner, Spinner,
TextField, TextField,
} from '@olly/modern-sk'; } from '@olly/modern-sk';
@@ -16,11 +17,17 @@ import {
useEnrichTrackMutation, useEnrichTrackMutation,
useGetTrackQuery, useGetTrackQuery,
useLazyGetMetadataMatchesQuery, useLazyGetMetadataMatchesQuery,
useOptimizeTrackMutation,
} from '../../api/endpoints/library'; } 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 { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { EmptyState } from '../../components/common/EmptyState';
import { ErrorState } from '../../components/common/ErrorState'; import { ErrorState } from '../../components/common/ErrorState';
import { Placeholder } from '../../components/common/Placeholder'; import { TrackRow } from '../../components/track/TrackRow';
interface Props { interface Props {
/** Single-track editor vs. batch editor — both A7, same scaffold. */ /** Single-track editor vs. batch editor — both A7, same scaffold. */
@@ -58,15 +65,31 @@ function fieldStyle(): React.CSSProperties {
return { width: '100%' }; return { width: '100%' };
} }
// Cap how many bulk metadata ops run at once — a large selection would otherwise
// fire hundreds of simultaneous requests (each also spins up a worker enqueue).
const BULK_CONCURRENCY = 6;
async function settleInBatches<T>(
items: T[],
limit: number,
op: (item: T) => Promise<unknown>,
): Promise<PromiseSettledResult<unknown>[]> {
const results: PromiseSettledResult<unknown>[] = [];
for (let i = 0; i < items.length; i += limit) {
const batch = items.slice(i, i + limit);
results.push(...(await Promise.allSettled(batch.map(op))));
}
return results;
}
/** /**
* `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID * `/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) { export function MetadataEditorPage({ batch = false }: Props) {
const { t } = useTranslation();
if (batch) { if (batch) {
return <Placeholder title={t('pages.metadataBatch')} />; return <BatchEditor />;
} }
return <SingleTrackEditor />; return <SingleTrackEditor />;
@@ -413,12 +436,67 @@ function SingleTrackEditor() {
)} )}
</div> </div>
</Card> </Card>
<OptimizeCard trackId={trackId} />
</div> </div>
</ScrollArea> </ScrollArea>
</div> </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({ function MatchRow({
match, match,
onUse, onUse,
@@ -589,3 +667,319 @@ function DiffView({
</div> </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 settleInBatches(ids, BULK_CONCURRENCY, 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>
);
}
+287 -11
View File
@@ -1,7 +1,31 @@
import type { ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Window, SegmentedControl, useTheme } from '@olly/modern-sk'; import {
Window,
SegmentedControl,
TextField,
Button,
Callout,
Badge,
useTheme,
} from '@olly/modern-sk';
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n'; 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,
useGetScrobblingQuery,
useUpdateScrobblingMutation,
} from '../../api/endpoints/settings';
import type {
ScrobbleProvider,
StreamQuality,
} from '../../api/types';
/** Labelled settings row: caption on the left, control on the right. */ /** Labelled settings row: caption on the left, control on the right. */
function SettingRow({ function SettingRow({
@@ -27,6 +51,14 @@ function SettingRow({
); );
} }
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 500,
marginBottom: '0.375rem',
color: 'var(--color-text-2)',
};
/** `/settings/profile` — profile + app language + theme (all wired). */ /** `/settings/profile` — profile + app language + theme (all wired). */
export function ProfileSettings() { export function ProfileSettings() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -66,38 +98,282 @@ export function ProfileSettings() {
); );
} }
/** `/settings/playback` — default stream quality / playback behaviour. Scaffold. */ const STREAM_QUALITIES: StreamQuality[] = ['original', 'high', 'medium', 'low'];
/** `/settings/playback` — default stream quality (server-synced). */
export function PlaybackSettings() { export function PlaybackSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading, isError, refetch } = useGetSettingsQuery();
const [updateSettings, { isLoading: isSaving }] = useUpdateSettingsMutation();
return ( return (
<Window title={t('settings.tabs.playback')}> <Window title={t('settings.tabs.playback')}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}> {isLoading && <LoadingSkeleton rows={1} height={40} />}
{t('common.comingSoon')} {isError && (
<ErrorState message={t('common.error')} onRetry={() => refetch()} />
)}
{data && (
<div
style={{
padding: '0.75rem 0',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}}
>
<SettingRow label={t('settings.playback.streamQuality')}>
<SegmentedControl
value={data.streamQuality}
onValueChange={(v) =>
void updateSettings({ streamQuality: v as StreamQuality })
}
items={STREAM_QUALITIES.map((q) => ({
value: q,
label: t(`settings.playback.quality.${q}`),
}))}
/>
</SettingRow>
<p
style={{
margin: 0,
fontSize: '0.8rem',
color: 'var(--color-text-3)',
}}
>
{isSaving
? t('settings.common.saving')
: t('settings.playback.streamQualityHint')}
</p> </p>
</div>
)}
</Window> </Window>
); );
} }
/** `/settings/scrobbling` — last.fm / ListenBrainz linking. Scaffold. */ const PROVIDERS: ScrobbleProvider[] = ['lastfm', 'listenbrainz'];
/** `/settings/scrobbling` — Last.fm / ListenBrainz linking (server-synced). */
export function ScrobblingSettings() { export function ScrobblingSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading, isError, refetch } = useGetScrobblingQuery();
const [updateScrobbling, { isLoading: isSaving }] =
useUpdateScrobblingMutation();
const [enabled, setEnabled] = useState(false);
const [provider, setProvider] = useState<ScrobbleProvider>('lastfm');
const [username, setUsername] = useState('');
const [sessionKey, setSessionKey] = useState('');
const [status, setStatus] = useState<'idle' | 'saved' | 'error'>('idle');
// Seed the form from the server once it loads (and on refetch).
useEffect(() => {
if (data) {
setEnabled(data.enabled);
setProvider(data.provider ?? 'lastfm');
setUsername(data.username ?? '');
}
}, [data]);
const handleSave = async () => {
setStatus('idle');
try {
await updateScrobbling({
enabled,
provider: enabled ? provider : null,
username: username.trim() || null,
sessionKey: sessionKey || undefined,
}).unwrap();
setSessionKey(''); // the key is write-only — don't keep it around
setStatus('saved');
} catch {
setStatus('error');
}
};
return ( return (
<Window title={t('settings.tabs.scrobbling')}> <Window title={t('settings.tabs.scrobbling')}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}> {isLoading && <LoadingSkeleton rows={3} height={40} />}
{t('common.comingSoon')} {isError && (
<ErrorState message={t('common.error')} onRetry={() => refetch()} />
)}
{data && (
<div
style={{
padding: '0.75rem 0',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
}}
>
<p
style={{
margin: 0,
fontSize: '0.875rem',
color: 'var(--color-text-2)',
}}
>
{t('settings.scrobbling.description')}
</p> </p>
<Badge variant={data.configured ? 'lime' : 'neutral'}>
{data.configured
? t('settings.scrobbling.configured')
: t('settings.scrobbling.notConfigured')}
</Badge>
</div>
<SettingRow label={t('settings.scrobbling.enabled')}>
<SegmentedControl
value={enabled ? 'on' : 'off'}
onValueChange={(v) => setEnabled(v === 'on')}
items={[
{ value: 'on', label: t('settings.scrobbling.on') },
{ value: 'off', label: t('settings.scrobbling.off') },
]}
/>
</SettingRow>
{enabled && (
<>
<SettingRow label={t('settings.scrobbling.provider')}>
<SegmentedControl
value={provider}
onValueChange={(v) => setProvider(v as ScrobbleProvider)}
items={PROVIDERS.map((p) => ({
value: p,
label: t(`settings.scrobbling.${p}`),
}))}
/>
</SettingRow>
<div>
<label style={labelStyle}>
{t('settings.scrobbling.username')}
</label>
<TextField
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t('settings.scrobbling.usernamePlaceholder')}
autoComplete="off"
/>
</div>
<div>
<label style={labelStyle}>
{t('settings.scrobbling.sessionKey')}
</label>
<TextField
type="password"
value={sessionKey}
onChange={(e) => setSessionKey(e.target.value)}
placeholder={
data.configured
? t('settings.scrobbling.sessionKeyKeep')
: t('settings.scrobbling.sessionKeyPlaceholder')
}
autoComplete="off"
/>
<span
style={{
display: 'block',
fontSize: '0.75rem',
color: 'var(--color-text-3)',
marginTop: '0.375rem',
}}
>
{t('settings.scrobbling.sessionKeyHint')}
</span>
</div>
</>
)}
{status === 'saved' && (
<Callout variant="success">
{t('settings.scrobbling.saved')}
</Callout>
)}
{status === 'error' && (
<Callout variant="danger">{t('settings.scrobbling.error')}</Callout>
)}
<div>
<Button
variant="primary"
disabled={isSaving}
onClick={() => void handleSave()}
>
{isSaving
? t('settings.common.saving')
: t('settings.common.save')}
</Button>
</div>
</div>
)}
</Window> </Window>
); );
} }
/** `/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() { export function InstanceSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const instance = getActiveInstance();
const handleSignOut = () => {
dispatch(logout());
void navigate('/connect');
};
return ( return (
<Window title={t('settings.tabs.instance')}> <Window title={t('settings.tabs.instance')}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}> <div
{t('common.comingSoon')} 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> </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> </Window>
); );
} }
+238 -3
View File
@@ -1,8 +1,243 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; 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() { export function StorageMaintenancePage() {
const { t } = useTranslation(); 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>
);
} }
+45 -23
View File
@@ -10,6 +10,7 @@ import {
import { nextTrack, prevTrack } from '../store/slices/queue'; import { nextTrack, prevTrack } from '../store/slices/queue';
import { play } from '../store/slices/player'; import { play } from '../store/slices/player';
import { getStreamUrl, getCoverUrl } from '../api/endpoints/streaming'; import { getStreamUrl, getCoverUrl } from '../api/endpoints/streaming';
import { useGetSettingsQuery } from '../api/endpoints/settings';
let audioElement: HTMLAudioElement | null = null; let audioElement: HTMLAudioElement | null = null;
@@ -26,54 +27,75 @@ export function useAudioPlayer() {
const player = useAppSelector((s) => s.player); const player = useAppSelector((s) => s.player);
const queue = useAppSelector((s) => s.queue); const queue = useAppSelector((s) => s.queue);
const accessToken = useAppSelector((s) => s.auth.accessToken); const accessToken = useAppSelector((s) => s.auth.accessToken);
const isSetup = useRef(false); // Default stream quality (server-synced §A10) → the backend transcodes to it
// `ended` is registered once below; read the latest loop flag through a ref // on demand; a cache miss transparently falls back to the original.
// so the listener doesn't need to be re-bound on every queue change. const { data: settings } = useGetSettingsQuery();
const streamQuality = settings?.streamQuality;
// `ended` reads the latest loop flag through a ref so the listener doesn't
// need re-binding on every queue change.
const loopRef = useRef(queue.loop); const loopRef = useRef(queue.loop);
loopRef.current = queue.loop; loopRef.current = queue.loop;
// The track id the current <audio>.src points at — lets us tell a real track
// change (start at 0) from a same-track URL change (preserve position).
const loadedTrackRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (isSetup.current) return;
isSetup.current = true;
const audio = getAudio(); const audio = getAudio();
// Named handlers + cleanup so a remount (React StrictMode, or the hook
audio.addEventListener('timeupdate', () => { // mounting elsewhere) can't leave duplicate listeners double-dispatching.
dispatch(setPosition(audio.currentTime)); const onTime = () => dispatch(setPosition(audio.currentTime));
}); const onDuration = () => dispatch(setDuration(audio.duration || 0));
audio.addEventListener('durationchange', () => { const onEnded = () => {
dispatch(setDuration(audio.duration || 0));
});
audio.addEventListener('ended', () => {
if (loopRef.current) { if (loopRef.current) {
audio.currentTime = 0; audio.currentTime = 0;
void audio.play(); void audio.play();
} else { } else {
dispatch(nextTrack()); dispatch(nextTrack());
} }
}); };
audio.addEventListener('pause', () => { const onPause = () => dispatch(pause());
dispatch(pause()); const onPlay = () => dispatch(resume());
});
audio.addEventListener('play', () => { audio.addEventListener('timeupdate', onTime);
dispatch(resume()); audio.addEventListener('durationchange', onDuration);
}); audio.addEventListener('ended', onEnded);
audio.addEventListener('pause', onPause);
audio.addEventListener('play', onPlay);
return () => {
audio.removeEventListener('timeupdate', onTime);
audio.removeEventListener('durationchange', onDuration);
audio.removeEventListener('ended', onEnded);
audio.removeEventListener('pause', onPause);
audio.removeEventListener('play', onPlay);
};
}, [dispatch]); }, [dispatch]);
useEffect(() => { useEffect(() => {
if (!player.currentTrackId || !accessToken) return; if (!player.currentTrackId || !accessToken) return;
const audio = getAudio(); const audio = getAudio();
const url = getStreamUrl(player.currentTrackId, accessToken); const url = getStreamUrl(player.currentTrackId, accessToken, streamQuality);
if (audio.src !== url) { if (audio.src !== url) {
// Same track, new URL (quality changed, or the access token was refreshed
// mid-play) → keep the playback position instead of restarting from 0.
const sameTrack = loadedTrackRef.current === player.currentTrackId;
const resumeAt = sameTrack ? audio.currentTime : 0;
audio.src = url; audio.src = url;
audio.load(); audio.load();
loadedTrackRef.current = player.currentTrackId;
if (resumeAt > 0) {
const onMeta = () => {
audio.currentTime = resumeAt;
audio.removeEventListener('loadedmetadata', onMeta);
};
audio.addEventListener('loadedmetadata', onMeta);
}
} }
if (player.isPlaying) { if (player.isPlaying) {
void audio.play(); void audio.play();
} else { } else {
audio.pause(); audio.pause();
} }
}, [player.currentTrackId, player.isPlaying, accessToken]); }, [player.currentTrackId, player.isPlaying, accessToken, streamQuality]);
useEffect(() => { useEffect(() => {
const audio = getAudio(); const audio = getAudio();
+34
View File
@@ -0,0 +1,34 @@
import { useStartRadioMutation } from '../api/endpoints/radio';
import { useAppDispatch } from './useAppDispatch';
import { startRadioQueue } from '../store/slices/queue';
import { pickToEntry } from '../lib/radio';
const DEFAULT_EXPLORATION = 0.25;
const INITIAL_COUNT = 20;
/** Start a radio (from a seed track or the caller's likes) and hand the picks to
* the play queue; RadioController then keeps it topped up. */
export function useStartRadio() {
const dispatch = useAppDispatch();
const [startRadio, { isLoading }] = useStartRadioMutation();
const start = async (opts: { seedTrackId?: string; fromLikes?: boolean }) => {
const res = await startRadio({
...opts,
exploration: DEFAULT_EXPLORATION,
count: INITIAL_COUNT,
}).unwrap();
dispatch(
startRadioQueue({
entries: res.picks.map(pickToEntry),
radio: {
seedTrackId: opts.seedTrackId ?? null,
fromLikes: opts.fromLikes ?? false,
exploration: DEFAULT_EXPLORATION,
},
}),
);
};
return { start, isStarting: isLoading };
}
+185
View File
@@ -163,6 +163,16 @@ const en = {
mute: 'Mute', mute: 'Mute',
unmute: 'Unmute', unmute: 'Unmute',
}, },
nowPlaying: {
expand: 'Now playing',
collapse: 'Collapse',
artwork: 'Artwork',
lyrics: 'Lyrics',
},
lyrics: {
none: 'No lyrics found for this track.',
refetch: 'Look again',
},
queue: { queue: {
title: 'Play queue', title: 'Play queue',
shuffle: 'Shuffle queue', shuffle: 'Shuffle queue',
@@ -178,6 +188,13 @@ const en = {
familiar: 'Familiar', familiar: 'Familiar',
new: 'New', new: 'New',
loadingMore: 'Loading more from radio…', loadingMore: 'Loading more from radio…',
whyPlaying: 'Why this is playing',
reason: {
ml: 'Recommended',
similar: 'Similar',
from_likes: 'From your likes',
discover: 'Something new',
},
doubleClickPlay: 'Double-click to play', doubleClickPlay: 'Double-click to play',
removeFromQueue: 'Remove from queue', removeFromQueue: 'Remove from queue',
menu: { menu: {
@@ -194,6 +211,7 @@ const en = {
playNow: 'Play now', playNow: 'Play now',
playNext: 'Play next', playNext: 'Play next',
addToQueue: 'Add to queue', addToQueue: 'Add to queue',
startRadio: 'Start radio',
info: 'Track info', info: 'Track info',
addToPlaylist: 'Add to playlist…', addToPlaylist: 'Add to playlist…',
removeFromPlaylist: 'Remove from playlist', removeFromPlaylist: 'Remove from playlist',
@@ -286,6 +304,36 @@ const en = {
}, },
storage: { storage: {
subtitle: 'Everything this instance has tucked away', 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', device: 'On this device',
server: 'On the server', server: 'On the server',
audioCache: 'Cached audio', audioCache: 'Cached audio',
@@ -357,6 +405,47 @@ const en = {
scrobbling: 'Scrobbling', scrobbling: 'Scrobbling',
instance: 'Instance', instance: 'Instance',
}, },
common: {
save: 'Save',
saving: 'Saving…',
},
instance: {
current: 'Current instance',
none: 'No instance selected.',
manage: 'Manage instances',
signOut: 'Sign out',
},
playback: {
streamQuality: 'Streaming quality',
streamQualityHint:
'Audio quality when streaming from the server. "Original" streams the file as-is.',
quality: {
original: 'Original',
high: 'High',
medium: 'Medium',
low: 'Low',
},
},
scrobbling: {
description: 'Send your listens to Last.fm or ListenBrainz.',
configured: 'Linked',
notConfigured: 'Not linked',
enabled: 'Scrobbling',
on: 'On',
off: 'Off',
provider: 'Service',
lastfm: 'Last.fm',
listenbrainz: 'ListenBrainz',
username: 'Username',
usernamePlaceholder: 'your account name',
sessionKey: 'Session key / token',
sessionKeyPlaceholder: 'paste your token',
sessionKeyKeep: 'leave blank to keep the current token',
sessionKeyHint:
'Stored encrypted on the server and never shown again.',
saved: 'Scrobbling settings saved.',
error: 'Could not save. Please try again.',
},
}, },
admin: { admin: {
userDetail: 'User', userDetail: 'User',
@@ -365,6 +454,77 @@ const en = {
sources: 'Sources', sources: 'Sources',
instance: 'Instance', 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: { notFound: {
title: 'Page not found', title: 'Page not found',
@@ -453,10 +613,35 @@ const en = {
manual: 'Edited manually — not auto-updated', 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: { metadataEditor: {
error: 'Failed to load track', error: 'Failed to load track',
saved: 'Metadata saved.', saved: 'Metadata saved.',
saveError: 'Failed to save metadata.', 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', save: 'Save',
fields: { fields: {
title: 'Title', title: 'Title',
+185
View File
@@ -164,6 +164,16 @@ const ru: Translations = {
mute: 'Выключить звук', mute: 'Выключить звук',
unmute: 'Включить звук', unmute: 'Включить звук',
}, },
nowPlaying: {
expand: 'Сейчас играет',
collapse: 'Свернуть',
artwork: 'Обложка',
lyrics: 'Текст',
},
lyrics: {
none: 'Текст для этого трека не найден.',
refetch: 'Искать снова',
},
queue: { queue: {
title: 'Очередь воспроизведения', title: 'Очередь воспроизведения',
shuffle: 'Перемешать очередь', shuffle: 'Перемешать очередь',
@@ -179,6 +189,13 @@ const ru: Translations = {
familiar: 'Знакомое', familiar: 'Знакомое',
new: 'Новое', new: 'Новое',
loadingMore: 'Загрузка радио…', loadingMore: 'Загрузка радио…',
whyPlaying: 'Почему это играет',
reason: {
ml: 'Рекомендация',
similar: 'Похожее',
from_likes: 'Из ваших лайков',
discover: 'Что-то новое',
},
doubleClickPlay: 'Двойной клик для воспроизведения', doubleClickPlay: 'Двойной клик для воспроизведения',
removeFromQueue: 'Убрать из очереди', removeFromQueue: 'Убрать из очереди',
menu: { menu: {
@@ -195,6 +212,7 @@ const ru: Translations = {
playNow: 'Играть сейчас', playNow: 'Играть сейчас',
playNext: 'Следующим', playNext: 'Следующим',
addToQueue: 'Добавить в очередь', addToQueue: 'Добавить в очередь',
startRadio: 'Запустить радио',
info: 'Информация о треке', info: 'Информация о треке',
addToPlaylist: 'Добавить в плейлист…', addToPlaylist: 'Добавить в плейлист…',
removeFromPlaylist: 'Убрать из плейлиста', removeFromPlaylist: 'Убрать из плейлиста',
@@ -287,6 +305,36 @@ const ru: Translations = {
}, },
storage: { storage: {
subtitle: 'Всё, что хранит этот инстанс', 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: 'На этом устройстве', device: 'На этом устройстве',
server: 'На сервере', server: 'На сервере',
audioCache: 'Кэш аудио', audioCache: 'Кэш аудио',
@@ -358,6 +406,47 @@ const ru: Translations = {
scrobbling: 'Скробблинг', scrobbling: 'Скробблинг',
instance: 'Сервер', instance: 'Сервер',
}, },
common: {
save: 'Сохранить',
saving: 'Сохранение…',
},
instance: {
current: 'Текущий инстанс',
none: 'Инстанс не выбран.',
manage: 'Управление инстансами',
signOut: 'Выйти',
},
playback: {
streamQuality: 'Качество потока',
streamQualityHint:
'Качество звука при стриминге с сервера. «Оригинал» отдаёт файл как есть.',
quality: {
original: 'Оригинал',
high: 'Высокое',
medium: 'Среднее',
low: 'Низкое',
},
},
scrobbling: {
description: 'Отправляйте свои прослушивания в Last.fm или ListenBrainz.',
configured: 'Подключено',
notConfigured: 'Не подключено',
enabled: 'Скробблинг',
on: 'Вкл',
off: 'Выкл',
provider: 'Сервис',
lastfm: 'Last.fm',
listenbrainz: 'ListenBrainz',
username: 'Имя пользователя',
usernamePlaceholder: 'ваш логин',
sessionKey: 'Ключ сессии / токен',
sessionKeyPlaceholder: 'вставьте токен',
sessionKeyKeep: 'оставьте пустым, чтобы сохранить текущий токен',
sessionKeyHint:
'Хранится на сервере в зашифрованном виде и больше не показывается.',
saved: 'Настройки скробблинга сохранены.',
error: 'Не удалось сохранить. Попробуйте ещё раз.',
},
}, },
admin: { admin: {
userDetail: 'Пользователь', userDetail: 'Пользователь',
@@ -366,6 +455,77 @@ const ru: Translations = {
sources: 'Источники', sources: 'Источники',
instance: 'Сервер', 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: { notFound: {
title: 'Страница не найдена', title: 'Страница не найдена',
@@ -455,7 +615,32 @@ const ru: Translations = {
manual: 'Изменено вручную — не обновляется автоматически', manual: 'Изменено вручную — не обновляется автоматически',
}, },
}, },
metadataBatch: {
selectedCount: 'Выбрано {{selected}} из {{total}}',
status: {
pending: 'Без метаданных',
failed: 'С ошибкой',
},
result: 'Обновлено {{ok}}, ошибок {{fail}}',
reEnrich: {
title: 'Повторное авто-обогащение',
hint: 'Поставить в очередь идентификацию AcoustID/MusicBrainz для выбранных треков.',
button: 'Переобогатить выбранные',
},
common: {
title: 'Применить общие поля',
hint: 'Заполненные поля запишутся всем выбранным трекам; пустые останутся без изменений.',
apply: 'Применить к выбранным',
},
empty: 'В этой категории пусто.',
},
metadataEditor: { metadataEditor: {
optimize: {
title: 'Оптимизировать формат',
hint: 'Перекодировать в компактный Opus + HLS в фоне.',
button: 'Оптимизировать',
enqueued: 'Оптимизация поставлена в очередь — выполнится в фоне.',
},
error: 'Не удалось загрузить трек', error: 'Не удалось загрузить трек',
saved: 'Метаданные сохранены.', saved: 'Метаданные сохранены.',
saveError: 'Не удалось сохранить метаданные.', saveError: 'Не удалось сохранить метаданные.',
+3
View File
@@ -21,7 +21,10 @@ import './api/endpoints/search';
import './api/endpoints/likes'; import './api/endpoints/likes';
import './api/endpoints/storage'; import './api/endpoints/storage';
import './api/endpoints/admin'; import './api/endpoints/admin';
import './api/endpoints/settings';
import './api/endpoints/upload'; import './api/endpoints/upload';
import './api/endpoints/lyrics';
import './api/endpoints/radio';
// Tier 3 offline: register the audio-caching service worker (no-op if the // Tier 3 offline: register the audio-caching service worker (no-op if the
// browser/origin doesn't support it). // browser/origin doesn't support it).
+50
View File
@@ -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;
}
+15
View File
@@ -0,0 +1,15 @@
import type { RadioPick } from '../api/types';
import type { QueueEntry } from '../store/slices/queue';
/** A radio/similar pick → a play-queue entry (carrying its reason code). */
export function pickToEntry(pick: RadioPick): QueueEntry {
return {
trackId: pick.track.id,
title: pick.track.title,
artistName: pick.track.artistName,
albumTitle: pick.track.albumTitle,
durationMs: pick.track.durationMs,
albumArtUrl: pick.track.albumArtUrl,
reason: pick.reason,
};
}
+10
View File
@@ -8,6 +8,7 @@ export interface PlayerState {
volume: number; volume: number;
muted: boolean; muted: boolean;
isQueueOpen: boolean; isQueueOpen: boolean;
isNowPlayingOpen: boolean;
} }
export const playerInitialState: PlayerState = { export const playerInitialState: PlayerState = {
@@ -18,6 +19,7 @@ export const playerInitialState: PlayerState = {
volume: 0.78, volume: 0.78,
muted: false, muted: false,
isQueueOpen: false, isQueueOpen: false,
isNowPlayingOpen: false,
}; };
export const playerSlice = createSlice({ export const playerSlice = createSlice({
@@ -55,6 +57,12 @@ export const playerSlice = createSlice({
toggleQueue(state) { toggleQueue(state) {
state.isQueueOpen = !state.isQueueOpen; 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, setVolume,
toggleMute, toggleMute,
toggleQueue, toggleQueue,
toggleNowPlaying,
setNowPlayingOpen,
} = playerSlice.actions; } = playerSlice.actions;
export default playerSlice.reducer; export default playerSlice.reducer;
+55
View File
@@ -15,6 +15,15 @@ export interface QueueEntry {
albumTitle: string; albumTitle: string;
durationMs: number; durationMs: number;
albumArtUrl?: string; albumArtUrl?: string;
/** Radio reason code (`ml`/`similar`/`from_likes`/`discover`) — §6.5. */
reason?: string;
}
/** Active-radio context: what seeds the infinite feed + the exploration bias. */
export interface RadioContext {
seedTrackId: string | null;
fromLikes: boolean;
exploration: number;
} }
export interface QueueState { export interface QueueState {
@@ -25,8 +34,16 @@ export interface QueueState {
sourceName: string | null; sourceName: string | null;
shuffle: boolean; shuffle: boolean;
loop: boolean; loop: boolean;
/** Non-null while a radio drives the queue (enables infinite loading). */
radio: RadioContext | null;
} }
// An infinite radio keeps appending, so cap the backlog: once the queue grows
// past QUEUE_CAP, drop the oldest *already-played* entries (never the current
// track or the look-ahead), keeping at least KEEP_BEHIND for prev-navigation.
const QUEUE_CAP = 300;
const KEEP_BEHIND = 50;
export const queueInitialState: QueueState = { export const queueInitialState: QueueState = {
entries: [], entries: [],
currentIndex: -1, currentIndex: -1,
@@ -35,6 +52,7 @@ export const queueInitialState: QueueState = {
sourceName: null, sourceName: null,
shuffle: false, shuffle: false,
loop: false, loop: false,
radio: null,
}; };
export const queueSlice = createSlice({ export const queueSlice = createSlice({
@@ -56,6 +74,38 @@ export const queueSlice = createSlice({
state.source = action.payload.source; state.source = action.payload.source;
state.sourceId = action.payload.sourceId ?? null; state.sourceId = action.payload.sourceId ?? null;
state.sourceName = action.payload.sourceName ?? null; state.sourceName = action.payload.sourceName ?? null;
state.radio = null; // a manual/album/… queue ends any active radio
},
startRadioQueue(
state,
action: PayloadAction<{ entries: QueueEntry[]; radio: RadioContext }>,
) {
state.entries = action.payload.entries;
state.currentIndex = 0;
state.source = 'radio';
state.sourceId = null;
state.sourceName = null;
state.radio = action.payload.radio;
},
appendToQueue(state, action: PayloadAction<QueueEntry[]>) {
state.entries.push(...action.payload);
if (state.entries.length > QUEUE_CAP) {
// Only trim entries safely behind the current one.
const drop = Math.min(
state.entries.length - QUEUE_CAP,
Math.max(0, state.currentIndex - KEEP_BEHIND),
);
if (drop > 0) {
state.entries.splice(0, drop);
state.currentIndex -= drop;
}
}
},
setExploration(state, action: PayloadAction<number>) {
if (state.radio) state.radio.exploration = action.payload;
},
stopRadio(state) {
state.radio = null;
}, },
addToQueue(state, action: PayloadAction<QueueEntry>) { addToQueue(state, action: PayloadAction<QueueEntry>) {
state.entries.push(action.payload); state.entries.push(action.payload);
@@ -102,6 +152,7 @@ export const queueSlice = createSlice({
clearQueue(state) { clearQueue(state) {
state.entries = []; state.entries = [];
state.currentIndex = -1; state.currentIndex = -1;
state.radio = null;
}, },
toggleShuffle(state) { toggleShuffle(state) {
state.shuffle = !state.shuffle; state.shuffle = !state.shuffle;
@@ -114,6 +165,10 @@ export const queueSlice = createSlice({
export const { export const {
setQueue, setQueue,
startRadioQueue,
appendToQueue,
setExploration,
stopRadio,
addToQueue, addToQueue,
addNextInQueue, addNextInQueue,
playNow, playNow,