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>
This commit is contained in:
Цвылев Александр Вадимович
2026-07-28 21:22:59 +03:00
parent 9d88d70be0
commit e45ef785cf
6 changed files with 98 additions and 4 deletions
+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;
+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 {
@@ -17,6 +17,7 @@ import {
useEnrichTrackMutation, useEnrichTrackMutation,
useGetTrackQuery, useGetTrackQuery,
useLazyGetMetadataMatchesQuery, useLazyGetMetadataMatchesQuery,
useOptimizeTrackMutation,
} from '../../api/endpoints/library'; } from '../../api/endpoints/library';
import { import {
useGetBrokenFilesQuery, useGetBrokenFilesQuery,
@@ -418,12 +419,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,
+7 -2
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,6 +27,10 @@ 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);
// Default stream quality (server-synced §A10) → the backend transcodes to it
// on demand; a cache miss transparently falls back to the original.
const { data: settings } = useGetSettingsQuery();
const streamQuality = settings?.streamQuality;
const isSetup = useRef(false); const isSetup = useRef(false);
// `ended` is registered once below; read the latest loop flag through a ref // `ended` is registered once below; read the latest loop flag through a ref
// so the listener doesn't need to be re-bound on every queue change. // so the listener doesn't need to be re-bound on every queue change.
@@ -63,7 +68,7 @@ export function useAudioPlayer() {
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) {
audio.src = url; audio.src = url;
audio.load(); audio.load();
@@ -73,7 +78,7 @@ export function useAudioPlayer() {
} 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();
+6
View File
@@ -628,6 +628,12 @@ const en = {
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',
+6
View File
@@ -627,6 +627,12 @@ const ru: Translations = {
empty: 'В этой категории пусто.', empty: 'В этой категории пусто.',
}, },
metadataEditor: { metadataEditor: {
optimize: {
title: 'Оптимизировать формат',
hint: 'Перекодировать в компактный Opus + HLS в фоне.',
button: 'Оптимизировать',
enqueued: 'Оптимизация поставлена в очередь — выполнится в фоне.',
},
error: 'Не удалось загрузить трек', error: 'Не удалось загрузить трек',
saved: 'Метаданные сохранены.', saved: 'Метаданные сохранены.',
saveError: 'Не удалось сохранить метаданные.', saveError: 'Не удалось сохранить метаданные.',