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>
This commit is contained in:
Цвылев Александр Вадимович
2026-07-28 22:15:47 +03:00
parent e45ef785cf
commit ac6b76bf98
12 changed files with 254 additions and 1 deletions
+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;
+14
View File
@@ -192,6 +192,20 @@ export interface DuplicateGroup {
tracks: Track[]; 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 /** 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. */ * error. `synced` is raw LRC (client parses timestamps); `plain` is fallback. */
export interface Lyrics { export interface Lyrics {
+2
View File
@@ -4,6 +4,7 @@ import { Sidebar } from './Sidebar';
import { PersistentPlayer } from '../player/PersistentPlayer'; import { PersistentPlayer } from '../player/PersistentPlayer';
import { NowPlaying } from '../player/NowPlaying'; 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';
@@ -39,6 +40,7 @@ export function AppShell() {
<AddToPlaylistDialog /> <AddToPlaylistDialog />
<PersistentPlayer /> <PersistentPlayer />
<NowPlaying /> <NowPlaying />
<RadioController />
</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>
+51
View File
@@ -0,0 +1,51 @@
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;
/**
* 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.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={() => {
+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 };
}
+8
View File
@@ -188,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: {
@@ -204,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',
+8
View File
@@ -189,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: {
@@ -205,6 +212,7 @@ const ru: Translations = {
playNow: 'Играть сейчас', playNow: 'Играть сейчас',
playNext: 'Следующим', playNext: 'Следующим',
addToQueue: 'Добавить в очередь', addToQueue: 'Добавить в очередь',
startRadio: 'Запустить радио',
info: 'Информация о треке', info: 'Информация о треке',
addToPlaylist: 'Добавить в плейлист…', addToPlaylist: 'Добавить в плейлист…',
removeFromPlaylist: 'Убрать из плейлиста', removeFromPlaylist: 'Убрать из плейлиста',
+1
View File
@@ -24,6 +24,7 @@ import './api/endpoints/admin';
import './api/endpoints/settings'; import './api/endpoints/settings';
import './api/endpoints/upload'; import './api/endpoints/upload';
import './api/endpoints/lyrics'; 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).
+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,
};
}
+38
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,6 +34,8 @@ 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;
} }
export const queueInitialState: QueueState = { export const queueInitialState: QueueState = {
@@ -35,6 +46,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 +68,27 @@ 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);
},
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 +135,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 +148,10 @@ export const queueSlice = createSlice({
export const { export const {
setQueue, setQueue,
startRadioQueue,
appendToQueue,
setExploration,
stopRadio,
addToQueue, addToQueue,
addNextInQueue, addNextInQueue,
playNow, playNow,