Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a59059738 | ||
|
|
bfcf6e48b2 | ||
|
|
02307802bc | ||
|
|
fff91c446b | ||
|
|
e158558da3 | ||
|
|
c84f90e2cc | ||
|
|
72567acd28 | ||
|
|
39aaaed969 |
No files matched your search
@@ -0,0 +1,27 @@
|
||||
name: ci / module boundaries
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/core/src/**'
|
||||
- 'apps/web/src/features/uploads/**'
|
||||
- 'services/api/internal/uploads/**'
|
||||
- 'scripts/check-boundaries.mjs'
|
||||
- '.gitea/workflows/ci-boundaries.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/core/src/**'
|
||||
- 'apps/web/src/features/uploads/**'
|
||||
- 'services/api/internal/uploads/**'
|
||||
- 'scripts/check-boundaries.mjs'
|
||||
- '.gitea/workflows/ci-boundaries.yml'
|
||||
|
||||
jobs:
|
||||
boundaries:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '22' }
|
||||
- run: node scripts/check-boundaries.mjs
|
||||
@@ -0,0 +1,10 @@
|
||||
# Работа с проектом
|
||||
|
||||
- Начинай с ближайшего локального `AGENTS.md` и файлов конкретной возможности. Карта документов: `docs/README.md`; реализация архитектуры: `docs/architecture.md`.
|
||||
- Web/desktop UI: `apps/web/src`; Android: `apps/mobile/src`; общие правила клиентов: `packages/core/src`; Go API: `services/api/internal`; рекомендации: `services/recommender/pipu_rec`.
|
||||
- Контракт HTTP — `api/openapi.yaml`. Читай нужную операцию и связанные схемы. Generated-код (`**/gen/**`, `**/generated/**`) и lock-файлы исследуй точечно; не редактируй вручную.
|
||||
- После изменения OpenAPI: `pnpm --filter @pipu/api-client generate`, затем из `services/api`: `go generate ./...`. Проверяй и коммить результат генерации.
|
||||
- Новые правила размещай у владельца сценария, IO — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя.
|
||||
- Промежуточно запускай проверки затронутой области; перед сдачей — необходимые связанные проверки. Команды запускаются из корня, если не сказано иначе.
|
||||
- Общие проверки: `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, `pnpm test:scripts`. Go: из `services/api` — `go vet ./...`, `go test ./...`. Python: из `services/recommender` — `uv run ruff check .`, `uv run mypy pipu_rec`, `uv run pytest`.
|
||||
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
|
||||
@@ -5,6 +5,7 @@ Self-hosted музыкальный сервис: веб, десктоп и Andro
|
||||
|
||||
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
||||
- Документация: [docs/README.md](docs/README.md)
|
||||
- Карта работы с кодом и проверок: [AGENTS.md](AGENTS.md)
|
||||
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
|
||||
|
||||
## Структура
|
||||
@@ -47,6 +48,7 @@ pnpm --filter @pipu/web dev
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
|
||||
pnpm check:boundaries
|
||||
```
|
||||
|
||||
```bash
|
||||
|
||||
+104
-7
@@ -1427,9 +1427,10 @@ paths:
|
||||
get:
|
||||
operationId: getOnboarding
|
||||
tags: [wave]
|
||||
summary: Онбординг вкуса — исполнители и жанры библиотеки для выбора
|
||||
summary: Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения
|
||||
description: |
|
||||
`required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
|
||||
`required` — пользователь не проходил и не пропускал анкету либо администратор сбросил рекомендации.
|
||||
`choice` содержит ответы с текущей приблизительной возрастной группой; исходная группа и год хранятся на сервере.
|
||||
responses:
|
||||
"200":
|
||||
description: Варианты и состояние
|
||||
@@ -1547,9 +1548,10 @@ paths:
|
||||
operationId: getAppClients
|
||||
tags: [app]
|
||||
security: [] # публично: страница /download без входа
|
||||
summary: Клиенты последнего релиза (Android и десктоп)
|
||||
summary: Клиенты последних релизов (Android и десктоп)
|
||||
description: |
|
||||
Тот же релиз Gitea, что и для Android: APK, установщик Windows, .deb, DMG.
|
||||
У каждой платформы `older` — предыдущие сборки (до 8 релизов, сколько есть с файлами).
|
||||
Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
|
||||
responses:
|
||||
"200":
|
||||
@@ -1705,6 +1707,54 @@ paths:
|
||||
'204':
|
||||
description: Deleted
|
||||
|
||||
/api/v1/me/playlists/{id}/share:
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
post:
|
||||
tags: [collection]
|
||||
operationId: sharePlaylist
|
||||
summary: Создать или вернуть ссылку только для чтения (только владелец)
|
||||
responses:
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
'200':
|
||||
description: Идентификатор ссылки для /shared/playlist/{shareId}; для открытия требуется вход
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PlaylistShare'
|
||||
delete:
|
||||
tags: [collection]
|
||||
operationId: revokePlaylistShare
|
||||
summary: Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку
|
||||
responses:
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
'204':
|
||||
description: Доступ по прежней ссылке отключён
|
||||
/api/v1/shared/playlists/{shareId}:
|
||||
parameters:
|
||||
- in: path
|
||||
name: shareId
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
get:
|
||||
tags: [collection]
|
||||
operationId: getSharedPlaylist
|
||||
summary: Прочитать плейлист по действующей ссылке (требуется вход)
|
||||
responses:
|
||||
default:
|
||||
$ref: '#/components/responses/Problem'
|
||||
'200':
|
||||
description: Плейлист с реакциями и ссылками на стрим для текущего слушателя
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Playlist'
|
||||
|
||||
components:
|
||||
parameters:
|
||||
SourceId:
|
||||
@@ -1765,6 +1815,11 @@ components:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
schemas:
|
||||
PlaylistShare:
|
||||
type: object
|
||||
required: [share_id]
|
||||
properties:
|
||||
share_id: { type: string, format: uuid }
|
||||
PlaylistInput:
|
||||
type: object
|
||||
required: [name, description, track_ids]
|
||||
@@ -1801,6 +1856,12 @@ components:
|
||||
- type: object
|
||||
required: [tracks]
|
||||
properties:
|
||||
share_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
readOnly: true
|
||||
description: Действующая ссылка; возвращается только владельцу через /me/playlists
|
||||
tracks:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Track' }
|
||||
@@ -1818,6 +1879,8 @@ components:
|
||||
properties:
|
||||
required:
|
||||
type: boolean
|
||||
choice:
|
||||
$ref: "#/components/schemas/OnboardingChoice"
|
||||
artists:
|
||||
type: array
|
||||
items:
|
||||
@@ -1853,6 +1916,19 @@ components:
|
||||
type: object
|
||||
required: [artist_ids, genres]
|
||||
properties:
|
||||
gender:
|
||||
type: string
|
||||
nullable: true
|
||||
enum: [male, female, other]
|
||||
age_group:
|
||||
type: string
|
||||
nullable: true
|
||||
enum: [under_13, "13_19", "20_29", "30_39", "40_49", "50_59", 60_plus]
|
||||
description: Текущая приблизительная возрастная группа. Сервер хранит год выбора и учитывает прошедшие годы.
|
||||
age_group_year:
|
||||
type: integer
|
||||
readOnly: true
|
||||
description: Год последнего выбора возрастной группы.
|
||||
artist_ids:
|
||||
type: array
|
||||
maxItems: 50
|
||||
@@ -3581,12 +3657,12 @@ components:
|
||||
AppClient:
|
||||
type: string
|
||||
enum: [android, windows, linux, macos]
|
||||
AppClientFile:
|
||||
AppClientBuild:
|
||||
type: object
|
||||
required: [client, filename, size, download_url]
|
||||
required: [version, filename, size, download_url]
|
||||
properties:
|
||||
client:
|
||||
$ref: "#/components/schemas/AppClient"
|
||||
version:
|
||||
type: string
|
||||
filename:
|
||||
type: string
|
||||
size:
|
||||
@@ -3596,6 +3672,27 @@ components:
|
||||
type: string
|
||||
download_url:
|
||||
type: string
|
||||
AppClientFile:
|
||||
type: object
|
||||
required: [client, filename, size, download_url]
|
||||
properties:
|
||||
client:
|
||||
$ref: "#/components/schemas/AppClient"
|
||||
version:
|
||||
type: string
|
||||
filename:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
format: int64
|
||||
sha256:
|
||||
type: string
|
||||
download_url:
|
||||
type: string
|
||||
older:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/AppClientBuild"
|
||||
AppClients:
|
||||
type: object
|
||||
required: [version, version_code, notes_md, clients]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Android
|
||||
|
||||
- `src/player/controller.ts` — фасад и зеркало native-очереди; `service.ts` — фоновые события. Владельцем воспроизводимой очереди остаётся TrackPlayer.
|
||||
- Общая политика волны — `packages/core/src`; офлайн-адаптация — `src/player/offlineWave.ts`, `src/offline`.
|
||||
- Сессия/настройки — `src/lib`; маршруты — `src/app`. Сохраняй работу при выгруженном UI и изоляцию офлайн-данных по пользователю/серверу.
|
||||
- `pnpm --filter @pipu/mobile typecheck`, `pnpm --filter @pipu/mobile test`.
|
||||
- Изменения native playback требуют проверки на устройстве: background, смена трека, offline→online. Unit-тесты не заменяют эту проверку.
|
||||
@@ -9,6 +9,7 @@ import { player } from '../../player/controller';
|
||||
import { usePlayerState } from '../../player/hooks';
|
||||
import { useColors } from '../../theme';
|
||||
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
|
||||
import { OnboardingGate } from '../../ui/Onboarding';
|
||||
import { WaveOrb } from '../../ui/wave/WaveOrb';
|
||||
|
||||
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
|
||||
@@ -22,6 +23,10 @@ function iconFor(name: string): IconName {
|
||||
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
|
||||
*/
|
||||
export default function HomeScreen() {
|
||||
return <OnboardingGate><WaveHome /></OnboardingGate>;
|
||||
}
|
||||
|
||||
function WaveHome() {
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const tr = t as unknown as (k: string) => string;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { player } from '../../player/controller';
|
||||
import { radius, useColors } from '../../theme';
|
||||
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
|
||||
import { useDownloads } from '../../ui/downloads';
|
||||
import { RecommendationSettings } from '../../ui/Onboarding';
|
||||
import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings';
|
||||
import { useUpdateState } from '../../ui/UpdateDialog';
|
||||
|
||||
@@ -74,6 +75,8 @@ export default function ProfileScreen() {
|
||||
@{user.username} · {t(`roles.${user.role}`)}
|
||||
</Muted>
|
||||
|
||||
<RecommendationSettings />
|
||||
|
||||
<Section>
|
||||
<Row label={t('language.label')}>
|
||||
<Segmented<Locale>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { playlistShareUrl, unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
|
||||
import { useSession, useUser } from '../lib/session';
|
||||
import { Alert, FlatList, Modal, ScrollView, Share, View } from 'react-native';
|
||||
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
||||
import { player } from '../player/controller';
|
||||
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
|
||||
|
||||
@@ -78,6 +78,7 @@ function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; tr
|
||||
|
||||
function Detail({ id }: { id: string }) {
|
||||
const session = useSession();
|
||||
const { server } = useSessionCtx();
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
@@ -89,6 +90,15 @@ function Detail({ id }: { id: string }) {
|
||||
const key = ['m-playlists', user.id, id];
|
||||
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||
const p = query.data;
|
||||
const sharing = useMutation({ mutationFn: async () => {
|
||||
if (!server || !p) throw new Error('Server unavailable');
|
||||
const token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })).share_id;
|
||||
cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: token } : current);
|
||||
await Share.share({ title: p.name, message: playlistShareUrl(server, token) }, { dialogTitle: t('playlists.share') });
|
||||
} });
|
||||
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })),
|
||||
onSuccess: () => { cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: null } : current); } });
|
||||
|
||||
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
|
||||
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
|
||||
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||
@@ -105,11 +115,14 @@ function Detail({ id }: { id: string }) {
|
||||
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
||||
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
|
||||
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
|
||||
<ErrorBox>{query.error || save.error || deletion.error || playError ? errorText(query.error ?? save.error ?? deletion.error ?? playError) : null}</ErrorBox>
|
||||
<ErrorBox>{query.error || save.error || deletion.error || playError || sharing.error || revoke.error ? errorText(query.error ?? save.error ?? deletion.error ?? playError ?? sharing.error ?? revoke.error) : null}</ErrorBox>
|
||||
{query.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
|
||||
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
|
||||
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
|
||||
<Button title={t('playlists.share')} variant="secondary" busy={sharing.isPending} disabled={revoke.isPending} onPress={() => sharing.mutate()} />
|
||||
<Muted>{t('playlists.share_hint')}</Muted>
|
||||
{p.share_id && <Button title={t('playlists.revoke_share')} variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onPress={() => revoke.mutate()} />}
|
||||
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
|
||||
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
|
||||
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import {
|
||||
EventBuffer,
|
||||
WaveCoordinator,
|
||||
EARLY_SKIP_MS,
|
||||
type WaveInfo,
|
||||
type WaveOutcome,
|
||||
type WaveFeedback,
|
||||
PlayAttempt,
|
||||
normalizeGain,
|
||||
unwrap,
|
||||
@@ -24,12 +29,7 @@ import { eventId } from './eventId';
|
||||
|
||||
export { offlineWave };
|
||||
|
||||
export interface WaveInfo {
|
||||
sessionId: string;
|
||||
activity: string | null;
|
||||
mood: string | null;
|
||||
fallback: boolean;
|
||||
}
|
||||
export type { WaveInfo } from '@pipu/core';
|
||||
|
||||
export interface PlayerState {
|
||||
ready: boolean;
|
||||
@@ -44,10 +44,6 @@ export interface PlayerState {
|
||||
wave: WaveInfo | null;
|
||||
}
|
||||
|
||||
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
|
||||
|
||||
const WAVE_REFILL_AT = 2;
|
||||
const EARLY_SKIP_MS = 30_000;
|
||||
const SAVE_EVERY_MS = 10_000;
|
||||
|
||||
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
|
||||
@@ -84,8 +80,13 @@ class Controller {
|
||||
private attempt: PlayAttempt | null = null;
|
||||
private lastPositionMs = 0;
|
||||
private lastSave = 0;
|
||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||
private waveLoading = false;
|
||||
private readonly wavePolicy = new WaveCoordinator({
|
||||
next: async (id: string, feedback: WaveFeedback[]) => unwrap(
|
||||
await this.session!.client.POST('/api/v1/wave/sessions/{id}/next', {
|
||||
params: { path: { id } }, body: { feedback },
|
||||
}),
|
||||
),
|
||||
});
|
||||
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
|
||||
private setupDone: Promise<void> | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
@@ -308,18 +309,22 @@ class Controller {
|
||||
if (!this.session) return;
|
||||
await this.setup();
|
||||
this.leaveWave();
|
||||
const version = this.wavePolicy.version;
|
||||
let res;
|
||||
try {
|
||||
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
|
||||
} catch (e) {
|
||||
if (!this.wavePolicy.isCurrent(version)) return;
|
||||
// Нет связи с сервером — офлайн-волна из скачанного.
|
||||
const local = offlineWave(downloads.downloaded());
|
||||
if (!local.length) throw e;
|
||||
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
|
||||
}
|
||||
if (!this.wavePolicy.isCurrent(version)) return;
|
||||
const tracks = res.tracks.filter(playable);
|
||||
if (!tracks.length) return;
|
||||
this.finishAttempt(this.lastPositionMs / 1000);
|
||||
this.wavePolicy.activate(res.session_id);
|
||||
this.update({
|
||||
queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id },
|
||||
wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback },
|
||||
@@ -332,13 +337,16 @@ class Controller {
|
||||
async setWaveContext(activity: string | null, mood: string | null) {
|
||||
const wave = this.state.wave;
|
||||
if (!wave || !this.session) return this.startWave(activity, mood);
|
||||
const version = this.wavePolicy.changeContext(wave.sessionId);
|
||||
const res = unwrap(
|
||||
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
||||
params: { path: { id: wave.sessionId } },
|
||||
body: { activity, mood },
|
||||
}),
|
||||
);
|
||||
await this.replaceUpcoming(res.tracks);
|
||||
if (!this.wavePolicy.isCurrent(version)) return;
|
||||
await this.replaceUpcoming(res.tracks, () => this.wavePolicy.isCurrent(version));
|
||||
if (!this.wavePolicy.isCurrent(version)) return;
|
||||
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
||||
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
|
||||
if (res.tracks.some(playable)) {
|
||||
@@ -351,8 +359,8 @@ class Controller {
|
||||
|
||||
private leaveWave() {
|
||||
const wave = this.state.wave;
|
||||
this.wavePolicy.activate(null);
|
||||
if (!wave) return;
|
||||
this.waveFeedback = [];
|
||||
this.update({ wave: null });
|
||||
void this.session?.client
|
||||
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
||||
@@ -360,46 +368,36 @@ class Controller {
|
||||
}
|
||||
|
||||
private noteWave(trackId: string, outcome: WaveOutcome) {
|
||||
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
|
||||
this.wavePolicy.note(trackId, outcome);
|
||||
}
|
||||
|
||||
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
|
||||
private async replaceUpcoming(tracks: Track[]) {
|
||||
private async replaceUpcoming(tracks: Track[], current: () => boolean = () => true) {
|
||||
const keep = this.state.queue.slice(0, this.state.index + 1);
|
||||
await TrackPlayer.removeUpcomingTracks();
|
||||
const add = tracks.filter(playable);
|
||||
await TrackPlayer.add(await this.toRntp(add));
|
||||
this.update({ queue: [...keep, ...add] });
|
||||
const native = await this.toRntp(add);
|
||||
if (!current()) return;
|
||||
await TrackPlayer.removeUpcomingTracks();
|
||||
if (!current()) return;
|
||||
await TrackPlayer.add(native);
|
||||
if (current()) this.update({ queue: [...keep, ...add] });
|
||||
}
|
||||
|
||||
private async refillWave(refresh = false) {
|
||||
const wave = this.state.wave;
|
||||
if (!wave || !this.session || this.waveLoading) return;
|
||||
if (!this.session) return;
|
||||
const remaining = this.state.queue.length - this.state.index - 1;
|
||||
if (!refresh && remaining > WAVE_REFILL_AT) return;
|
||||
this.waveLoading = true;
|
||||
const feedback = this.waveFeedback;
|
||||
this.waveFeedback = [];
|
||||
try {
|
||||
const res = unwrap(
|
||||
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
|
||||
params: { path: { id: wave.sessionId } },
|
||||
body: { feedback },
|
||||
}),
|
||||
);
|
||||
if (this.state.wave?.sessionId !== wave.sessionId) return;
|
||||
if (refresh) await this.replaceUpcoming(res.tracks);
|
||||
await this.wavePolicy.refill(remaining, refresh, async (res, current) => {
|
||||
if (refresh) await this.replaceUpcoming(res.tracks, current);
|
||||
else {
|
||||
const add = res.tracks.filter(playable);
|
||||
await TrackPlayer.add(await this.toRntp(add));
|
||||
const native = await this.toRntp(add);
|
||||
if (!current()) return;
|
||||
await TrackPlayer.add(native);
|
||||
if (!current()) return;
|
||||
this.update({ queue: [...this.state.queue, ...add] });
|
||||
}
|
||||
this.update({ wave: { ...wave, fallback: res.fallback } });
|
||||
} catch {
|
||||
this.waveFeedback = [...feedback, ...this.waveFeedback];
|
||||
} finally {
|
||||
this.waveLoading = false;
|
||||
}
|
||||
if (current()) this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
|
||||
});
|
||||
}
|
||||
|
||||
// --- состояние ---
|
||||
@@ -443,6 +441,7 @@ class Controller {
|
||||
}
|
||||
|
||||
async reset() {
|
||||
this.wavePolicy.activate(null);
|
||||
this.attempt = null;
|
||||
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
|
||||
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
||||
import { useColors } from '../theme';
|
||||
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from './kit';
|
||||
|
||||
type Options = components['schemas']['Onboarding'];
|
||||
type Choice = components['schemas']['OnboardingChoice'];
|
||||
|
||||
function useOnboarding() {
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
const { server } = useSessionCtx();
|
||||
return useQuery({ queryKey: ['onboarding', server, user.id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
|
||||
staleTime: 0, refetchInterval: 30_000 });
|
||||
}
|
||||
|
||||
export function OnboardingGate({ children }: { children: ReactNode }) {
|
||||
const query = useOnboarding();
|
||||
const client = useQueryClient();
|
||||
if (query.isPending) return <Screen><ActivityIndicator /></Screen>;
|
||||
if (!query.data?.required) return <>{children}</>;
|
||||
return <Screen><ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||
<Onboarding options={query.data} onDone={() => void client.invalidateQueries({ queryKey: ['onboarding'] })} />
|
||||
</ScrollView></Screen>;
|
||||
}
|
||||
|
||||
export function RecommendationSettings() {
|
||||
const { t } = useTranslation();
|
||||
const query = useOnboarding();
|
||||
const client = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const errorText = useErrorText();
|
||||
return <View>
|
||||
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
|
||||
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
|
||||
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
|
||||
<ErrorBox>{query.error ? errorText(query.error) : null}</ErrorBox>
|
||||
</View>;
|
||||
}
|
||||
|
||||
function Onboarding({ options, onDone, editing = false }: { options: Options; onDone: () => void; editing?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const [choice, setChoice] = useState<Choice>(options.choice ?? { artist_ids: [], genres: [], gender: null, age_group: null });
|
||||
const save = useMutation({ mutationFn: async (skip: boolean) => unwrap(await session.client.PUT('/api/v1/me/onboarding', {
|
||||
body: skip ? { artist_ids: [], genres: [], gender: null, age_group: null } : choice,
|
||||
})), onSuccess: onDone });
|
||||
const toggle = (key: 'artist_ids' | 'genres', value: string) => setChoice((old) => ({ ...old,
|
||||
[key]: old[key].includes(value) ? old[key].filter((x) => x !== value) : [...old[key], value],
|
||||
}));
|
||||
return <View style={{ gap: 20 }}>
|
||||
<H1>{t('onboarding.title')}</H1><Muted>{t('onboarding.subtitle')}</Muted>
|
||||
<Choices title={t('onboarding.gender')} items={[{ value: '', label: t('onboarding.unspecified') }, ...GENDERS.map((g) => ({ value: g, label: t(`onboarding.genders.${g}`) }))]}
|
||||
selected={[choice.gender ?? '']} onPick={(v) => setChoice((old) => ({ ...old, gender: (v || null) as Choice['gender'] }))} />
|
||||
<Choices title={t('onboarding.age_group')} items={[{ value: '', label: t('onboarding.unspecified') }, ...AGE_GROUPS.map((g) => ({ value: g, label: t(`onboarding.ages.${g}`) }))]}
|
||||
selected={[choice.age_group ?? '']} onPick={(v) => setChoice((old) => ({ ...old, age_group: (v || null) as Choice['age_group'] }))} />
|
||||
<Muted>{t('onboarding.age_hint')}</Muted>
|
||||
<Choices title={t('onboarding.artists')} items={options.artists.map((a) => ({ value: a.id, label: a.name }))} selected={choice.artist_ids} onPick={(v) => toggle('artist_ids', v)} />
|
||||
<Choices title={t('onboarding.genres')} items={options.genres.map((g) => ({ value: g.name, label: g.name }))} selected={choice.genres} onPick={(v) => toggle('genres', v)} />
|
||||
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
|
||||
<Button title={t(editing ? 'settings.save' : 'onboarding.done')} busy={save.isPending} onPress={() => save.mutate(false)} />
|
||||
<Button title={t(editing ? 'form.cancel' : 'onboarding.skip')} variant="ghost" disabled={save.isPending} onPress={() => editing ? onDone() : save.mutate(true)} />
|
||||
</View>;
|
||||
}
|
||||
|
||||
function Choices({ title, items, selected, onPick }: { title: string; items: { value: string; label: string }[]; selected: string[]; onPick: (value: string) => void }) {
|
||||
const colors = useColors();
|
||||
return <View style={{ gap: 10 }}><Text style={{ color: colors.text, fontWeight: '600' }}>{title}</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>{items.map((item) => {
|
||||
const on = selected.includes(item.value);
|
||||
return <Pressable key={item.value} accessibilityRole="button" accessibilityState={{ selected: on }} onPress={() => onPick(item.value)}
|
||||
style={{ padding: 12, borderRadius: 20, backgroundColor: on ? colors['accent-fill'] : colors.surface2 }}>
|
||||
<Text style={{ color: on ? '#fff' : colors.text }}>{item.label}</Text>
|
||||
</Pressable>;
|
||||
})}</View>
|
||||
</View>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { initialWaveState, step, WAVE_COLORS, waveScale, type WaveState } from '@pipu/core';
|
||||
import { initialWaveState, step, wavePalette, waveScale, type WaveState } from '@pipu/core';
|
||||
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { AppState, Pressable, View } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
|
||||
|
||||
@@ -36,7 +36,7 @@ export function WaveOrb({ variant, playing, bpm = 100, energy = 0.6, colors, siz
|
||||
}, []),
|
||||
);
|
||||
const [foreground, setForeground] = useState(AppState.currentState === 'active');
|
||||
const palette = (colors?.length === 3 ? colors : WAVE_COLORS) as [string, string, string];
|
||||
const palette = useMemo(() => wavePalette(colors), [colors]);
|
||||
|
||||
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
|
||||
const state = useSharedValue<WaveState>(initialWaveState(size));
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AuthSession, Track } from '@pipu/core';
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@react-native-community/netinfo', () => ({ default: {
|
||||
fetch: async () => ({ type: 'wifi' }), addEventListener: () => () => {},
|
||||
} }));
|
||||
vi.mock('../src/lib/prefs', () => ({
|
||||
qualityPrefs: { load: async () => ({ wifi: 'high', cellular: 'medium' }) },
|
||||
playerCache: { clearIfRequested: async () => {} },
|
||||
}));
|
||||
vi.mock('../src/offline/events', () => ({ PersistentEvents: class {} }));
|
||||
vi.mock('../src/offline/downloads', () => ({
|
||||
downloads: { isDownloaded: () => false, localTrack: () => undefined, localCover: () => undefined, downloaded: () => [] },
|
||||
sizedCover: (s: string) => s,
|
||||
}));
|
||||
vi.mock('react-native-track-player', () => ({
|
||||
default: {
|
||||
isServiceRunning: async () => true, setupPlayer: async () => {}, updateOptions: async () => {},
|
||||
addEventListener: vi.fn(), reset: vi.fn().mockResolvedValue(undefined),
|
||||
add: vi.fn().mockResolvedValue(undefined), play: vi.fn().mockResolvedValue(undefined),
|
||||
skip: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
AppKilledPlaybackBehavior: {}, Capability: {}, Event: {}, State: {},
|
||||
}));
|
||||
|
||||
function track(id: string): Track {
|
||||
return { id, title: id, duration_ms: 1000, artists: [],
|
||||
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
|
||||
genres: [], disc_no: 1, track_no: 1, available: true, stream_url: `/s/${id}?sig=x`, reaction: null, features: null };
|
||||
}
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
const promise = new Promise<T>((yes) => { resolve = yes; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
const reply = (id: string) => ({ data: { session_id: id, activity: null, mood: null, fallback: false, tracks: [track(id)] }, response: new Response() });
|
||||
|
||||
beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); });
|
||||
|
||||
it('ignores a wave creation response after the player is reset', async () => {
|
||||
const { player } = await import('../src/player/controller');
|
||||
const native = (await import('react-native-track-player')).default;
|
||||
const pending = deferred<ReturnType<typeof reply>>();
|
||||
const post = vi.fn().mockReturnValue(pending.promise);
|
||||
player.attach({ client: { POST: post } } as unknown as AuthSession, 'http://localhost', null);
|
||||
const start = player.startWave(null, null);
|
||||
await vi.waitFor(() => expect(post).toHaveBeenCalled());
|
||||
await player.reset();
|
||||
pending.resolve(reply('old'));
|
||||
await start;
|
||||
expect(player.getState().wave).toBeNull();
|
||||
expect(player.getState().queue).toEqual([]);
|
||||
expect(native.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not let a late context response replace a manually selected playlist', async () => {
|
||||
const { player } = await import('../src/player/controller');
|
||||
const pending = deferred<ReturnType<typeof reply>>();
|
||||
const session = { client: {
|
||||
POST: vi.fn().mockResolvedValue(reply('wave')),
|
||||
PATCH: vi.fn().mockReturnValue(pending.promise), DELETE: vi.fn().mockResolvedValue({}),
|
||||
} };
|
||||
player.attach(session as unknown as AuthSession, 'http://localhost', null);
|
||||
await player.startWave(null, null);
|
||||
const change = player.setWaveContext('focus', null);
|
||||
await player.playList([track('manual')], 0, { type: 'album', id: 'album' });
|
||||
pending.resolve(reply('stale'));
|
||||
await change;
|
||||
expect(player.getState().wave).toBeNull();
|
||||
expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']);
|
||||
expect(player.getState().context?.type).toBe('album');
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
# Web и desktop UI
|
||||
|
||||
- Маршруты: `src/app/router.tsx`; сессия: `src/app/session.tsx`; страницы собирают возможности, `src/features` владеет сценариями.
|
||||
- Воспроизведение: `src/player/player.ts` — фасад; адаптеры рядом. Общие правила очереди, событий и волны — `packages/core/src`.
|
||||
- Загрузки: `src/features/uploads`; страница очереди — `src/pages/admin/UploadQueuePage.tsx`. Сохраняй query keys/invalidation, права и поведение диалогов.
|
||||
- `pnpm --filter @pipu/web typecheck`; `pnpm --filter @pipu/web test`. Для локальной проверки можно передать путь теста после `test`.
|
||||
- UI/навигация/диалоги: `pnpm --filter @pipu/web test:e2e`. Проверки используют fake API; реальная серверная авторизация покрывается Go отдельно.
|
||||
- Restore не запускает музыку и не создаёт событие прослушивания. Ошибка preload не пропускает трек. Dispose должен освобождать обработчики.
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
|
||||
for (const width of [320, 1280]) {
|
||||
test(`playlists at ${width}px`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||
@@ -10,7 +11,10 @@ for (const width of [320, 1280]) {
|
||||
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
|
||||
const playlistsHeader = page.locator('header').filter({
|
||||
has: page.getByRole('heading', { name: 'Плейлисты', exact: true }),
|
||||
});
|
||||
await playlistsHeader.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
|
||||
await page.getByLabel('Название', { exact: true }).fill('Музыка для долгой дороги');
|
||||
await page.getByLabel('Описание', { exact: true }).fill('Любимые песни всегда рядом');
|
||||
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||
@@ -27,10 +31,22 @@ for (const width of [320, 1280]) {
|
||||
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
|
||||
await page.getByRole('button', { name: 'Управлять треками' }).click();
|
||||
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
|
||||
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(sharedUrl).toMatch(/^http:\/\/127\.0\.0\.1:4173\/shared\/playlist\/[a-f0-9-]+$/);
|
||||
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toBeVisible();
|
||||
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const main = document.querySelector('main')!;
|
||||
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||
})).toBeLessThanOrEqual(1);
|
||||
await page.screenshot({ path: info.outputPath('playlist.png') });
|
||||
await page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toBe(sharedUrl);
|
||||
|
||||
});
|
||||
}
|
||||
@@ -48,6 +48,15 @@ test('settings expose crossfade and loudness normalization', async ({ page }) =>
|
||||
const slider = page.getByRole('slider', { name: /Кроссфейд/ });
|
||||
await slider.fill('4');
|
||||
await expect(page.getByText('Кроссфейд, 4 с')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Настроить рекомендации' }).click();
|
||||
await page.getByRole('button', { name: 'Мужской', exact: true }).click();
|
||||
await page.getByRole('button', { name: '13–19', exact: true }).click();
|
||||
const form = page.getByRole('heading', { name: 'Что вы любите слушать?' }).locator('../..');
|
||||
await form.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Настроить рекомендации' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Мужской', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.getByRole('button', { name: '13–19', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
});
|
||||
test('9. login page switches between Russian and English', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
@@ -10,7 +10,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { App } from './App';
|
||||
import { createAppRouter, safeRedirect } from './app/router';
|
||||
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -291,7 +291,7 @@ describe('player', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
|
||||
expect(router.state.location.pathname).toBe(path);
|
||||
await user.click(screen.getByRole('listitem'));
|
||||
await user.click(screen.getByRole('button', { name: 'Слушать' }));
|
||||
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
|
||||
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
|
||||
@@ -479,3 +479,82 @@ describe('playlists', () => {
|
||||
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('recommendation preferences', () => {
|
||||
it('saves demographics, edits the age group and asks again after an admin reset', async () => {
|
||||
const admin = makeUser('owner', 'admin');
|
||||
const { user, api, router, session } = await renderApp([admin], '/settings', { signedInAs: admin });
|
||||
await user.click(await screen.findByRole('button', { name: 'Настроить рекомендации' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Мужской' }));
|
||||
await user.click(screen.getByRole('button', { name: '13–19' }));
|
||||
await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!);
|
||||
await screen.findByRole('button', { name: 'Настроить рекомендации' });
|
||||
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toMatchObject({ gender: 'male', age_group: '13_19' });
|
||||
await user.click(screen.getByRole('button', { name: 'Настроить рекомендации' }));
|
||||
expect(await screen.findByRole('button', { name: '13–19' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(screen.getByRole('button', { name: '20–29' }));
|
||||
await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!);
|
||||
await screen.findByRole('button', { name: 'Настроить рекомендации' });
|
||||
await session.client.POST('/api/v1/admin/users/{id}/reset-recommendations', { params: { path: { id: admin.id } } });
|
||||
await router.navigate({ to: '/' });
|
||||
await screen.findByRole('heading', { name: 'Что вы любите слушать?' });
|
||||
expect(screen.getByRole('button', { name: 'Мужской' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: '20–29' })).toHaveAttribute('aria-pressed', 'false');
|
||||
await user.click(screen.getByRole('button', { name: 'Пропустить' }));
|
||||
await screen.findByRole('button', { name: 'Запустить «Мою волну»' });
|
||||
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('playlist sharing', () => {
|
||||
it('copies a stable read-only link and revokes it without exposing editing controls', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { user, router, session, api } = await renderApp([alice], '/', { signedInAs: alice });
|
||||
const created = await session.client.POST('/api/v1/me/playlists', { body: { name: 'Общий микс', description: 'Для друзей', track_ids: [albumTracks[0]!.id] } });
|
||||
const playlist = created.data!;
|
||||
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
|
||||
await screen.findByRole('heading', { name: 'Общий микс' });
|
||||
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('Ссылка на плейлист скопирована');
|
||||
const shareId = api.playlists.get(playlist.id)!.share_id!;
|
||||
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}/shared/playlist/${shareId}`);
|
||||
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Поделиться плейлистом' })).toBeEnabled());
|
||||
expect(api.calls.filter(c => c.method === 'POST' && c.path.endsWith('/share'))).toHaveLength(2);
|
||||
expect(api.playlists.get(playlist.id)?.share_id).toBe(shareId);
|
||||
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
|
||||
await screen.findByRole('heading', { name: 'Общий микс' });
|
||||
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Управлять треками' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Удалить плейлист' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Слушать всё' }));
|
||||
await screen.findByRole('contentinfo', { name: 'Плеер' });
|
||||
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
|
||||
await user.click(await screen.findByRole('button', { name: 'Отключить доступ по ссылке' }));
|
||||
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeNull());
|
||||
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeTruthy());
|
||||
expect(api.playlists.get(playlist.id)?.share_id).not.toBe(shareId);
|
||||
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.queryByRole('heading', { name: 'Общий микс' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns to the shared playlist after login without starting playback', async () => {
|
||||
const bob = makeUser('bob', 'user');
|
||||
const shareId = '00000000-0000-4000-9001-000000000001';
|
||||
const { api, user, router } = await renderApp([bob], `/shared/playlist/${shareId}`);
|
||||
api.playlists.set('00000000-0000-4000-9000-000000000001', { id: '00000000-0000-4000-9000-000000000001', name: 'От друга', description: '', revision: 1, tracks: [albumTracks[0]!], track_count: 1, duration_ms: 200000, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', share_id: shareId });
|
||||
await screen.findByRole('heading', { name: 'Вход' });
|
||||
await user.type(screen.getByLabelText('Логин'), bob.username);
|
||||
await user.type(screen.getByLabelText('Пароль'), bob.password);
|
||||
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
await screen.findByRole('heading', { name: 'От друга' });
|
||||
expect(router.state.location.pathname).toBe(`/shared/playlist/${shareId}`);
|
||||
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -145,6 +145,7 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
||||
|
||||
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
||||
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
|
||||
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
||||
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
||||
|
||||
@@ -280,6 +281,7 @@ const routeTree = rootRoute.addChildren([
|
||||
homeRoute,
|
||||
trackRoute,
|
||||
playlistsRoute,
|
||||
sharedPlaylistRoute,
|
||||
playlistRoute,
|
||||
settingsRoute,
|
||||
searchRoute,
|
||||
|
||||
@@ -8,12 +8,18 @@
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.cover img {
|
||||
.coverImg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
|
||||
.coverImgOn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.round {
|
||||
@@ -59,10 +65,52 @@
|
||||
width: 128px;
|
||||
}
|
||||
|
||||
.cardCoverWrap {
|
||||
position: relative;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.cardCover {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 4px;
|
||||
transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.card:hover .cardCover,
|
||||
.card:focus-within .cardCover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.cardPlay {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: #05070d;
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transition:
|
||||
opacity 0.18s,
|
||||
transform 0.18s;
|
||||
}
|
||||
|
||||
.card:hover .cardPlay,
|
||||
.card:focus-within .cardPlay,
|
||||
.cardPlay:focus-visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.cardPlay:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
@@ -104,6 +152,12 @@
|
||||
min-height: 56px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 10px;
|
||||
animation: pipuIn 0.2s ease-out both;
|
||||
animation-delay: var(--stagger, 0ms);
|
||||
}
|
||||
|
||||
.trackStatic {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.track:has(.trackAlbum) {
|
||||
@@ -272,6 +326,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.cardPlay { opacity: 1; transform: none; }
|
||||
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
|
||||
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Fragment, useRef, type ReactNode } from 'react';
|
||||
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
|
||||
import { AddToPlaylist } from './playlists';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { usePlayer, usePlayerState } from '../player/context';
|
||||
import type { PlayContext } from '../player/player';
|
||||
@@ -59,7 +61,7 @@ export function Cover({
|
||||
aria-hidden
|
||||
>
|
||||
{album.cover_url ? (
|
||||
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
|
||||
<CoverImg src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} />
|
||||
) : (
|
||||
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
|
||||
)}
|
||||
@@ -83,10 +85,39 @@ export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CoverImg({ src }: { src: string }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className={cx(s.coverImg, ready && s.coverImgOn)}
|
||||
onLoad={() => setReady(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlbumCard({ album }: { album: AlbumSummary }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const player = usePlayer();
|
||||
const play = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void (async () => {
|
||||
const full = unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id: album.id } } }));
|
||||
if (full.tracks.some((tr) => tr.available)) player.playList(full.tracks, 0, { type: 'album', id: album.id, label: album.title });
|
||||
})();
|
||||
};
|
||||
return (
|
||||
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
|
||||
<Cover album={album} size="100%" className={s.cardCover} />
|
||||
<span className={s.cardCoverWrap}>
|
||||
<Cover album={album} size="100%" className={s.cardCover} />
|
||||
<button type="button" className={s.cardPlay} aria-label={t('catalog.play')} onClick={play}>
|
||||
<Icon src={playIcon} size={22} />
|
||||
</button>
|
||||
</span>
|
||||
<span className={s.cardTitle}>{album.title}</span>
|
||||
<span className={s.cardMeta}>
|
||||
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
|
||||
@@ -155,7 +186,8 @@ export function TrackList({
|
||||
role="listitem"
|
||||
tabIndex={tr.available ? 0 : -1}
|
||||
aria-current={isCurrent || undefined}
|
||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current, virtual && s.trackStatic)}
|
||||
style={{ '--stagger': `${Math.min(i, 12) * 40}ms` } as CSSProperties}
|
||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||
onClick={(e) => {
|
||||
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { useSession, useUser } from '../app/session';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
||||
import { playlistAddIcon } from '../shared/ui/icons';
|
||||
@@ -18,10 +18,13 @@ export function usePlaylists(enabled = true) {
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||
}
|
||||
|
||||
export function usePlaylist(id: string) {
|
||||
export function usePlaylist(id: string, shared = false) {
|
||||
const session = useSession();
|
||||
return useQuery({ queryKey: ['playlists', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||
const user = useUser();
|
||||
return useQuery({ queryKey: shared ? ['shared-playlists', id, user.id] : ['playlists', id], staleTime: 0,
|
||||
queryFn: async () => shared
|
||||
? unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }))
|
||||
: unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||
}
|
||||
|
||||
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { useUploadsApi } from './queries';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
|
||||
import s from '../../pages/page.module.css';
|
||||
export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const api = useUploadsApi();
|
||||
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: () => api.updateBatch(batchId, {
|
||||
...(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { AuthSession } from '@pipu/core';
|
||||
import { createI18n } from '@pipu/i18n';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { SessionProvider } from '../../app/session';
|
||||
import { mobileFixtures } from '../../test/mobileFixtures';
|
||||
import { EditDialog } from './EditDialog';
|
||||
import type { UploadFile } from './common';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
async function fixture(fail = false) {
|
||||
const file = (mobileFixtures['/api/v1/admin/uploads'] as { items: UploadFile[] }).items[0]!;
|
||||
const requests: unknown[] = [];
|
||||
const fetchImpl: typeof fetch = async (input) => {
|
||||
const req = input as Request;
|
||||
if (req.method === 'PATCH') {
|
||||
requests.push(await req.json());
|
||||
return new Response(JSON.stringify(fail ? { status: 409, code: 'uploads.wrong_status' } : file), { status: fail ? 409 : 200 });
|
||||
}
|
||||
return new Response(JSON.stringify(mobileFixtures['/api/v1/admin/sources']));
|
||||
};
|
||||
const session = new AuthSession({ baseUrl: 'http://localhost', fetch: fetchImpl, clientType: 'web' });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
const onSaved = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const i18n = await createI18n('ru');
|
||||
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={queryClient}>
|
||||
<EditDialog file={file} onSaved={onSaved} onClose={onClose} />
|
||||
</QueryClientProvider></SessionProvider></I18nextProvider>);
|
||||
const user = userEvent.setup();
|
||||
await user.type(screen.getByLabelText('Исполнитель', { exact: true }), 'Кино');
|
||||
await user.type(screen.getByLabelText('Название', { exact: true }), 'Перемен');
|
||||
return { requests, onSaved, onClose, user };
|
||||
}
|
||||
|
||||
it('saves edited metadata with the target source and closes only after success', async () => {
|
||||
const f = await fixture();
|
||||
await f.user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(f.onSaved).toHaveBeenCalledOnce());
|
||||
expect(f.onClose).toHaveBeenCalledOnce();
|
||||
expect(f.requests).toEqual([expect.objectContaining({ metadata: expect.objectContaining({ artist: 'Кино', title: 'Перемен' }), target_source_id: expect.any(String) })]);
|
||||
});
|
||||
|
||||
it('keeps the editor and input when a concurrent review rejects the save', async () => {
|
||||
const f = await fixture(true);
|
||||
await f.user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toBeVisible());
|
||||
expect(screen.getByRole('dialog')).toBeVisible();
|
||||
expect(screen.getByLabelText('Название', { exact: true })).toHaveValue('Перемен');
|
||||
expect(f.onSaved).not.toHaveBeenCalled();
|
||||
expect(f.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
|
||||
import { useUploadsApi, useUploadSources } from './queries';
|
||||
import type { UploadFile } from './common';
|
||||
import s from '../../pages/page.module.css';
|
||||
import u from '../../pages/uploads/uploads.module.css';
|
||||
|
||||
type Meta = components['schemas']['UploadMeta'];
|
||||
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
|
||||
|
||||
export function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const api = useUploadsApi();
|
||||
const errorText = useErrorText();
|
||||
const fieldError = useFieldError();
|
||||
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
|
||||
const [source, setSource] = useState(file.target_source_id ?? '');
|
||||
const sources = useUploadSources();
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.updateFile(file.id, { metadata: m, ...(source ? { target_source_id: source } : {}) }),
|
||||
onSuccess: () => {
|
||||
onSaved();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
|
||||
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
|
||||
);
|
||||
const num = (k: 'year' | 'track' | 'disc') => (
|
||||
<Field
|
||||
label={t(`uploads.meta_${k}`)}
|
||||
value={m[k] === null ? '' : String(m[k])}
|
||||
inputMode="numeric"
|
||||
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
|
||||
/>
|
||||
);
|
||||
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
|
||||
return (
|
||||
<Dialog
|
||||
title={t('uploads.edit_title')}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
|
||||
{t('uploads.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={s.sessions}>
|
||||
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
|
||||
<div className={u.grid}>
|
||||
{text('artist')}
|
||||
{text('albumartist')}
|
||||
{text('album')}
|
||||
{text('title')}
|
||||
{text('genre')}
|
||||
{num('year')}
|
||||
{num('track')}
|
||||
{num('disc')}
|
||||
</div>
|
||||
<label className={s.sessions}>
|
||||
<span>{t('uploads.target_source')}</span>
|
||||
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
|
||||
<option value="">{t('uploads.target_default')}</option>
|
||||
{accepting.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className={s.note}>{t('uploads.tags_note')}</p>
|
||||
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
||||
import { editIcon, refreshIcon } from '../../shared/ui/icons';
|
||||
import { needsFormatAttention } from './files';
|
||||
import { UploadStatusBadge, useReasonText, type UploadFile } from './common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, cx, Icon } from '../../shared/ui/ui';
|
||||
import s from '../../pages/page.module.css';
|
||||
import u from '../../pages/uploads/uploads.module.css';
|
||||
export function BatchCard({
|
||||
files,
|
||||
selectable,
|
||||
selected,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onEditBatch,
|
||||
onRetry,
|
||||
}: {
|
||||
files: UploadFile[];
|
||||
selectable: boolean;
|
||||
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');
|
||||
const first = files[0]!;
|
||||
const ids = files.map((f) => f.id);
|
||||
const all = ids.every((id) => selected.has(id));
|
||||
const size = files.reduce((n, f) => n + f.size_bytes, 0);
|
||||
return (
|
||||
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
|
||||
<div className={u.batchHead}>
|
||||
{selectable && (
|
||||
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
|
||||
)}
|
||||
<b>{first.author.username}</b>
|
||||
<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
|
||||
key={f.id}
|
||||
file={f}
|
||||
selectable={selectable}
|
||||
checked={selected.has(f.id)}
|
||||
onCheck={(on) => onToggle([f.id], on)}
|
||||
onEdit={() => onEdit(f)}
|
||||
onRetry={() => onRetry(f.id)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueRow({
|
||||
file: f,
|
||||
selectable,
|
||||
checked,
|
||||
onCheck,
|
||||
onEdit,
|
||||
onRetry,
|
||||
}: {
|
||||
file: UploadFile;
|
||||
selectable: boolean;
|
||||
checked: boolean;
|
||||
onCheck: (on: boolean) => void;
|
||||
onEdit: () => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation('admin');
|
||||
const reason = useReasonText()(f.status_reason);
|
||||
const m = f.metadata;
|
||||
const quality = [
|
||||
f.format?.toUpperCase(),
|
||||
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
|
||||
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
|
||||
f.bit_depth ? `${f.bit_depth} bit` : null,
|
||||
f.duration_ms ? formatDuration(f.duration_ms) : null,
|
||||
formatBytes(f.size_bytes, i18n.language),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const editable = f.status === 'pending_review' || f.status === 'import_failed';
|
||||
return (
|
||||
<div className={u.queueRow}>
|
||||
{selectable ? (
|
||||
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className={u.meta}>
|
||||
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
|
||||
{m && (
|
||||
<span className={u.sub}>
|
||||
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
{f.metadata_edited && ` · ${t('uploads.edited')}`}
|
||||
</span>
|
||||
)}
|
||||
<span className={u.sub}>{quality}</span>
|
||||
<span className={u.sub} title={f.client_rel_path ?? undefined}>
|
||||
{f.client_rel_path ?? f.original_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className={u.meta}>
|
||||
<div className={u.flags}>
|
||||
<UploadStatusBadge status={f.status} />
|
||||
{needsFormatAttention(f.format, f.original_name) && (
|
||||
<span className={cx(s.badge, u.tone_warn)} title={t('uploads.format_attention')}>
|
||||
{t('uploads.flag_attention')}
|
||||
</span>
|
||||
)}
|
||||
{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>
|
||||
) : (
|
||||
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
|
||||
)}
|
||||
</div>
|
||||
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
|
||||
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
|
||||
{f.duplicate_level && f.status === 'duplicate' && (
|
||||
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
|
||||
)}
|
||||
</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} />
|
||||
</Button>
|
||||
)}
|
||||
{f.status === 'import_failed' && (
|
||||
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
|
||||
{t('uploads.retry')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dialog } from '../../shared/ui/ui';
|
||||
import s from '../../pages/page.module.css';
|
||||
import u from '../../pages/uploads/uploads.module.css';
|
||||
export function RejectDialog({
|
||||
count,
|
||||
busy,
|
||||
onClose,
|
||||
onReject,
|
||||
}: {
|
||||
count: number;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onReject: (reason: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const [reason, setReason] = useState('');
|
||||
return (
|
||||
<Dialog
|
||||
title={t('uploads.reject_title', { count })}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
|
||||
{t('uploads.reject')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label className={s.sessions}>
|
||||
<span>{t('uploads.reject_reason')}</span>
|
||||
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
|
||||
</label>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { ApiError } from '@pipu/core';
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { checkIcon, closeIcon, inboxIcon } from '../../shared/ui/icons';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { QuotaMeter, type UploadFile, type UploadStatus } from './common';
|
||||
import { BatchCard } from './QueueRows';
|
||||
import { BatchEditDialog } from './BatchEditDialog';
|
||||
import { EditDialog } from './EditDialog';
|
||||
import { RejectDialog } from './RejectDialog';
|
||||
import { useUploadQueue, useUploadsApi } from './queries';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, EmptyState, FormError } from '../../shared/ui/ui';
|
||||
import s from '../../pages/page.module.css';
|
||||
import u from '../../pages/uploads/uploads.module.css';
|
||||
const TABS = {
|
||||
review: ['pending_review'],
|
||||
failed: ['import_failed'],
|
||||
progress: ['uploading', 'processing', 'approved', 'importing'],
|
||||
imported: ['imported'],
|
||||
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
|
||||
} satisfies Record<string, UploadStatus[]>;
|
||||
type Tab = keyof typeof TABS;
|
||||
|
||||
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
|
||||
export function UploadQueue() {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const api = useUploadsApi();
|
||||
const queryClient = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [tab, setTab] = useState<Tab>('review');
|
||||
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 = useUploadQueue(TABS[tab], tab, onlyPossible);
|
||||
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
|
||||
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
|
||||
|
||||
const refresh = async () => {
|
||||
setSelected(new Set());
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
||||
};
|
||||
const review = useMutation({
|
||||
mutationFn: (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) => api.review(v),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const retry = useMutation({
|
||||
mutationFn: api.retry,
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
|
||||
|
||||
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
|
||||
const batches = useMemo(() => {
|
||||
const m = new Map<string, UploadFile[]>();
|
||||
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
|
||||
return [...m.values()];
|
||||
}, [items]);
|
||||
const toggle = (ids: string[], on: boolean) =>
|
||||
setSelected((cur) => {
|
||||
const next = new Set(cur);
|
||||
for (const id of ids) {
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const selectable = tab === 'review' || tab === 'failed';
|
||||
const ids = [...selected];
|
||||
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<div className={s.head}>
|
||||
<div>
|
||||
<h1>{t('uploads.queue_title')}</h1>
|
||||
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
|
||||
</div>
|
||||
<QuotaMeter />
|
||||
</div>
|
||||
<div className={s.toolbar}>
|
||||
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
|
||||
{(Object.keys(TABS) as Tab[]).map((k) => (
|
||||
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
|
||||
{t(`uploads.tab_${k}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ flex: 1 }} />
|
||||
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
|
||||
{t('uploads.only_possible')}
|
||||
</label>
|
||||
</div>
|
||||
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
|
||||
{failed.length > 0 && (
|
||||
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
|
||||
)}
|
||||
{list.isSuccess && items.length === 0 && <EmptyState icon={inboxIcon}>{t('uploads.queue_empty')}</EmptyState>}
|
||||
|
||||
{batches.map((files) => (
|
||||
<BatchCard
|
||||
key={files[0]!.batch_id}
|
||||
files={files}
|
||||
selectable={selectable}
|
||||
selected={selected}
|
||||
onToggle={toggle}
|
||||
onEdit={setEditing}
|
||||
onEditBatch={setBatchEdit}
|
||||
onRetry={(id) => retry.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
{list.hasNextPage && (
|
||||
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||
{t('uploads.load_more')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{ids.length > 0 && (
|
||||
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
|
||||
<span>{t('uploads.selected', { count: ids.length })}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button variant="ghost" onClick={() => setSelected(new Set())}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
|
||||
{t('uploads.reject')}
|
||||
</Button>
|
||||
{tab === 'review' && (
|
||||
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
|
||||
{t('uploads.approve')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchEdit && (
|
||||
<BatchEditDialog
|
||||
batchId={batchEdit}
|
||||
onClose={() => setBatchEdit(null)}
|
||||
onSaved={() => {
|
||||
setBatchEdit(null);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditDialog
|
||||
file={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
review.reset();
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{rejecting && (
|
||||
<RejectDialog
|
||||
count={ids.length}
|
||||
busy={review.isPending}
|
||||
onClose={() => setRejecting(false)}
|
||||
onReject={(reason) => {
|
||||
setRejecting(false);
|
||||
review.mutate({ decision: 'reject', ids, reason });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Problem из ответа массового решения → объект, понятный useErrorText. */
|
||||
function problemError(p: components['schemas']['Problem']) {
|
||||
return new ApiError(p.status, p.code, p.params ?? {});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Field, FormError } from '../../shared/ui/ui';
|
||||
import { useUploadsApi, useUploadSources, useUploadSettings } from './queries';
|
||||
import s from '../../pages/page.module.css';
|
||||
import u from '../../pages/uploads/uploads.module.css';
|
||||
|
||||
type Settings = components['schemas']['UploadSettings'];
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
|
||||
export function UploadSettingsSection() {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const api = useUploadsApi();
|
||||
const queryClient = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const settings = useUploadSettings();
|
||||
const sources = useUploadSources();
|
||||
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]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const f = form!;
|
||||
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
|
||||
return api.updateSettings({
|
||||
queue_limit_bytes: gb(f.queue), 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,
|
||||
});
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
queryClient.setQueryData(['upload-settings'], data);
|
||||
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
||||
},
|
||||
});
|
||||
if (!form) return null;
|
||||
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
|
||||
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
|
||||
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
|
||||
return (
|
||||
<section className={s.section}>
|
||||
<h2>{t('uploads.settings_title')}</h2>
|
||||
<div className={u.settingsGrid}>
|
||||
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
|
||||
<Field
|
||||
label={t('uploads.set_per_user')}
|
||||
value={form.perUser}
|
||||
hint={t('uploads.set_per_user_hint')}
|
||||
onChange={(v) => setForm({ ...form, perUser: v })}
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
|
||||
<label>
|
||||
<span className={u.settingsLabel}>{t('uploads.set_source')}</span>
|
||||
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
|
||||
<option value="">{t('uploads.set_source_auto')}</option>
|
||||
{sources.data
|
||||
?.filter((x) => x.accepts_uploads)
|
||||
.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div>
|
||||
<label className={u.settingsCheck}>
|
||||
<input type="checkbox" checked={form.upgrade} onChange={(e) => setForm({ ...form, upgrade: e.target.checked })} />
|
||||
<span>{t('uploads.set_upgrade')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className={s.note}>{t('uploads.set_note')}</p>
|
||||
<div className={s.toolbar}>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
|
||||
{t('uploads.save')}
|
||||
</Button>
|
||||
</div>
|
||||
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function toForm(st: Settings) {
|
||||
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
|
||||
return {
|
||||
queue: gb(st.queue_limit_bytes),
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ApiClient, components, paths } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
|
||||
type FilePatch = paths['/api/v1/admin/uploads/files/{id}']['patch']['requestBody']['content']['application/json'];
|
||||
type BatchPatch = paths['/api/v1/admin/uploads/batches/{id}']['patch']['requestBody']['content']['application/json'];
|
||||
type SettingsPatch = paths['/api/v1/admin/settings/uploads']['patch']['requestBody']['content']['application/json'];
|
||||
|
||||
/** The feature owns transport mapping; views only provide form values. */
|
||||
export function uploadsApi(client: ApiClient) {
|
||||
return {
|
||||
list: async (status: components['schemas']['UploadStatus'][], possible: boolean, cursor?: string) => unwrap(
|
||||
await client.GET('/api/v1/admin/uploads', {
|
||||
params: { query: { status, possible_duplicate: possible || undefined, cursor, limit: 100 } },
|
||||
}),
|
||||
),
|
||||
review: async (v: { ids: string[]; decision: 'approve' | 'reject'; reason?: string }) => unwrap(
|
||||
await client.POST('/api/v1/admin/uploads/review', { body: { file_ids: v.ids, decision: v.decision, reason: v.reason } }),
|
||||
),
|
||||
retry: async (id: string) => unwrap(await client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
|
||||
updateFile: async (id: string, body: FilePatch) => unwrap(await client.PATCH('/api/v1/admin/uploads/files/{id}', { params: { path: { id } }, body })),
|
||||
updateBatch: async (id: string, body: BatchPatch) => unwrap(await client.PATCH('/api/v1/admin/uploads/batches/{id}', { params: { path: { id } }, body })),
|
||||
sources: async () => unwrap(await client.GET('/api/v1/admin/sources')).items,
|
||||
settings: async () => unwrap(await client.GET('/api/v1/admin/settings/uploads')),
|
||||
updateSettings: async (body: SettingsPatch) => unwrap(await client.PATCH('/api/v1/admin/settings/uploads', { body })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
import { formatBytes } from '../../shared/lib/format';
|
||||
import { cx } from '../../shared/ui/ui';
|
||||
import s from '../../pages/page.module.css';
|
||||
import u from '../../pages/uploads/uploads.module.css';
|
||||
|
||||
export type UploadStatus = components['schemas']['UploadStatus'];
|
||||
export type UploadFile = components['schemas']['UploadFile'];
|
||||
|
||||
const TONE: Record<UploadStatus, 'ok' | 'warn' | 'bad' | 'info' | 'muted'> = {
|
||||
uploading: 'info',
|
||||
processing: 'info',
|
||||
pending_review: 'warn',
|
||||
approved: 'info',
|
||||
importing: 'info',
|
||||
imported: 'ok',
|
||||
import_failed: 'bad',
|
||||
duplicate: 'muted',
|
||||
invalid: 'bad',
|
||||
rejected: 'bad',
|
||||
withdrawn: 'muted',
|
||||
expired: 'muted',
|
||||
};
|
||||
|
||||
/** Статусы, которые ещё меняются сами (обработка, импорт) — список опрашивается. */
|
||||
export const MOVING: UploadStatus[] = ['uploading', 'processing', 'approved', 'importing'];
|
||||
|
||||
export function UploadStatusBadge({ status }: { status: UploadStatus }) {
|
||||
const { t } = useTranslation('admin');
|
||||
return <span className={cx(s.badge, u[`tone_${TONE[status]}`])}>{t(`uploads.status.${status}`)}</span>;
|
||||
}
|
||||
|
||||
export function useUploadQuota() {
|
||||
const session = useSession();
|
||||
return useQuery({
|
||||
queryKey: ['upload-quota'],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/quota')),
|
||||
});
|
||||
}
|
||||
|
||||
/** «Очередь: 6,2 ГБ из 10 ГБ» — видят модераторы и админ (docs/uploads.md#3). */
|
||||
export function QuotaMeter() {
|
||||
const { t, i18n } = useTranslation('admin');
|
||||
const quota = useUploadQuota();
|
||||
const id = useId();
|
||||
if (!quota.data) return null;
|
||||
const q = quota.data;
|
||||
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
|
||||
const fmt = (n: number) => formatBytes(n, i18n.language);
|
||||
return (
|
||||
<div className={u.quota}>
|
||||
<span className={u.quotaText} id={id}>
|
||||
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
|
||||
{q.per_user_limit_bytes !== null &&
|
||||
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
|
||||
</span>
|
||||
<div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||||
<span style={{ width: `${ratio * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Понятный текст причины: код из errors:<code> или текст отклонения админа. */
|
||||
export function useReasonText(): (reason: string | null) => string | null {
|
||||
const { t, i18n } = useTranslation(['admin', 'errors']);
|
||||
const tr = t as unknown as (k: string) => string;
|
||||
return (reason) => {
|
||||
if (!reason) return null;
|
||||
const code = reason.split(':')[0]!.trim();
|
||||
const key = `errors:${code}`;
|
||||
if (i18n.exists(key)) {
|
||||
const rest = reason.slice(code.length + 1).trim();
|
||||
return rest ? `${tr(key)}: ${rest}` : tr(key);
|
||||
}
|
||||
return reason;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import { useSession } from '../../app/session';
|
||||
import { uploadsApi } from './api';
|
||||
import { MOVING, type UploadStatus } from './common';
|
||||
|
||||
export function useUploadsApi() { return uploadsApi(useSession().client); }
|
||||
|
||||
export function useUploadQueue(status: UploadStatus[], tab: string, onlyPossible: boolean) {
|
||||
const api = useUploadsApi();
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['admin-uploads', tab, onlyPossible],
|
||||
initialPageParam: undefined as string | undefined,
|
||||
queryFn: ({ pageParam }) => api.list(status, onlyPossible, pageParam),
|
||||
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
||||
refetchInterval: (q) => q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadSources() {
|
||||
const api = useUploadsApi();
|
||||
return useQuery({ queryKey: ['sources'], queryFn: api.sources });
|
||||
}
|
||||
|
||||
export function useUploadSettings() {
|
||||
const api = useUploadsApi();
|
||||
return useQuery({ queryKey: ['upload-settings'], queryFn: api.settings });
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover, TrackList } from '../entities/catalog';
|
||||
import { Cover, TrackList, coverSrc } from '../entities/catalog';
|
||||
import { AddToPlaylist } from '../entities/playlists';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||
import { Button } from '../shared/ui/ui';
|
||||
import { Button, PageSkeleton } from '../shared/ui/ui';
|
||||
import { NotFound } from './NotFound';
|
||||
import s from './page.module.css';
|
||||
import c from './catalog.module.css';
|
||||
@@ -23,30 +23,41 @@ export function AlbumPage({ id }: { id: string }) {
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
|
||||
});
|
||||
|
||||
if (album.isPending) {
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<PageSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (album.isError) return <NotFound error={album.error} />;
|
||||
const a = album.data;
|
||||
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
|
||||
const ctx = { type: 'album' as const, id, label: a?.title };
|
||||
if (!a) return null;
|
||||
const colors = a.cover_colors.length === 3 ? a.cover_colors : null;
|
||||
const ctx = { type: 'album' as const, id, label: a.title };
|
||||
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<header className={c.hero}>
|
||||
{a.cover_url && (
|
||||
<span className={c.heroBg} aria-hidden>
|
||||
<img src={coverSrc(a.cover_url, 300)} alt="" />
|
||||
</span>
|
||||
)}
|
||||
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
|
||||
<Cover album={a ?? { id }} size={200} />
|
||||
<Cover album={a} size={200} className={c.heroCover} />
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('catalog.album')}</span>
|
||||
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1>
|
||||
{a && (
|
||||
<span className={c.heroMeta}>
|
||||
<Link to="/artist/$id" params={{ id: a.artist.id }}>
|
||||
{a.artist.name}
|
||||
</Link>
|
||||
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
|
||||
</span>
|
||||
)}
|
||||
{a && a.tracks.some((tr) => tr.available) && (
|
||||
<h1 className={c.heroTitle}>{a.title}</h1>
|
||||
<span className={c.heroMeta}>
|
||||
<Link to="/artist/$id" params={{ id: a.artist.id }}>
|
||||
{a.artist.name}
|
||||
</Link>
|
||||
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
|
||||
</span>
|
||||
{a.tracks.some((tr) => tr.available) && (
|
||||
<div className={c.heroActions}>
|
||||
<AddToPlaylist trackIds={a.tracks.map(tr => tr.id)} />
|
||||
<AddToPlaylist trackIds={a.tracks.map((tr) => tr.id)} />
|
||||
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
||||
{t('catalog.play')}
|
||||
</Button>
|
||||
@@ -67,7 +78,7 @@ export function AlbumPage({ id }: { id: string }) {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{a && <TrackList tracks={a.tracks} numbered context={ctx} />}
|
||||
<TrackList tracks={a.tracks} numbered context={ctx} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import s from './auth.module.css';
|
||||
|
||||
/**
|
||||
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
|
||||
* Сборки — последний релиз Gitea через сервер; первым предлагается клиент текущей ОС.
|
||||
* Сборки — до 8 последних релизов Gitea через сервер; первым предлагается клиент текущей ОС.
|
||||
*/
|
||||
export function AppDownloadPage() {
|
||||
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||
@@ -40,10 +40,17 @@ export function AppDownloadPage() {
|
||||
<span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
|
||||
<h1>{t('app.title')}</h1>
|
||||
<p className={s.lead}>{t('app.subtitle')}</p>
|
||||
{latest.isPending ? null : data ? (
|
||||
{latest.isPending ? (
|
||||
<p role="status">{t('common:loading')}</p>
|
||||
) : latest.isError ? (
|
||||
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||
) : data ? (
|
||||
<>
|
||||
{primary ? (
|
||||
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
|
||||
<>
|
||||
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: primary.version ?? data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
|
||||
<OlderVersions items={primary.older ?? []} />
|
||||
</>
|
||||
) : detected ? (
|
||||
<div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
|
||||
) : null}
|
||||
@@ -72,12 +79,15 @@ export function AppDownloadPage() {
|
||||
{others.map((id) => {
|
||||
const file = files[id]!;
|
||||
return (
|
||||
<a key={id} className={s.downloadAlt} href={file.download_url} download={file.filename}>
|
||||
{t(`app.download.${id}`)}
|
||||
<span>
|
||||
{t('app.version', { version: data.version })} · {formatBytes(file.size, i18n.language)}
|
||||
</span>
|
||||
</a>
|
||||
<div key={id} className={s.otherBlock}>
|
||||
<a className={s.downloadAlt} href={file.download_url} download={file.filename}>
|
||||
{t(`app.download.${id}`)}
|
||||
<span>
|
||||
{t('app.version', { version: file.version ?? data.version })} · {formatBytes(file.size, i18n.language)}
|
||||
</span>
|
||||
</a>
|
||||
<OlderVersions items={file.older ?? []} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -91,6 +101,22 @@ export function AppDownloadPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function OlderVersions({ items }: { items: { version: string; filename?: string; size: number; download_url: string }[] }) {
|
||||
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<details className={s.older}>
|
||||
<summary>{t('app.older')}</summary>
|
||||
{items.map((b) => (
|
||||
<a key={b.version} className={s.downloadAlt} href={b.download_url} download={b.filename}>
|
||||
{t('app.version', { version: b.version })}
|
||||
<span>{formatBytes(b.size, i18n.language)}</span>
|
||||
</a>
|
||||
))}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) {
|
||||
const { t } = useTranslation('auth');
|
||||
return (
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { AlbumGrid, Cover, TrackList } from '../entities/catalog';
|
||||
import { AlbumGrid, Cover, TrackList, coverSrc } from '../entities/catalog';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { playIcon, waveIcon } from '../shared/ui/icons';
|
||||
import { Button } from '../shared/ui/ui';
|
||||
import { Button, PageSkeleton } from '../shared/ui/ui';
|
||||
import { NotFound } from './NotFound';
|
||||
import s from './page.module.css';
|
||||
import c from './catalog.module.css';
|
||||
@@ -29,42 +29,51 @@ export function ArtistPage({ id }: { id: string }) {
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
||||
});
|
||||
|
||||
if (artist.isPending) {
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<PageSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (artist.isError) return <NotFound error={artist.error} />;
|
||||
const a = artist.data;
|
||||
if (!a) return null;
|
||||
const tracks = top.data ?? [];
|
||||
const ctx = { type: 'artist' as const, id, label: a?.name };
|
||||
const ctx = { type: 'artist' as const, id, label: a.name };
|
||||
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
|
||||
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
|
||||
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<header className={c.hero}>
|
||||
<Cover album={{ id, cover_url: a?.image_url }} size={168} round />
|
||||
{a.image_url && (
|
||||
<span className={c.heroBg} aria-hidden>
|
||||
<img src={coverSrc(a.image_url, 300)} alt="" />
|
||||
</span>
|
||||
)}
|
||||
<Cover album={{ id, cover_url: a.image_url }} size={168} round className={c.heroCover} />
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('catalog.artist')}</span>
|
||||
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
|
||||
{a && (
|
||||
<span className={c.heroMeta}>
|
||||
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
|
||||
</span>
|
||||
)}
|
||||
{a && (
|
||||
<div className={c.heroActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={c.pill}
|
||||
icon={waveIcon}
|
||||
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
|
||||
>
|
||||
{t('catalog.artist_wave')}
|
||||
<h1 className={c.heroTitle}>{a.name}</h1>
|
||||
<span className={c.heroMeta}>
|
||||
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
|
||||
</span>
|
||||
<div className={c.heroActions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={c.pill}
|
||||
icon={waveIcon}
|
||||
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
|
||||
>
|
||||
{t('catalog.artist_wave')}
|
||||
</Button>
|
||||
{tracks.length > 0 && (
|
||||
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
|
||||
{t('catalog.play')}
|
||||
</Button>
|
||||
{tracks.length > 0 && (
|
||||
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
|
||||
{t('catalog.play')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{tracks.length > 0 && (
|
||||
|
||||
@@ -2,16 +2,21 @@ import type { ReactNode } from 'react';
|
||||
|
||||
import { Logo } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||
import { WaveButton } from '../widgets/wave/WaveButton';
|
||||
import s from './auth.module.css';
|
||||
|
||||
/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */
|
||||
/** Экран без каркаса: декоративная волна и стеклянная карточка по центру. */
|
||||
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
||||
const theme = typeof document !== 'undefined' && document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
|
||||
return (
|
||||
<div className={s.screen}>
|
||||
<div className={s.backdrop} aria-hidden>
|
||||
<span className={s.blob1} />
|
||||
<span className={s.blob2} />
|
||||
<span className={s.blob3} />
|
||||
<div className={s.waveDecor}>
|
||||
<WaveButton interactive={false} size={620} label="" theme={theme} />
|
||||
</div>
|
||||
</div>
|
||||
<header className={s.top}>
|
||||
<Logo />
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useCollectionCounts } from '../entities/collection';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||
import { Button, Dialog, FormError, Icon } from '../shared/ui/ui';
|
||||
import { Button, Dialog, EmptyState, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
|
||||
import c from './catalog.module.css';
|
||||
import s from './page.module.css';
|
||||
|
||||
@@ -109,7 +109,12 @@ function History() {
|
||||
</div>
|
||||
)}
|
||||
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
|
||||
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
|
||||
{history.isPending && <PageSkeleton />}
|
||||
{history.isSuccess && items.length === 0 && (
|
||||
<EmptyState icon={historyFillIcon} action={<Link to="/">{t('catalog.back_home')}</Link>}>
|
||||
{t('collection.empty_history')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{groups.map((g) => (
|
||||
<section key={g.key} className={c.section}>
|
||||
<h2 className={c.day}>{g.title}</h2>
|
||||
@@ -189,8 +194,18 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
|
||||
</div>
|
||||
)}
|
||||
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||
{list.isPending && <PageSkeleton />}
|
||||
{list.isSuccess && tracks.length === 0 && (
|
||||
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
|
||||
<EmptyState
|
||||
icon={reaction === 'like' ? likeFillIcon : dislikeFillIcon}
|
||||
action={
|
||||
<Link to="/search" search={{ q: '' }}>
|
||||
{t('playlists.find_tracks')}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{tracks.length > 0 && (
|
||||
<TrackList
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
waveSunIcon,
|
||||
waveWeekendIcon,
|
||||
} from '../shared/ui/icons';
|
||||
import { cx, FormError, Icon } from '../shared/ui/ui';
|
||||
import { cx, FormError, Icon, Skeleton } from '../shared/ui/ui';
|
||||
import { WaveButton } from '../widgets/wave/WaveButton';
|
||||
import s from './home.module.css';
|
||||
import { Onboarding } from './Onboarding';
|
||||
@@ -41,8 +41,6 @@ const ICONS: Record<string, string> = {
|
||||
rainy: waveRainyIcon,
|
||||
};
|
||||
|
||||
const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||
|
||||
type Tr = (key: string) => string;
|
||||
|
||||
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
|
||||
@@ -52,8 +50,16 @@ export function HomePage() {
|
||||
const onboarding = useQuery({
|
||||
queryKey: ['onboarding'],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
|
||||
staleTime: Infinity,
|
||||
staleTime: 0,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
if (onboarding.isPending) {
|
||||
return (
|
||||
<div className={s.home}>
|
||||
<Skeleton round style={{ width: 320, height: 320, maxWidth: '70vw', maxHeight: '70vw' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (onboarding.data?.required) {
|
||||
return (
|
||||
<Onboarding
|
||||
@@ -89,8 +95,6 @@ function WaveHome() {
|
||||
const waveOn = st.wave !== null;
|
||||
const playing = waveOn && st.playing;
|
||||
const track = waveOn ? st.track : null;
|
||||
const colors: [string, string, string] =
|
||||
track?.album.cover_colors.length === 3 ? (track.album.cover_colors as [string, string, string]) : WAVE_COLORS;
|
||||
|
||||
const run = (fn: () => Promise<void> | void) => {
|
||||
setError(null);
|
||||
@@ -121,7 +125,7 @@ function WaveHome() {
|
||||
readAudio={player.readAudio}
|
||||
bpm={track?.features?.bpm ?? undefined}
|
||||
energy={track?.features?.energy ?? undefined}
|
||||
colors={colors}
|
||||
colors={track?.album.cover_colors}
|
||||
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
|
||||
size={320}
|
||||
label={t(playing ? 'wave.pause' : 'wave.start')}
|
||||
@@ -136,6 +140,12 @@ function WaveHome() {
|
||||
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
|
||||
<FormError>{error ? errorText(error) : null}</FormError>
|
||||
|
||||
{options.isPending && (showActivities || showMoods) && (
|
||||
<div className={s.picker}>
|
||||
<Skeleton style={{ width: 280, height: 38, borderRadius: 999 }} />
|
||||
<Skeleton style={{ width: 240, height: 38, borderRadius: 999 }} />
|
||||
</div>
|
||||
)}
|
||||
{options.data && (showActivities || showMoods) && (
|
||||
<div className={s.picker}>
|
||||
{showActivities && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -20,17 +20,19 @@ const MIN_ARTISTS = 3;
|
||||
* Онбординг вкуса (docs/recommendations.md#7): исполнители и жанры библиотеки — начальный вкус «Моей волны»,
|
||||
* пока своей истории почти нет. Пропуск тоже запоминается: второй раз не спросим.
|
||||
*/
|
||||
export function Onboarding({ options, onDone }: { options: Options; onDone: () => void }) {
|
||||
export function Onboarding({ options, onDone, editing = false }: { options: Options; onDone: () => void; editing?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const [artists, setArtists] = useState<Set<string>>(new Set());
|
||||
const [genres, setGenres] = useState<Set<string>>(new Set());
|
||||
const [artists, setArtists] = useState<Set<string>>(new Set(options.choice?.artist_ids));
|
||||
const [genres, setGenres] = useState<Set<string>>(new Set(options.choice?.genres));
|
||||
const [gender, setGender] = useState(options.choice?.gender ?? null);
|
||||
const [ageGroup, setAgeGroup] = useState(options.choice?.age_group ?? null);
|
||||
const save = useMutation({
|
||||
mutationFn: async (skip: boolean) =>
|
||||
unwrap(
|
||||
await session.client.PUT('/api/v1/me/onboarding', {
|
||||
body: skip ? { artist_ids: [], genres: [] } : { artist_ids: [...artists], genres: [...genres] },
|
||||
body: skip ? { artist_ids: [], genres: [], gender: null, age_group: null } : { artist_ids: [...artists], genres: [...genres], gender, age_group: ageGroup },
|
||||
}),
|
||||
),
|
||||
onSuccess: onDone,
|
||||
@@ -43,7 +45,7 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
const enough = artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0);
|
||||
const enough = editing || artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0) || !!gender || !!ageGroup || options.artists.length === 0;
|
||||
|
||||
return (
|
||||
<div className={s.page}>
|
||||
@@ -52,6 +54,21 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
|
||||
<p>{t('onboarding.subtitle')}</p>
|
||||
</header>
|
||||
|
||||
<section aria-label={t('onboarding.gender')}>
|
||||
<h2 className={s.label}>{t('onboarding.gender')}</h2>
|
||||
<div className={s.genres}>
|
||||
<button type="button" className={s.genre} aria-pressed={!gender} onClick={() => setGender(null)}>{t('onboarding.unspecified')}</button>
|
||||
{GENDERS.map((g) => <button type="button" key={g} className={s.genre} aria-pressed={gender === g} onClick={() => setGender(g)}>{t(`onboarding.genders.${g}`)}</button>)}
|
||||
</div>
|
||||
</section>
|
||||
<section aria-label={t('onboarding.age_group')}>
|
||||
<h2 className={s.label}>{t('onboarding.age_group')}</h2>
|
||||
<p>{t('onboarding.age_hint')}</p>
|
||||
<div className={s.genres}>
|
||||
<button type="button" className={s.genre} aria-pressed={!ageGroup} onClick={() => setAgeGroup(null)}>{t('onboarding.unspecified')}</button>
|
||||
{AGE_GROUPS.map((g) => <button type="button" key={g} className={s.genre} aria-pressed={ageGroup === g} onClick={() => setAgeGroup(g)}>{t(`onboarding.ages.${g}`)}</button>)}
|
||||
</div>
|
||||
</section>
|
||||
<section aria-label={t('onboarding.artists')}>
|
||||
<h2 className={s.label}>{t('onboarding.artists')}</h2>
|
||||
<div className={s.artists}>
|
||||
@@ -101,11 +118,11 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
|
||||
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
<div className={s.actions}>
|
||||
<Button variant="ghost" busy={save.isPending && save.variables} onClick={() => save.mutate(true)}>
|
||||
{t('onboarding.skip')}
|
||||
<Button variant="ghost" disabled={save.isPending} busy={save.isPending && save.variables} onClick={() => editing ? onDone() : save.mutate(true)}>
|
||||
{t(editing ? 'form.cancel' : 'onboarding.skip')}
|
||||
</Button>
|
||||
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough} onClick={() => save.mutate(false)}>
|
||||
{enough ? t('onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
|
||||
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough || save.isPending} onClick={() => save.mutate(false)}>
|
||||
{enough ? t(editing ? 'settings.save' : 'onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useState, type MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover, TrackList } from '../entities/catalog';
|
||||
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { sharePlaylist } from '../shared/lib/share';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { Button, Dialog, Field, FormError } from '../shared/ui/ui';
|
||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
|
||||
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon, shareIcon } from '../shared/ui/icons';
|
||||
import c from './catalog.module.css';
|
||||
import s from './page.module.css';
|
||||
import p from './playlists.module.css';
|
||||
@@ -27,30 +29,33 @@ export function PlaylistsPage() {
|
||||
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header>
|
||||
<Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
|
||||
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||
{list.isSuccess && !items.length && <p className={s.empty}>{t(search ? 'playlists.no_results' : 'playlists.empty')}</p>}
|
||||
<div className={p.grid}>{items.map(item => <Link key={item.id} to="/playlist/$id" params={{ id: item.id }} className={p.card}>
|
||||
<Cover album={item} size="100%" />
|
||||
<strong>{item.name}</strong><span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
|
||||
{item.description && <span className={p.description}>{item.description}</span>}
|
||||
</Link>)}</div>
|
||||
{list.isPending && <PageSkeleton />}
|
||||
{list.isSuccess && !items.length && (
|
||||
<EmptyState
|
||||
icon={search ? searchIcon : playlistAddIcon}
|
||||
action={!search ? <Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button> : undefined}
|
||||
>
|
||||
{t(search ? 'playlists.no_results' : 'playlists.empty')}
|
||||
</EmptyState>
|
||||
)}
|
||||
<div className={p.grid}>{items.map(item => <PlaylistCard key={item.id} item={item} />)}</div>
|
||||
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function PlaylistPage({ id }: { id: string }) {
|
||||
export function PlaylistPage({ id, shared = false }: { id: string; shared?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const query = usePlaylist(id);
|
||||
const query = usePlaylist(id, shared);
|
||||
const errorText = useErrorText();
|
||||
return <div className={s.page}>
|
||||
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
|
||||
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||
{query.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
|
||||
{query.isPending && <PageSkeleton />}
|
||||
{query.data && !query.isError && <PlaylistDetail key={id} playlist={query.data} readOnly={shared} shareId={shared ? id : undefined} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||
function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Playlist; readOnly?: boolean; shareId?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const cache = useQueryClient();
|
||||
@@ -60,6 +65,21 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [manage, setManage] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const sharing = useMutation({ mutationFn: async () => {
|
||||
setCopied(false);
|
||||
let token = shareId ?? playlist.share_id;
|
||||
if (!readOnly) {
|
||||
token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })).share_id;
|
||||
cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: token } : current);
|
||||
}
|
||||
if (!token) throw new Error('Share link unavailable');
|
||||
const result = await sharePlaylist(token, playlist.name);
|
||||
if (result === 'copied') setCopied(true);
|
||||
} });
|
||||
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })),
|
||||
onSuccess: () => { setCopied(false); cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } });
|
||||
|
||||
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
|
||||
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
|
||||
params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids },
|
||||
@@ -80,7 +100,7 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||
return <>
|
||||
<header className={c.colHero}>
|
||||
<Cover album={playlist.tracks[0]?.album ?? playlist} size={144} />
|
||||
<div className={c.heroText}><span className={c.kicker}>{t('playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1>
|
||||
<div className={c.heroText}><span className={c.kicker}>{t(readOnly || playlist.share_id ? 'playlists.shared' : 'playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1>
|
||||
<p className={p.description}>{playlist.description}</p>
|
||||
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
|
||||
</div>
|
||||
@@ -88,22 +108,76 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||
<div className={s.toolbar}>
|
||||
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
|
||||
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
|
||||
<Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>
|
||||
{!readOnly && <>
|
||||
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
|
||||
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
|
||||
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
|
||||
{playlist.share_id && <Button variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onClick={() => revoke.mutate()}>{t('playlists.revoke_share')}</Button>}
|
||||
</>}
|
||||
</div>
|
||||
{!readOnly && <p>{t('playlists.share_hint')}</p>}
|
||||
{copied && <p role="status">{t('playlists.link_copied')}</p>}
|
||||
<FormError>{sharing.error ? t('player.share_error') : revoke.error ? errorText(revoke.error) : null}</FormError>
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
{!playlist.tracks.length && <div className={s.empty}><p>{t('playlists.empty_tracks')}</p><Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link></div>}
|
||||
{!playlist.tracks.length && (
|
||||
<EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}>
|
||||
{t('playlists.empty_tracks')}
|
||||
</EmptyState>
|
||||
)}
|
||||
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
|
||||
actions={manage ? (track, i) => <span className={p.actions}>
|
||||
actions={!readOnly && manage ? (track, i) => <span className={p.actions}>
|
||||
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
|
||||
<button type="button" disabled={i === playlist.tracks.length - 1 || save.isPending} aria-label={t('playlists.move_down', { name: track.title })} onClick={() => move(i, 1)}>↓</button>
|
||||
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
|
||||
</span> : undefined} />
|
||||
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
||||
{remove && <Dialog title={t('playlists.delete')} onClose={() => { if (!deletion.isPending) setRemove(false); }} footer={<>
|
||||
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
|
||||
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
|
||||
</>}><p>{t('playlists.delete_confirm', { name: playlist.name })}</p><FormError>{deletion.error ? errorText(deletion.error) : null}</FormError></Dialog>}
|
||||
{!readOnly && edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
||||
{!readOnly && remove && (
|
||||
<Dialog
|
||||
title={t('playlists.delete')}
|
||||
onClose={() => {
|
||||
if (!deletion.isPending) setRemove(false);
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
|
||||
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>{t('playlists.delete_confirm', { name: playlist.name })}</p>
|
||||
<FormError>{deletion.error ? errorText(deletion.error) : null}</FormError>
|
||||
</Dialog>
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
|
||||
function PlaylistCard({ item }: { item: components['schemas']['PlaylistSummary'] }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const player = usePlayer();
|
||||
const play = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void (async () => {
|
||||
const full = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id: item.id } } }));
|
||||
const tracks = full.tracks.filter((x) => x.available);
|
||||
if (tracks.length) player.playList(tracks, 0, { type: 'collection', id: item.id, label: item.name });
|
||||
})();
|
||||
};
|
||||
return (
|
||||
<Link to="/playlist/$id" params={{ id: item.id }} className={p.card}>
|
||||
<span className={p.coverWrap}>
|
||||
<Cover album={item} size="100%" />
|
||||
{item.track_count > 0 && (
|
||||
<button type="button" className={p.play} aria-label={t('catalog.play')} onClick={play}>
|
||||
<Icon src={playIcon} size={22} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<strong>{item.name}</strong>
|
||||
<span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
|
||||
{item.description && <span className={p.description}>{item.description}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { Button, FormError } from '../shared/ui/ui';
|
||||
import { searchIcon } from '../shared/ui/icons';
|
||||
import { Button, EmptyState, FormError, PageSkeleton } from '../shared/ui/ui';
|
||||
import s from './page.module.css';
|
||||
import c from './catalog.module.css';
|
||||
|
||||
@@ -68,7 +69,11 @@ export function SearchPage({ q, type }: { q: string; type: SearchType }) {
|
||||
</div>
|
||||
|
||||
<FormError>{results.error ? errorText(results.error) : null}</FormError>
|
||||
{empty && <div className={s.empty}>{t('search.nothing')}</div>}
|
||||
{query === '' && <EmptyState icon={searchIcon}>{t('search.type_query')}</EmptyState>}
|
||||
{query !== '' && results.isPending && <PageSkeleton />}
|
||||
{empty && (
|
||||
<EmptyState icon={searchIcon}>{t('search.nothing')}</EmptyState>
|
||||
)}
|
||||
|
||||
{artists.length > 0 && (
|
||||
<section className={c.section}>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { formatDateTime } from '../shared/lib/format';
|
||||
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||
import s from './page.module.css';
|
||||
import { Onboarding } from './Onboarding';
|
||||
|
||||
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
|
||||
type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>;
|
||||
@@ -24,6 +25,7 @@ export function SettingsPage() {
|
||||
<h1>{t('settings.title')}</h1>
|
||||
</div>
|
||||
<ProfileSection />
|
||||
<RecommendationSettings />
|
||||
<AppearanceSection />
|
||||
<PasswordSection />
|
||||
<SessionsSection />
|
||||
@@ -357,3 +359,17 @@ function SessionsSection() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendationSettings() {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const errorText = useErrorText();
|
||||
const options = useQuery({ queryKey: ['onboarding'], queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), staleTime: 0 });
|
||||
return <section className={s.section}>
|
||||
{editing && options.data ? <Onboarding key={JSON.stringify(options.data.choice)} options={options.data} editing onDone={() => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ['onboarding'] }); }} />
|
||||
: <Button busy={options.isFetching} onClick={() => { void options.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }}>{t('onboarding.edit')}</Button>}
|
||||
<FormError>{options.error ? errorText(options.error) : null}</FormError>
|
||||
</section>;
|
||||
}
|
||||
@@ -4,8 +4,12 @@ import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { ArtistLinks, Cover, TrackList } from '../entities/catalog';
|
||||
import { ArtistLinks, Cover, coverSrc } from '../entities/catalog';
|
||||
import { AddToPlaylist } from '../entities/playlists';
|
||||
import { usePlayer, usePlayerState } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { pauseIcon, playIcon } from '../shared/ui/icons';
|
||||
import { Button, PageSkeleton } from '../shared/ui/ui';
|
||||
import { NotFound } from './NotFound';
|
||||
import s from './page.module.css';
|
||||
import c from './catalog.module.css';
|
||||
@@ -14,27 +18,63 @@ import { TrackGenius } from './TrackGenius';
|
||||
export function TrackPage({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const track = useQuery({
|
||||
queryKey: ['track', id],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })),
|
||||
});
|
||||
if (track.isPending) {
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<PageSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (track.isError) return <NotFound error={track.error} />;
|
||||
const tr = track.data;
|
||||
if (!tr) return null;
|
||||
const ctx = { type: 'queue' as const };
|
||||
const current = st.track?.id === tr.id;
|
||||
const playing = current && st.playing;
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<header className={c.hero}>
|
||||
{tr && <Cover album={tr.album} size={200} />}
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('player.track_page')}</span>
|
||||
<h1 className={c.heroTitle}>{tr?.title ?? '…'}</h1>
|
||||
{tr && <>
|
||||
<span className={c.heroMeta}><ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}</span>
|
||||
<Link to="/album/$id" params={{ id: tr.album.id }}>{tr.album.title}</Link>
|
||||
</>}
|
||||
<div className={c.listen}>
|
||||
<header className={c.hero}>
|
||||
{tr.album.cover_url && (
|
||||
<span className={c.heroBg} aria-hidden>
|
||||
<img src={coverSrc(tr.album.cover_url, 300)} alt="" />
|
||||
</span>
|
||||
)}
|
||||
<Cover album={tr.album} size={280} className={c.heroCover} />
|
||||
<div className={c.heroBody}>
|
||||
<span className={c.kind}>{t('player.track_page')}</span>
|
||||
<h1 className={c.heroTitle}>{tr.title}</h1>
|
||||
<span className={c.heroMeta}>
|
||||
<ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}
|
||||
</span>
|
||||
<Link to="/album/$id" params={{ id: tr.album.id }}>
|
||||
{tr.album.title}
|
||||
</Link>
|
||||
<div className={c.heroActions}>
|
||||
{tr.available && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className={c.pill}
|
||||
icon={playing ? pauseIcon : playIcon}
|
||||
onClick={() => (current ? player.toggle() : player.playList([tr], 0, ctx))}
|
||||
>
|
||||
{t(playing ? 'player.pause' : 'catalog.play')}
|
||||
</Button>
|
||||
)}
|
||||
<AddToPlaylist trackIds={[tr.id]} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className={c.listenLyrics}>
|
||||
<TrackGenius id={id} />
|
||||
</div>
|
||||
</header>
|
||||
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
|
||||
{tr && <TrackGenius id={id} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import s from '../page.module.css';
|
||||
import { UploadSettingsSection } from './UploadQueuePage';
|
||||
import { UploadSettingsSection } from '../../features/uploads/UploadSettingsSection';
|
||||
|
||||
/** «Настройки» админки: параметры сервера, которые меняет администратор (пока — очередь на добавление). */
|
||||
export function AdminSettingsPage() {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { formatDateTime } from '../../shared/lib/format';
|
||||
import { Button, FormError } from '../../shared/ui/ui';
|
||||
import { Button, EmptyState, FormError, PageSkeleton } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
|
||||
type Entry = components['schemas']['AuditEntry'];
|
||||
@@ -67,8 +67,8 @@ export function AuditPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.isSuccess && entries.length === 0 && <div className={s.empty}>{t('audit.empty')}</div>}
|
||||
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
|
||||
{list.isSuccess && entries.length === 0 && <EmptyState>{t('audit.empty')}</EmptyState>}
|
||||
{list.isPending && <PageSkeleton />}
|
||||
</div>
|
||||
{list.hasNextPage && (
|
||||
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useSession, useUser } from '../../app/session';
|
||||
import { GeniusPanel } from './GeniusPanel';
|
||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
|
||||
import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
|
||||
import { Button, Dialog, EmptyState, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
import l from './library.module.css';
|
||||
import { ScanLine, StatusBadge } from './ScanStatus';
|
||||
@@ -46,7 +46,11 @@ export function SourcesPage() {
|
||||
</div>
|
||||
<FormError>{sources.error ? errorText(sources.error) : null}</FormError>
|
||||
{user.role === 'admin' && <GeniusPanel />}
|
||||
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>}
|
||||
{sources.isSuccess && sources.data.length === 0 && (
|
||||
<EmptyState icon={folderIcon} action={<Button variant="primary" onClick={() => setEditing('new')}>{t('sources.add')}</Button>}>
|
||||
{t('sources.empty')}
|
||||
</EmptyState>
|
||||
)}
|
||||
<div className={l.list}>
|
||||
{sources.data?.map((src) => (
|
||||
<SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} />
|
||||
|
||||
@@ -1,640 +1,3 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { ApiError, unwrap } from '@pipu/core';
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UploadQueue } from '../../features/uploads/UploadQueue';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
import { needsFormatAttention } from '../../features/uploads/files';
|
||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
||||
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
|
||||
import { Button, cx, Dialog, Field, FormError, Icon } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common';
|
||||
import u from '../uploads/uploads.module.css';
|
||||
|
||||
type Meta = components['schemas']['UploadMeta'];
|
||||
type Settings = components['schemas']['UploadSettings'];
|
||||
|
||||
const TABS = {
|
||||
review: ['pending_review'],
|
||||
failed: ['import_failed'],
|
||||
progress: ['uploading', 'processing', 'approved', 'importing'],
|
||||
imported: ['imported'],
|
||||
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
|
||||
} satisfies Record<string, UploadStatus[]>;
|
||||
type Tab = keyof typeof TABS;
|
||||
|
||||
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
|
||||
export function UploadQueuePage() {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [tab, setTab] = useState<Tab>('review');
|
||||
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({
|
||||
queryKey: ['admin-uploads', tab, onlyPossible],
|
||||
initialPageParam: undefined as string | undefined,
|
||||
queryFn: async ({ pageParam }) =>
|
||||
unwrap(
|
||||
await session.client.GET('/api/v1/admin/uploads', {
|
||||
params: {
|
||||
query: { status: [...TABS[tab]], possible_duplicate: onlyPossible || undefined, cursor: pageParam, limit: 100 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
|
||||
});
|
||||
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
|
||||
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
|
||||
|
||||
const refresh = async () => {
|
||||
setSelected(new Set());
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
||||
};
|
||||
const review = useMutation({
|
||||
mutationFn: async (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) =>
|
||||
unwrap(
|
||||
await session.client.POST('/api/v1/admin/uploads/review', {
|
||||
body: { file_ids: v.ids, decision: v.decision, reason: v.reason },
|
||||
}),
|
||||
),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const retry = useMutation({
|
||||
mutationFn: async (id: string) =>
|
||||
unwrap(await session.client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
|
||||
onSuccess: refresh,
|
||||
});
|
||||
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
|
||||
|
||||
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
|
||||
const batches = useMemo(() => {
|
||||
const m = new Map<string, UploadFile[]>();
|
||||
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
|
||||
return [...m.values()];
|
||||
}, [items]);
|
||||
const toggle = (ids: string[], on: boolean) =>
|
||||
setSelected((cur) => {
|
||||
const next = new Set(cur);
|
||||
for (const id of ids) {
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const selectable = tab === 'review' || tab === 'failed';
|
||||
const ids = [...selected];
|
||||
|
||||
return (
|
||||
<div className={s.page}>
|
||||
<div className={s.head}>
|
||||
<div>
|
||||
<h1>{t('uploads.queue_title')}</h1>
|
||||
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
|
||||
</div>
|
||||
<QuotaMeter />
|
||||
</div>
|
||||
<div className={s.toolbar}>
|
||||
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
|
||||
{(Object.keys(TABS) as Tab[]).map((k) => (
|
||||
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
|
||||
{t(`uploads.tab_${k}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span style={{ flex: 1 }} />
|
||||
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
|
||||
{t('uploads.only_possible')}
|
||||
</label>
|
||||
</div>
|
||||
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
|
||||
{failed.length > 0 && (
|
||||
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
|
||||
)}
|
||||
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.queue_empty')}</div>}
|
||||
|
||||
{batches.map((files) => (
|
||||
<BatchCard
|
||||
key={files[0]!.batch_id}
|
||||
files={files}
|
||||
selectable={selectable}
|
||||
selected={selected}
|
||||
onToggle={toggle}
|
||||
onEdit={setEditing}
|
||||
onEditBatch={setBatchEdit}
|
||||
onRetry={(id) => retry.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
{list.hasNextPage && (
|
||||
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||
{t('uploads.load_more')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{ids.length > 0 && (
|
||||
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
|
||||
<span>{t('uploads.selected', { count: ids.length })}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button variant="ghost" onClick={() => setSelected(new Set())}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
|
||||
{t('uploads.reject')}
|
||||
</Button>
|
||||
{tab === 'review' && (
|
||||
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
|
||||
{t('uploads.approve')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchEdit && (
|
||||
<BatchEditDialog
|
||||
batchId={batchEdit}
|
||||
onClose={() => setBatchEdit(null)}
|
||||
onSaved={() => {
|
||||
setBatchEdit(null);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditDialog
|
||||
file={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
review.reset();
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{rejecting && (
|
||||
<RejectDialog
|
||||
count={ids.length}
|
||||
busy={review.isPending}
|
||||
onClose={() => setRejecting(false)}
|
||||
onReject={(reason) => {
|
||||
setRejecting(false);
|
||||
review.mutate({ decision: 'reject', ids, reason });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Problem из ответа массового решения → объект, понятный useErrorText. */
|
||||
function problemError(p: components['schemas']['Problem']) {
|
||||
return new ApiError(p.status, p.code, p.params ?? {});
|
||||
}
|
||||
|
||||
function BatchCard({
|
||||
files,
|
||||
selectable,
|
||||
selected,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onEditBatch,
|
||||
onRetry,
|
||||
}: {
|
||||
files: UploadFile[];
|
||||
selectable: boolean;
|
||||
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');
|
||||
const first = files[0]!;
|
||||
const ids = files.map((f) => f.id);
|
||||
const all = ids.every((id) => selected.has(id));
|
||||
const size = files.reduce((n, f) => n + f.size_bytes, 0);
|
||||
return (
|
||||
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
|
||||
<div className={u.batchHead}>
|
||||
{selectable && (
|
||||
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
|
||||
)}
|
||||
<b>{first.author.username}</b>
|
||||
<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
|
||||
key={f.id}
|
||||
file={f}
|
||||
selectable={selectable}
|
||||
checked={selected.has(f.id)}
|
||||
onCheck={(on) => onToggle([f.id], on)}
|
||||
onEdit={() => onEdit(f)}
|
||||
onRetry={() => onRetry(f.id)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueRow({
|
||||
file: f,
|
||||
selectable,
|
||||
checked,
|
||||
onCheck,
|
||||
onEdit,
|
||||
onRetry,
|
||||
}: {
|
||||
file: UploadFile;
|
||||
selectable: boolean;
|
||||
checked: boolean;
|
||||
onCheck: (on: boolean) => void;
|
||||
onEdit: () => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation('admin');
|
||||
const reason = useReasonText()(f.status_reason);
|
||||
const m = f.metadata;
|
||||
const quality = [
|
||||
f.format?.toUpperCase(),
|
||||
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
|
||||
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
|
||||
f.bit_depth ? `${f.bit_depth} bit` : null,
|
||||
f.duration_ms ? formatDuration(f.duration_ms) : null,
|
||||
formatBytes(f.size_bytes, i18n.language),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const editable = f.status === 'pending_review' || f.status === 'import_failed';
|
||||
return (
|
||||
<div className={u.queueRow}>
|
||||
{selectable ? (
|
||||
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className={u.meta}>
|
||||
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
|
||||
{m && (
|
||||
<span className={u.sub}>
|
||||
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
{f.metadata_edited && ` · ${t('uploads.edited')}`}
|
||||
</span>
|
||||
)}
|
||||
<span className={u.sub}>{quality}</span>
|
||||
<span className={u.sub} title={f.client_rel_path ?? undefined}>
|
||||
{f.client_rel_path ?? f.original_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className={u.meta}>
|
||||
<div className={u.flags}>
|
||||
<UploadStatusBadge status={f.status} />
|
||||
{needsFormatAttention(f.format, f.original_name) && (
|
||||
<span className={cx(s.badge, u.tone_warn)} title={t('uploads.format_attention')}>
|
||||
{t('uploads.flag_attention')}
|
||||
</span>
|
||||
)}
|
||||
{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>
|
||||
) : (
|
||||
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
|
||||
)}
|
||||
</div>
|
||||
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
|
||||
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
|
||||
{f.duplicate_level && f.status === 'duplicate' && (
|
||||
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
|
||||
)}
|
||||
</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} />
|
||||
</Button>
|
||||
)}
|
||||
{f.status === 'import_failed' && (
|
||||
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
|
||||
{t('uploads.retry')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
const errorText = useErrorText();
|
||||
const fieldError = useFieldError();
|
||||
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
|
||||
const [source, setSource] = useState(file.target_source_id ?? '');
|
||||
const sources = useQuery({
|
||||
queryKey: ['sources'],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
|
||||
});
|
||||
const save = useMutation({
|
||||
mutationFn: async () =>
|
||||
unwrap(
|
||||
await session.client.PATCH('/api/v1/admin/uploads/files/{id}', {
|
||||
params: { path: { id: file.id } },
|
||||
body: { metadata: m, ...(source ? { target_source_id: source } : {}) },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => {
|
||||
onSaved();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
|
||||
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
|
||||
);
|
||||
const num = (k: 'year' | 'track' | 'disc') => (
|
||||
<Field
|
||||
label={t(`uploads.meta_${k}`)}
|
||||
value={m[k] === null ? '' : String(m[k])}
|
||||
inputMode="numeric"
|
||||
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
|
||||
/>
|
||||
);
|
||||
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
|
||||
return (
|
||||
<Dialog
|
||||
title={t('uploads.edit_title')}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
|
||||
{t('uploads.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={s.sessions}>
|
||||
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
|
||||
<div className={u.grid}>
|
||||
{text('artist')}
|
||||
{text('albumartist')}
|
||||
{text('album')}
|
||||
{text('title')}
|
||||
{text('genre')}
|
||||
{num('year')}
|
||||
{num('track')}
|
||||
{num('disc')}
|
||||
</div>
|
||||
<label className={s.sessions}>
|
||||
<span>{t('uploads.target_source')}</span>
|
||||
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
|
||||
<option value="">{t('uploads.target_default')}</option>
|
||||
{accepting.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className={s.note}>{t('uploads.tags_note')}</p>
|
||||
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RejectDialog({
|
||||
count,
|
||||
busy,
|
||||
onClose,
|
||||
onReject,
|
||||
}: {
|
||||
count: number;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onReject: (reason: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const [reason, setReason] = useState('');
|
||||
return (
|
||||
<Dialog
|
||||
title={t('uploads.reject_title', { count })}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
|
||||
{t('uploads.reject')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label className={s.sessions}>
|
||||
<span>{t('uploads.reject_reason')}</span>
|
||||
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
|
||||
</label>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
|
||||
export function UploadSettingsSection() {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const settings = useQuery({
|
||||
queryKey: ['upload-settings'],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/settings/uploads')),
|
||||
});
|
||||
const sources = useQuery({
|
||||
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; upgrade: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
if (settings.data) setForm(toForm(settings.data));
|
||||
}, [settings.data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const f = form!;
|
||||
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
|
||||
return unwrap(
|
||||
await session.client.PATCH('/api/v1/admin/settings/uploads', {
|
||||
body: {
|
||||
queue_limit_bytes: gb(f.queue),
|
||||
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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
queryClient.setQueryData(['upload-settings'], data);
|
||||
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
||||
},
|
||||
});
|
||||
if (!form) return null;
|
||||
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
|
||||
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
|
||||
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
|
||||
return (
|
||||
<section className={s.section}>
|
||||
<h2>{t('uploads.settings_title')}</h2>
|
||||
<div className={u.settingsGrid}>
|
||||
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
|
||||
<Field
|
||||
label={t('uploads.set_per_user')}
|
||||
value={form.perUser}
|
||||
hint={t('uploads.set_per_user_hint')}
|
||||
onChange={(v) => setForm({ ...form, perUser: v })}
|
||||
inputMode="decimal"
|
||||
/>
|
||||
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
|
||||
<label>
|
||||
<span className={u.settingsLabel}>{t('uploads.set_source')}</span>
|
||||
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
|
||||
<option value="">{t('uploads.set_source_auto')}</option>
|
||||
{sources.data
|
||||
?.filter((x) => x.accepts_uploads)
|
||||
.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div>
|
||||
<label className={u.settingsCheck}>
|
||||
<input type="checkbox" checked={form.upgrade} onChange={(e) => setForm({ ...form, upgrade: e.target.checked })} />
|
||||
<span>{t('uploads.set_upgrade')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className={s.note}>{t('uploads.set_note')}</p>
|
||||
<div className={s.toolbar}>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
|
||||
{t('uploads.save')}
|
||||
</Button>
|
||||
</div>
|
||||
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function toForm(st: Settings) {
|
||||
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
|
||||
return {
|
||||
queue: gb(st.queue_limit_bytes),
|
||||
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,
|
||||
};
|
||||
}
|
||||
export function UploadQueuePage() { return <UploadQueue />; }
|
||||
@@ -9,7 +9,7 @@ import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { formatRelative } from '../../shared/lib/format';
|
||||
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
|
||||
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, refreshIcon, searchIcon } from '../../shared/ui/icons';
|
||||
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
|
||||
import { Avatar, Button, cx, Dialog, EmptyState, Field, FormError, Icon, Menu, PageSkeleton, type MenuItem } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
|
||||
type AdminUser = components['schemas']['AdminUser'];
|
||||
@@ -232,8 +232,8 @@ export function UsersPage() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.isSuccess && users.length === 0 && <div className={s.empty}>{t('users.empty')}</div>}
|
||||
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
|
||||
{list.isSuccess && users.length === 0 && <EmptyState icon={searchIcon}>{t('users.empty')}</EmptyState>}
|
||||
{list.isPending && <PageSkeleton />}
|
||||
</div>
|
||||
{list.hasNextPage && (
|
||||
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
filter: blur(30px);
|
||||
}
|
||||
|
||||
.waveDecor {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 46%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0.55;
|
||||
filter: blur(0);
|
||||
}
|
||||
|
||||
.blob1 {
|
||||
width: 62vmax;
|
||||
height: 62vmax;
|
||||
@@ -244,6 +253,12 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.otherBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.otherTitle {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text3);
|
||||
@@ -272,6 +287,20 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.older {
|
||||
font-size: 13px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.older summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.older .downloadAlt {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.link {
|
||||
align-self: center;
|
||||
}
|
||||
@@ -60,12 +60,23 @@
|
||||
|
||||
.heroBg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.35;
|
||||
inset: -20%;
|
||||
opacity: 0.4;
|
||||
filter: blur(60px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.heroBg img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.heroCover {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -81,12 +92,40 @@
|
||||
|
||||
.heroBody {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.listen {
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.listenLyrics {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.listen {
|
||||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.listen .hero {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.listen .heroTitle {
|
||||
font-size: clamp(28px, 3vw, 40px);
|
||||
}
|
||||
}
|
||||
|
||||
.kind {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -98,6 +98,10 @@
|
||||
background: var(--surface3);
|
||||
}
|
||||
|
||||
.chip:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.chipOn,
|
||||
.chipOn:hover {
|
||||
background: var(--accent-fill);
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; }
|
||||
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface, #ffffff08); }
|
||||
.card > :first-child { aspect-ratio: 1; border-radius: 12px; }
|
||||
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface); animation: pipuIn 0.2s ease-out both; }
|
||||
.coverWrap { position: relative; aspect-ratio: 1; border-radius: 12px; overflow: hidden; }
|
||||
.coverWrap > :first-child { width: 100%; height: 100%; }
|
||||
.card strong { overflow-wrap: anywhere; }
|
||||
.card span { font-size: 13px; }
|
||||
.card:hover { background: #ffffff10; }
|
||||
.card:hover { background: var(--hover); }
|
||||
.play {
|
||||
position: absolute; right: 10px; bottom: 10px; z-index: 1; display: grid; place-items: center;
|
||||
width: 44px; height: 44px; border-radius: 50%; background: #fff; color: #05070d;
|
||||
opacity: 0; transform: translateY(6px); transition: opacity 0.18s, transform 0.18s;
|
||||
}
|
||||
.card:hover .play, .card:focus-within .play, .play:focus-visible { opacity: 1; transform: none; }
|
||||
.description { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.actions { display: flex; align-items: center; }
|
||||
@@ -11,4 +18,4 @@
|
||||
.actions button:disabled { opacity: .3; cursor: default; }
|
||||
.hero { overflow-wrap: anywhere; }
|
||||
@media (max-width: 600px) { .hero { flex-direction: column; align-items: flex-start; gap: 16px; } .hero > div { width: 100%; } }
|
||||
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
|
||||
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } .play { opacity: 1; transform: none; } }
|
||||
@@ -8,7 +8,7 @@ import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { formatBytes, formatDateTime } from '../../shared/lib/format';
|
||||
import { addIcon } from '../../shared/ui/icons';
|
||||
import { Button, cx, FormError } from '../../shared/ui/ui';
|
||||
import { Button, cx, EmptyState, FormError } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from './common';
|
||||
import u from './uploads.module.css';
|
||||
@@ -86,7 +86,11 @@ export function MyUploadsPage() {
|
||||
</Link>
|
||||
</div>
|
||||
<FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError>
|
||||
{list.isSuccess && items.length === 0 && <div className={s.empty}>{t('uploads.mine_empty')}</div>}
|
||||
{list.isSuccess && items.length === 0 && (
|
||||
<EmptyState icon={addIcon} action={<Link to="/uploads/new">{t('uploads.suggest_title')}</Link>}>
|
||||
{t('uploads.mine_empty')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<div className={s.tableWrap}>
|
||||
<table className={s.table}>
|
||||
|
||||
@@ -1,84 +1 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
import { formatBytes } from '../../shared/lib/format';
|
||||
import { cx } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
import u from './uploads.module.css';
|
||||
|
||||
export type UploadStatus = components['schemas']['UploadStatus'];
|
||||
export type UploadFile = components['schemas']['UploadFile'];
|
||||
|
||||
const TONE: Record<UploadStatus, 'ok' | 'warn' | 'bad' | 'info' | 'muted'> = {
|
||||
uploading: 'info',
|
||||
processing: 'info',
|
||||
pending_review: 'warn',
|
||||
approved: 'info',
|
||||
importing: 'info',
|
||||
imported: 'ok',
|
||||
import_failed: 'bad',
|
||||
duplicate: 'muted',
|
||||
invalid: 'bad',
|
||||
rejected: 'bad',
|
||||
withdrawn: 'muted',
|
||||
expired: 'muted',
|
||||
};
|
||||
|
||||
/** Статусы, которые ещё меняются сами (обработка, импорт) — список опрашивается. */
|
||||
export const MOVING: UploadStatus[] = ['uploading', 'processing', 'approved', 'importing'];
|
||||
|
||||
export function UploadStatusBadge({ status }: { status: UploadStatus }) {
|
||||
const { t } = useTranslation('admin');
|
||||
return <span className={cx(s.badge, u[`tone_${TONE[status]}`])}>{t(`uploads.status.${status}`)}</span>;
|
||||
}
|
||||
|
||||
export function useUploadQuota() {
|
||||
const session = useSession();
|
||||
return useQuery({
|
||||
queryKey: ['upload-quota'],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/quota')),
|
||||
});
|
||||
}
|
||||
|
||||
/** «Очередь: 6,2 ГБ из 10 ГБ» — видят модераторы и админ (docs/uploads.md#3). */
|
||||
export function QuotaMeter() {
|
||||
const { t, i18n } = useTranslation('admin');
|
||||
const quota = useUploadQuota();
|
||||
const id = useId();
|
||||
if (!quota.data) return null;
|
||||
const q = quota.data;
|
||||
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
|
||||
const fmt = (n: number) => formatBytes(n, i18n.language);
|
||||
return (
|
||||
<div className={u.quota}>
|
||||
<span className={u.quotaText} id={id}>
|
||||
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
|
||||
{q.per_user_limit_bytes !== null &&
|
||||
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
|
||||
</span>
|
||||
<div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||||
<span style={{ width: `${ratio * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Понятный текст причины: код из errors:<code> или текст отклонения админа. */
|
||||
export function useReasonText(): (reason: string | null) => string | null {
|
||||
const { t, i18n } = useTranslation(['admin', 'errors']);
|
||||
const tr = t as unknown as (k: string) => string;
|
||||
return (reason) => {
|
||||
if (!reason) return null;
|
||||
const code = reason.split(':')[0]!.trim();
|
||||
const key = `errors:${code}`;
|
||||
if (i18n.exists(key)) {
|
||||
const rest = reason.slice(code.length + 1).trim();
|
||||
return rest ? `${tr(key)}: ${rest}` : tr(key);
|
||||
}
|
||||
return reason;
|
||||
};
|
||||
}
|
||||
export * from '../../features/uploads/common';
|
||||
@@ -0,0 +1,31 @@
|
||||
export class AudioAnalysis {
|
||||
constructor(private readonly getAnalyser: () => AnalyserNode | null) {}
|
||||
private spectrum = new Uint8Array(512);
|
||||
private audioFrame = { bass: 0, mid: 0, treble: 0 };
|
||||
private sampledAt = -Infinity;
|
||||
/** Shared spectrum snapshot; animation consumers never trigger React renders. */
|
||||
read = (active: boolean) => {
|
||||
const now = performance.now();
|
||||
if (now - this.sampledAt < 12) return this.audioFrame;
|
||||
this.sampledAt = now;
|
||||
const analyser = this.getAnalyser();
|
||||
if (!active || !analyser) {
|
||||
this.audioFrame.bass = this.audioFrame.mid = this.audioFrame.treble = 0;
|
||||
return this.audioFrame;
|
||||
}
|
||||
analyser.getByteFrequencyData(this.spectrum);
|
||||
const band = (low: number, high: number) => {
|
||||
const hz = analyser.context.sampleRate / analyser.fftSize;
|
||||
const start = Math.max(1, Math.ceil(low / hz));
|
||||
const end = Math.min(this.spectrum.length, Math.ceil(high / hz));
|
||||
let sum = 0;
|
||||
for (let i = start; i < end; i++) sum += (this.spectrum[i]! / 255) ** 2;
|
||||
return Math.sqrt(sum / Math.max(1, end - start));
|
||||
};
|
||||
this.audioFrame.bass = band(40, 220);
|
||||
this.audioFrame.mid = band(220, 2500);
|
||||
this.audioFrame.treble = band(2500, 10000);
|
||||
return this.audioFrame;
|
||||
};
|
||||
|
||||
}
|
||||
@@ -1,48 +1,57 @@
|
||||
import type { AuthSession } from '@pipu/core';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Player } from './player';
|
||||
import { AudioAnalysis } from './analysis';
|
||||
import { AudioEngine } from './engine';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function fixture() {
|
||||
const player = new Player({} as AuthSession, document.createElement('audio'));
|
||||
const sample = vi.fn((data: Uint8Array) => {
|
||||
data.fill(0);
|
||||
// 48 kHz / 1024: bins 1..4 cover the bass band.
|
||||
data.fill(255, 1, 5);
|
||||
});
|
||||
Object.assign(player, {
|
||||
state: { ...player.getState(), playing: true },
|
||||
analyser: { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample },
|
||||
});
|
||||
return { player, sample };
|
||||
const analyser = { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample } as unknown as AnalyserNode;
|
||||
const analysis = new AudioAnalysis(() => analyser);
|
||||
return { analysis, sample };
|
||||
}
|
||||
|
||||
describe('audio reactivity', () => {
|
||||
it('separates bass from the other frequency bands and shares the frame', () => {
|
||||
vi.spyOn(performance, 'now').mockReturnValue(100);
|
||||
const { player, sample } = fixture();
|
||||
expect(player.readAudio()).toEqual({ bass: 1, mid: 0, treble: 0 });
|
||||
player.readAudio();
|
||||
const { analysis, sample } = fixture();
|
||||
expect(analysis.read(true)).toEqual({ bass: 1, mid: 0, treble: 0 });
|
||||
analysis.read(true);
|
||||
expect(sample).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns silence after pausing or muting', () => {
|
||||
const clock = vi.spyOn(performance, 'now').mockReturnValue(100);
|
||||
const { player, sample } = fixture();
|
||||
player.readAudio();
|
||||
Object.assign(player, { state: { ...player.getState(), playing: false } });
|
||||
const { analysis, sample } = fixture();
|
||||
analysis.read(true);
|
||||
clock.mockReturnValue(120);
|
||||
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
|
||||
Object.assign(player, { state: { ...player.getState(), playing: true, muted: true } });
|
||||
expect(analysis.read(false)).toEqual({ bass: 0, mid: 0, treble: 0 });
|
||||
clock.mockReturnValue(140);
|
||||
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
|
||||
expect(analysis.read(false)).toEqual({ bass: 0, mid: 0, treble: 0 });
|
||||
expect(sample).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('works when Web Audio is unavailable', () => {
|
||||
const player = new Player({} as AuthSession, document.createElement('audio'));
|
||||
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
|
||||
const analysis = new AudioAnalysis(() => null);
|
||||
expect(analysis.read(false)).toEqual({ bass: 0, mid: 0, treble: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('audio engine lifetime', () => {
|
||||
it('removes audio listeners on disposal', () => {
|
||||
const audio = document.createElement('audio');
|
||||
const engine = new AudioEngine(audio);
|
||||
const playing = vi.fn();
|
||||
engine.on('playing', playing);
|
||||
audio.dispatchEvent(new Event('playing'));
|
||||
expect(playing).toHaveBeenCalledTimes(1);
|
||||
engine.dispose();
|
||||
audio.dispatchEvent(new Event('playing'));
|
||||
expect(playing).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { AudioAnalysis } from './analysis';
|
||||
|
||||
/** Browser audio graph and element lifetime; playback policy belongs to Player/core. */
|
||||
export class AudioEngine {
|
||||
readonly audio: HTMLAudioElement;
|
||||
readonly overlap: HTMLAudioElement;
|
||||
private gain: GainNode | null = null;
|
||||
private wired = false;
|
||||
private analyser: AnalyserNode | null = null;
|
||||
private audioContext: AudioContext | null = null;
|
||||
readonly analysis = new AudioAnalysis(() => this.analyser);
|
||||
private readonly unbind: (() => void)[] = [];
|
||||
constructor(audio?: HTMLAudioElement) {
|
||||
this.audio = audio ?? new Audio();
|
||||
this.overlap = new Audio();
|
||||
this.audio.preload = this.overlap.preload = 'auto';
|
||||
}
|
||||
resume() {
|
||||
return this.audioContext?.state === 'suspended' ? this.audioContext.resume() : undefined;
|
||||
}
|
||||
on<K extends keyof HTMLMediaElementEventMap>(event: K, handler: (event: HTMLMediaElementEventMap[K]) => void) {
|
||||
this.audio.addEventListener(event, handler);
|
||||
this.unbind.push(() => this.audio.removeEventListener(event, handler));
|
||||
}
|
||||
|
||||
|
||||
applyLoudness(level: number) {
|
||||
if (!this.wired && typeof AudioContext !== 'undefined') {
|
||||
try {
|
||||
const ctx = new AudioContext();
|
||||
this.audioContext = ctx;
|
||||
const src = ctx.createMediaElementSource(this.audio);
|
||||
this.gain = ctx.createGain();
|
||||
src.connect(this.gain);
|
||||
this.gain.connect(ctx.destination);
|
||||
this.analyser = ctx.createAnalyser();
|
||||
this.analyser.fftSize = 1024;
|
||||
this.analyser.smoothingTimeConstant = 0.55;
|
||||
this.gain.connect(this.analyser);
|
||||
// The overlap is already audible through its own output; analyse it only.
|
||||
try {
|
||||
const overlapSource = ctx.createMediaElementSource(this.overlap);
|
||||
overlapSource.connect(ctx.destination);
|
||||
overlapSource.connect(this.analyser);
|
||||
} catch { /* Crossfade analysis is optional. */ }
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (const stop of this.unbind.splice(0)) stop();
|
||||
for (const audio of [this.audio, this.overlap]) {
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
}
|
||||
void this.audioContext?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Track } from '@pipu/core';
|
||||
import { desktopHandle } from '../desktop';
|
||||
import type { PlayerState } from './types';
|
||||
|
||||
export interface PlaybackControls {
|
||||
play(): void; pause(): void; toggle(): void; next(): void; prev(): void; seek(ms: number): void;
|
||||
}
|
||||
|
||||
export class MediaControls {
|
||||
private lastDesktop: { id?: string; playing?: boolean } = {};
|
||||
private desktopUnlisten: (() => void) | null = null;
|
||||
private disposed = false;
|
||||
constructor(
|
||||
private readonly controls: PlaybackControls,
|
||||
private readonly getState: () => PlayerState,
|
||||
private readonly getAudio: () => HTMLAudioElement,
|
||||
) {
|
||||
this.bindMediaSession();
|
||||
this.bindDesktopMedia();
|
||||
}
|
||||
|
||||
private bindDesktopMedia() {
|
||||
void desktopHandle()?.listenMedia((action) => {
|
||||
if (action === 'play') this.controls.play();
|
||||
else if (action === 'pause' || action === 'stop') this.controls.pause();
|
||||
else if (action === 'toggle') this.controls.toggle();
|
||||
else if (action === 'next') this.controls.next();
|
||||
else if (action === 'previous') this.controls.prev();
|
||||
}).then((stop) => {
|
||||
if (this.disposed) stop();
|
||||
else this.desktopUnlisten = stop;
|
||||
});
|
||||
}
|
||||
|
||||
syncDesktopMedia() {
|
||||
const desktop = desktopHandle();
|
||||
const track = this.getState().track;
|
||||
if (!desktop || !track) return;
|
||||
if (this.lastDesktop.id === track.id && this.lastDesktop.playing === this.getState().playing) return;
|
||||
this.lastDesktop = { id: track.id, playing: this.getState().playing };
|
||||
desktop.setMedia({
|
||||
title: track.title,
|
||||
artist: track.artists.map((a) => a.name).join(', '),
|
||||
album: track.album.title,
|
||||
coverUrl: track.album.cover_url,
|
||||
playing: this.getState().playing,
|
||||
});
|
||||
}
|
||||
|
||||
private bindMediaSession() {
|
||||
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||
if (!ms) return;
|
||||
const set = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
|
||||
try {
|
||||
ms.setActionHandler(action, fn);
|
||||
} catch {
|
||||
// действие не поддерживается браузером
|
||||
}
|
||||
};
|
||||
set('play', () => this.controls.play());
|
||||
set('pause', () => this.controls.pause());
|
||||
set('nexttrack', () => this.controls.next());
|
||||
set('previoustrack', () => this.controls.prev());
|
||||
set('seekbackward', (d) => this.controls.seek(this.getState().positionMs - (d.seekOffset ?? 10) * 1000));
|
||||
set('seekforward', (d) => this.controls.seek(this.getState().positionMs + (d.seekOffset ?? 10) * 1000));
|
||||
set('seekto', (d) => d.seekTime !== undefined && this.controls.seek(d.seekTime * 1000));
|
||||
}
|
||||
|
||||
updateMediaMetadata(track: Track) {
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaSession || typeof MediaMetadata === 'undefined') return;
|
||||
const cover = track.album.cover_url;
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: track.title,
|
||||
artist: track.artists.map((a) => a.name).join(', '),
|
||||
album: track.album.title,
|
||||
artwork: cover
|
||||
? [
|
||||
{ src: `${cover}&size=300`, sizes: '300x300', type: 'image/webp' },
|
||||
{ src: `${cover}&size=1200`, sizes: '1200x1200', type: 'image/webp' },
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
updatePositionState() {
|
||||
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||
const d = this.getAudio().duration;
|
||||
if (!ms?.setPositionState || !Number.isFinite(d) || d <= 0) return;
|
||||
try {
|
||||
ms.setPositionState({ duration: d, position: Math.min(this.getAudio().currentTime, d), playbackRate: 1 });
|
||||
} catch {
|
||||
// некорректная позиция во время смены трека
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
this.desktopUnlisten?.();
|
||||
this.desktopUnlisten = null;
|
||||
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||
for (const action of ['play', 'pause', 'nexttrack', 'previoustrack', 'seekbackward', 'seekforward', 'seekto'] as const) {
|
||||
try { ms?.setActionHandler(action, null); } catch { /* Unsupported action. */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { unwrap, type AuthSession, type PlayContext, type RepeatMode } from '@pipu/core';
|
||||
import type { PlayerState } from './types';
|
||||
|
||||
const LOCAL_KEY = 'pipu.player';
|
||||
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
|
||||
const MAX_SAVED_QUEUE = 500;
|
||||
|
||||
export interface Saved {
|
||||
queue: string[];
|
||||
index: number;
|
||||
positionMs: number;
|
||||
context: PlayContext | null;
|
||||
volume: number;
|
||||
shuffle: boolean;
|
||||
repeat: RepeatMode;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function deviceId(): string {
|
||||
try {
|
||||
let id = localStorage.getItem('pipu.device');
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem('pipu.device', id);
|
||||
}
|
||||
return id;
|
||||
} catch {
|
||||
return 'web';
|
||||
}
|
||||
}
|
||||
|
||||
export class PlaybackPersistence {
|
||||
private readonly device = deviceId();
|
||||
lastServerSave = 0;
|
||||
constructor(private readonly session: AuthSession, private readonly getState: () => PlayerState) {}
|
||||
snapshot(state: PlayerState): Saved {
|
||||
const ids = state.queue.map((t) => t.id);
|
||||
const start = Math.max(0, Math.min(state.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
|
||||
return {
|
||||
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
|
||||
index: state.index - start,
|
||||
positionMs: state.positionMs,
|
||||
context: state.context,
|
||||
volume: state.volume,
|
||||
shuffle: state.shuffle,
|
||||
repeat: state.repeat,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
saveLocal() {
|
||||
if (!this.getState().track) return;
|
||||
try {
|
||||
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot(this.getState())));
|
||||
} catch {
|
||||
// недоступное хранилище не мешает воспроизведению
|
||||
}
|
||||
}
|
||||
|
||||
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
saveSoon() {
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
|
||||
}
|
||||
|
||||
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
|
||||
saveServer(keepalive = false) {
|
||||
const cur = this.getState().track;
|
||||
if (!cur) return;
|
||||
this.lastServerSave = Date.now();
|
||||
this.saveLocal();
|
||||
const s = this.snapshot(this.getState());
|
||||
void this.session.client
|
||||
.PUT('/api/v1/me/playback-state', {
|
||||
body: {
|
||||
track_id: cur.id,
|
||||
position_ms: s.positionMs,
|
||||
queue: s.queue,
|
||||
queue_index: s.index,
|
||||
context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined,
|
||||
shuffle: s.shuffle,
|
||||
repeat_mode: s.repeat,
|
||||
volume: s.volume,
|
||||
device_id: this.device,
|
||||
},
|
||||
keepalive,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
async restore(): Promise<Saved | null> {
|
||||
let local: Saved | null = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_KEY);
|
||||
local = raw ? (JSON.parse(raw) as Saved) : null;
|
||||
} catch {
|
||||
local = null;
|
||||
}
|
||||
let saved = local;
|
||||
try {
|
||||
const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
|
||||
if (
|
||||
server.track_id &&
|
||||
server.updated_at &&
|
||||
(!local || (server.device_id !== this.device && server.updated_at > local.savedAt))
|
||||
) {
|
||||
saved = {
|
||||
queue: server.queue,
|
||||
index: server.queue_index,
|
||||
positionMs: server.position_ms,
|
||||
context: (server.context?.type ? server.context : null) as PlayContext | null,
|
||||
volume: server.volume,
|
||||
shuffle: server.shuffle,
|
||||
repeat: server.repeat_mode,
|
||||
savedAt: server.updated_at,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// офлайн — остаёмся с локальной копией
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
dispose() {
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
}
|
||||
+60
-339
@@ -1,5 +1,9 @@
|
||||
import {
|
||||
EventBuffer,
|
||||
WaveCoordinator,
|
||||
EARLY_SKIP_MS,
|
||||
type WaveOutcome,
|
||||
type WaveFeedback,
|
||||
normalizeGain,
|
||||
PlayAttempt,
|
||||
PlayQueue,
|
||||
@@ -10,80 +14,29 @@ import {
|
||||
type Track,
|
||||
} from '@pipu/core';
|
||||
|
||||
import { desktopHandle } from '../desktop';
|
||||
import { MediaControls } from './media';
|
||||
import type { PlayerState } from './types';
|
||||
import { AudioEngine } from './engine';
|
||||
import { PlaybackPersistence } from './persistence';
|
||||
|
||||
export type { PlayContext, Track };
|
||||
|
||||
export interface WaveInfo {
|
||||
sessionId: string;
|
||||
activity: string | null;
|
||||
mood: string | null;
|
||||
/** Рекомендатель недоступен — играет запасная волна. */
|
||||
fallback: boolean;
|
||||
}
|
||||
export type { WaveInfo } from '@pipu/core';
|
||||
|
||||
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
|
||||
export type { PlayerState } from './types';
|
||||
|
||||
/** Когда в очереди волны остаётся столько треков, просим следующую пачку. */
|
||||
const WAVE_REFILL_AT = 2;
|
||||
/** Скип раньше этого — «ранний» (сильный отрицательный сигнал). */
|
||||
const EARLY_SKIP_MS = 30_000;
|
||||
|
||||
export interface PlayerState {
|
||||
wave: WaveInfo | null;
|
||||
track: Track | null;
|
||||
queue: Track[];
|
||||
index: number;
|
||||
context: PlayContext | null;
|
||||
playing: boolean;
|
||||
loading: boolean;
|
||||
positionMs: number;
|
||||
durationMs: number;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
shuffle: boolean;
|
||||
repeat: RepeatMode;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const LOCAL_KEY = 'pipu.player';
|
||||
const SAVE_EVERY_MS = 10_000;
|
||||
const FLUSH_EVERY_MS = 30_000;
|
||||
const PRELOAD_BEFORE_MS = 20_000;
|
||||
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
|
||||
const MAX_SAVED_QUEUE = 500;
|
||||
|
||||
interface Saved {
|
||||
queue: string[];
|
||||
index: number;
|
||||
positionMs: number;
|
||||
context: PlayContext | null;
|
||||
volume: number;
|
||||
shuffle: boolean;
|
||||
repeat: RepeatMode;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function deviceId(): string {
|
||||
try {
|
||||
let id = localStorage.getItem('pipu.device');
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem('pipu.device', id);
|
||||
}
|
||||
return id;
|
||||
} catch {
|
||||
return 'web';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Плеер веба. Логика очереди и учёта событий — из @pipu/core; здесь — адаптер к HTMLAudioElement,
|
||||
* Media Session и сохранение «трек и момент» (локально и на сервере).
|
||||
*/
|
||||
export class Player {
|
||||
private readonly audio: HTMLAudioElement;
|
||||
private readonly overlap: HTMLAudioElement;
|
||||
private readonly engine: AudioEngine;
|
||||
private get audio() { return this.engine.audio; }
|
||||
private get overlap() { return this.engine.overlap; }
|
||||
private fading = false;
|
||||
private ignoreEnded = false;
|
||||
private readonly queue = new PlayQueue();
|
||||
@@ -92,13 +45,17 @@ export class Player {
|
||||
private context: PlayContext | null = null;
|
||||
private state: PlayerState;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly device = deviceId();
|
||||
private lastServerSave = 0;
|
||||
private readonly persistence: PlaybackPersistence;
|
||||
private preloaded: string | null = null;
|
||||
private refreshedFor: string | null = null;
|
||||
private timers: ReturnType<typeof setInterval>[] = [];
|
||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||
private waveLoading = false;
|
||||
private readonly wavePolicy = new WaveCoordinator({
|
||||
next: async (id: string, feedback: WaveFeedback[]) => unwrap(
|
||||
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
|
||||
params: { path: { id } }, body: { feedback },
|
||||
}),
|
||||
),
|
||||
});
|
||||
private restoreVersion = 0;
|
||||
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
||||
onReaction: (() => void) | null = null;
|
||||
@@ -107,10 +64,7 @@ export class Player {
|
||||
private readonly session: AuthSession,
|
||||
audio?: HTMLAudioElement,
|
||||
) {
|
||||
this.audio = audio ?? new Audio();
|
||||
this.audio.preload = 'auto';
|
||||
this.overlap = new Audio();
|
||||
this.overlap.preload = 'auto';
|
||||
this.engine = new AudioEngine(audio);
|
||||
this.events = new EventBuffer({
|
||||
send: async (events) => {
|
||||
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
|
||||
@@ -121,9 +75,9 @@ export class Player {
|
||||
track: null, queue: [], index: 0, context: null, playing: false, loading: false, positionMs: 0,
|
||||
durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null,
|
||||
};
|
||||
this.persistence = new PlaybackPersistence(session, this.getState);
|
||||
this.bindAudio();
|
||||
this.bindMediaSession();
|
||||
this.bindDesktopMedia();
|
||||
this.media = new MediaControls(this, this.getState, () => this.audio);
|
||||
}
|
||||
|
||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||
@@ -140,13 +94,12 @@ export class Player {
|
||||
|
||||
getState = () => this.state;
|
||||
|
||||
private lastDesktop: { id?: string; playing?: boolean } = {};
|
||||
private desktopUnlisten: (() => void) | null = null;
|
||||
private readonly media: MediaControls;
|
||||
|
||||
private update(patch: Partial<PlayerState>) {
|
||||
this.state = { ...this.state, ...patch };
|
||||
for (const fn of this.listeners) fn();
|
||||
this.syncDesktopMedia();
|
||||
this.media.syncDesktopMedia();
|
||||
}
|
||||
|
||||
private syncQueue() {
|
||||
@@ -183,16 +136,19 @@ export class Player {
|
||||
this.restoreVersion++;
|
||||
this.finishAttempt('skip');
|
||||
this.leaveWave();
|
||||
const version = this.wavePolicy.version;
|
||||
const res = unwrap(
|
||||
await this.session.client.POST('/api/v1/wave/sessions', {
|
||||
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) },
|
||||
}),
|
||||
);
|
||||
if (!this.wavePolicy.isCurrent(version)) return;
|
||||
const tracks = res.tracks.filter((t) => t.available && t.stream_url);
|
||||
if (!tracks.length) {
|
||||
this.update({ error: 'wave.empty' });
|
||||
return;
|
||||
}
|
||||
this.wavePolicy.activate(res.session_id);
|
||||
this.context = { type: 'wave', id: res.session_id, label: seed?.label };
|
||||
this.queue.shuffle = false;
|
||||
this.queue.repeat = 'off';
|
||||
@@ -210,12 +166,14 @@ export class Player {
|
||||
const wave = this.state.wave;
|
||||
if (!wave) return this.startWave(activity, mood);
|
||||
this.update({ wave: { ...wave, activity, mood } });
|
||||
const version = this.wavePolicy.changeContext(wave.sessionId);
|
||||
const res = unwrap(
|
||||
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
||||
params: { path: { id: wave.sessionId } },
|
||||
body: { activity, mood },
|
||||
}),
|
||||
);
|
||||
if (!this.wavePolicy.isCurrent(version)) return;
|
||||
const fresh = res.tracks.filter((t) => t.available && t.stream_url);
|
||||
this.queue.dropUpcoming();
|
||||
this.queue.append(fresh);
|
||||
@@ -236,8 +194,8 @@ export class Player {
|
||||
|
||||
private leaveWave() {
|
||||
const wave = this.state.wave;
|
||||
this.wavePolicy.activate(null);
|
||||
if (!wave) return;
|
||||
this.waveFeedback = [];
|
||||
this.update({ wave: null });
|
||||
void this.session.client
|
||||
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
||||
@@ -245,7 +203,7 @@ export class Player {
|
||||
}
|
||||
|
||||
private noteWave(trackId: string, outcome: WaveOutcome) {
|
||||
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
|
||||
this.wavePolicy.note(trackId, outcome);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,29 +211,12 @@ export class Player {
|
||||
* после лайка, дизлайка или раннего скипа очередь должна сразу отразить реакцию.
|
||||
*/
|
||||
private async refillWave(refresh = false) {
|
||||
const wave = this.state.wave;
|
||||
if (!wave || this.waveLoading) return;
|
||||
if (!refresh && this.queue.remaining > WAVE_REFILL_AT) return;
|
||||
this.waveLoading = true;
|
||||
const feedback = this.waveFeedback;
|
||||
this.waveFeedback = [];
|
||||
try {
|
||||
const res = unwrap(
|
||||
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
|
||||
params: { path: { id: wave.sessionId } },
|
||||
body: { feedback },
|
||||
}),
|
||||
);
|
||||
if (this.state.wave?.sessionId !== wave.sessionId) return; // волну уже выключили
|
||||
await this.wavePolicy.refill(this.queue.remaining, refresh, (res) => {
|
||||
if (refresh) this.queue.dropUpcoming();
|
||||
this.queue.append(res.tracks.filter((t) => t.available && t.stream_url));
|
||||
this.update({ wave: { ...this.state.wave, fallback: res.fallback } });
|
||||
this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
|
||||
this.syncQueue();
|
||||
} catch {
|
||||
this.waveFeedback = [...feedback, ...this.waveFeedback]; // отправим со следующим запросом
|
||||
} finally {
|
||||
this.waveLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggle() {
|
||||
@@ -289,7 +230,7 @@ export class Player {
|
||||
void this.load(true, this.state.positionMs);
|
||||
return;
|
||||
}
|
||||
if (this.audioContext?.state === 'suspended') void this.audioContext.resume().catch(() => undefined);
|
||||
void this.engine.resume()?.catch(() => undefined);
|
||||
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
|
||||
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
|
||||
}
|
||||
@@ -402,7 +343,7 @@ export class Player {
|
||||
// Keep the restored element's buffer when the user presses Play.
|
||||
if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url;
|
||||
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
|
||||
this.updateMediaMetadata(track);
|
||||
this.media.updateMediaMetadata(track);
|
||||
if (!autoplay) return;
|
||||
this.applyLoudness();
|
||||
this.attempt = new PlayAttempt(track, startMs);
|
||||
@@ -418,7 +359,8 @@ export class Player {
|
||||
void this.refillWave();
|
||||
if (autoplay) {
|
||||
try {
|
||||
if (this.audioContext?.state === 'suspended') await this.audioContext.resume();
|
||||
const resumed = this.engine.resume();
|
||||
if (resumed) await resumed;
|
||||
await this.audio.play();
|
||||
} catch {
|
||||
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
|
||||
@@ -465,73 +407,11 @@ export class Player {
|
||||
return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0));
|
||||
}
|
||||
|
||||
/** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */
|
||||
private gain: GainNode | null = null;
|
||||
private wired = false;
|
||||
private analyser: AnalyserNode | null = null;
|
||||
private audioContext: AudioContext | null = null;
|
||||
private spectrum = new Uint8Array(512);
|
||||
private audioFrame = { bass: 0, mid: 0, treble: 0 };
|
||||
private sampledAt = -Infinity;
|
||||
|
||||
/** Shared spectrum snapshot; animation consumers never trigger React renders. */
|
||||
readAudio = () => {
|
||||
const now = performance.now();
|
||||
if (now - this.sampledAt < 12) return this.audioFrame;
|
||||
this.sampledAt = now;
|
||||
const active = this.state.playing && !this.state.loading && !this.state.muted;
|
||||
const analyser = this.analyser;
|
||||
if (!active || !analyser) {
|
||||
this.audioFrame.bass = this.audioFrame.mid = this.audioFrame.treble = 0;
|
||||
return this.audioFrame;
|
||||
}
|
||||
analyser.getByteFrequencyData(this.spectrum);
|
||||
const band = (low: number, high: number) => {
|
||||
const hz = analyser.context.sampleRate / analyser.fftSize;
|
||||
const start = Math.max(1, Math.ceil(low / hz));
|
||||
const end = Math.min(this.spectrum.length, Math.ceil(high / hz));
|
||||
let sum = 0;
|
||||
for (let i = start; i < end; i++) sum += (this.spectrum[i]! / 255) ** 2;
|
||||
return Math.sqrt(sum / Math.max(1, end - start));
|
||||
};
|
||||
this.audioFrame.bass = band(40, 220);
|
||||
this.audioFrame.mid = band(220, 2500);
|
||||
this.audioFrame.treble = band(2500, 10000);
|
||||
return this.audioFrame;
|
||||
};
|
||||
|
||||
readAudio = () => this.engine.analysis.read(this.state.playing && !this.state.loading && !this.state.muted);
|
||||
|
||||
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();
|
||||
this.audioContext = ctx;
|
||||
const src = ctx.createMediaElementSource(this.audio);
|
||||
this.gain = ctx.createGain();
|
||||
src.connect(this.gain);
|
||||
this.gain.connect(ctx.destination);
|
||||
this.analyser = ctx.createAnalyser();
|
||||
this.analyser.fftSize = 1024;
|
||||
this.analyser.smoothingTimeConstant = 0.55;
|
||||
this.gain.connect(this.analyser);
|
||||
// The overlap is already audible through its own output; analyse it only.
|
||||
try {
|
||||
const overlapSource = ctx.createMediaElementSource(this.overlap);
|
||||
overlapSource.connect(ctx.destination);
|
||||
overlapSource.connect(this.analyser);
|
||||
} catch { /* Crossfade analysis is optional. */ }
|
||||
|
||||
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);
|
||||
this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix);
|
||||
}
|
||||
|
||||
/** В конце трека накладывает следующий на crossfade_s секунд. */
|
||||
@@ -590,7 +470,7 @@ export class Player {
|
||||
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);
|
||||
this.media.updateMediaMetadata(next);
|
||||
void this.audio.play().finally(() => {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
@@ -599,13 +479,13 @@ export class Player {
|
||||
|
||||
private bindAudio() {
|
||||
const a = this.audio;
|
||||
a.addEventListener('playing', () => {
|
||||
this.engine.on('playing', () => {
|
||||
this.update({ playing: true, loading: false });
|
||||
if (this.attempt && this.state.positionMs > 0 && a.currentTime > 0.5) {
|
||||
this.events.push({ type: 'resume', track_id: this.attempt.track.id, position_ms: this.state.positionMs });
|
||||
}
|
||||
});
|
||||
a.addEventListener('pause', () => {
|
||||
this.engine.on('pause', () => {
|
||||
if (a.ended) return;
|
||||
this.update({ playing: false });
|
||||
const t = this.queue.current;
|
||||
@@ -614,22 +494,22 @@ export class Player {
|
||||
this.saveServer();
|
||||
}
|
||||
});
|
||||
a.addEventListener('waiting', () => {
|
||||
this.engine.on('waiting', () => {
|
||||
if (this.attempt) this.update({ loading: true });
|
||||
});
|
||||
a.addEventListener('timeupdate', () => {
|
||||
this.engine.on('timeupdate', () => {
|
||||
const ms = Math.round(a.currentTime * 1000);
|
||||
this.attempt?.tick(ms);
|
||||
this.update({ positionMs: ms });
|
||||
this.updatePositionState();
|
||||
this.media.updatePositionState();
|
||||
if (this.state.playing) {
|
||||
this.maybePreload(ms);
|
||||
this.maybeCrossfade(ms);
|
||||
}
|
||||
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
||||
if (this.state.playing && Date.now() - this.persistence.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
||||
this.saveLocal();
|
||||
});
|
||||
a.addEventListener('ended', () => {
|
||||
this.engine.on('ended', () => {
|
||||
if (this.ignoreEnded || this.fading || !this.attempt) return;
|
||||
this.finishAttempt('play_end');
|
||||
if (this.queue.next(true)) {
|
||||
@@ -640,7 +520,7 @@ export class Player {
|
||||
this.saveServer();
|
||||
}
|
||||
});
|
||||
a.addEventListener('error', () => void this.onError());
|
||||
this.engine.on('error', () => void this.onError());
|
||||
}
|
||||
|
||||
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
|
||||
@@ -683,138 +563,11 @@ export class Player {
|
||||
void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
|
||||
}
|
||||
|
||||
// --- Media Session ---
|
||||
|
||||
private bindDesktopMedia() {
|
||||
void desktopHandle()?.listenMedia((action) => {
|
||||
if (action === 'play') this.play();
|
||||
else if (action === 'pause' || action === 'stop') this.pause();
|
||||
else if (action === 'toggle') this.toggle();
|
||||
else if (action === 'next') this.next();
|
||||
else if (action === 'previous') this.prev();
|
||||
}).then((stop) => {
|
||||
this.desktopUnlisten = stop;
|
||||
});
|
||||
}
|
||||
|
||||
private syncDesktopMedia() {
|
||||
const desktop = desktopHandle();
|
||||
const track = this.state.track;
|
||||
if (!desktop || !track) return;
|
||||
if (this.lastDesktop.id === track.id && this.lastDesktop.playing === this.state.playing) return;
|
||||
this.lastDesktop = { id: track.id, playing: this.state.playing };
|
||||
desktop.setMedia({
|
||||
title: track.title,
|
||||
artist: track.artists.map((a) => a.name).join(', '),
|
||||
album: track.album.title,
|
||||
coverUrl: track.album.cover_url,
|
||||
playing: this.state.playing,
|
||||
});
|
||||
}
|
||||
|
||||
private bindMediaSession() {
|
||||
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||
if (!ms) return;
|
||||
const set = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
|
||||
try {
|
||||
ms.setActionHandler(action, fn);
|
||||
} catch {
|
||||
// действие не поддерживается браузером
|
||||
}
|
||||
};
|
||||
set('play', () => this.play());
|
||||
set('pause', () => this.pause());
|
||||
set('nexttrack', () => this.next());
|
||||
set('previoustrack', () => this.prev());
|
||||
set('seekbackward', (d) => this.seek(this.state.positionMs - (d.seekOffset ?? 10) * 1000));
|
||||
set('seekforward', (d) => this.seek(this.state.positionMs + (d.seekOffset ?? 10) * 1000));
|
||||
set('seekto', (d) => d.seekTime !== undefined && this.seek(d.seekTime * 1000));
|
||||
}
|
||||
|
||||
private updateMediaMetadata(track: Track) {
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaSession || typeof MediaMetadata === 'undefined') return;
|
||||
const cover = track.album.cover_url;
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: track.title,
|
||||
artist: track.artists.map((a) => a.name).join(', '),
|
||||
album: track.album.title,
|
||||
artwork: cover
|
||||
? [
|
||||
{ src: `${cover}&size=300`, sizes: '300x300', type: 'image/webp' },
|
||||
{ src: `${cover}&size=1200`, sizes: '1200x1200', type: 'image/webp' },
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
private updatePositionState() {
|
||||
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||
const d = this.audio.duration;
|
||||
if (!ms?.setPositionState || !Number.isFinite(d) || d <= 0) return;
|
||||
try {
|
||||
ms.setPositionState({ duration: d, position: Math.min(this.audio.currentTime, d), playbackRate: 1 });
|
||||
} catch {
|
||||
// некорректная позиция во время смены трека
|
||||
}
|
||||
}
|
||||
|
||||
// --- сохранение состояния ---
|
||||
|
||||
private snapshot(): Saved {
|
||||
const ids = this.queue.tracks.map((t) => t.id);
|
||||
const start = Math.max(0, Math.min(this.queue.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
|
||||
return {
|
||||
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
|
||||
index: this.queue.index - start,
|
||||
positionMs: this.state.positionMs,
|
||||
context: this.context,
|
||||
volume: this.state.volume,
|
||||
shuffle: this.queue.shuffle,
|
||||
repeat: this.queue.repeat,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private saveLocal() {
|
||||
if (!this.queue.current) return;
|
||||
try {
|
||||
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot()));
|
||||
} catch {
|
||||
// недоступное хранилище не мешает воспроизведению
|
||||
}
|
||||
}
|
||||
|
||||
private saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private saveSoon() {
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
|
||||
}
|
||||
|
||||
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
|
||||
saveServer(keepalive = false) {
|
||||
const cur = this.queue.current;
|
||||
if (!cur) return;
|
||||
this.lastServerSave = Date.now();
|
||||
this.saveLocal();
|
||||
const s = this.snapshot();
|
||||
void this.session.client
|
||||
.PUT('/api/v1/me/playback-state', {
|
||||
body: {
|
||||
track_id: cur.id,
|
||||
position_ms: s.positionMs,
|
||||
queue: s.queue,
|
||||
queue_index: s.index,
|
||||
context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined,
|
||||
shuffle: s.shuffle,
|
||||
repeat_mode: s.repeat,
|
||||
volume: s.volume,
|
||||
device_id: this.device,
|
||||
},
|
||||
keepalive,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
private saveLocal() { this.persistence.saveLocal(); }
|
||||
private saveSoon() { this.persistence.saveSoon(); }
|
||||
saveServer(keepalive = false) { this.persistence.saveServer(keepalive); }
|
||||
|
||||
/** Уход со страницы: дослать события и состояние. */
|
||||
onHide() {
|
||||
@@ -828,35 +581,7 @@ export class Player {
|
||||
*/
|
||||
async restore() {
|
||||
const version = ++this.restoreVersion;
|
||||
let local: Saved | null = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_KEY);
|
||||
local = raw ? (JSON.parse(raw) as Saved) : null;
|
||||
} catch {
|
||||
local = null;
|
||||
}
|
||||
let saved = local;
|
||||
try {
|
||||
const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
|
||||
if (
|
||||
server.track_id &&
|
||||
server.updated_at &&
|
||||
(!local || (server.device_id !== this.device && server.updated_at > local.savedAt))
|
||||
) {
|
||||
saved = {
|
||||
queue: server.queue,
|
||||
index: server.queue_index,
|
||||
positionMs: server.position_ms,
|
||||
context: (server.context?.type ? server.context : null) as PlayContext | null,
|
||||
volume: server.volume,
|
||||
shuffle: server.shuffle,
|
||||
repeat: server.repeat_mode,
|
||||
savedAt: server.updated_at,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// офлайн — остаёмся с локальной копией
|
||||
}
|
||||
const saved = await this.persistence.restore();
|
||||
if (version !== this.restoreVersion || !saved?.queue.length) return;
|
||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
||||
if (version !== this.restoreVersion) return;
|
||||
@@ -872,25 +597,21 @@ export class Player {
|
||||
const pos = tracks[idx]?.id === wanted ? saved.positionMs : 0;
|
||||
this.syncQueue();
|
||||
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
|
||||
this.updateMediaMetadata(tracks[idx]!);
|
||||
this.media.updateMediaMetadata(tracks[idx]!);
|
||||
// Prepare at the saved position without creating a listening attempt.
|
||||
await this.load(false, pos);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.restoreVersion++;
|
||||
this.wavePolicy.activate(null);
|
||||
this.fading = false;
|
||||
this.finishAttempt('skip');
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
this.overlap.pause();
|
||||
this.overlap.removeAttribute('src');
|
||||
this.overlap.load();
|
||||
this.engine.dispose();
|
||||
this.persistence.dispose();
|
||||
for (const t of this.timers) clearInterval(t);
|
||||
this.timers = [];
|
||||
this.desktopUnlisten?.();
|
||||
this.desktopUnlisten = null;
|
||||
this.media.dispose();
|
||||
void this.events.flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { WaveInfo, Track, PlayContext, RepeatMode } from '@pipu/core';
|
||||
|
||||
export interface PlayerState {
|
||||
wave: WaveInfo | null;
|
||||
track: Track | null;
|
||||
queue: Track[];
|
||||
index: number;
|
||||
context: PlayContext | null;
|
||||
playing: boolean;
|
||||
loading: boolean;
|
||||
positionMs: number;
|
||||
durationMs: number;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
shuffle: boolean;
|
||||
repeat: RepeatMode;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { shareTrack } from './share';
|
||||
import { sharePlaylist, shareTrack } from './share';
|
||||
import * as desktop from '../../desktop';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
@@ -38,3 +39,34 @@ describe('shareTrack', () => {
|
||||
finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('sharePlaylist', () => {
|
||||
it('copies the shared page URL without exposing streaming links', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText } });
|
||||
try {
|
||||
expect(await sharePlaylist('token', 'Mix')).toBe('copied');
|
||||
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/shared/playlist/token`);
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it('uses the configured music server when sharing from desktop', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.spyOn(desktop, 'desktopHandle').mockReturnValue({ server: 'https://music.example' } as desktop.DesktopHandle);
|
||||
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText } });
|
||||
try {
|
||||
await sharePlaylist('token', 'Mix');
|
||||
expect(writeText).toHaveBeenCalledWith('https://music.example/shared/playlist/token');
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it('opens the native share sheet on mobile browsers', async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal('navigator', { userAgent: 'Android', share });
|
||||
try {
|
||||
expect(await sharePlaylist('token', 'Mix')).toBe('shared');
|
||||
expect(share).toHaveBeenCalledWith({ title: 'Mix', url: `${window.location.origin}/shared/playlist/token` });
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,16 @@
|
||||
import { trackShareUrl } from '@pipu/core';
|
||||
import { playlistShareUrl, trackShareUrl } from '@pipu/core';
|
||||
import { desktopHandle } from '../../desktop';
|
||||
|
||||
/** Desktop always copies; mobile browsers can open their native share sheet. */
|
||||
export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
|
||||
const url = trackShareUrl(window.location.origin, id);
|
||||
return shareUrl(trackShareUrl(desktopHandle()?.server ?? window.location.origin, id), title);
|
||||
}
|
||||
|
||||
export async function sharePlaylist(shareId: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
|
||||
return shareUrl(playlistShareUrl(desktopHandle()?.server ?? window.location.origin, shareId), title);
|
||||
}
|
||||
|
||||
async function shareUrl(url: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
|
||||
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title, url });
|
||||
|
||||
@@ -41,7 +41,12 @@
|
||||
transition:
|
||||
background 0.15s,
|
||||
box-shadow 0.15s,
|
||||
opacity 0.15s;
|
||||
opacity 0.15s,
|
||||
transform 0.12s;
|
||||
}
|
||||
|
||||
.button:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
@@ -53,6 +58,115 @@
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: pipuSpin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pipuSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
color: var(--text3);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
margin: 0;
|
||||
max-width: 28rem;
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.empty a,
|
||||
.empty button {
|
||||
color: var(--accent2);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.empty a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: block;
|
||||
background: var(--surface3);
|
||||
border-radius: 8px;
|
||||
animation: pipuShimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeletonRound {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@keyframes pipuShimmer {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pageSkel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.skelHero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.skelCover {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 12px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.skelCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.skelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--accent-fill);
|
||||
color: #fff;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
type ButtonHTMLAttributes,
|
||||
type CSSProperties,
|
||||
type InputHTMLAttributes,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
@@ -52,7 +53,7 @@ export function Button({ variant = 'secondary', icon, busy, className, children,
|
||||
aria-busy={busy || undefined}
|
||||
className={cx(s.button, s[variant], className)}
|
||||
>
|
||||
{icon && <Icon src={icon} size={18} />}
|
||||
{busy ? <span className={s.spinner} aria-hidden /> : icon ? <Icon src={icon} size={18} /> : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
@@ -283,6 +284,61 @@ export function Menu({
|
||||
);
|
||||
}
|
||||
|
||||
/** Пустой список: иконка, текст и действие (docs/design.md#5). */
|
||||
export function EmptyState({
|
||||
icon,
|
||||
children,
|
||||
action,
|
||||
}: {
|
||||
icon?: string;
|
||||
children: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={s.empty} role="status">
|
||||
{icon && <Icon src={icon} size={40} className={s.emptyIcon} />}
|
||||
<p className={s.emptyText}>{children}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
className,
|
||||
style,
|
||||
round,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
round?: boolean;
|
||||
}) {
|
||||
return <span className={cx(s.skeleton, round && s.skeletonRound, className)} style={style} aria-hidden />;
|
||||
}
|
||||
|
||||
/** Каркас героя и строк списка, пока страница ещё грузится. */
|
||||
export function PageSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={s.pageSkel} role="status" aria-busy="true" aria-label={t('loading')}>
|
||||
<div className={s.skelHero}>
|
||||
<Skeleton className={s.skelCover} />
|
||||
<div className={s.skelCopy}>
|
||||
<Skeleton style={{ width: 72, height: 12 }} />
|
||||
<Skeleton style={{ width: '55%', height: 28 }} />
|
||||
<Skeleton style={{ width: '38%', height: 14 }} />
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className={s.skelRow}>
|
||||
<Skeleton style={{ width: 40, height: 40 }} />
|
||||
<Skeleton style={{ flex: 1, height: 14 }} />
|
||||
<Skeleton style={{ width: 36, height: 12 }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
|
||||
const initials = name
|
||||
.split(/[\s._-]+/)
|
||||
|
||||
@@ -70,11 +70,13 @@ export function makeUser(username: string, role: Role, extra: Partial<FakeUser>
|
||||
*/
|
||||
export function fakeApi(initial: FakeUser[]) {
|
||||
const users = new Map(initial.map((u) => [u.id, { ...u }]));
|
||||
const onboarding = new Map<string, components['schemas']['OnboardingChoice'] | null>();
|
||||
const prefs = new Map<string, components['schemas']['UiPrefs']>();
|
||||
let current: string | null = null; // id вошедшего (одна сессия на тест)
|
||||
let token = 0;
|
||||
const calls: { method: string; path: string; body: unknown }[] = [];
|
||||
const reactions = new Map<string, 'like' | 'dislike'>();
|
||||
const playlistOwners = new Map<string, string>();
|
||||
const playlists = new Map<string, components['schemas']['Playlist']>();
|
||||
|
||||
const me = (u: FakeUser): Me => ({
|
||||
@@ -122,7 +124,13 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
version_code: 1_030_099,
|
||||
notes_md: '',
|
||||
clients: [
|
||||
{ client: 'windows', filename: 'pipu-music_1.3.0_x64-setup.exe', size: 20_000_000, download_url: '/api/v1/app/download/windows/1.3.0' },
|
||||
{
|
||||
client: 'windows',
|
||||
filename: 'pipu-music_1.3.0_x64-setup.exe',
|
||||
size: 20_000_000,
|
||||
download_url: '/api/v1/app/download/windows/1.3.0',
|
||||
older: [{ version: '1.2.0', filename: 'pipu-music_1.2.0_x64-setup.exe', size: 19_000_000, download_url: '/api/v1/app/download/windows/1.2.0' }],
|
||||
},
|
||||
{ client: 'linux', filename: 'pipu-music_1.3.0_amd64.deb', size: 4_000_000, download_url: '/api/v1/app/download/linux/1.3.0' },
|
||||
{ client: 'macos', filename: 'pipu-music_1.3.0_aarch64.dmg', size: 22_000_000, download_url: '/api/v1/app/download/macos/1.3.0' },
|
||||
{ client: 'android', filename: 'pipu-music-1.3.0.apk', size: 12_000_000, sha256: 'ab'.repeat(32), download_url: '/api/v1/app/download/android/1.3.0' },
|
||||
@@ -150,20 +158,37 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
|
||||
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
||||
|
||||
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()] });
|
||||
|
||||
const sharedPath = /^\/api\/v1\/shared\/playlists\/([^/]+)$/.exec(path);
|
||||
if (sharedPath && req.method === 'GET') {
|
||||
const item = [...playlists.values()].find(p => p.share_id === sharedPath[1]);
|
||||
return item ? json({ ...item, share_id: null }) : problem(404, 'playlist.not_found');
|
||||
}
|
||||
const sharePath = /^\/api\/v1\/me\/playlists\/([^/]+)\/share$/.exec(path);
|
||||
if (sharePath) {
|
||||
const item = playlists.get(sharePath[1]!);
|
||||
if (!item || playlistOwners.get(item.id) !== user.id) return problem(404, 'playlist.not_found');
|
||||
if (req.method === 'POST') {
|
||||
const share_id = item.share_id ?? `00000000-0000-4000-9001-${String(++seq).padStart(12, '0')}`;
|
||||
playlists.set(item.id, { ...item, share_id }); return json({ share_id });
|
||||
}
|
||||
if (req.method === 'DELETE') { playlists.set(item.id, { ...item, share_id: null }); return new Response(null, { status: 204 }); }
|
||||
}
|
||||
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()].filter(p => !playlistOwners.has(p.id) || playlistOwners.get(p.id) === user.id) });
|
||||
const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path);
|
||||
if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) {
|
||||
const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`;
|
||||
const old = playlists.get(id);
|
||||
if (playlistPath && !old) return problem(404, 'playlist.not_found');
|
||||
if (playlistPath && (!old || (playlistOwners.has(id) && playlistOwners.get(id) !== user.id))) return problem(404, 'playlist.not_found');
|
||||
if (req.method === 'GET') return json(old);
|
||||
if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); }
|
||||
if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict');
|
||||
const tracks = (body?.track_ids as string[]).map(id => albumTracks.find(t => t.id === id)!);
|
||||
const item = { id, name: body?.name as string, description: body?.description as string, tracks,
|
||||
const item = { ...old, id, name: body?.name as string, description: body?.description as string, tracks,
|
||||
track_count: tracks.length, duration_ms: tracks.reduce((sum, t) => sum + t.duration_ms, 0),
|
||||
revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||
playlists.set(id, item);
|
||||
if (!old) playlistOwners.set(id, user.id);
|
||||
return json(item, old ? 200 : 201);
|
||||
}
|
||||
|
||||
@@ -200,7 +225,10 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
case 'POST /api/v1/uploads/seen':
|
||||
return new Response(null, { status: 204 });
|
||||
case 'GET /api/v1/onboarding':
|
||||
return json({ required: false, artists: [], genres: [] });
|
||||
return json({ required: onboarding.get(user!.id) === null, artists: [], genres: [], choice: onboarding.get(user!.id) ?? undefined });
|
||||
case 'PUT /api/v1/me/onboarding':
|
||||
onboarding.set(user!.id, body as components['schemas']['OnboardingChoice']);
|
||||
return new Response(null, { status: 204 });
|
||||
case 'GET /api/v1/me/likes':
|
||||
case 'GET /api/v1/me/dislikes':
|
||||
case 'GET /api/v1/me/history':
|
||||
@@ -279,8 +307,10 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
case `GET /api/v1/artists/${kino.id}/albums`:
|
||||
return json({ items: [albumSummary] });
|
||||
}
|
||||
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path))
|
||||
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path)) {
|
||||
onboarding.set(path.split('/')[5]!, null);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
const userPath = /^\/api\/v1\/admin\/users\/([^/]+)$/.exec(path);
|
||||
if (userPath && req.method === 'PATCH') {
|
||||
if (!user || user.role === 'user') return problem(403, 'auth.forbidden');
|
||||
|
||||
@@ -227,12 +227,33 @@
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 8px;
|
||||
z-index: 50;
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--elev);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transform: translateY(-120%);
|
||||
}
|
||||
|
||||
.skip:focus {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 32px 36px;
|
||||
}
|
||||
|
||||
.routeIn {
|
||||
animation: pipuIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { queueIcon } from '../shared/ui/icons';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { Suspense, useCallback, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
chevronRightIcon,
|
||||
storageFillIcon,
|
||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||
import { Avatar, cx, Dialog, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||
import { Avatar, cx, Dialog, Icon, Logo, Menu, PageSkeleton } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from './LanguageSwitch';
|
||||
import { PlayerProvider, usePlayer, usePlayerState } from '../player/context';
|
||||
import { Ambient } from './Ambient';
|
||||
@@ -226,6 +226,9 @@ function Shell() {
|
||||
return (
|
||||
<div className={cx(s.shell, island && s.island)}>
|
||||
{user.ui_prefs.ambient !== false && <ShellAmbient />}
|
||||
<a href="#content" className={s.skip}>
|
||||
{t('nav.skip')}
|
||||
</a>
|
||||
<header className={s.top}>
|
||||
<div className={s.topLeft}>
|
||||
<Link to="/" className={s.logoLink}>
|
||||
@@ -284,17 +287,26 @@ function Shell() {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<main className={s.content}>
|
||||
<Suspense fallback={null}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
<ShellMain />
|
||||
|
||||
<PlayerBar variant={island ? 'island' : 'bar'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShellMain() {
|
||||
const key = useRouterState({ select: (st) => st.location.pathname });
|
||||
return (
|
||||
<main id="content" className={s.content} tabIndex={-1}>
|
||||
<div key={key} className={s.routeIn}>
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** Третье пятно фона — в цвете обложки текущего трека. */
|
||||
function ShellAmbient() {
|
||||
const player = usePlayer();
|
||||
|
||||
@@ -149,6 +149,115 @@ a.source:hover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.play:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.nowArt {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.nowEq {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.coverLive {
|
||||
animation: pipuCoverPulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pipuCoverPulse {
|
||||
50% {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
.queueScrim,
|
||||
.sheetScrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 15;
|
||||
background: var(--overlay);
|
||||
animation: pipuIn 0.18s ease-out;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 16;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
max-height: min(92dvh, 760px);
|
||||
padding: 12px 20px calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
border-radius: 24px 24px 0 0;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(28px) saturate(1.3);
|
||||
animation: pipuIn 0.22s ease-out;
|
||||
}
|
||||
|
||||
.sheetHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sheetCover {
|
||||
width: min(240px, 56vw) !important;
|
||||
height: min(240px, 56vw) !important;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.sheetMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sheetTitle {
|
||||
font-family: var(--font-display);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sheetControls,
|
||||
.sheetExtra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sheet .progress {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.sheet .volume {
|
||||
flex: 1;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.play[aria-busy='true'] {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -218,6 +327,10 @@ a.source:hover {
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.now {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding: 0 12px;
|
||||
@@ -228,7 +341,7 @@ a.source:hover {
|
||||
.right,
|
||||
.controls > :first-child,
|
||||
.controls > :last-child,
|
||||
.now > button {
|
||||
.now > button:not(.nowArt) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -237,7 +350,7 @@ a.source:hover {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 100px;
|
||||
z-index: 6;
|
||||
z-index: 16;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(380px, calc(100% - 32px));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||
import { ArtistLinks, Cover, Equalizer } from '../entities/catalog';
|
||||
import { AddToPlaylist } from '../entities/playlists';
|
||||
import { usePlayer, usePlayerState } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
@@ -37,6 +37,8 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const narrow = useNarrow();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
@@ -97,8 +99,24 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
|
||||
return (
|
||||
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
|
||||
<div className={s.now}>
|
||||
<Cover album={track.album} size={52} />
|
||||
<div
|
||||
className={s.now}
|
||||
onClick={
|
||||
narrow
|
||||
? (e) => {
|
||||
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{narrow ? (
|
||||
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
|
||||
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
|
||||
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
|
||||
</button>
|
||||
) : (
|
||||
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
|
||||
)}
|
||||
<div className={s.meta}>
|
||||
<span className={s.title}>{track.title}</span>
|
||||
<span className={s.artists}>
|
||||
@@ -232,12 +250,150 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
{sheetOpen && narrow && (
|
||||
<NowPlayingSheet
|
||||
variant={variant}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
share={share}
|
||||
sharing={sharing}
|
||||
/>
|
||||
)}
|
||||
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||||
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function useNarrow() {
|
||||
const [narrow, setNarrow] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia?.('(max-width: 767px)');
|
||||
if (!mq) return;
|
||||
const sync = () => setNarrow(mq.matches);
|
||||
sync();
|
||||
mq.addEventListener?.('change', sync);
|
||||
return () => mq.removeEventListener?.('change', sync);
|
||||
}, []);
|
||||
return narrow;
|
||||
}
|
||||
|
||||
function NowPlayingSheet({
|
||||
variant,
|
||||
onClose,
|
||||
share,
|
||||
sharing,
|
||||
}: {
|
||||
variant: 'bar' | 'island';
|
||||
onClose: () => void;
|
||||
share: () => Promise<void>;
|
||||
sharing: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const track = st.track;
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [onClose]);
|
||||
if (!track) return null;
|
||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
||||
return createPortal(
|
||||
<div className={s.sheetScrim} onClick={onClose}>
|
||||
<div
|
||||
className={cx(s.sheet, variant === 'island' && s.sheetIsland)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('player.now_playing')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={s.sheetHead}>
|
||||
<span>{t('player.now_playing')}</span>
|
||||
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
||||
<Icon src={closeIcon} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<Cover album={track.album} size={240} className={cx(s.sheetCover, st.playing && s.coverLive)} />
|
||||
<div className={s.sheetMeta}>
|
||||
<Link to="/track/$id" params={{ id: track.id }} className={s.sheetTitle} onClick={onClose}>
|
||||
{track.title}
|
||||
</Link>
|
||||
<span className={s.artists}>
|
||||
<ArtistLinks artists={track.artists} />
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.progress}>
|
||||
<span className={s.time}>{formatDuration(st.positionMs)}</span>
|
||||
<input
|
||||
type="range"
|
||||
className={s.range}
|
||||
min={0}
|
||||
max={st.durationMs || track.duration_ms}
|
||||
step={1000}
|
||||
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
|
||||
aria-label={t('player.progress')}
|
||||
onChange={(e) => player.seek(Number(e.target.value))}
|
||||
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
|
||||
</div>
|
||||
<div className={s.sheetControls}>
|
||||
<button type="button" className={cx(s.icon, track.reaction === 'dislike' && s.danger)} aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')} aria-pressed={track.reaction === 'dislike'} onClick={() => react('dislike')}>
|
||||
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||
<Icon src={prevIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} onClick={() => player.toggle()}>
|
||||
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||||
<Icon src={nextIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={cx(s.icon, track.reaction === 'like' && s.accent)} aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')} aria-pressed={track.reaction === 'like'} onClick={() => react('like')}>
|
||||
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={s.sheetExtra}>
|
||||
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} onClick={() => player.toggleShuffle()}>
|
||||
<Icon src={shuffleIcon} size={22} />
|
||||
</button>
|
||||
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
|
||||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.share')} disabled={sharing} onClick={() => void share()}>
|
||||
<Icon src={shareIcon} size={20} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t(st.muted ? 'player.unmute' : 'player.mute')} onClick={() => player.toggleMute()}>
|
||||
<Icon src={st.muted || st.volume === 0 ? volumeOffIcon : volumeIcon} size={22} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
className={cx(s.range, s.volume)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={st.muted ? 0 : st.volume}
|
||||
aria-label={t('player.volume')}
|
||||
onChange={(e) => player.setVolume(Number(e.target.value))}
|
||||
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
|
||||
function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -256,7 +412,9 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
|
||||
}, [onClose]);
|
||||
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
|
||||
return createPortal(
|
||||
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
|
||||
<>
|
||||
<div className={s.queueScrim} onClick={onClose} />
|
||||
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
|
||||
<div className={s.drawerHead}>
|
||||
<span>{t('player.queue')}</span>
|
||||
<button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
||||
@@ -282,7 +440,8 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
|
||||
))}
|
||||
</div>
|
||||
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
|
||||
</aside>,
|
||||
</aside>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover } from '../entities/catalog';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { searchIcon } from '../shared/ui/icons';
|
||||
import { cx, Icon } from '../shared/ui/ui';
|
||||
@@ -36,6 +37,7 @@ export function SearchBox({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const player = usePlayer();
|
||||
const navigate = useNavigate();
|
||||
const listId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -118,8 +120,7 @@ export function SearchBox({
|
||||
title: tr.title,
|
||||
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
|
||||
cover: tr.album,
|
||||
// Воспроизведение — M3; пока трек открывает свой альбом.
|
||||
go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }),
|
||||
go: () => player.playList([tr], 0, { type: 'search', id: debounced, label: debounced }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,7 +150,8 @@ export function SearchBox({
|
||||
}
|
||||
};
|
||||
|
||||
const showList = open && debounced !== '' && data !== undefined;
|
||||
const showPopover = open && debounced !== '';
|
||||
const showList = showPopover && data !== undefined;
|
||||
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
|
||||
const optionId = (i: number) => `${listId}-${i}`;
|
||||
|
||||
@@ -188,12 +190,17 @@ export function SearchBox({
|
||||
<kbd className={s.kbd} aria-hidden>
|
||||
/
|
||||
</kbd>
|
||||
{showList && (
|
||||
{showPopover && (
|
||||
<div className={s.popover}>
|
||||
{data.corrected_query && (
|
||||
{showPopover && !showList && (
|
||||
<div className={s.nothing} role="status">
|
||||
{t('search.searching')}
|
||||
</div>
|
||||
)}
|
||||
{data?.corrected_query && (
|
||||
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
|
||||
)}
|
||||
<div id={listId} role="listbox" aria-label={t('search.label')}>
|
||||
{showList && <div id={listId} role="listbox" aria-label={t('search.label')}>
|
||||
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
|
||||
{groups.map((g) => {
|
||||
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
|
||||
@@ -221,7 +228,7 @@ export function SearchBox({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>}
|
||||
<button
|
||||
type="button"
|
||||
className={s.all}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
|
||||
import { wavePalette, WAVE_COLORS } from '@pipu/core';
|
||||
import { useEffect, useMemo, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
|
||||
|
||||
import { drawLiquid, drawOrb, step, type WaveState } from './draw';
|
||||
|
||||
@@ -8,7 +9,7 @@ export interface WaveButtonProps {
|
||||
readAudio?: () => { bass: number; mid: number; treble: number };
|
||||
bpm?: number;
|
||||
energy?: number;
|
||||
colors?: [string, string, string];
|
||||
colors?: readonly string[];
|
||||
theme?: 'dark' | 'light';
|
||||
interactive?: boolean;
|
||||
size?: number;
|
||||
@@ -17,8 +18,6 @@ export interface WaveButtonProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
@@ -29,6 +28,7 @@ function prefersReducedMotion(): boolean {
|
||||
*/
|
||||
export function WaveButton(props: WaveButtonProps) {
|
||||
const size = props.size ?? 320;
|
||||
const colors = useMemo(() => wavePalette(props.colors), [props.colors]);
|
||||
const audioRef = useRef(props.readAudio);
|
||||
audioRef.current = props.readAudio;
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
@@ -36,7 +36,7 @@ export function WaveButton(props: WaveButtonProps) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const st = useRef<WaveState>({
|
||||
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
|
||||
speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: DEFAULT_COLORS,
|
||||
speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: WAVE_COLORS,
|
||||
dark: true, reduced: false, size,
|
||||
});
|
||||
// Пропсы читаются в цикле анимации через ref — перерисовка React не нужна.
|
||||
@@ -45,7 +45,7 @@ export function WaveButton(props: WaveButtonProps) {
|
||||
s.playing = !!props.playing;
|
||||
s.bpm = props.bpm ?? 100;
|
||||
s.energy = props.energy ?? 0.6;
|
||||
s.colors = props.colors ?? DEFAULT_COLORS;
|
||||
s.colors = colors;
|
||||
s.dark = props.theme !== 'light';
|
||||
s.size = size;
|
||||
|
||||
@@ -129,6 +129,15 @@ export function WaveButton(props: WaveButtonProps) {
|
||||
aria-pressed={interactive ? !!props.playing : undefined}
|
||||
aria-hidden={interactive ? undefined : true}
|
||||
onMouseMove={interactive ? onMove : undefined}
|
||||
onFocus={interactive ? () => (s.hoverT = 1) : undefined}
|
||||
onBlur={
|
||||
interactive
|
||||
? () => {
|
||||
s.hoverT = 0;
|
||||
s.pressT = 0;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseEnter={interactive ? () => (s.hoverT = 1) : undefined}
|
||||
onMouseLeave={
|
||||
interactive
|
||||
|
||||
+2
-2
@@ -106,8 +106,8 @@
|
||||
### Мобильное обновление — [mobile.md](mobile.md#самообновление)
|
||||
| Метод | Путь | Описание |
|
||||
|---|---|---|
|
||||
| GET | `/app/clients` | `{version, version_code, notes_md, clients:[{client, filename, size, sha256?, download_url}]}` — Android, Windows, Linux, macOS |
|
||||
| GET | `/app/download/{client}/{version}` | файл клиента (прокси / кэш релиза Gitea) |
|
||||
| GET | `/app/clients` | `{version, version_code, notes_md, clients:[{client, filename, size, sha256?, download_url, older:[{version, filename, size, download_url}]}]}` — до 8 релизов на платформу |
|
||||
| GET | `/app/download/{client}/{version}` | файл клиента этой версии (прокси / кэш релиза Gitea) |
|
||||
| GET | `/app/android/latest` | `{version, version_code, notes_md, size, sha256, download_url, min_supported}` |
|
||||
| GET | `/app/android/download/{version}` | APK (прокси / кэш релиза Gitea) |
|
||||
|
||||
|
||||
+28
-10
@@ -31,11 +31,11 @@
|
||||
| Сервис | Язык | Ответственность |
|
||||
|---|---|---|
|
||||
| `nginx` | — | TLS, раздача SPA, reverse proxy на `api`, отдача аудиофайлов и обложек через `X-Accel-Redirect` (internal locations), gzip/brotli, кэш статики. |
|
||||
| `api` | Go | Весь публичный REST API: auth, пользователи, каталог, поиск (прокси к Meilisearch), коллекция, состояние плеера, события прослушиваний, админка, фоновые задачи (сканер, транскодинг) через очередь River (на Postgres). |
|
||||
| `api` | Go | Весь публичный REST API: auth, пользователи, каталог, поиск (прокси к Meilisearch), коллекция, состояние плеера, события прослушиваний, админка, фоновые задачи: сканер внутри процесса, upload-воркеры со статусами в PostgreSQL. |
|
||||
| `storage-agent` | Go | Единственный привилегированный контейнер: монтирует / размонтирует NFS-шары в `/library/<source_id>`, проверяет доступность, **записывает одобренные загрузки** в принимающие хранилища (только создание новых файлов). Принимает команды только от `api` по внутренней сети. |
|
||||
| `recommender` | Python | Онлайн-сервис рекомендаций: генерация кандидатов, ранжирование, пачки для «Моей волны», обработка обратной связи в реальном времени; ночное обучение моделей (ALS, LightGBM). |
|
||||
| `analyzer` | Python | Воркер аудиоанализа: Essentia — BPM, энергия, танцевальность, настроение, эмбеддинги. Тот же образ, что `recommender`, другая команда запуска. |
|
||||
| `postgres` | — | Основное хранилище, pgvector для эмбеддингов, очередь задач River. |
|
||||
| `postgres` | — | Основное хранилище, pgvector для эмбеддингов, состояния upload/analysis jobs. |
|
||||
| `redis` | — | Rate limiting, кэш, Redis Streams для событий в `recommender`, сессионные векторы волны. |
|
||||
| `meilisearch` | — | Полнотекстовый поиск с опечатками и автокомплитом. |
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
### 3.3 Сканирование библиотеки
|
||||
1. Админ добавляет хранилище → `api` → `storage-agent` монтирует NFS.
|
||||
2. `api` ставит задачу `scan_source` в River → обход файлов, чтение тегов (Go, `dhowden/tag` + `ffprobe`),
|
||||
2. `api` запускает `Scanner.Start` в процессе → обход файлов, чтение тегов (Go, `dhowden/tag` + `ffprobe`),
|
||||
применение шаблонов путей, upsert в БД, индексирование в Meilisearch.
|
||||
3. Для новых / изменённых треков создаётся задача анализа (таблица `analysis_jobs`) → `analyzer`.
|
||||
|
||||
@@ -94,11 +94,10 @@ pipu-music/
|
||||
│ │ ├── cmd/api/ # main сервиса
|
||||
│ │ ├── cmd/storage-agent/ # main агента хранилищ
|
||||
│ │ ├── internal/
|
||||
│ │ │ ├── auth/ users/ library/ catalog/ search/
|
||||
│ │ │ ├── playback/ collection/ events/ wave/ admin/ uploads/
|
||||
│ │ │ ├── auth/ library/ search/ genius/
|
||||
│ │ │ ├── playback/ wave/ uploads/ appupdate/ system/
|
||||
│ │ │ └── platform/ # db, redis, config, logging, http, jobs
|
||||
│ │ ├── migrations/ # goose SQL-миграции
|
||||
│ │ └── queries/ # sqlc SQL
|
||||
│ │ └── migrations/ # goose SQL-миграции; SQL-запросы — в адаптерах internal/
|
||||
│ └── recommender/ # Python (uv)
|
||||
│ ├── pipu_rec/
|
||||
│ │ ├── api/ # FastAPI
|
||||
@@ -120,16 +119,35 @@ pipu-music/
|
||||
## 5. Архитектурный стиль
|
||||
|
||||
- **Модульный монолит** в `api`: каждый модуль в `internal/<module>` — `handler` (HTTP) → `service`
|
||||
(бизнес-логика, интерфейсы) → `repository` (sqlc). Модули общаются через интерфейсы сервисов,
|
||||
не лезут в чужие таблицы напрямую.
|
||||
(операции приложения) → `store` (ручной SQL через pgx). Узкие порты отделяют изменяемые операции
|
||||
от адаптеров; миграция существующих модулей постепенная. Read-проекции могут делать JOIN между областями.
|
||||
- **Contract-first**: `api/openapi.yaml` — источник истины. Go-сервер генерирует типы и интерфейс
|
||||
хендлеров (`oapi-codegen`, strict server), TS-клиент генерируется в `packages/api-client`.
|
||||
Сгенерированный код совпадает с контрактом.
|
||||
- **Внутренний API** `api ↔ recommender` и `api ↔ storage-agent` — HTTP/JSON во внутренней docker-сети,
|
||||
описан отдельными `internal-*.yaml` спеками.
|
||||
реализован Go/Python DTO и обработчиками; отдельных `internal-*.yaml` сейчас нет.
|
||||
- **Идемпотентность**: события и мутирующие запросы клиентов принимают `Idempotency-Key` / `event_id` (UUIDv7),
|
||||
что важно для офлайн-синхронизации мобильного.
|
||||
- **Деградация**: если `recommender` недоступен, `api` отдаёт волну по упрощённому fallback-алгоритму
|
||||
(похожие по жанрам + лайки + случайное), приложение продолжает работать.
|
||||
- **Наблюдаемость**: структурированные JSON-логи (`slog`, `structlog`), `/healthz` и `/readyz` у каждого сервиса,
|
||||
метрики Prometheus `/metrics` (опционально подключаются Grafana / Prometheus).
|
||||
|
||||
## 6. Реализация и планы
|
||||
|
||||
Точки изменения после выделения обязанностей:
|
||||
|
||||
- `apps/web/src/player/player.ts` — фасад воспроизведения; `engine.ts`, `analysis.ts`, `media.ts`, `persistence.ts` — браузерные адаптеры. Типы состояния — `types.ts`.
|
||||
- `packages/core/src/wave.ts` — общая политика refill/feedback и поколения сессий. Web и Android сохраняют собственные очереди.
|
||||
- `apps/web/src/features/uploads` — API, queries, экран очереди, диалоги и настройки. Страница очереди только собирает feature.
|
||||
- `services/api/internal/library`: `sources.go`, `templates.go`, `scans.go`, `schedule.go` — отдельные сценарии; `Service` сохраняет фасад для HTTP и сборки приложения.
|
||||
- `services/api/internal/uploads`: `placement.go` планирует размещение через узкие порты, `persistence.go` владеет SQL-операциями и транзакциями.
|
||||
- `services/api/internal/playback/personalizer.go` персонализирует каталог независимо от HTTP; `events.go` принимает события в транзакции.
|
||||
|
||||
`pnpm check:boundaries` проверяет новые границы без сборки приложений. Его отдельный CI workflow
|
||||
запускается при изменении соответствующих областей; read-проекции и composition root не запрещены.
|
||||
|
||||
River и sqlc не используются. Сканер и расписание живут в процессе API; upload workers читают
|
||||
состояния PostgreSQL и пробуждаются каналами/таймерами. Анализатор использует `analysis_jobs`.
|
||||
Добавление устойчивой очереди или генератора SQL — отдельное архитектурное решение при появлении
|
||||
соответствующей потребности, не обязательная часть текущего рефакторинга.
|
||||
@@ -45,6 +45,12 @@
|
||||
|
||||
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — светлая «p»-нота на сфере с радиальным градиентом `#9CC6FF → #2F80FF → #6354E8 → #3A1FA8`.
|
||||
|
||||
Цвета обложки адаптируются в `wavePalette` (`packages/core/src/wave-anim.ts`) одинаково для веба и Android:
|
||||
основой служит оттенок с наибольшей разницей RGB-каналов (> 0.08), акценты отклоняются на ±28°,
|
||||
насыщенность ограничена 72–82%, светлота — 46–58%. Так тёмные цвета остаются видимыми,
|
||||
а противоположные оттенки не обесцвечивают друг друга при наложении. Для почти бесцветных обложек
|
||||
и отсутствующей или некорректной палитры используется стандартная палитра волны.
|
||||
|
||||
Исходник значка — `apps/web/public/favicon.svg`; он же используется в шапке веб-приложения.
|
||||
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
|
||||
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
|
||||
|
||||
+2
-2
@@ -99,13 +99,13 @@
|
||||
|
||||
## Самообновление
|
||||
|
||||
Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, адрес сервера и инструкция.
|
||||
Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, предыдущие версии (до 8 последних релизов, сколько есть), адрес сервера и инструкция.
|
||||
|
||||
Приложение распространяется только через Gitea Releases (не Google Play).
|
||||
|
||||
**Поток:**
|
||||
1. При запуске (не чаще раза в 6 ч) и вручную в профиле: `GET /api/v1/app/android/latest` на **сервере Pipu**.
|
||||
Сервер сам опрашивает Gitea API (`GET /api/v1/repos/{owner}/{repo}/releases/latest`) с серверным токеном,
|
||||
Сервер сам опрашивает Gitea API (список релизов, до 8 с файлами; fallback — `/releases/latest`) с серверным токеном,
|
||||
кэширует ответ на 10 мин и APK на диске. Так:
|
||||
- мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным);
|
||||
- версия приложения согласована с версией сервера (`min_supported` из `server-info`).
|
||||
|
||||
+21
-2
@@ -1,6 +1,6 @@
|
||||
# Личные плейлисты
|
||||
|
||||
Плейлисты хранятся в PostgreSQL и доступны владельцу на вебе и в React Native-клиенте. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции.
|
||||
Плейлисты хранятся в PostgreSQL. Владелец управляет ими в вебе, desktop и React Native-клиенте; по желанию может поделиться ссылкой для прослушивания. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции.
|
||||
|
||||
Можно создать плейлист, изменить название и описание, добавить трек из поиска, альбома или коллекции, убрать трек и переместить его выше/ниже. В вебе можно добавить целый альбом. Один трек встречается в плейлисте один раз: повторное добавление не создаёт дубль. Воспроизведение доступно в сохранённом или случайном порядке; недоступные треки остаются видимыми, но пропускаются при запуске всего плейлиста. Удаление плейлиста требует подтверждения и не удаляет музыку из каталога.
|
||||
|
||||
@@ -11,12 +11,31 @@
|
||||
- `GET /api/v1/me/playlists/{id}` — метаданные и упорядоченные треки с персональными реакциями и подписанными ссылками.
|
||||
- `PUT /api/v1/me/playlists/{id}` — атомарная замена метаданных и порядка треков; дополнительно требуется `revision` из прочитанного плейлиста.
|
||||
- `DELETE /api/v1/me/playlists/{id}` — удаление.
|
||||
- `POST /api/v1/me/playlists/{id}/share` — создать или вернуть действующий `share_id` (только владелец).
|
||||
- `DELETE /api/v1/me/playlists/{id}/share` — отключить доступ по ссылке (только владелец).
|
||||
- `GET /api/v1/shared/playlists/{shareId}` — прочитать плейлист по действующей ссылке; требуется вход на этот сервер.
|
||||
|
||||
Все операции требуют авторизации. Чужой и отсутствующий плейлист возвращают одинаковый `404`. Изменения блокируют строку плейлиста в транзакции; устаревшая версия возвращает `409 playlist.conflict`. Редактор сохраняет исходную версию до закрытия, даже если фоновый запрос получил новую. Ошибка трека откатывает изменение целиком. Ввод ограничен 120 символами названия, 2000 символами описания и 2000 уникальными треками.
|
||||
|
||||
Миграция `00013_playlists.sql` создаёт таблицы `playlists`, `playlist_tracks` и индексы. Удаление пользователя очищает его плейлисты; удаление трека из каталога очищает его членство. Таблицы не затрагивают музыкальные файлы.
|
||||
|
||||
Плейлисты личные; публичные ссылки, совместное редактирование и загрузка пользовательских обложек не входят в эту реализацию. Карточки имеют градиентную обложку, страница использует обложку первого трека. Контекст воспроизведения — `collection` с UUID и названием плейлиста.
|
||||
Изначально плейлисты личные. Кнопка «Поделиться плейлистом» включает доступ по ссылке
|
||||
`/shared/playlist/{shareId}`. В вебе/desktop ссылка копируется, в мобильном браузере и Android открывается
|
||||
системное меню «Поделиться». Desktop использует адрес музыкального сервера, а не локального WebView.
|
||||
Как и ссылки на треки, ссылка требует входа на тот же сервер. После входа открывается запрошенный плейлист,
|
||||
без автоматического запуска музыки. Получатель может слушать в обычном или случайном порядке и переслать
|
||||
ту же ссылку, но не может редактировать или удалять исходный плейлист.
|
||||
|
||||
Ссылка содержит отдельный случайный UUID и не раскрывает токены авторизации или подписанные URL аудио.
|
||||
Список `/me/playlists` и обычное чтение по UUID плейлиста остаются доступны только владельцу. Реакции
|
||||
на треки и ссылки стрима в общем плейлисте принадлежат текущему слушателю. Данные общих страниц в веб-кэше
|
||||
разделяются по пользователям.
|
||||
|
||||
«Отключить доступ по ссылке» отзывает прежний `share_id`; следующая публикация создаёт новый.
|
||||
Повторное нажатие «Поделиться» без отзыва сохраняет ссылку. Ссылка показывает актуальный порядок и метаданные;
|
||||
изменение доступа не меняет `revision` и не вызывает конфликт редактора. При удалении плейлиста или блокировке
|
||||
владельца ссылка возвращает `404`. Миграция `00016_playlist_sharing.sql` добавляет `share_id` и уникальный индекс.
|
||||
Совместное редактирование и загрузка пользовательских обложек не входят в эту реализацию. Карточки имеют градиентную обложку, страница использует обложку первого трека. Контекст воспроизведения — `collection` с UUID и названием плейлиста.
|
||||
|
||||
## Проверки
|
||||
|
||||
|
||||
+21
-4
@@ -219,10 +219,27 @@ MMR-разнообразие и квоты остаются. Пока модел
|
||||
- **Новый пользователь**: опциональный онбординг (выбрать 3+ жанра или исполнителя) → начальные центроиды.
|
||||
Без онбординга — первые пачки из `popular` и максимально разнообразных кластеров библиотеки
|
||||
(по одному треку из разных кластеров), exploration 40% на первые 30 треков.
|
||||
- Реализация онбординга: `GET /onboarding` (40 исполнителей с наибольшим числом треков, 24 жанра; `required` —
|
||||
не пройден и своей истории < 10 треков), `PUT /me/onboarding` → `user_profiles_rec.onboarding`; пустой выбор —
|
||||
пропуск. Рекомендатель, пока своих оценок < 30, добавляет псевдооценки: треки выбранных исполнителей +1,
|
||||
жанров +0.5 (до 300 треков). Веб показывает онбординг на главной вместо волны.
|
||||
- Онбординг: `GET /onboarding` возвращает 40 исполнителей, 24 жанра, сохранённый `choice` и `required`.
|
||||
Анкета показывается на главной в вебе/desktop и Android до первого заполнения или пропуска, независимо
|
||||
от истории и наличия библиотеки. Её можно повторно открыть в настройках профиля.
|
||||
- `PUT /me/onboarding` сохраняет исполнителей, жанры, необязательный пол (`male`, `female`, `other`) и
|
||||
возрастную группу (`under_13`, `13_19`, `20_29`, `30_39`, `40_49`, `50_59`, `60_plus`) в
|
||||
`user_profiles_rec.onboarding`. Пустые списки и `null` для пола/возраста означают пропуск.
|
||||
- Вместе с исходной возрастной группой сервер сохраняет год выбора (`age_group_year`); клиент не может
|
||||
подменить его. SQL-функция `rec_age_group` прибавляет прошедшие годы к середине выбранного диапазона
|
||||
(для открытых диапазонов: 6 и 65 лет). Например, 13–19 лет в 2006 → примерно 30–39 лет в 2026.
|
||||
API возвращает текущую приблизительную группу. При сохранении той же текущей группы исходная группа
|
||||
и год остаются прежними; выбор другой группы задаёт новую точку отсчёта. Это оценка, не дата рождения.
|
||||
- Пока собственных оценок меньше 30, рекомендатель добавляет псевдооценки: исполнители +1, жанры +0.5,
|
||||
а треки, понравившиеся минимум двум другим незаблокированным слушателям с тем же полом и текущей
|
||||
возрастной группой, — до +0.25. Если указано одно поле, сравнивается только оно; если ни одного —
|
||||
демографического сигнала нет. Личные оценки не перезаписываются, дизлайки исключаются.
|
||||
На каждый источник действует лимит 300 треков. Музыкальные предпочтения пола/возраста заранее не задаются.
|
||||
- Запасная Go-волна учитывает выбранных исполнителей/жанры и те же демографические когорты после личных
|
||||
лайков и любимых исполнителей. Случайные треки сохраняют разнообразие.
|
||||
- Администраторский сброс удаляет профиль целиком, включая все ответы и год выбора. Клиенты обновляют
|
||||
состояние анкеты каждые 30 секунд, а веб также при возвращении на главную/в окно. Пользователь проходит
|
||||
настройку снова; текущее воспроизведение не прерывается.
|
||||
- **Новый трек**: сразу доступен через `audio_sim` / `fresh` (как только проанализирован), без единого прослушивания.
|
||||
|
||||
## 8. Обучение на всех пользователях
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# Стоимость работы агентов и план SOLID-рефакторинга — 2026-10-22
|
||||
|
||||
## Вывод и границы исходного исследования
|
||||
|
||||
Проект уже имеет полезные границы: приложения разделены, есть общий `packages/core`, контракт OpenAPI, интерфейсы auth, отдельный Python-рекомендатель. Основная проблема — не количество технологий, а широкий контекст локальных изменений: несколько крупных оркестраторов смешивают правила, IO и состояние, а часть правил повторяется на клиентах.
|
||||
|
||||
Проверены структура tracked-файлов, ключевые Go/TS/Python-компоненты, тесты, CI, документация и два примера изменений из git. Это архитектурное исследование с фокусом на трудоёмкости доработок, а не полный аудит безопасности, производительности или визуального UI. Непрочитанные исходники не считаются проверенными. На этапе исходного исследования код приложения не изменялся и тесты не запускались. Реализация и проверки следующего этапа описаны в конце отчёта.
|
||||
|
||||
Фактические токены, стоимость и повторные попытки прошлых сессий не исследованы: в материалах проверки нет соответствующей телеметрии. Ниже — подтверждённые свойства репозитория и объяснение того, как они могут увеличивать работу агента. Процент экономии без замера не установлен.
|
||||
|
||||
## Prior Reviews Summary
|
||||
|
||||
В `docs/reviews/` найден один предыдущий отчёт: `2026-10-21-review-1.md`. Все 9 задач уже отмечены `[x]`, их повторно не открывали. Открытых задач для переноса: 0. Вновь подтверждённых исправлений ранее открытых задач: 0.
|
||||
|
||||
## Измерения
|
||||
|
||||
Снимок: HEAD `c84f90e`; локальные посторонние untracked-файлы не включены и не изменены.
|
||||
|
||||
- 419 tracked-файлов.
|
||||
- 260 исходников с расширениями `.go`, `.ts`, `.tsx`, `.py`, `.rs`, исключая каталоги `gen`, `generated` и файлы `.gen.go`: 39 605 строк, включая тесты.
|
||||
- Из этих исходников 24 файла длиннее 400 строк, 9 — длиннее 600. Длина — сигнал для изучения обязанностей, не самостоятельное нарушение SOLID.
|
||||
- `services/api/internal/api/gen/api.gen.go`: 11 234 строки.
|
||||
- `packages/api-client/src/generated/schema.ts`: 4 599 строк.
|
||||
- `pnpm-lock.yaml`: 8 523 строки.
|
||||
- Итого эти три файла: 24 356 строк. Их полный вывод в контекст обычно избыточен; это не оценка числа токенов.
|
||||
- `api/openapi.yaml`: 3 717 строк. Контракт нужен, но обычно достаточно конкретной операции и связанных схем.
|
||||
|
||||
Воспроизведение: список из `git ls-files`, подсчёт через `[System.IO.File]::ReadAllLines`, отбор расширений и указанных generated-путей. Это физические строки, без семантического анализа. Наличие файла в git не означает, что агент автоматически загружает его целиком.
|
||||
|
||||
## Почему работа агента может быть дорогой
|
||||
|
||||
### 1. Недостаточно локальной навигации
|
||||
|
||||
В проверенном дереве не найдены `AGENTS.md`; корневой README даёт общие команды, `docs/README.md` — карту продуктовых документов. Нет краткой карты «задача → владельцы кода → инварианты → целевые проверки». Новая сессия вынуждена восстанавливать её поиском.
|
||||
|
||||
Нужна короткая корневая инструкция и локальные инструкции по областям, с минимальным пересечением. Большая энциклопедия в корневом AGENTS сама увеличит постоянный контекст. Исключения для generated-кода должны означать «читать точечно при необходимости», а не запрещать проверку контракта.
|
||||
|
||||
### 2. Документация описывает частично проектируемую архитектуру как существующую
|
||||
|
||||
`docs/architecture.md:34`, `:63`, `:101`, `:123`, `:129` обещают River, sqlc, общение модулей через сервисные интерфейсы и отдельные внутренние YAML-спецификации. В проверенном `services/api/go.mod` нет River/sqlc, сканирование запускает `Scanner.Start` (`library/service.go:614`), SQL находится прямо в store/service, в `api/` найден публичный `openapi.yaml`.
|
||||
|
||||
Агенту приходится перепроверять описание. Документ следует разделить на «реализовано» и «планируется», описать действующие очереди/воркеры и не внедрять River/sqlc только ради соответствия старому тексту.
|
||||
|
||||
### 3. Слишком широкий контекст плеера
|
||||
|
||||
`apps/web/src/player/player.ts:84` — 896 строк: очередь, волна, события, HTMLAudioElement, crossfade, preloading, Web Audio, Media Session, Tauri media, localStorage и серверное восстановление. Изменение одной обязанности требует проверки остальных.
|
||||
|
||||
`apps/mobile/src/player/controller.ts:78` — 457 строк с похожей оркестрацией, но другим движком, офлайн-хранилищем и фоновым жизненным циклом. Одинаковые `WAVE_REFILL_AT`, `EARLY_SKIP_MS`, накопление feedback и `refillWave` находятся в обоих клиентах (`web:255`, `mobile:375`). Правку правила нужно сопоставлять в двух реализациях.
|
||||
|
||||
### 4. Границы backend непоследовательны
|
||||
|
||||
- `uploads/service.go:31–52` зависит от конкретных Store, library.Service и Scanner; `:328` обращается к `s.store.pool.Exec`, `:387` принимает `pgx.Tx` в бизнес-операции.
|
||||
- `library/service.go:49` объединяет источники, подключение, шаблоны, preview, сканирование, расписание и health-check; всего 829 строк.
|
||||
- `playback/handler.go:37` объединяет стриминг, персонализацию, события, состояние, историю и реакции; всего 609 строк, использует конкретный `library.Store`.
|
||||
- `cmd/api/main.go:157–160`: DTO ссылается на playback Handler, а Handler получает DTO через замыкание. Это усложняет сборку и изолированное тестирование. Это не цикл импортов Go.
|
||||
- `genius/service.go:27` сам создаёт внешний клиент, а также работает с БД и запускает worker. Прямая зависимость от внешнего клиента затрудняет его замену в тесте.
|
||||
|
||||
Чтение чужих таблиц в поисковой проекции само по себе допустимо. Главный приоритет — изолировать бизнес-операции и записи; не вводить универсальные репозитории для всех JOIN.
|
||||
|
||||
### 5. Проверки не всегда соответствуют границам изменений
|
||||
|
||||
Уже есть полезные unit-, integration- и E2E-тесты; CI API и PostgreSQL integration разделены. Но `apps/mobile/test/screens.test.ts` проверяет offlineWave/releaseNeed, а не Controller. В проверенном наборе `apps/mobile/test` нет сценариев жизненного цикла Controller.
|
||||
|
||||
`apps/web/src/player/audio.test.ts:15` подменяет внутреннее состояние Player через `Object.assign`. Это конкретный признак неудобной тестовой границы: тесту нужен маленький анализатор, но приходится собирать большой плеер и менять его внутренности.
|
||||
|
||||
Без быстрых поведенческих проверок изменяемого модуля агенту требуется больше рассуждений и более широкий прогон. Наличие тестов не доказывает покрытие всех гонок.
|
||||
|
||||
### 6. Часть ширины изменений оправдана продуктом
|
||||
|
||||
Добавление Genius (`73ad61e`) затронуло 34 файла, включая миграцию, оба клиента, переводы и generated-код. Это сквозная возможность, и само число файлов не доказывает плохую архитектуру. Добавление текущего трека в плейлист (`fb20258`) затронуло 8 файлов, не потребовало переделки backend.
|
||||
|
||||
Уменьшать нужно непредвиденную связанность и повторное открытие одних и тех же файлов, сохраняя необходимые контрактные, платформенные и локализационные изменения.
|
||||
|
||||
## Phase 1: Code Quality / SOLID
|
||||
|
||||
### S — одна причина для изменения
|
||||
|
||||
Выделить у веб-плеера `AudioEngine`, `AudioAnalysis`, `PlaybackPersistence`, `MediaControls`. Player сохранить как фасад с текущим публичным API. Сначала вынести наиболее независимые обязанности с сохранением поведения; не переносить весь плеер за один PR.
|
||||
|
||||
Разделить library по сценариям: управление источниками, шаблоны/preview, запуск сканов, расписание/проверки доступности. Простое разнесение методов того же Service по файлам улучшит поиск, но не уменьшит связанность без разделения зависимостей.
|
||||
|
||||
### O — расширение через ограниченные точки замены
|
||||
|
||||
Вынести одинаковую политику волны в `packages/core` как `WaveSession`/`WaveCoordinator`: session generation, feedback, пороги refill и отбрасывание устаревших ответов. Сетевой API передавать через небольшой `WaveGateway`.
|
||||
|
||||
Web и Android применяют результат к собственным очередям. Native queue, offline playback и особенности ExoPlayer остаются в Android-адаптере; единый абстрактный движок для всех платформ заранее не нужен.
|
||||
|
||||
### L — заменяемость
|
||||
|
||||
Доказанных нарушений LSP в исследованной области не обнаружено. Для новых адаптеров проверять одинаковые обещания: порядок результатов, ошибки, отмену и отсутствие воздействия устаревшей сессии. Синхронный HTMLAudioElement и асинхронный native player нельзя объявлять взаимозаменяемыми без явного контракта.
|
||||
|
||||
### I — узкие интерфейсы
|
||||
|
||||
`auth/store.go:43` объединяет пользователей, refresh-токены, администрирование, audit и настройки; `WithTx` возвращает тот же широкий Store. Это кандидат на последующее разделение по потребителям, но auth уже имеет рабочий интерфейс и тестовый memstore — приоритет ниже плеера/uploads.
|
||||
|
||||
Новые границы создавать по реально используемым операциям, например `SourceLookup`, `ScanStarter`, `PlacementCatalog`. Не копировать все методы конкретного Store в интерфейс.
|
||||
|
||||
### D — зависимость от операций, а не инфраструктуры
|
||||
|
||||
В uploads вынести SQL и pgx-транзакции в persistence-адаптер; сервису дать операции `UpdateMetadata`/`Approve`/`Reject` с согласованными результатами. Сохранить атомарность смены статуса, квот и метаданных решения. Интеграционный тест реальной БД обязателен для изменений транзакций.
|
||||
|
||||
Выделить персонализацию каталога из HTTP Handler в самостоятельный объект; DTO и playback получают его при сборке. Конкретные зависимости в composition root (`main.go`, `server/router.go`) нормальны, массовое оборачивание всех конструкторов интерфейсами пользы не даст.
|
||||
|
||||
### Performance / Correctness
|
||||
|
||||
Новые подтверждённые runtime-дефекты и измеренные проблемы производительности этим исследованием не установлены. Размер SQL-запроса или исходника не считается доказательством медленной работы. Метрики качества рекомендаций и сетевого воспроизведения не измерялись.
|
||||
|
||||
## Phase 2: Logical Consistency
|
||||
|
||||
Целевой путь backend: HTTP handler → операция приложения → узкие порты → PostgreSQL/файлы/HTTP. Границы транзакций остаются явными. Межмодульные операции используют контракты потребителя; read-модели могут сохранять обоснованные JOIN.
|
||||
|
||||
Целевой путь клиента: UI → фасад плеера/feature → общие правила → платформенный адаптер. Для каждого состояния нужен один владелец. На Android владельцем воспроизводимой очереди остаётся native player; общий core не становится второй конкурирующей очередью.
|
||||
|
||||
Generated-типы оставить на HTTP-границах. Не создавать копии всех DTO ради формального разделения слоёв. Независимую модель вводить там, где правило использует небольшой стабильный набор полей.
|
||||
|
||||
## Phase 3: UI/UX
|
||||
|
||||
`apps/web/src/pages/admin/UploadQueuePage.tsx` содержит экран, строку очереди, пакетное редактирование, редактирование файла, отклонение и настройки загрузок (`:30`, `:205`, `:353`, `:422`, `:503`, `:542`), всего 640 строк. Настройки экспортируются из файла страницы очереди, сетевые вызовы находятся в компонентах.
|
||||
|
||||
Вынести в `features/uploads`: API/queries, экран очереди, отдельные диалоги, настройки. Странице оставить сборку. Сохранять ключи React Query и правила invalidation; единый большой `useUploadsEverything` лишь переместит проблему.
|
||||
|
||||
Новых визуальных и accessibility-дефектов не заявлено: браузерная проверка не проводилась. Закрытые задачи мобильного UI из прошлого ревью не дублировались.
|
||||
|
||||
## Порядок внедрения и условия готовности
|
||||
|
||||
1. **Навигация и достоверность.** Короткие инструкции по областям, актуальная архитектура, карта локальных проверок. Для типовой задачи понятны 2–5 начальных файлов и команды без чтения всех docs. Это ориентир, а не запрет на более широкий поиск.
|
||||
2. **Упростить Player, сохранив фасад.** Сначала persistence/media/analysis; отдельно engine/crossfade. Проверки: restore без autoplay, ошибка preload и повтор Play, пауза/seek, очистка обработчиков. Существующие тесты сохранить; новые нужны для поведения на новой границе.
|
||||
3. **Общая политика волны.** Сначала зафиксировать сценарии, затем подключать web и Android по отдельности. Проверки: поздний ответ после смены сессии, retry feedback, refresh очереди, ранний skip. На устройстве — background playback и offline→online.
|
||||
4. **Uploads.** Сначала выделить persistence-операции с сохранением транзакций, затем placement и worker-порты. Проверки: конкурирующие решения, квота, повторный import, восстановление после рестарта, очистка staging; результат на реальной БД и файловые побочные эффекты.
|
||||
5. **Library / playback / catalog.** Разделить use cases и убрать DTO→HTTP Handler из персонализации. Сохранить HTTP-контракт, подписи stream URL и поведение X-Accel. Проверять персонализацию батча, реакции, history/state и fallback wave.
|
||||
6. **Локализация UI и закрепление границ.** Разделить uploads feature. Добавить автоматическую проверку запрещённых зависимостей с точными исключениями для composition root и проекций. Отложить дробление auth до изменения соответствующей функциональности.
|
||||
|
||||
Каждый шаг — отдельное ревьюируемое изменение. Сначала выделение границ без изменения поведения, затем новые возможности. Generated-файлы обновлять штатными генераторами; публичный OpenAPI не дробить до появления измеримой проблемы и готового процесса сборки спецификации.
|
||||
|
||||
## Как проверить экономию
|
||||
|
||||
Взять сравнимые задачи до/после: изменение порога волны на обоих клиентах, изменение правила размещения upload, добавление поля настроек загрузок. Не сравнивать эти разные задачи между собой; фиксировать модель, режим рассуждения и применённые навыки.
|
||||
|
||||
Для каждой записывать input/cached input/output tokens, если доступны; отдельно число прочитанных файлов, объём вывода инструментов, повторные чтения, итерации исправлений, длительность проверок и затронутые модули. Сопоставлять распределение нескольких задач, а не один удачный запуск.
|
||||
|
||||
Немедленные меры: `rg` по символам, чтение диапазонов, ограниченный вывод, исключение generated/lock из общего исследования, целевые проверки на промежуточных шагах и необходимые полные проверки перед сдачей. Крупные patch/diff тоже читать сначала без generated, затем отдельно проверять генерацию.
|
||||
|
||||
Выбор workflow влияет независимо от кода: полный аудит полезен для такого исследования, но запуск всех его фаз на маленькой правке добавляет работы. Большой каталог навыков, системные инструкции и история чата также могут занимать контекст; их вклад и кэширование не измерены и не устраняются рефакторингом приложения.
|
||||
|
||||
## Tasks
|
||||
|
||||
Перенесено: 0. Новых задач: 8. Порядок — ожидаемая польза для локальности изменений; сроки и проценты экономии не оценивались.
|
||||
|
||||
- [x] 1. [CodeQuality] Добавить короткую карту работы агентов: области, точки входа, источники истины и целевые проверки; новые локальные AGENTS держать компактными — `README.md` line 36.
|
||||
- [x] 2. [Logic] Привести архитектуру к реализации, явно отделить планы River/sqlc/internal specs — `docs/architecture.md` line 123.
|
||||
- [x] 3. [SOLID] Сохранить Player как фасад, выделить persistence/media/analysis/audio с независимыми зависимостями и проверками поведения — `apps/web/src/player/player.ts` line 84.
|
||||
- [x] 4. [SOLID] Вынести общую политику wave/feedback/refill в core, сохранив платформенные очереди и офлайн-адаптацию; покрыть смену сессии и retry — `apps/web/src/player/player.ts` line 255; `apps/mobile/src/player/controller.ts` line 375.
|
||||
- [x] 5. [SOLID] Убрать SQL/pgx из uploads use cases, выделить атомарные persistence-операции и узкие порты библиотеки/сканера, сохранить квоты и статусные переходы — `services/api/internal/uploads/service.go` line 328.
|
||||
- [x] 6. [SOLID] Разделить управление источниками, шаблоны и расписание сканов по обязанностям и зависимостям — `services/api/internal/library/service.go` line 49.
|
||||
- [x] 7. [SOLID] Выделить персонализацию из playback Handler, убрать циклическую сборку DTO через замыкание, постепенно отделить playback use cases от HTTP — `services/api/cmd/api/main.go` line 157; `services/api/internal/playback/handler.go` line 37.
|
||||
- [x] 8. [CodeQuality] Вынести uploads API/queries, диалоги и настройки в feature-модули с локальными тестами, оставить странице композицию — `apps/web/src/pages/admin/UploadQueuePage.tsx` line 30.
|
||||
|
||||
|
||||
## Исправления — 2026-10-22
|
||||
|
||||
Реализованы все 8 задач по коду; внешняя проверка PostgreSQL и физического Android остаётся отдельным условием валидации. Исходные метрики и ссылки выше описывают состояние до исправлений.
|
||||
|
||||
1. Добавлены компактные корневой и локальные `AGENTS.md`; README ссылается на карту работы. Общая инструкция не загружает всю документацию.
|
||||
2. Архитектура описывает реальные pgx-запросы, in-process scanner и статусные upload jobs; River/sqlc/internal YAML отделены от реализации. Добавлена актуальная карта компонентов.
|
||||
3. Player сохраняет прежний интерфейс, а аудиограф/элементы, анализ, Media Session/Tauri и сохранение выделены в `engine.ts`, `analysis.ts`, `media.ts`, `persistence.ts`. Типы состояния отделены. Обработчики аудио и media освобождаются, отложенное сохранение отменяется при dispose. Player: 896 → 617 строк; crossfade и оркестрация очереди остаются в фасаде.
|
||||
4. `WaveCoordinator` в core обслуживает пороги, feedback и версии запросов на обеих платформах. Поздние ответы/ошибки не меняют новую сессию; смена контекста сохраняет ещё не отправленный feedback. На Android добавлены проверки Controller после reset и ручного выбора списка, native queue остаётся владельцем воспроизведения.
|
||||
5. SQL и pgx убраны из uploads service/worker. `persistence.go` предоставляет атомарные операции изменения метаданных, решений и завершения импорта; транзакционный механизм Transition сохранён. Выделен PlacementPlanner и порты SourceCatalog/RootResolver/ImportedScanner. Guarded UPDATE теперь сообщает конфликт, если статус успел измениться. Добавлены integration-проверки rollback решения, конкурирующих отказов, quota и повторного завершения импорта; без PostgreSQL они не исполнялись.
|
||||
6. Library Service стал фасадом над SourceService, TemplateService, ScanService и Scheduler. Зависимости разделены интерфейсами. Результат health-check передаётся через канал, исключая общую запись из timeout и worker веток.
|
||||
7. Personalizer отделён от HTTP Handler, DTO собирается напрямую без замыкания. Приём событий выделен в EventService; остальные playback-сценарии остаются в существующем handler для постепенного дальнейшего разделения. Unit-тесты проверяют батч персонализации, недоступные треки и ошибки чтения.
|
||||
8. Страница очереди стала оболочкой из 3 строк; feature содержит экран (181 строка), отдельные диалоги, строки списка, настройки, API и queries. Ключи запросов и invalidation сохранены. Локальные тесты проверяют сохранение метаданных и сохранность формы при конфликте.
|
||||
|
||||
Дополнительно: `pnpm check:boundaries` и отдельный CI workflow проверяют введённые границы без сборки всего проекта. Проверка целевая, не заменяет полноценный анализ графа импортов.
|
||||
|
||||
### Проверки
|
||||
|
||||
- Web typecheck и production build — успешно.
|
||||
- Web unit/component suite — 51 тест успешно; в том числе restore без autoplay, ошибки preload и новые диалоги.
|
||||
- Playwright — 10 сценариев успешно: desktop и mobile layouts, очередь/диалоги, навигация и плеер.
|
||||
- Core — 39 тестов успешно, включая 4 сценария WaveCoordinator.
|
||||
- Android typecheck — успешно; unit-тесты — 5 успешно, включая 2 сценария Controller с заглушкой native-плеера.
|
||||
- `go test ./...` и `go vet ./...` — успешно. Новые unit-тесты library/playback проходят.
|
||||
- Integration build — успешно; `TestMigrationsUpDownUp`, `TestLibraryAndUploads`, `TestPlaylistPersistenceAndIsolation`, `TestUserJourney` явно **SKIP**: не задан `TEST_DATABASE_URL`, Docker daemon недоступен. Результат не подтверждает транзакции на реальной БД.
|
||||
- Физический Android/background/offline→online не проверялся.
|
||||
- Публичный OpenAPI и generated-код не менялись. Экономия токенов после рефакторинга ещё не измерена.
|
||||
@@ -6,6 +6,7 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"check:boundaries": "node scripts/check-boundaries.mjs",
|
||||
"icons": "node scripts/generate-icons.mjs",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
|
||||
@@ -1225,8 +1225,9 @@ export interface paths {
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Онбординг вкуса — исполнители и жанры библиотеки для выбора
|
||||
* @description `required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
|
||||
* Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения
|
||||
* @description `required` — пользователь не проходил и не пропускал анкету либо администратор сбросил рекомендации.
|
||||
* `choice` содержит ответы с текущей приблизительной возрастной группой; исходная группа и год хранятся на сервере.
|
||||
*/
|
||||
get: operations["getOnboarding"];
|
||||
put?: never;
|
||||
@@ -1335,8 +1336,9 @@ export interface paths {
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Клиенты последнего релиза (Android и десктоп)
|
||||
* Клиенты последних релизов (Android и десктоп)
|
||||
* @description Тот же релиз Gitea, что и для Android: APK, установщик Windows, .deb, DMG.
|
||||
* У каждой платформы `older` — предыдущие сборки (до 8 релизов, сколько есть с файлами).
|
||||
* Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
|
||||
*/
|
||||
get: operations["getAppClients"];
|
||||
@@ -1437,10 +1439,53 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/playlists/{id}/share": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Создать или вернуть ссылку только для чтения (только владелец) */
|
||||
post: operations["sharePlaylist"];
|
||||
/** Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку */
|
||||
delete: operations["revokePlaylistShare"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/shared/playlists/{shareId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
shareId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
/** Прочитать плейлист по действующей ссылке (требуется вход) */
|
||||
get: operations["getSharedPlaylist"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
PlaylistShare: {
|
||||
/** Format: uuid */
|
||||
share_id: string;
|
||||
};
|
||||
PlaylistInput: {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -1464,6 +1509,11 @@ export interface components {
|
||||
updated_at: string;
|
||||
};
|
||||
Playlist: components["schemas"]["PlaylistSummary"] & {
|
||||
/**
|
||||
* Format: uuid
|
||||
* @description Действующая ссылка; возвращается только владельцу через /me/playlists
|
||||
*/
|
||||
readonly share_id?: string | null;
|
||||
tracks: components["schemas"]["Track"][];
|
||||
};
|
||||
PlaylistList: {
|
||||
@@ -1471,6 +1521,7 @@ export interface components {
|
||||
};
|
||||
Onboarding: {
|
||||
required: boolean;
|
||||
choice?: components["schemas"]["OnboardingChoice"];
|
||||
artists: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -1486,6 +1537,15 @@ export interface components {
|
||||
}[];
|
||||
};
|
||||
OnboardingChoice: {
|
||||
/** @enum {string|null} */
|
||||
gender?: "male" | "female" | "other" | null;
|
||||
/**
|
||||
* @description Текущая приблизительная возрастная группа. Сервер хранит год выбора и учитывает прошедшие годы.
|
||||
* @enum {string|null}
|
||||
*/
|
||||
age_group?: "under_13" | "13_19" | "20_29" | "30_39" | "40_49" | "50_59" | "60_plus" | null;
|
||||
/** @description Год последнего выбора возрастной группы. */
|
||||
readonly age_group_year?: number;
|
||||
artist_ids: string[];
|
||||
genres: string[];
|
||||
};
|
||||
@@ -2356,14 +2416,24 @@ export interface components {
|
||||
};
|
||||
/** @enum {string} */
|
||||
AppClient: "android" | "windows" | "linux" | "macos";
|
||||
AppClientFile: {
|
||||
client: components["schemas"]["AppClient"];
|
||||
AppClientBuild: {
|
||||
version: string;
|
||||
filename: string;
|
||||
/** Format: int64 */
|
||||
size: number;
|
||||
sha256?: string;
|
||||
download_url: string;
|
||||
};
|
||||
AppClientFile: {
|
||||
client: components["schemas"]["AppClient"];
|
||||
version?: string;
|
||||
filename: string;
|
||||
/** Format: int64 */
|
||||
size: number;
|
||||
sha256?: string;
|
||||
download_url: string;
|
||||
older?: components["schemas"]["AppClientBuild"][];
|
||||
};
|
||||
AppClients: {
|
||||
version: string;
|
||||
version_code: number;
|
||||
@@ -4585,4 +4655,71 @@ export interface operations {
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
sharePlaylist: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Идентификатор ссылки для /shared/playlist/{shareId}; для открытия требуется вход */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PlaylistShare"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
revokePlaylistShare: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Доступ по прежней ссылке отключён */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getSharedPlaylist: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
shareId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Плейлист с реакциями и ссылками на стрим для текущего слушателя */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Playlist"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Общие правила клиентов
|
||||
|
||||
- Очередь/учёт событий: `src/player.ts`; волна: `src/wave.ts`; сессия: `src/auth.ts`.
|
||||
- Не импортируй web, React Native или системные media API. Платформы передают IO через узкие порты.
|
||||
- Поздние ответы старой wave-сессии не меняют новую; feedback после ошибки возвращается только в ту же сессию.
|
||||
- `pnpm --filter @pipu/core test`, `pnpm --filter @pipu/core typecheck`; после изменения экспортов проверь web и mobile.
|
||||
@@ -22,6 +22,10 @@ export {
|
||||
type Track,
|
||||
} from './player';
|
||||
export { normalizeGain, TARGET_LUFS } from './gain';
|
||||
export { trackShareUrl } from './share';
|
||||
export { playlistShareUrl, trackShareUrl } from './share';
|
||||
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
|
||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveScale, type Rgb, type WaveState } from './wave-anim';
|
||||
|
||||
export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave';
|
||||
|
||||
export { AGE_GROUPS, GENDERS } from './onboarding';
|
||||
@@ -0,0 +1,2 @@
|
||||
export const AGE_GROUPS = ['under_13', '13_19', '20_29', '30_39', '40_49', '50_59', '60_plus'] as const;
|
||||
export const GENDERS = ['male', 'female', 'other'] as const;
|
||||
@@ -1,9 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { trackShareUrl } from './share';
|
||||
import { playlistShareUrl, trackShareUrl } from './share';
|
||||
|
||||
describe('trackShareUrl', () => {
|
||||
it('builds a page URL without stream credentials', () => {
|
||||
expect(trackShareUrl('https://music.example', 'ab/cd')).toBe('https://music.example/track/ab%2Fcd');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it('builds a shared playlist URL from the server without stream credentials', () => {
|
||||
expect(playlistShareUrl('https://music.example/api/v1', 'ab/cd')).toBe('https://music.example/shared/playlist/ab%2Fcd');
|
||||
});
|
||||
@@ -2,3 +2,8 @@
|
||||
export function trackShareUrl(server: string, trackId: string): string {
|
||||
return new URL(`/track/${encodeURIComponent(trackId)}`, server).href;
|
||||
}
|
||||
|
||||
/** Revocable read-only playlist page; requires a listener account on this server. */
|
||||
export function playlistShareUrl(server: string, shareId: string): string {
|
||||
return new URL(`/shared/playlist/${encodeURIComponent(shareId)}`, server).href;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { rgb, wavePalette, WAVE_COLORS } from './wave-anim';
|
||||
|
||||
function hsl(color: string) {
|
||||
const [r, g, b] = rgb(color).map((v) => v / 255) as [number, number, number];
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const c = max - min;
|
||||
const l = (max + min) / 2;
|
||||
const h = c === 0 ? 0 : max === r ? (g - b) / c : max === g ? (b - r) / c + 2 : (r - g) / c + 4;
|
||||
return { h: (h * 60 + 360) % 360, s: c === 0 ? 0 : c / (1 - Math.abs(2 * l - 1)), l };
|
||||
}
|
||||
|
||||
describe('wave cover palette', () => {
|
||||
it.each([
|
||||
undefined, [], ['#fff'], ['#000', '#888', '#fff'],
|
||||
['#010203', '#fefdfc', '#808182'], ['#ff0000', 'invalid', '#0000ff'],
|
||||
])('uses the default palette for missing, neutral or malformed colors: %j', (colors) => {
|
||||
expect(wavePalette(colors)).toEqual(WAVE_COLORS);
|
||||
});
|
||||
|
||||
it('ignores neutral colors and preserves the strongest cover hue', () => {
|
||||
const colors = ['#ffffff', '#007733', '#222222'];
|
||||
const before = [...colors];
|
||||
expect(hsl(wavePalette(colors)[0]).h).toBeCloseTo(hsl('#007733').h, 0);
|
||||
expect(wavePalette(colors)).toEqual(wavePalette(['#007733', '#222222', '#ffffff']));
|
||||
expect(colors).toEqual(before);
|
||||
expect(wavePalette(['#fff', '#073', '#222'])).toEqual(wavePalette(colors));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['#040e08', '#18281d', '#374450'], // dark, muted cover like the reported case
|
||||
['#ff0000', '#00ff00', '#0000ff'], // opposing hues must not mix to gray
|
||||
['#ffff00', '#ffffff', '#000000'],
|
||||
['#ff0080', '#540029', '#fefefe'], // hue offsets wrap around red
|
||||
])('keeps a visible, saturated, harmonious palette for %j', (...colors) => {
|
||||
const palette = wavePalette(colors);
|
||||
const base = hsl(palette[0]);
|
||||
for (const color of palette) {
|
||||
expect(color).toMatch(/^#[\da-f]{6}$/i);
|
||||
const { h, s, l } = hsl(color);
|
||||
expect(s).toBeGreaterThanOrEqual(0.70);
|
||||
expect(s).toBeLessThanOrEqual(0.84);
|
||||
expect(l).toBeGreaterThanOrEqual(0.45);
|
||||
expect(l).toBeLessThanOrEqual(0.59);
|
||||
const distance = Math.abs(h - base.h);
|
||||
expect(Math.min(distance, 360 - distance)).toBeLessThan(29);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,39 @@ export interface WaveState {
|
||||
|
||||
export const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||
|
||||
/** Keep the cover's strongest hue, but avoid muddy or complementary additive blends. */
|
||||
export function wavePalette(colors?: readonly string[]): [string, string, string] {
|
||||
if (colors?.length !== 3 || !colors.every((c) => /^#(?:[\da-f]{3}|[\da-f]{6})$/i.test(c))) {
|
||||
return [...WAVE_COLORS];
|
||||
}
|
||||
let hue = 0;
|
||||
let strongest = 0;
|
||||
for (const color of colors) {
|
||||
const [r, g, b] = rgb(color).map((v) => v / 255) as Rgb;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const chroma = max - min;
|
||||
// Absolute chroma rejects both near-black noise and nearly white/gray covers.
|
||||
if (chroma <= Math.max(0.08, strongest)) continue;
|
||||
strongest = chroma;
|
||||
hue = max === r ? ((g - b) / chroma) % 6 : max === g ? (b - r) / chroma + 2 : (r - g) / chroma + 4;
|
||||
hue = (hue * 60 + 360) % 360;
|
||||
}
|
||||
if (!strongest) return [...WAVE_COLORS];
|
||||
// A narrow hue range retains color when the translucent layers overlap.
|
||||
return [paletteColor(hue, 0.78, 0.50), paletteColor(hue + 28, 0.72, 0.46), paletteColor(hue - 28, 0.82, 0.58)];
|
||||
}
|
||||
|
||||
function paletteColor(hue: number, saturation: number, lightness: number): string {
|
||||
const h = ((hue % 360) + 360) % 360 / 60;
|
||||
const c = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
||||
const x = c * (1 - Math.abs(h % 2 - 1));
|
||||
const m = lightness - c / 2;
|
||||
const channels = h < 1 ? [c, x, 0] : h < 2 ? [x, c, 0] : h < 3 ? [0, c, x]
|
||||
: h < 4 ? [0, x, c] : h < 5 ? [x, 0, c] : [c, 0, x];
|
||||
return `#${channels.map((v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
export function initialWaveState(size: number): WaveState {
|
||||
return {
|
||||
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WaveCoordinator, type WaveFeedback } from './wave';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
let reject!: (e: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('wave coordination', () => {
|
||||
it('preserves unsent feedback when changing context in the same session', async () => {
|
||||
const next = vi.fn().mockResolvedValue(1);
|
||||
const wave = new WaveCoordinator<number>({ next });
|
||||
wave.activate('a');
|
||||
wave.note('track', 'like');
|
||||
wave.changeContext('a');
|
||||
await wave.refill(0, true, () => {});
|
||||
expect(next).toHaveBeenCalledWith('a', [{ track_id: 'track', outcome: 'like' }]);
|
||||
});
|
||||
it('ignores late results and allows the new session to refill immediately', async () => {
|
||||
const old = deferred<number>();
|
||||
const next = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValueOnce(2);
|
||||
const wave = new WaveCoordinator<number>({ next });
|
||||
const apply = vi.fn();
|
||||
wave.activate('old');
|
||||
const first = wave.refill(0, false, apply);
|
||||
wave.activate('new');
|
||||
await wave.refill(0, false, apply);
|
||||
old.resolve(1);
|
||||
await first;
|
||||
expect(apply.mock.calls.map(([value]) => value)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('retries feedback in order and respects threshold, refresh and single in-flight request', async () => {
|
||||
const request = deferred<number>();
|
||||
const next = vi.fn().mockReturnValueOnce(request.promise).mockResolvedValueOnce(2);
|
||||
const wave = new WaveCoordinator<number>({ next });
|
||||
const apply = vi.fn();
|
||||
wave.activate('a');
|
||||
wave.note('one', 'skip_early');
|
||||
await wave.refill(3, false, apply);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
const first = wave.refill(3, true, apply);
|
||||
wave.note('two', 'like');
|
||||
await wave.refill(0, false, apply);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
request.reject(new Error('offline'));
|
||||
await first;
|
||||
await wave.refill(2, false, apply);
|
||||
expect(next.mock.calls[1]).toEqual(['a', [
|
||||
{ track_id: 'one', outcome: 'skip_early' }, { track_id: 'two', outcome: 'like' },
|
||||
] satisfies WaveFeedback[]]);
|
||||
expect(apply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not restore old feedback or clear a newer in-flight request after a stale failure', async () => {
|
||||
const old = deferred<number>();
|
||||
const fresh = deferred<number>();
|
||||
const next = vi.fn().mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
|
||||
const wave = new WaveCoordinator<number>({ next });
|
||||
wave.activate('a');
|
||||
wave.note('old', 'dislike');
|
||||
const first = wave.refill(0, false, () => {});
|
||||
wave.activate('a'); // same session, new context
|
||||
wave.note('new', 'like');
|
||||
const second = wave.refill(0, false, () => {});
|
||||
old.reject(new Error('late failure'));
|
||||
await first;
|
||||
await wave.refill(0, false, () => {});
|
||||
expect(next).toHaveBeenCalledTimes(2);
|
||||
expect(next.mock.calls[1]?.[1]).toEqual([{ track_id: 'new', outcome: 'like' }]);
|
||||
fresh.resolve(2);
|
||||
await second;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/** Policy shared by web and native players; queue ownership stays with the adapter. */
|
||||
export const EARLY_SKIP_MS = 30_000;
|
||||
export const WAVE_REFILL_AT = 2;
|
||||
export type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
|
||||
export interface WaveInfo {
|
||||
sessionId: string;
|
||||
activity: string | null;
|
||||
mood: string | null;
|
||||
fallback: boolean;
|
||||
}
|
||||
export interface WaveFeedback { track_id: string; outcome: WaveOutcome }
|
||||
export interface WaveGateway<T> {
|
||||
next(sessionId: string, feedback: WaveFeedback[]): Promise<T>;
|
||||
}
|
||||
|
||||
export class WaveCoordinator<T> {
|
||||
private sessionId: string | null = null;
|
||||
private generation = 0;
|
||||
private pending: WaveFeedback[] = [];
|
||||
private loading: number | null = null;
|
||||
|
||||
constructor(private readonly gateway: WaveGateway<T>) {}
|
||||
|
||||
get version() { return this.generation; }
|
||||
isCurrent(version: number) { return version === this.generation; }
|
||||
|
||||
/** Also invalidates in-flight responses when the context changes within a session. */
|
||||
activate(sessionId: string | null) {
|
||||
this.sessionId = sessionId;
|
||||
this.pending = [];
|
||||
this.loading = null;
|
||||
return ++this.generation;
|
||||
}
|
||||
|
||||
changeContext(sessionId: string) {
|
||||
const pending = this.sessionId === sessionId ? this.pending : [];
|
||||
const version = this.activate(sessionId);
|
||||
this.pending = pending;
|
||||
return version;
|
||||
}
|
||||
|
||||
note(trackId: string, outcome: WaveOutcome) {
|
||||
if (this.sessionId) this.pending.push({ track_id: trackId, outcome });
|
||||
}
|
||||
|
||||
async refill(remaining: number, refresh: boolean, apply: (result: T, current: () => boolean) => void | Promise<void>) {
|
||||
const id = this.sessionId;
|
||||
const version = this.generation;
|
||||
if (!id || this.loading === version || (!refresh && remaining > WAVE_REFILL_AT)) return;
|
||||
this.loading = version;
|
||||
const feedback = this.pending;
|
||||
this.pending = [];
|
||||
try {
|
||||
const result = await this.gateway.next(id, feedback);
|
||||
if (this.isCurrent(version)) await apply(result, () => this.isCurrent(version));
|
||||
} catch {
|
||||
if (this.isCurrent(version)) this.pending = [...feedback, ...this.pending];
|
||||
} finally {
|
||||
if (this.loading === version) this.loading = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"version": "version {{version}}",
|
||||
"other": "Other platforms",
|
||||
"older": "Previous versions",
|
||||
"step_install": {
|
||||
"android": "Open the downloaded file. If Android asks, allow installs from this source.",
|
||||
"windows": "Run the installer and follow the steps. pipu will appear in the Start menu.",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"en": "English"
|
||||
},
|
||||
"nav": {
|
||||
"skip": "Skip to content",
|
||||
"more": "More",
|
||||
"main": "Navigation",
|
||||
"home": "Home",
|
||||
@@ -96,7 +97,9 @@
|
||||
"source_queue": "Queue",
|
||||
"queue": "Queue",
|
||||
"source_wave_seed": "Artist wave · {{label}}",
|
||||
"queue_wave": "The wave never ends: next tracks are picked as you listen"
|
||||
"queue_wave": "The wave never ends: next tracks are picked as you listen",
|
||||
"now_playing": "Now playing",
|
||||
"expand": "Open player"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -161,6 +164,8 @@
|
||||
"tracks": "Tracks",
|
||||
"all": "All",
|
||||
"nothing": "Nothing found",
|
||||
"searching": "Searching…",
|
||||
"type_query": "Type a query in the search field above",
|
||||
"corrected": "Showing results for “{{query}}”",
|
||||
"show_all": "All results",
|
||||
"title": "Search: {{query}}",
|
||||
@@ -313,9 +318,33 @@
|
||||
"pick_more_one": "Pick {{count}} more artist",
|
||||
"pick_more_few": "Pick {{count}} more artists",
|
||||
"pick_more_many": "Pick {{count}} more artists",
|
||||
"pick_more_other": "Pick {{count}} more artists"
|
||||
"pick_more_other": "Pick {{count}} more artists",
|
||||
"gender": "Gender",
|
||||
"age_group": "Age group",
|
||||
"unspecified": "Prefer not to say",
|
||||
"edit": "Recommendation preferences",
|
||||
"age_hint": "Your group will advance approximately from the year selected. You can correct it in your profile.",
|
||||
"genders": {
|
||||
"male": "Male",
|
||||
"female": "Female",
|
||||
"other": "Other"
|
||||
},
|
||||
"ages": {
|
||||
"under_13": "До 13",
|
||||
"13_19": "13–19",
|
||||
"20_29": "20–29",
|
||||
"30_39": "30–39",
|
||||
"40_49": "40–49",
|
||||
"50_59": "50–59",
|
||||
"60_plus": "60+"
|
||||
}
|
||||
},
|
||||
"playlists": {
|
||||
"share": "Share playlist",
|
||||
"link_copied": "Playlist link copied",
|
||||
"shared": "Shared by link",
|
||||
"revoke_share": "Disable link access",
|
||||
"share_hint": "Anyone with an account on this server and this link can listen. Only you can edit the playlist.",
|
||||
"name": "Name",
|
||||
"delete_confirm": "Delete playlist “{{name}}”? Tracks will remain in your library.",
|
||||
"manage": "Manage tracks",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"version": "версия {{version}}",
|
||||
"other": "Другие платформы",
|
||||
"older": "Предыдущие версии",
|
||||
"step_install": {
|
||||
"android": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
|
||||
"windows": "Запустите установщик и следуйте шагам. pipu появится в меню Пуск.",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"en": "English"
|
||||
},
|
||||
"nav": {
|
||||
"skip": "К содержимому",
|
||||
"more": "Ещё",
|
||||
"main": "Навигация",
|
||||
"home": "Главная",
|
||||
@@ -96,7 +97,9 @@
|
||||
"source_queue": "Очередь",
|
||||
"queue": "Очередь",
|
||||
"source_wave_seed": "Волна по исполнителю · {{label}}",
|
||||
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания"
|
||||
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания",
|
||||
"now_playing": "Сейчас играет",
|
||||
"expand": "Открыть плеер"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки",
|
||||
@@ -161,6 +164,8 @@
|
||||
"tracks": "Треки",
|
||||
"all": "Всё",
|
||||
"nothing": "Ничего не нашли",
|
||||
"searching": "Ищем…",
|
||||
"type_query": "Введите запрос в поиске сверху",
|
||||
"corrected": "Показаны результаты для «{{query}}»",
|
||||
"show_all": "Все результаты",
|
||||
"title": "Поиск: {{query}}",
|
||||
@@ -313,9 +318,33 @@
|
||||
"pick_more_one": "Выберите ещё {{count}} исполнителя",
|
||||
"pick_more_few": "Выберите ещё {{count}} исполнителя",
|
||||
"pick_more_many": "Выберите ещё {{count}} исполнителей",
|
||||
"pick_more_other": "Выберите ещё {{count}} исполнителя"
|
||||
"pick_more_other": "Выберите ещё {{count}} исполнителя",
|
||||
"gender": "Пол",
|
||||
"age_group": "Возрастная группа",
|
||||
"unspecified": "Не указывать",
|
||||
"edit": "Настроить рекомендации",
|
||||
"age_hint": "Группа будет обновляться приблизительно с учётом года выбора. Её можно исправить в профиле.",
|
||||
"genders": {
|
||||
"male": "Мужской",
|
||||
"female": "Женский",
|
||||
"other": "Другой"
|
||||
},
|
||||
"ages": {
|
||||
"under_13": "До 13",
|
||||
"13_19": "13–19",
|
||||
"20_29": "20–29",
|
||||
"30_39": "30–39",
|
||||
"40_49": "40–49",
|
||||
"50_59": "50–59",
|
||||
"60_plus": "60+"
|
||||
}
|
||||
},
|
||||
"playlists": {
|
||||
"share": "Поделиться плейлистом",
|
||||
"link_copied": "Ссылка на плейлист скопирована",
|
||||
"shared": "Доступ по ссылке",
|
||||
"revoke_share": "Отключить доступ по ссылке",
|
||||
"share_hint": "Любой пользователь этого сервера со ссылкой сможет слушать плейлист. Изменять его можете только вы.",
|
||||
"name": "Название",
|
||||
"delete_confirm": "Удалить плейлист «{{name}}»? Треки останутся в библиотеке.",
|
||||
"manage": "Управлять треками",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { resolve, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||
function sources(dir, extension) {
|
||||
return readdirSync(resolve(root, dir), { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = `${dir}/${entry.name}`;
|
||||
return entry.isDirectory() ? sources(path, extension) : path.endsWith(extension) ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
// Check the boundaries introduced by the refactor. Existing query projections and
|
||||
// composition roots are deliberately outside these use-case-specific rules.
|
||||
const rules = [
|
||||
{
|
||||
files: sources('packages/core/src', '.ts'),
|
||||
forbidden: /(?:from\s*|import\s*)['"](?:[^'"]*apps\/|(?:@react-native[^'"]*|react(?:-native)?(?:[-\/]|['"]))|expo(?:[-\/]|['"])|\.\.[^'"]*(?:web|mobile|desktop)\/)/,
|
||||
message: 'core must not import platform/UI implementations',
|
||||
},
|
||||
{
|
||||
files: ['service.go', 'worker.go', 'placement.go', 'batch.go'].map((p) => `services/api/internal/uploads/${p}`),
|
||||
forbidden: /github\.com\/jackc\/pgx|\.store\.pool\b|\b(?:tx|pool)\.(?:Exec|Query|QueryRow)\(/,
|
||||
message: 'upload SQL and pgx belong in store/persistence adapters',
|
||||
},
|
||||
{
|
||||
files: sources('apps/web/src/features/uploads', '.tsx').filter((p) => !p.endsWith('/common.tsx')),
|
||||
forbidden: /\b(?:session|client)\.(?:GET|POST|PUT|PATCH|DELETE)\(/,
|
||||
message: 'upload views must use feature API/queries',
|
||||
},
|
||||
];
|
||||
|
||||
const errors = [];
|
||||
for (const rule of rules) {
|
||||
for (const path of rule.files) {
|
||||
const text = readFileSync(resolve(root, path), 'utf8');
|
||||
text.split(/\r?\n/).forEach((line, i) => {
|
||||
if (rule.forbidden.test(line)) errors.push(`${relative(root, resolve(root, path))}:${i + 1}: ${rule.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (errors.length) {
|
||||
console.error(errors.join('\n'));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('Module boundary checks passed.');
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Go API
|
||||
|
||||
- Сборка зависимостей: `cmd/api/main.go`; маршрутизация: `internal/server/router.go`. Конкретные адаптеры здесь допустимы.
|
||||
- HTTP-типы: `internal/api/gen`; исходный контракт: корневой `api/openapi.yaml`. Генерация: `go generate ./...`.
|
||||
- Области: `auth`, `library`, `uploads`, `playback`, `wave`, `search`, `genius`. Начинай с handler → операция → store затронутого сценария.
|
||||
- SQL и pgx-транзакции принадлежат persistence-адаптерам. Смена статуса upload, reviewer metadata и квота должны сохранять атомарность. Read-проекции могут объединять таблицы модулей.
|
||||
- Целевая проверка: `go test ./internal/<module>/...`; перед сдачей Go-рефакторинга: `go test ./...`, `go vet ./...`.
|
||||
- PostgreSQL integration: `TEST_DATABASE_URL` и `go test -tags integration ./internal/dbtest ./internal/playback`. E2E API: `go test -tags integration ./cmd/api` (проверь заголовок теста/переменные окружения).
|
||||
- Сканер работает внутри процесса; upload jobs используют статусы PostgreSQL и worker loops. River/sqlc сейчас не используются.
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/library"
|
||||
"pipu/api/internal/library/meta"
|
||||
"pipu/api/internal/platform/config"
|
||||
@@ -281,17 +282,41 @@ func TestUserJourney(t *testing.T) {
|
||||
|
||||
// 7½. Онбординг: варианты из библиотеки, выбор сохраняется, второй раз не предлагается.
|
||||
var onb struct {
|
||||
Required bool `json:"required"`
|
||||
Choice *gen.OnboardingChoice `json:"choice"`
|
||||
Required bool `json:"required"`
|
||||
Artists []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"artists"`
|
||||
}
|
||||
c.ok("GET", "/api/v1/onboarding", nil, &onb)
|
||||
require.NotEmpty(t, onb.Artists)
|
||||
c.ok("PUT", "/api/v1/me/onboarding", map[string]any{"artist_ids": []string{onb.Artists[0].ID}, "genres": []string{}}, nil)
|
||||
c.ok("PUT", "/api/v1/me/onboarding", map[string]any{"artist_ids": []string{onb.Artists[0].ID}, "genres": []string{}, "gender": "male", "age_group": "13_19", "age_group_year": 1900}, nil)
|
||||
c.ok("GET", "/api/v1/onboarding", nil, &onb)
|
||||
require.False(t, onb.Required)
|
||||
|
||||
require.NotNil(t, onb.Choice)
|
||||
require.Equal(t, gen.Male, *onb.Choice.Gender)
|
||||
require.Equal(t, gen.N1319, *onb.Choice.AgeGroup)
|
||||
require.Greater(t, *onb.Choice.AgeGroupYear, 1900, "client cannot forge the selection year")
|
||||
_, err = pool.Exec(ctx, `UPDATE user_profiles_rec SET onboarding = jsonb_set(onboarding,
|
||||
'{age_group_year}', to_jsonb(EXTRACT(YEAR FROM CURRENT_DATE)::int - 20)) WHERE user_id = $1`, tokens.User.ID)
|
||||
require.NoError(t, err)
|
||||
c.ok("GET", "/api/v1/onboarding", nil, &onb)
|
||||
require.Equal(t, gen.N3039, *onb.Choice.AgeGroup, "13–19 twenty years ago is approximately 30–39 now")
|
||||
originalYear := *onb.Choice.AgeGroupYear
|
||||
c.ok("PUT", "/api/v1/me/onboarding", onb.Choice, nil)
|
||||
c.ok("GET", "/api/v1/onboarding", nil, &onb)
|
||||
require.Equal(t, originalYear, *onb.Choice.AgeGroupYear, "editing tastes preserves the age anchor")
|
||||
require.Equal(t, gen.N3039, *onb.Choice.AgeGroup)
|
||||
c.ok("POST", "/api/v1/admin/users/"+tokens.User.ID+"/reset-recommendations", nil, nil)
|
||||
onb.Choice = nil
|
||||
c.ok("GET", "/api/v1/onboarding", nil, &onb)
|
||||
require.True(t, onb.Required)
|
||||
require.Nil(t, onb.Choice, "all onboarding answers are cleared")
|
||||
c.ok("PUT", "/api/v1/me/onboarding", map[string]any{"artist_ids": []string{}, "genres": []string{}}, nil)
|
||||
c.ok("GET", "/api/v1/onboarding", nil, &onb)
|
||||
require.False(t, onb.Required, "skip is remembered")
|
||||
|
||||
// 8. «Моя волна»: рекомендатель недоступен — запасная волна из библиотеки.
|
||||
var wave struct {
|
||||
SessionID string `json:"session_id"`
|
||||
|
||||
@@ -154,10 +154,10 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
|
||||
}
|
||||
return libSvc.Root(src)
|
||||
}
|
||||
var dto library.DTO
|
||||
pb := playback.NewHandler(playback.NewStore(pool), libStore, func() library.DTO { return dto }, signer, transcoder,
|
||||
pbStore := playback.NewStore(pool)
|
||||
dto := library.DTO{Signer: signer, Personal: playback.NewPersonalizer(pbStore, signer)}
|
||||
pb := playback.NewHandler(pbStore, libStore, dto, signer, transcoder,
|
||||
rootFor, cfg.XAccel, log)
|
||||
dto = library.DTO{Signer: signer, Personal: pb}
|
||||
libSvc.OnCatalogChanged = indexer.Trigger
|
||||
indexer.Trigger() // индекс мог отстать (новая версия, сбой Meilisearch) — пересобираем при старте
|
||||
go libSvc.Restore(ctx)
|
||||
@@ -169,7 +169,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
|
||||
upSvc.Run(ctx)
|
||||
|
||||
wh := wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
|
||||
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log)
|
||||
Client: &http.Client{}}, libStore, dto, log)
|
||||
wh.Warm = pb.Prewarm
|
||||
return server.NewRouter(server.Deps{
|
||||
Genius: genius.NewHandler(geniusSvc),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -30,6 +31,9 @@ const CodeUnavailable = "app.update_unavailable"
|
||||
|
||||
const cacheTTL = 10 * time.Minute
|
||||
|
||||
// keepClientReleases — сколько последних релизов с файлами отдаём на /download (как prune в CI).
|
||||
const keepClientReleases = 8
|
||||
|
||||
var tagRe = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$`)
|
||||
|
||||
// VersionCode — versionCode по тегу; та же формула, что в scripts/version.mjs:
|
||||
@@ -68,9 +72,12 @@ type asset struct {
|
||||
}
|
||||
|
||||
type release struct {
|
||||
Tag string `json:"tag_name"`
|
||||
Body string `json:"body"`
|
||||
Assets []asset `json:"assets"`
|
||||
Tag string `json:"tag_name"`
|
||||
Body string `json:"body"`
|
||||
Draft bool `json:"draft"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Assets []asset `json:"assets"`
|
||||
}
|
||||
|
||||
type clientFile struct {
|
||||
@@ -98,11 +105,16 @@ type Handler struct {
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
cached *Latest
|
||||
cached *catalog
|
||||
cachedAt time.Time
|
||||
dl sync.Mutex
|
||||
}
|
||||
|
||||
type catalog struct {
|
||||
latest *Latest
|
||||
all []*Latest
|
||||
}
|
||||
|
||||
// NewHandler создаёт обработчик.
|
||||
func NewHandler(cfg Config, log *slog.Logger) *Handler {
|
||||
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
|
||||
@@ -196,7 +208,7 @@ func pickClientFiles(assets []asset) map[string]clientFile {
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
||||
func (h *Handler) catalog(ctx context.Context) (*catalog, error) {
|
||||
if !h.configured() {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "gitea is not configured")
|
||||
}
|
||||
@@ -208,7 +220,8 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
base := strings.TrimRight(h.cfg.GiteaURL, "/")
|
||||
res, err := h.get(ctx, base+"/api/v1/repos/"+h.cfg.Repo+"/releases/latest")
|
||||
repo := h.cfg.Repo
|
||||
rels, err := h.listReleases(ctx, base, repo)
|
||||
if err != nil {
|
||||
h.log.WarnContext(ctx, "app update: gitea unavailable", "err", err)
|
||||
if h.cached != nil {
|
||||
@@ -216,37 +229,114 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
||||
}
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release is unavailable")
|
||||
}
|
||||
var rel release
|
||||
err = json.NewDecoder(res.Body).Decode(&rel)
|
||||
_ = res.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitea: decode release: %w", err)
|
||||
var all []*Latest
|
||||
for _, rel := range rels {
|
||||
if rel.Draft {
|
||||
continue
|
||||
}
|
||||
l := h.parseRelease(ctx, rel)
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
all = append(all, l)
|
||||
if len(all) >= keepClientReleases {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(all) == 0 {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
|
||||
}
|
||||
cat := &catalog{latest: all[0], all: all}
|
||||
h.cached = cat
|
||||
h.cachedAt = time.Now()
|
||||
return cat, nil
|
||||
}
|
||||
|
||||
func (h *Handler) listReleases(ctx context.Context, base, repo string) ([]release, error) {
|
||||
res, err := h.get(ctx, base+"/api/v1/repos/"+repo+"/releases?limit=20")
|
||||
if err == nil {
|
||||
var rels []release
|
||||
decErr := json.NewDecoder(res.Body).Decode(&rels)
|
||||
_ = res.Body.Close()
|
||||
if decErr != nil {
|
||||
return nil, fmt.Errorf("gitea: decode releases: %w", decErr)
|
||||
}
|
||||
sort.SliceStable(rels, func(i, j int) bool {
|
||||
ti, tj := rels[i].PublishedAt, rels[j].PublishedAt
|
||||
if ti.IsZero() {
|
||||
ti = rels[i].CreatedAt
|
||||
}
|
||||
if tj.IsZero() {
|
||||
tj = rels[j].CreatedAt
|
||||
}
|
||||
return ti.After(tj)
|
||||
})
|
||||
return rels, nil
|
||||
}
|
||||
res, err = h.get(ctx, base+"/api/v1/repos/"+repo+"/releases/latest")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rel release
|
||||
decErr := json.NewDecoder(res.Body).Decode(&rel)
|
||||
_ = res.Body.Close()
|
||||
if decErr != nil {
|
||||
return nil, fmt.Errorf("gitea: decode release: %w", decErr)
|
||||
}
|
||||
return []release{rel}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) parseRelease(ctx context.Context, rel release) *Latest {
|
||||
version, code, err := VersionCode(rel.Tag)
|
||||
if err != nil {
|
||||
return nil, apperr.NotFound(CodeUnavailable, err.Error())
|
||||
return nil
|
||||
}
|
||||
files := pickClientFiles(rel.Assets)
|
||||
if len(files) == 0 {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
|
||||
return nil
|
||||
}
|
||||
if apk, ok := files["android"]; ok {
|
||||
if apk.ChecksumURL == "" {
|
||||
delete(files, "android")
|
||||
} else if digest, err := h.readDigest(ctx, apk.ChecksumURL); err != nil {
|
||||
h.log.WarnContext(ctx, "app update: apk checksum unavailable", "err", err)
|
||||
h.log.WarnContext(ctx, "app update: apk checksum unavailable", "err", err, "version", version)
|
||||
delete(files, "android")
|
||||
} else {
|
||||
apk.SHA256 = digest
|
||||
files["android"] = apk
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files}
|
||||
}
|
||||
|
||||
func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
||||
cat, err := h.catalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cat.latest, nil
|
||||
}
|
||||
|
||||
func (c *catalog) release(version string) *Latest {
|
||||
for _, l := range c.all {
|
||||
if l.Version == version {
|
||||
return l
|
||||
}
|
||||
}
|
||||
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files}
|
||||
h.cachedAt = time.Now()
|
||||
return h.cached, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *catalog) keepNames() map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for _, l := range c.all {
|
||||
for _, f := range l.Files {
|
||||
out[f.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) readDigest(ctx context.Context, url string) (string, error) {
|
||||
@@ -272,33 +362,73 @@ func (h *Handler) fileURL(kind, version string) string {
|
||||
|
||||
// GetAppClients реализует gen.StrictServerInterface.
|
||||
func (h *Handler) GetAppClients(ctx context.Context, _ gen.GetAppClientsRequestObject) (gen.GetAppClientsResponseObject, error) {
|
||||
l, err := h.latest(ctx)
|
||||
cat, err := h.catalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l := cat.latest
|
||||
order := []string{"windows", "macos", "linux", "android"}
|
||||
clients := make([]gen.AppClientFile, 0, len(l.Files))
|
||||
clients := make([]gen.AppClientFile, 0, len(order))
|
||||
for _, kind := range order {
|
||||
f, ok := l.Files[kind]
|
||||
item, ok := h.clientWithOlder(cat, kind)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := gen.AppClientFile{
|
||||
Client: gen.AppClient(kind),
|
||||
Filename: f.Name,
|
||||
Size: f.Size,
|
||||
DownloadUrl: h.fileURL(kind, l.Version),
|
||||
}
|
||||
if f.SHA256 != "" {
|
||||
item.Sha256 = &f.SHA256
|
||||
}
|
||||
clients = append(clients, item)
|
||||
}
|
||||
if len(clients) == 0 {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
|
||||
}
|
||||
return gen.GetAppClients200JSONResponse{
|
||||
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Clients: clients,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) clientWithOlder(cat *catalog, kind string) (gen.AppClientFile, bool) {
|
||||
var head *Latest
|
||||
var file clientFile
|
||||
var older []gen.AppClientBuild
|
||||
for _, rel := range cat.all {
|
||||
f, ok := rel.Files[kind]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if head == nil {
|
||||
head, file = rel, f
|
||||
continue
|
||||
}
|
||||
older = append(older, gen.AppClientBuild{
|
||||
Version: rel.Version,
|
||||
Filename: f.Name,
|
||||
Size: f.Size,
|
||||
DownloadUrl: h.fileURL(kind, rel.Version),
|
||||
Sha256: shaPtr(f.SHA256),
|
||||
})
|
||||
}
|
||||
if head == nil {
|
||||
return gen.AppClientFile{}, false
|
||||
}
|
||||
item := gen.AppClientFile{
|
||||
Client: gen.AppClient(kind),
|
||||
Version: &head.Version,
|
||||
Filename: file.Name,
|
||||
Size: file.Size,
|
||||
DownloadUrl: h.fileURL(kind, head.Version),
|
||||
Sha256: shaPtr(file.SHA256),
|
||||
}
|
||||
if len(older) > 0 {
|
||||
item.Older = &older
|
||||
}
|
||||
return item, true
|
||||
}
|
||||
|
||||
func shaPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// GetAndroidLatest реализует gen.StrictServerInterface.
|
||||
func (h *Handler) GetAndroidLatest(ctx context.Context, _ gen.GetAndroidLatestRequestObject) (gen.GetAndroidLatestResponseObject, error) {
|
||||
l, err := h.latest(ctx)
|
||||
@@ -359,16 +489,20 @@ func (h *Handler) ensure(ctx context.Context, f clientFile) (string, error) {
|
||||
}
|
||||
|
||||
func (h *Handler) cleanup(keepName string) {
|
||||
keep := map[string]struct{}{keepName: {}}
|
||||
h.mu.Lock()
|
||||
if h.cached != nil {
|
||||
for name := range h.cached.keepNames() {
|
||||
keep[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*"))
|
||||
ver := versionFromName(keepName)
|
||||
for _, m := range matches {
|
||||
base := filepath.Base(m)
|
||||
if ver != "" && strings.Contains(base, ver) {
|
||||
if _, ok := keep[filepath.Base(m)]; ok {
|
||||
continue
|
||||
}
|
||||
if base != keepName {
|
||||
_ = os.Remove(m)
|
||||
}
|
||||
_ = os.Remove(m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +516,7 @@ func versionFromName(name string) string {
|
||||
|
||||
func (h *Handler) serveFile(ctx context.Context, l *Latest, kind string) (fileResponse, error) {
|
||||
f, ok := l.Files[kind]
|
||||
if !ok || (kind == "android" && f.SHA256 == "") {
|
||||
if !ok {
|
||||
return fileResponse{}, apperr.NotFound(CodeUnavailable, "client build is unavailable")
|
||||
}
|
||||
path, err := h.ensure(ctx, f)
|
||||
@@ -393,26 +527,31 @@ func (h *Handler) serveFile(ctx context.Context, l *Latest, kind string) (fileRe
|
||||
return fileResponse{path: path, name: f.Name, req: httpx.RequestFrom(ctx)}, nil
|
||||
}
|
||||
|
||||
// DownloadAndroidApk реализует gen.StrictServerInterface. Отдаётся только последняя версия.
|
||||
// DownloadAndroidApk реализует gen.StrictServerInterface. Версия должна быть в каталоге (до 8 релизов).
|
||||
func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) {
|
||||
l, err := h.latest(ctx)
|
||||
cat, err := h.catalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Version != l.Version {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
|
||||
l := cat.release(req.Version)
|
||||
if l == nil {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "this version is no longer available")
|
||||
}
|
||||
if _, ok := l.android(); !ok {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "client build is unavailable")
|
||||
}
|
||||
return h.serveFile(ctx, l, "android")
|
||||
}
|
||||
|
||||
// DownloadAppClient реализует gen.StrictServerInterface.
|
||||
func (h *Handler) DownloadAppClient(ctx context.Context, req gen.DownloadAppClientRequestObject) (gen.DownloadAppClientResponseObject, error) {
|
||||
l, err := h.latest(ctx)
|
||||
cat, err := h.catalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Version != l.Version {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
|
||||
l := cat.release(req.Version)
|
||||
if l == nil {
|
||||
return nil, apperr.NotFound(CodeUnavailable, "this version is no longer available")
|
||||
}
|
||||
return h.serveFile(ctx, l, string(req.Client))
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
)
|
||||
|
||||
func TestPickClientFiles(t *testing.T) {
|
||||
@@ -51,19 +54,30 @@ func TestLatestAndDownload(t *testing.T) {
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "token secret", r.Header.Get("Authorization"))
|
||||
assets := []map[string]any{
|
||||
{"name": "pipu-music-0.6.1.apk", "size": len(apk), "browser_download_url": srv.URL + "/apk"},
|
||||
{"name": "pipu-music-0.6.1.apk.sha256", "size": 65, "browser_download_url": srv.URL + "/sum"},
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/repos/o/r/releases":
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{
|
||||
{"tag_name": "v0.6.1", "body": "## Новое", "draft": false, "published_at": "2026-01-02T00:00:00Z", "assets": assets},
|
||||
{"tag_name": "v0.6.0", "body": "", "draft": false, "published_at": "2026-01-01T00:00:00Z", "assets": []map[string]any{
|
||||
{"name": "pipu-music-0.6.0.apk", "size": 8, "browser_download_url": srv.URL + "/apk-old"},
|
||||
{"name": "pipu-music-0.6.0.apk.sha256", "size": 65, "browser_download_url": srv.URL + "/sum-old"},
|
||||
{"name": "pipu-music-0.6.0-windows-setup.exe", "size": 9, "browser_download_url": srv.URL + "/exe-old"},
|
||||
}},
|
||||
})
|
||||
case "/api/v1/repos/o/r/releases/latest":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"tag_name": "v0.6.1", "body": "## Новое",
|
||||
"assets": []map[string]any{
|
||||
{"name": "pipu-music-0.6.1.apk", "size": len(apk), "browser_download_url": srv.URL + "/apk"},
|
||||
{"name": "pipu-music-0.6.1.apk.sha256", "size": 65, "browser_download_url": srv.URL + "/sum"},
|
||||
},
|
||||
"tag_name": "v0.6.1", "body": "## Новое", "assets": assets,
|
||||
})
|
||||
case "/apk":
|
||||
_, _ = w.Write(apk)
|
||||
case "/sum":
|
||||
_, _ = w.Write([]byte(hex.EncodeToString(sum[:]) + "\n"))
|
||||
case "/sum-old":
|
||||
_, _ = w.Write([]byte(strings.Repeat("ab", 32) + "\n"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -91,4 +105,23 @@ func TestLatestAndDownload(t *testing.T) {
|
||||
assert.ErrorContains(t, err, "checksum mismatch")
|
||||
_, statErr := os.Stat(filepath.Join(dir, "pipu-music-0.6.1.apk"))
|
||||
assert.True(t, os.IsNotExist(statErr))
|
||||
|
||||
out, err := h.GetAppClients(context.Background(), gen.GetAppClientsRequestObject{})
|
||||
require.NoError(t, err)
|
||||
body := out.(gen.GetAppClients200JSONResponse)
|
||||
var androidClient, windowsClient *gen.AppClientFile
|
||||
for i := range body.Clients {
|
||||
switch body.Clients[i].Client {
|
||||
case gen.AppClientAndroid:
|
||||
androidClient = &body.Clients[i]
|
||||
case gen.AppClientWindows:
|
||||
windowsClient = &body.Clients[i]
|
||||
}
|
||||
}
|
||||
require.NotNil(t, androidClient)
|
||||
require.NotNil(t, androidClient.Older)
|
||||
assert.Equal(t, "0.6.0", (*androidClient.Older)[0].Version)
|
||||
require.NotNil(t, windowsClient)
|
||||
assert.Contains(t, windowsClient.Filename, "0.6.0")
|
||||
assert.True(t, windowsClient.Older == nil || len(*windowsClient.Older) == 0)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -227,12 +228,77 @@ func TestLibraryAndUploads(t *testing.T) {
|
||||
assert.Empty(t, stale, "only uploading files expire")
|
||||
up.Audit(ctx, &author, "upload.test", fileID, map[string]any{"ok": true})
|
||||
|
||||
// Импорт: связь с файлом библиотеки, место освобождено.
|
||||
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusPendingReview}, uploads.StatusApproved, nil))
|
||||
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusApproved}, uploads.StatusImporting, nil))
|
||||
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusImporting}, uploads.StatusImported,
|
||||
func(tx pgx.Tx) error { return uploads.MarkImported(ctx, tx, fileID, fileIDs[1]) }))
|
||||
// A failure writing reviewer metadata must roll back both status and quota.
|
||||
require.Error(t, up.Decide(ctx, fileID, uuid.New(), false, "invalid reviewer"))
|
||||
f, err = up.File(ctx, fileID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uploads.StatusPendingReview, f.Status)
|
||||
assert.Nil(t, f.ReviewedBy)
|
||||
used, _, err = up.Usage(ctx, author)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 400, used)
|
||||
|
||||
// Import operations retain the status/reason/link and quota invariants.
|
||||
require.NoError(t, up.Decide(ctx, fileID, author, true, "discard on approval"))
|
||||
f, err = up.File(ctx, fileID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, f.ReviewedBy)
|
||||
assert.Equal(t, author, *f.ReviewedBy)
|
||||
assert.Nil(t, f.StatusReason)
|
||||
require.NoError(t, up.ChangeStatus(ctx, fileID, []uploads.Status{uploads.StatusApproved}, uploads.StatusImporting))
|
||||
require.NoError(t, up.FailImport(ctx, fileID, "retryable failure"))
|
||||
f, err = up.File(ctx, fileID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uploads.StatusImportFailed, f.Status)
|
||||
require.NotNil(t, f.StatusReason)
|
||||
assert.Equal(t, "retryable failure", *f.StatusReason)
|
||||
require.NoError(t, up.ChangeStatus(ctx, fileID, []uploads.Status{uploads.StatusImportFailed}, uploads.StatusApproved))
|
||||
require.NoError(t, up.ChangeStatus(ctx, fileID, []uploads.Status{uploads.StatusApproved}, uploads.StatusImporting))
|
||||
require.NoError(t, up.CompleteImport(ctx, fileID, src.ID, "Кино - Группа крови.flac", fileIDs[1]))
|
||||
require.ErrorIs(t, up.CompleteImport(ctx, fileID, src.ID, "ignored.flac", fileIDs[1]), uploads.ErrWrongStatus)
|
||||
|
||||
used, _, err = up.Usage(ctx, author)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 0, used)
|
||||
// Two reviewers cannot both decide the same pending file or release quota twice.
|
||||
secondHash := sha256.Sum256([]byte("concurrent decision"))
|
||||
_, reserved, err := up.CreateBatch(ctx, author, nil, []uploads.NewFile{
|
||||
{ClientID: "race", Name: "race.flac", Size: 300, SHA256: secondHash[:]},
|
||||
}, settings)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reserved, 1)
|
||||
raceID := reserved[0].FileID
|
||||
require.NotEqual(t, uuid.Nil, raceID)
|
||||
require.NoError(t, up.ChangeStatus(ctx, raceID, []uploads.Status{uploads.StatusUploading}, uploads.StatusProcessing))
|
||||
require.NoError(t, up.ChangeStatus(ctx, raceID, []uploads.Status{uploads.StatusProcessing}, uploads.StatusPendingReview))
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan error, 2)
|
||||
for _, reason := range []string{"first", "second"} {
|
||||
wg.Add(1)
|
||||
go func(reason string) { defer wg.Done(); results <- up.Decide(ctx, raceID, author, false, reason) }(reason)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
success, conflicts := 0, 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
success++
|
||||
} else {
|
||||
require.ErrorIs(t, err, uploads.ErrWrongStatus)
|
||||
conflicts++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, success)
|
||||
assert.Equal(t, 1, conflicts)
|
||||
used, _, err = up.Usage(ctx, author)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, used)
|
||||
f, err = up.File(ctx, raceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uploads.StatusRejected, f.Status)
|
||||
require.NotNil(t, f.ReviewedBy)
|
||||
assert.Equal(t, author, *f.ReviewedBy)
|
||||
require.NotNil(t, f.StatusReason)
|
||||
assert.Contains(t, []string{"first", "second"}, *f.StatusReason)
|
||||
|
||||
}
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user