feat: finish the upload, playback, and test gaps from the docs
ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s

Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mrleo1nidandCursor committed 2026-10-21 09:37:53 +03:00
1 parent 2b76060ea3
commit 066ba24834
61 files changed
+2818 -463

No files matched your search

+17
View File
@@ -4,6 +4,7 @@ import { QueryClient } from '@tanstack/react-query';
import { createMemoryHistory } from '@tanstack/react-router';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import axe from 'axe-core';
import { I18nextProvider } from 'react-i18next';
import { afterEach, describe, expect, it } from 'vitest';
@@ -314,6 +315,22 @@ describe('my wave', () => {
});
});
describe('accessibility', () => {
it('home and login have no serious axe violations', async () => {
const alice = makeUser('alice', 'user');
await renderApp([alice], '/', { signedInAs: alice });
await screen.findByRole('button', { name: 'Бегаю' });
const home = await axe.run(document.body, { resultTypes: ['violations'] });
expect(home.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]);
cleanup();
await renderApp([], '/login');
await screen.findByRole('heading', { name: 'Вход' });
const login = await axe.run(document.body, { resultTypes: ['violations'] });
expect(login.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]);
});
});
describe('safeRedirect', () => {
it.each([
['/settings', '/settings'],
+10 -9
View File
@@ -7,16 +7,17 @@ import {
redirect,
type ParsedLocation,
} from '@tanstack/react-router';
import { lazy } from 'react';
import { AuditPage } from '../pages/admin/AuditPage';
import { LibraryPage } from '../pages/admin/LibraryPage';
import { PatternsPage } from '../pages/admin/PatternsPage';
import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage';
import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
import { SuggestPage } from '../pages/uploads/SuggestPage';
import { SourcesPage } from '../pages/admin/SourcesPage';
import { UsersPage } from '../pages/admin/UsersPage';
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
const AdminSettingsPage = lazy(() => import('../pages/admin/AdminSettingsPage').then((m) => ({ default: m.AdminSettingsPage })));
const UploadQueuePage = lazy(() => import('../pages/admin/UploadQueuePage').then((m) => ({ default: m.UploadQueuePage })));
const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((m) => ({ default: m.MyUploadsPage })));
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
import { AlbumPage } from '../pages/AlbumPage';
import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage';
+105 -71
View File
@@ -1,6 +1,7 @@
import type { components } from '@pipu/api-client';
import { Link } from '@tanstack/react-router';
import { Fragment } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Fragment, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { formatDuration } from '../shared/lib/format';
@@ -129,82 +130,115 @@ export function TrackList({
const { t } = useTranslation();
const player = usePlayer();
const { track: current, playing } = usePlayerState();
const listRef = useRef<HTMLDivElement>(null);
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
const multiDisc = Boolean(numbered && discs.size > 1);
const virtual = tracks.length > 30 && !multiDisc;
const virtualizer = useVirtualizer({
count: virtual ? tracks.length : 0,
getScrollElement: () => listRef.current?.closest('main') ?? null,
estimateSize: () => 68,
overscan: 8,
});
const row = (tr: Track, i: number) => {
const isCurrent = current?.id === tr.id;
const play = () => tr.available && player.playList(tracks, i, context);
const liked = tr.reaction === 'like';
const meta = rowMeta?.(tr, i);
return (
<div
role="listitem"
tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => {
if (!(e.target as HTMLElement).closest('a, button')) play();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) play();
}}
>
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
<span className={cx(s.num, disliked && s.dimmed)}>
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
{tr.available && (
<span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} />
</span>
)}
</span>
<span className={cx(s.trackMain, disliked && s.dimmed)}>
<span className={s.trackTitle}>{tr.title}</span>
<span className={s.trackArtists}>
<ArtistLinks artists={tr.artists} />
{meta && <span className={s.rowMeta}> · {meta}</span>}
</span>
</span>
{showAlbum && (
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
{tr.album.title}
</Link>
)}
<span className={s.end}>
{disliked ? (
<button
type="button"
className={cx(s.like, s.liked)}
aria-label={t('player.undislike')}
onClick={() => void player.react(tr, null).catch(() => undefined)}
>
<Icon src={undoIcon} size={18} />
</button>
) : (
<button
type="button"
className={cx(s.like, liked && s.liked)}
aria-label={t(liked ? 'player.unlike' : 'player.like')}
aria-pressed={liked}
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
>
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
</button>
)}
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
</span>
</div>
);
};
let lastDisc: number | null = null;
return (
<div className={s.tracks} role="list" aria-label={t('search.tracks')}>
{tracks.map((tr, i) => {
const disc = tr.disc_no ?? 1;
const header = numbered && discs.size > 1 && disc !== lastDisc;
lastDisc = disc;
const isCurrent = current?.id === tr.id;
const play = () => tr.available && player.playList(tracks, i, context);
const liked = tr.reaction === 'like';
const meta = rowMeta?.(tr, i);
return (
<Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
<div
ref={listRef}
className={s.tracks}
role="list"
aria-label={t('search.tracks')}
style={virtual ? { height: virtualizer.getTotalSize(), position: 'relative' } : undefined}
>
{virtual
? virtualizer.getVirtualItems().map((vi) => (
<div
role="listitem"
tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => {
if (!(e.target as HTMLElement).closest('a, button')) play();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) play();
}}
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
>
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
<span className={cx(s.num, disliked && s.dimmed)}>
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
{tr.available && (
<span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} />
</span>
)}
</span>
<span className={cx(s.trackMain, disliked && s.dimmed)}>
<span className={s.trackTitle}>{tr.title}</span>
<span className={s.trackArtists}>
<ArtistLinks artists={tr.artists} />
{meta && <span className={s.rowMeta}> · {meta}</span>}
</span>
</span>
{showAlbum && (
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
{tr.album.title}
</Link>
)}
<span className={s.end}>
{disliked ? (
<button
type="button"
className={cx(s.like, s.liked)}
aria-label={t('player.undislike')}
onClick={() => void player.react(tr, null).catch(() => undefined)}
>
<Icon src={undoIcon} size={18} />
</button>
) : (
<button
type="button"
className={cx(s.like, liked && s.liked)}
aria-label={t(liked ? 'player.unlike' : 'player.like')}
aria-pressed={liked}
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
>
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
</button>
)}
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
</span>
{row(tracks[vi.index]!, vi.index)}
</div>
</Fragment>
);
})}
))
: tracks.map((tr, i) => {
const disc = tr.disc_no ?? 1;
const header = multiDisc && disc !== lastDisc;
lastDisc = disc;
return (
<Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
{row(tr, i)}
</Fragment>
);
})}
</div>
);
}
+140 -12
View File
@@ -17,15 +17,18 @@ export type Stage =
| 'uploading'
| 'done'
| 'rejected'
| 'error';
| 'error'
| 'resume';
export interface UploadItem {
id: string;
file: File;
file: File | null;
size: number;
relPath: string;
stage: Stage;
progress: number; // 0..1 текущего этапа (хэш или загрузка)
sha256?: string;
tags?: { artist?: string; title?: string; album?: string; duration_ms?: number };
trackId?: string | null;
possibleDuplicate?: boolean;
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
@@ -44,6 +47,17 @@ export interface UploaderState {
const PARALLEL = 4;
const CHUNK = 8 << 20;
const PENDING_KEY = 'pipu.uploads.pending';
interface PendingUpload {
id: string;
name: string;
size: number;
relPath: string;
sha256: string;
tusUrl: string;
uploadFileId: string;
}
let seq = 0;
@@ -56,7 +70,9 @@ export class Uploader {
private uploads = new Map<string, tus.Upload>();
private refreshing: Promise<unknown> | null = null;
constructor(private readonly session: AuthSession) {}
constructor(private readonly session: AuthSession) {
this.restore();
}
subscribe = (fn: () => void) => {
this.listeners.add(fn);
@@ -89,8 +105,8 @@ export class Uploader {
continue;
}
// Тот же путь и размер уже в списке — не добавляем второй раз.
if (this.state.items.some((it) => it.relPath === f.relPath && it.file.size === f.file.size)) continue;
fresh.push({ id: `f${++seq}`, file: f.file, relPath: f.relPath, stage: 'hashing', progress: 0 });
if (this.state.items.some((it) => it.relPath === f.relPath && it.size === f.file.size)) continue;
fresh.push({ id: `f${++seq}`, file: f.file, size: f.file.size, relPath: f.relPath, stage: 'hashing', progress: 0 });
}
this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null });
this.hashQueue.push(...fresh.map((it) => it.id));
@@ -101,6 +117,7 @@ export class Uploader {
if (this.state.busy) return;
this.hashQueue = this.hashQueue.filter((x) => x !== id);
this.set({ items: this.state.items.filter((it) => it.id !== id) });
this.persist();
}
/** Очистить список (после завершения или чтобы начать заново). */
@@ -108,6 +125,7 @@ export class Uploader {
if (this.state.busy) return;
this.hashQueue = [];
this.set({ items: [], skipped: 0, batchId: null, error: null });
this.persist();
}
private hashWorker(): Worker {
@@ -125,6 +143,7 @@ export class Uploader {
if (!it) continue;
try {
const sha256 = await this.hash(it);
await this.readTags(it);
this.patch(id, { sha256, stage: 'checking', progress: 1 });
} catch (err) {
this.patch(id, { stage: 'error', message: err instanceof Error ? err.message : String(err) });
@@ -137,13 +156,15 @@ export class Uploader {
}
private hash(it: UploadItem): Promise<string> {
const file = it.file;
if (!file) return Promise.reject(new Error('no file'));
const w = this.hashWorker();
return new Promise((resolve, reject) => {
const onMessage = (e: MessageEvent<HashResponse>) => {
const m = e.data;
if (m.id !== it.id) return;
if (m.type === 'progress') {
this.patch(it.id, { progress: m.done / Math.max(it.file.size, 1) });
this.patch(it.id, { progress: m.done / Math.max(it.size, 1) });
return;
}
w.removeEventListener('message', onMessage);
@@ -151,7 +172,7 @@ export class Uploader {
else reject(new Error(m.message));
};
w.addEventListener('message', onMessage);
w.postMessage({ id: it.id, file: it.file } satisfies HashRequest);
w.postMessage({ id: it.id, file } satisfies HashRequest);
});
}
@@ -162,7 +183,17 @@ export class Uploader {
try {
const res = unwrap(
await this.session.client.POST('/api/v1/uploads/precheck', {
body: { files: pending.map((it) => ({ client_id: it.id, size: it.file.size, sha256: it.sha256! })) },
body: {
files: pending.map((it) => ({
client_id: it.id,
size: it.size,
sha256: it.sha256!,
artist: it.tags?.artist,
title: it.tags?.title,
album: it.tags?.album,
duration_ms: it.tags?.duration_ms,
})),
},
}),
);
for (const r of res.files) {
@@ -192,9 +223,9 @@ export class Uploader {
comment: comment.trim() || undefined,
files: ready.map((it) => ({
client_id: it.id,
name: it.file.name,
name: it.file?.name ?? it.relPath,
rel_path: it.relPath.includes('/') ? it.relPath : undefined,
size: it.file.size,
size: it.size,
sha256: it.sha256!,
})),
},
@@ -209,6 +240,7 @@ export class Uploader {
queue.push({ id: f.client_id, url: f.tus_url });
}
}
this.persist();
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
} catch (err) {
this.set({ error: err });
@@ -239,9 +271,10 @@ export class Uploader {
private upload(id: string, url: string): Promise<void> {
const it = this.item(id);
if (!it) return Promise.resolve();
if (!it?.file) return Promise.resolve();
const file = it.file;
return new Promise((resolve) => {
const up = new tus.Upload(it.file, {
const up = new tus.Upload(file, {
uploadUrl: url,
chunkSize: CHUNK,
retryDelays: [0, 1000, 3000, 5000, 10000, 20000],
@@ -264,6 +297,7 @@ export class Uploader {
onSuccess: () => {
this.uploads.delete(id);
this.patch(id, { stage: 'done', progress: 1 });
this.persist();
resolve();
},
onError: (err) => {
@@ -276,6 +310,100 @@ export class Uploader {
up.start();
});
}
private async readTags(it: UploadItem) {
if (!it.file) return;
try {
const { parseBlob } = await import('music-metadata');
const meta = await parseBlob(it.file);
const duration = meta.format.duration;
this.patch(it.id, {
tags: {
artist: meta.common.artist,
title: meta.common.title,
album: meta.common.album,
duration_ms: duration ? Math.round(duration * 1000) : undefined,
},
});
} catch {
// Теги — подсказка для предпроверки; без них сервер прочитает их сам.
}
}
/** Незавершённые tus-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */
private persist() {
const pending: PendingUpload[] = this.state.items
.filter((it): it is UploadItem & { sha256: string; tusUrl: string; uploadFileId: string } =>
Boolean(it.tusUrl && it.sha256 && it.uploadFileId && it.stage !== 'done'),
)
.map((it) => ({
id: it.id,
name: it.file?.name ?? it.relPath,
size: it.size,
relPath: it.relPath,
sha256: it.sha256,
tusUrl: it.tusUrl,
uploadFileId: it.uploadFileId,
}));
try {
if (pending.length) localStorage.setItem(PENDING_KEY, JSON.stringify(pending));
else localStorage.removeItem(PENDING_KEY);
} catch {
// Приватный режим — докачка после перезагрузки недоступна.
}
}
private restore() {
try {
const raw = localStorage.getItem(PENDING_KEY);
if (!raw) return;
const rows = JSON.parse(raw) as PendingUpload[];
if (!Array.isArray(rows) || !rows.length) return;
this.state = {
...this.state,
items: rows.map((r) => ({
id: r.id,
file: null,
size: r.size,
relPath: r.relPath,
stage: 'resume' as const,
progress: 0,
sha256: r.sha256,
tusUrl: r.tusUrl,
uploadFileId: r.uploadFileId,
})),
};
} catch {
localStorage.removeItem(PENDING_KEY);
}
}
/** Приложить заново те же файлы и продолжить tus с принятого смещения. */
async continueWith(files: PickedFile[]) {
const waiting = this.state.items.filter((it) => it.stage === 'resume' && it.tusUrl);
if (!waiting.length) return;
const queue: { id: string; url: string }[] = [];
for (const picked of files) {
const it = waiting.find((w) => w.size === picked.file.size && (w.relPath === picked.relPath || w.relPath.endsWith(picked.file.name)));
if (!it) continue;
this.patch(it.id, { file: picked.file, stage: 'hashing', progress: 0 });
const sha = await this.hash(this.item(it.id)!);
if (sha !== it.sha256) {
this.patch(it.id, { stage: 'error', message: 'sha256' });
continue;
}
this.patch(it.id, { stage: 'uploading', progress: 0 });
queue.push({ id: it.id, url: it.tusUrl! });
}
if (!queue.length) return;
this.set({ busy: true });
try {
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
} finally {
this.set({ busy: false });
this.persist();
}
}
}
let instance: { session: AuthSession; uploader: Uploader } | null = null;
+4
View File
@@ -48,6 +48,10 @@ function deviceName(): string {
}
applyTheme(undefined);
if (import.meta.env.VITE_E2E === '1') {
const { installE2EFetch } = await import('./test/e2eBridge');
installE2EFetch();
}
const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages]));
document.documentElement.lang = i18n.language;
i18n.on('languageChanged', (l) => {
+25 -1
View File
@@ -143,7 +143,7 @@ function AppearanceSection() {
<span>{t('settings.search_collapse')}</span>
<Segmented<'on' | 'off'>
label={t('settings.search_collapse')}
value={user.ui_prefs.search_collapse === false ? 'off' : 'on'}
value={user.ui_prefs.search_collapse === true ? 'on' : 'off'}
onChange={(v) => void setPrefs({ search_collapse: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
@@ -187,6 +187,30 @@ function AppearanceSection() {
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.normalize')}</span>
<Segmented<'on' | 'off'>
label={t('settings.normalize')}
value={user.ui_prefs.normalize === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ normalize: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}</span>
<input
type="range"
min={0}
max={6}
step={1}
aria-label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}
value={user.ui_prefs.crossfade_s ?? 0}
onChange={(e) => void setPrefs({ crossfade_s: Number(e.target.value) })}
/>
</div>
<div className={s.settingRow}>
<span>{t('language.label')}</span>
<LanguageSwitch />
+101 -1
View File
@@ -35,6 +35,7 @@ export function UploadQueuePage() {
const [onlyPossible, setOnlyPossible] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<UploadFile | null>(null);
const [batchEdit, setBatchEdit] = useState<string | null>(null);
const [rejecting, setRejecting] = useState(false);
const list = useInfiniteQuery({
@@ -131,6 +132,7 @@ export function UploadQueuePage() {
selected={selected}
onToggle={toggle}
onEdit={setEditing}
onEditBatch={setBatchEdit}
onRetry={(id) => retry.mutate(id)}
/>
))}
@@ -158,6 +160,17 @@ export function UploadQueuePage() {
</div>
)}
{batchEdit && (
<BatchEditDialog
batchId={batchEdit}
onClose={() => setBatchEdit(null)}
onSaved={() => {
setBatchEdit(null);
void refresh();
}}
/>
)}
{editing && (
<EditDialog
file={editing}
@@ -194,6 +207,7 @@ function BatchCard({
selected,
onToggle,
onEdit,
onEditBatch,
onRetry,
}: {
files: UploadFile[];
@@ -201,6 +215,7 @@ function BatchCard({
selected: Set<string>;
onToggle: (ids: string[], on: boolean) => void;
onEdit: (f: UploadFile) => void;
onEditBatch: (batchId: string) => void;
onRetry: (id: string) => void;
}) {
const { t, i18n } = useTranslation('admin');
@@ -218,6 +233,12 @@ function BatchCard({
<span>{formatDateTime(first.created_at, i18n.language)}</span>
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
<span style={{ flex: 1 }} />
{selectable && (
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
{t('uploads.edit_batch')}
</Button>
)}
</div>
{files.map((f) => (
<QueueRow
@@ -289,6 +310,7 @@ function QueueRow({
<div className={u.flags}>
<UploadStatusBadge status={f.status} />
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
{f.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
{f.matched_album_id ? (
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
) : (
@@ -302,6 +324,9 @@ function QueueRow({
)}
</div>
<div className={u.rowActions}>
{f.stream_url && (
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
)}
{editable && (
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
<Icon src={editIcon} size={18} />
@@ -319,6 +344,75 @@ function QueueRow({
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const [artist, setArtist] = useState('');
const [album, setAlbum] = useState('');
const [year, setYear] = useState('');
const [cover, setCover] = useState<string | null | undefined>(undefined);
const save = useMutation({
mutationFn: async () =>
unwrap(
await session.client.PATCH('/api/v1/admin/uploads/batches/{id}', {
params: { path: { id: batchId } },
body: {
...(artist.trim() ? { artist: artist.trim() } : {}),
...(album.trim() ? { album: album.trim() } : {}),
...(year.trim() ? { year: Number.parseInt(year, 10) } : {}),
...(cover !== undefined ? { cover_base64: cover } : {}),
},
}),
),
onSuccess: onSaved,
});
const pickCover = (file: File | undefined) => {
if (!file) {
setCover(undefined);
return;
}
const reader = new FileReader();
reader.onload = () => {
const data = String(reader.result ?? '');
const comma = data.indexOf(',');
setCover(comma >= 0 ? data.slice(comma + 1) : data);
};
reader.readAsDataURL(file);
};
return (
<Dialog
title={t('uploads.batch_edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{t('uploads.batch_edit_hint')}</p>
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
<label className={s.sessions}>
<span>{t('uploads.batch_cover')}</span>
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} />
</label>
<Button variant="ghost" onClick={() => setCover(null)}>
{t('uploads.batch_cover_clear')}
</Button>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</div>
</Dialog>
);
}
function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
@@ -452,7 +546,7 @@ export function UploadSettingsSection() {
queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
});
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string } | null>(null);
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string; upgrade: boolean } | null>(null);
useEffect(() => {
if (settings.data) setForm(toForm(settings.data));
}, [settings.data]);
@@ -468,6 +562,7 @@ export function UploadSettingsSection() {
max_file_bytes: gb(f.maxFile),
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
default_source_id: f.source || null,
allow_quality_upgrade: f.upgrade,
},
}),
);
@@ -507,6 +602,10 @@ export function UploadSettingsSection() {
))}
</select>
</label>
<label className={s.sessions} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={form.upgrade} onChange={(e) => setForm({ ...form, upgrade: e.target.checked })} />
<span>{t('uploads.set_upgrade')}</span>
</label>
</div>
<p className={s.note}>{t('uploads.set_note')}</p>
<div className={s.toolbar}>
@@ -528,5 +627,6 @@ function toForm(st: Settings) {
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
maxFile: gb(st.max_file_bytes),
source: st.default_source_id ?? '',
upgrade: st.allow_quality_upgrade,
};
}
+7 -1
View File
@@ -1,7 +1,7 @@
import { unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
@@ -31,6 +31,12 @@ export function MyUploadsPage() {
const errorText = useErrorText();
const [filter, setFilter] = useState<Filter>('all');
useEffect(() => {
void session.client.POST('/api/v1/uploads/seen').then(() => {
void queryClient.invalidateQueries({ queryKey: ['uploads-unseen'] });
});
}, [session, queryClient]);
const list = useInfiniteQuery({
queryKey: ['my-uploads', filter],
initialPageParam: undefined as string | undefined,
+15 -4
View File
@@ -39,9 +39,10 @@ export function SuggestPage() {
}, []);
const ready = st.items.filter((it) => it.stage === 'ready');
const readyBytes = ready.reduce((n, it) => n + it.file.size, 0);
const readyBytes = ready.reduce((n, it) => n + it.size, 0);
const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking');
const finished = !st.busy && st.batchId !== null;
const resume = st.items.filter((it) => it.stage === 'resume').length;
const failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length;
const uploaded = st.items.filter((it) => it.stage === 'done').length;
const free = quota.data ? Math.max(quota.data.limit_bytes - quota.data.used_bytes, 0) : null;
@@ -96,7 +97,9 @@ export function SuggestPage() {
multiple
accept={ACCEPT}
onChange={(e) => {
uploader.add(fromInput(e.target.files));
const picked = fromInput(e.target.files);
if (uploader.getState().items.some((it) => it.stage === 'resume')) void uploader.continueWith(picked);
else uploader.add(picked);
e.target.value = '';
}}
/>
@@ -106,12 +109,20 @@ export function SuggestPage() {
type="file"
multiple
onChange={(e) => {
uploader.add(fromInput(e.target.files));
const picked = fromInput(e.target.files);
if (uploader.getState().items.some((it) => it.stage === 'resume')) void uploader.continueWith(picked);
else uploader.add(picked);
e.target.value = '';
}}
/>
</div>
{resume > 0 && (
<section className={s.section}>
<p>{t('uploads.resume_hint', { count: resume })}</p>
<Button onClick={() => filesRef.current?.click()}>{t('uploads.resume_pick')}</Button>
</section>
)}
{st.skipped > 0 && <p className={s.note}>{t('uploads.skipped', { count: st.skipped })}</p>}
{st.items.length > 0 && (
@@ -215,7 +226,7 @@ function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean
)}
{note && <span className={note.cls}>{note.text}</span>}
</div>
<span className={u.size}>{formatBytes(item.file.size, i18n.language)}</span>
<span className={u.size}>{formatBytes(item.size, i18n.language)}</span>
{locked ? (
<span />
) : (
+114 -3
View File
@@ -1,5 +1,6 @@
import {
EventBuffer,
normalizeGain,
PlayAttempt,
PlayQueue,
unwrap,
@@ -80,6 +81,9 @@ function deviceId(): string {
*/
export class Player {
private readonly audio: HTMLAudioElement;
private readonly overlap: HTMLAudioElement;
private fading = false;
private ignoreEnded = false;
private readonly queue = new PlayQueue();
private readonly events: EventBuffer;
private attempt: PlayAttempt | null = null;
@@ -102,6 +106,8 @@ export class Player {
) {
this.audio = audio ?? new Audio();
this.audio.preload = 'auto';
this.overlap = new Audio();
this.overlap.preload = 'auto';
this.events = new EventBuffer({
send: async (events) => {
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
@@ -324,15 +330,17 @@ export class Player {
}
setVolume(v: number) {
this.audio.volume = Math.min(Math.max(v, 0), 1);
const volume = Math.min(Math.max(v, 0), 1);
this.audio.muted = false;
this.update({ volume: this.audio.volume, muted: false });
this.update({ volume, muted: false });
this.applyLoudness();
this.saveLocal();
}
toggleMute() {
this.audio.muted = !this.audio.muted;
this.update({ muted: this.audio.muted });
this.applyLoudness();
}
toggleShuffle() {
@@ -382,6 +390,7 @@ export class Player {
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
this.audio.src = track.stream_url;
if (startMs > 0) this.audio.currentTime = startMs / 1000;
this.applyLoudness();
this.attempt = new PlayAttempt(track, startMs);
this.preloaded = null;
this.events.push({
@@ -429,6 +438,107 @@ export class Player {
}
}
private prefs() {
const user = this.session.state.status === 'signed_in' ? this.session.state.user : null;
return user?.ui_prefs;
}
private normalizeOn() {
return this.prefs()?.normalize !== false;
}
private crossfadeSeconds() {
return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0));
}
/** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */
private gain: GainNode | null = null;
private wired = false;
private applyLoudness(mix = 1) {
const track = this.state.track;
const user = this.state.muted ? 0 : this.state.volume;
const level = user * normalizeGain(track?.features?.loudness_lufs, this.normalizeOn()) * mix;
if (!this.wired && typeof AudioContext !== 'undefined') {
try {
const ctx = new AudioContext();
const src = ctx.createMediaElementSource(this.audio);
this.gain = ctx.createGain();
src.connect(this.gain);
this.gain.connect(ctx.destination);
this.audio.volume = 1;
this.wired = true;
} catch {
this.wired = true;
}
}
if (this.gain) this.gain.gain.value = level;
else this.audio.volume = Math.min(1, level);
}
/** В конце трека накладывает следующий на crossfade_s секунд. */
private maybeCrossfade(positionMs: number) {
const cf = this.crossfadeSeconds();
if (this.fading || cf <= 0 || this.state.repeat === 'one') return;
const dur = this.audio.duration;
if (!Number.isFinite(dur)) return;
const left = dur * 1000 - positionMs;
if (left <= 0 || left > cf * 1000) return;
const next = this.state.queue[this.state.index + 1];
if (!next?.stream_url || !next.available) return;
this.fading = true;
this.overlap.src = next.stream_url;
this.overlap.volume = 0;
void this.overlap.play().catch(() => {
this.fading = false;
});
const started = performance.now();
const step = () => {
if (!this.fading) return;
const p = Math.min(1, (performance.now() - started) / (cf * 1000));
this.applyLoudness(1 - p);
this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p);
if (p < 1) {
requestAnimationFrame(step);
return;
}
this.finishCrossfade(next);
};
requestAnimationFrame(step);
}
private finishCrossfade(next: Track) {
const at = this.overlap.currentTime;
this.ignoreEnded = true;
this.audio.pause();
this.overlap.pause();
this.finishAttempt('play_end');
if (!this.queue.next(true)) {
this.fading = false;
this.ignoreEnded = false;
this.update({ playing: false, positionMs: 0 });
return;
}
this.syncQueue();
const src = next.stream_url;
if (!src) {
this.fading = false;
this.ignoreEnded = false;
this.update({ playing: false });
return;
}
this.audio.src = src;
if (at > 0) this.audio.currentTime = at;
this.applyLoudness();
this.attempt = new PlayAttempt(next, Math.round(at * 1000));
this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms });
this.updateMediaMetadata(next);
void this.audio.play().finally(() => {
this.fading = false;
this.ignoreEnded = false;
});
}
private bindAudio() {
const a = this.audio;
a.addEventListener('playing', () => {
@@ -451,10 +561,12 @@ export class Player {
this.update({ positionMs: ms });
this.updatePositionState();
this.maybePreload(ms);
this.maybeCrossfade(ms);
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
this.saveLocal();
});
a.addEventListener('ended', () => {
if (this.ignoreEnded || this.fading) return;
this.finishAttempt('play_end');
if (this.queue.next(true)) {
this.syncQueue();
@@ -465,7 +577,6 @@ export class Player {
}
});
a.addEventListener('error', () => void this.onError());
a.addEventListener('volumechange', () => this.update({ volume: a.volume, muted: a.muted }));
}
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
+15
View File
@@ -0,0 +1,15 @@
import { fakeApi, makeUser } from './fakeApi';
/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */
export function installE2EFetch() {
const api = fakeApi([
makeUser('admin', 'admin', { must_change_credentials: true }),
makeUser('alice', 'user'),
]);
const orig = window.fetch.bind(window);
window.fetch = async (input, init) => {
const req = new Request(input, init);
if (new URL(req.url, window.location.origin).pathname.startsWith('/api/')) return api.fetch(req);
return orig(input, init);
};
}
+10
View File
@@ -153,6 +153,16 @@ export function fakeApi(initial: FakeUser[]) {
return tokens(user);
case 'GET /api/v1/me/sessions':
return json({ items: [] });
case 'GET /api/v1/uploads/unseen':
return json({ count: 0 });
case 'POST /api/v1/uploads/seen':
return new Response(null, { status: 204 });
case 'GET /api/v1/onboarding':
return json({ required: false, artists: [], genres: [] });
case 'GET /api/v1/me/likes':
case 'GET /api/v1/me/dislikes':
case 'GET /api/v1/me/history':
return json({ items: [], next_cursor: null, total: 0 });
case 'GET /api/v1/admin/users': {
if (user.role === 'user') return problem(403, 'auth.forbidden');
const q = (url.searchParams.get('q') ?? '').toLowerCase();
+19 -4
View File
@@ -1,7 +1,7 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { useState, type ReactNode } from 'react';
import { Suspense, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session';
@@ -151,9 +151,10 @@ function Shell() {
const staff = user.role !== 'user';
const island = user.ui_prefs.web_player === 'island';
const searchAt = user.ui_prefs.search_position ?? 'center';
const search = <SearchBox align={searchAt} collapsible={user.ui_prefs.search_collapse !== false} />;
const search = <SearchBox align={searchAt} collapsible={user.ui_prefs.search_collapse === true} />;
const counts = useCollectionCounts();
const pending = usePendingUploads(user.role === 'admin');
const unseen = useUnseenUploads(staff);
return (
<div className={cx(s.shell, island && s.island)}>
@@ -214,7 +215,7 @@ function Shell() {
)}
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
{user.role === 'admin' && (
<>
<NavLink
@@ -233,7 +234,9 @@ function Shell() {
</nav>
<main className={s.content}>
<Outlet />
<Suspense fallback={null}>
<Outlet />
</Suspense>
</main>
<PlayerBar variant={island ? 'island' : 'bar'} />
@@ -261,3 +264,15 @@ function usePendingUploads(enabled: boolean) {
});
return q.data;
}
/** Новые решения по своим загрузкам — бейдж «Мои предложения» у персонала. */
function useUnseenUploads(enabled: boolean) {
const session = useSession();
const q = useQuery({
queryKey: ['uploads-unseen'],
enabled,
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/unseen')).count,
refetchInterval: 60_000,
});
return q.data || undefined;
}