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
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:
1 parent
2b76060ea3
commit
066ba24834
61 files changed
+2818
-463
No files matched your search
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
) : (
|
||||
|
||||
Reference in new issue
Block a user