3 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
4 changed files with 79 additions and 23 deletions
+6 -1
View File
@@ -8,6 +8,11 @@ import { pickToEntry } from '../../lib/radio';
// the end of the queue. // the end of the queue.
const LOOKAHEAD = 3; 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 * 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 * (§6.5 / B6 infinite feed). Mounted once in AppShell. A failed prefetch just
@@ -34,7 +39,7 @@ export function RadioController() {
fromLikes: radio.fromLikes, fromLikes: radio.fromLikes,
exploration: radio.exploration, exploration: radio.exploration,
count: 10, count: 10,
excludeIds: entries.map((e) => e.trackId), excludeIds: entries.slice(-EXCLUDE_WINDOW).map((e) => e.trackId),
}).unwrap(); }).unwrap();
if (res.picks.length > 0) { if (res.picks.length > 0) {
dispatch(appendToQueue(res.picks.map(pickToEntry))); dispatch(appendToQueue(res.picks.map(pickToEntry)));
@@ -65,6 +65,23 @@ 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` operates on a * match picker with a current-vs-proposed diff. `/metadata/batch` operates on a
@@ -723,7 +740,7 @@ function BatchEditor() {
setBusy(true); setBusy(true);
setResult(null); setResult(null);
const ids = [...selected]; const ids = [...selected];
const outcomes = await Promise.allSettled(ids.map(op)); const outcomes = await settleInBatches(ids, BULK_CONCURRENCY, op);
const ok = outcomes.filter((o) => o.status === 'fulfilled').length; const ok = outcomes.filter((o) => o.status === 'fulfilled').length;
setResult({ ok, fail: ids.length - ok }); setResult({ ok, fail: ids.length - ok });
setBusy(false); setBusy(false);
+38 -21
View File
@@ -31,38 +31,43 @@ export function useAudioPlayer() {
// on demand; a cache miss transparently falls back to the original. // on demand; a cache miss transparently falls back to the original.
const { data: settings } = useGetSettingsQuery(); const { data: settings } = useGetSettingsQuery();
const streamQuality = settings?.streamQuality; const streamQuality = settings?.streamQuality;
const isSetup = useRef(false); // `ended` reads the latest loop flag through a ref so the listener doesn't
// `ended` is registered once below; read the latest loop flag through a ref // need re-binding on every queue change.
// so the listener doesn't need to be re-bound 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(() => {
@@ -70,8 +75,20 @@ export function useAudioPlayer() {
const audio = getAudio(); const audio = getAudio();
const url = getStreamUrl(player.currentTrackId, accessToken, streamQuality); 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();
+17
View File
@@ -38,6 +38,12 @@ export interface QueueState {
radio: RadioContext | null; 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,
@@ -83,6 +89,17 @@ export const queueSlice = createSlice({
}, },
appendToQueue(state, action: PayloadAction<QueueEntry[]>) { appendToQueue(state, action: PayloadAction<QueueEntry[]>) {
state.entries.push(...action.payload); 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>) { setExploration(state, action: PayloadAction<number>) {
if (state.radio) state.radio.exploration = action.payload; if (state.radio) state.radio.exploration = action.payload;