feat: finish the upload, playback, and test gaps from the docs
ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s
ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s
Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
2b76060ea3
commit
066ba24834
61 files changed
+2818
-463
No files matched your search
@@ -51,3 +51,5 @@ jobs:
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm test
|
||||
- run: pnpm --filter @pipu/web build
|
||||
- run: pnpm --filter @pipu/web exec playwright install chromium
|
||||
- run: pnpm --filter @pipu/web test:e2e
|
||||
@@ -0,0 +1,37 @@
|
||||
# Ночные проверки, которым нужен поднятый стенд или эмулятор. На каждый push не запускаются.
|
||||
name: nightly / e2e
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
|
||||
jobs:
|
||||
schemathesis:
|
||||
name: schemathesis
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: pip install schemathesis
|
||||
- name: Фаззинг API
|
||||
env:
|
||||
PIPU_API_URL: ${{ vars.PIPU_API_URL }}
|
||||
PIPU_API_TOKEN: ${{ secrets.PIPU_API_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$PIPU_API_URL" ]; then echo "PIPU_API_URL is not set"; exit 0; fi
|
||||
sh tests/contract/schemathesis.sh
|
||||
|
||||
maestro:
|
||||
name: maestro
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Сценарии Android
|
||||
env:
|
||||
PIPU_E2E_SERVER: ${{ vars.PIPU_E2E_SERVER }}
|
||||
PIPU_E2E_USER: ${{ vars.PIPU_E2E_USER }}
|
||||
PIPU_E2E_PASSWORD: ${{ secrets.PIPU_E2E_PASSWORD }}
|
||||
run: |
|
||||
if [ -z "$PIPU_E2E_SERVER" ]; then echo "emulator flow is configured in apps/mobile/.maestro"; exit 0; fi
|
||||
curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||
~/.maestro/bin/maestro test apps/mobile/.maestro
|
||||
@@ -27,6 +27,8 @@ __pycache__/
|
||||
|
||||
# generated test audio
|
||||
tests/fixtures/music/out/
|
||||
apps/web/test-results/
|
||||
apps/web/playwright-report/
|
||||
|
||||
# os / editors
|
||||
.DS_Store
|
||||
|
||||
+123
-3
@@ -451,6 +451,35 @@ paths:
|
||||
$ref: "#/components/schemas/UploadFilePage"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/uploads/unseen:
|
||||
get:
|
||||
operationId: unseenUploadDecisions
|
||||
tags: [uploads]
|
||||
summary: Сколько решений по своим предложениям с прошлого визита (moderator, admin)
|
||||
responses:
|
||||
"200":
|
||||
description: Число
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [count]
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/uploads/seen:
|
||||
post:
|
||||
operationId: markUploadsSeen
|
||||
tags: [uploads]
|
||||
summary: Отметить свои предложения просмотренными
|
||||
responses:
|
||||
"204":
|
||||
description: Отмечено
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/uploads/files/{id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/EntityId"
|
||||
@@ -535,6 +564,65 @@ paths:
|
||||
$ref: "#/components/schemas/UploadFile"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/uploads/files/{id}/stream:
|
||||
get:
|
||||
operationId: streamUpload
|
||||
tags: [uploads]
|
||||
security: []
|
||||
summary: Прослушать файл из staging (подпись, как у стрима трека)
|
||||
description: Ссылку даёт `stream_url` файла очереди. Range поддерживается.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/EntityId"
|
||||
- name: exp
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
- name: sig
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Аудио из staging
|
||||
content:
|
||||
audio/*:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/uploads/batches/{id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/EntityId"
|
||||
patch:
|
||||
operationId: updateUploadBatch
|
||||
tags: [uploads]
|
||||
summary: Правка метаданных и обложки на всю пачку (admin)
|
||||
description: Исполнитель, альбом и год применяются к файлам в pending_review и import_failed. Обложка встраивается при импорте.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UploadBatchPatch"
|
||||
responses:
|
||||
"200":
|
||||
description: Обновлённые файлы пачки
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UploadFile"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/uploads/review:
|
||||
post:
|
||||
operationId: reviewUploads
|
||||
@@ -1620,6 +1708,9 @@ components:
|
||||
type: string
|
||||
duration_ms:
|
||||
type: integer
|
||||
album:
|
||||
type: string
|
||||
maxLength: 500
|
||||
UploadPrecheckResult:
|
||||
type: object
|
||||
required: [files]
|
||||
@@ -1733,7 +1824,7 @@ components:
|
||||
nullable: true
|
||||
UploadFile:
|
||||
type: object
|
||||
required: [id, batch_id, batch_comment, author, status, original_name, client_rel_path, size_bytes, received_bytes, format, codec, bitrate, sample_rate, bit_depth, duration_ms, tags, metadata, metadata_edited, matched_artist_id, matched_album_id, duplicate_of_track_id, duplicate_level, possible_duplicate, target_source_id, target_rel_path, status_reason, reviewed_at, created_at, updated_at, tus_url]
|
||||
required: [id, batch_id, batch_comment, author, status, original_name, client_rel_path, size_bytes, received_bytes, format, codec, bitrate, sample_rate, bit_depth, duration_ms, tags, metadata, metadata_edited, matched_artist_id, matched_album_id, duplicate_of_track_id, duplicate_level, possible_duplicate, quality_upgrade, target_source_id, target_rel_path, status_reason, reviewed_at, created_at, updated_at, tus_url, stream_url]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
@@ -1807,6 +1898,9 @@ components:
|
||||
enum: [sha256, metadata, fingerprint, null]
|
||||
possible_duplicate:
|
||||
type: boolean
|
||||
quality_upgrade:
|
||||
type: boolean
|
||||
description: Тот же трек, но лучшего качества — ждёт решения, если включён allow_quality_upgrade
|
||||
target_source_id:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -1832,6 +1926,10 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Только автору и пока файл в статусе uploading
|
||||
stream_url:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Подписанная ссылка прослушивания из staging; null, если файла уже нет
|
||||
UploadFilePage:
|
||||
type: object
|
||||
required: [items, next_cursor]
|
||||
@@ -1851,6 +1949,23 @@ components:
|
||||
target_source_id:
|
||||
type: string
|
||||
format: uuid
|
||||
UploadBatchPatch:
|
||||
type: object
|
||||
description: Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки.
|
||||
properties:
|
||||
artist:
|
||||
type: string
|
||||
maxLength: 500
|
||||
album:
|
||||
type: string
|
||||
maxLength: 500
|
||||
year:
|
||||
type: integer
|
||||
nullable: true
|
||||
cover_base64:
|
||||
type: string
|
||||
nullable: true
|
||||
description: JPEG, PNG или WebP, не больше 8 МБ после декодирования
|
||||
UploadReviewRequest:
|
||||
type: object
|
||||
required: [file_ids, decision]
|
||||
@@ -1890,7 +2005,7 @@ components:
|
||||
- $ref: "#/components/schemas/Problem"
|
||||
UploadSettings:
|
||||
type: object
|
||||
required: [queue_limit_bytes, per_user_limit_bytes, max_file_bytes, default_source_id]
|
||||
required: [queue_limit_bytes, per_user_limit_bytes, max_file_bytes, default_source_id, allow_quality_upgrade]
|
||||
properties:
|
||||
queue_limit_bytes:
|
||||
type: integer
|
||||
@@ -1906,6 +2021,9 @@ components:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
allow_quality_upgrade:
|
||||
type: boolean
|
||||
description: Тот же трек в лучшем качестве идёт на рассмотрение, а не в дубли
|
||||
UploadSettingsPatch:
|
||||
type: object
|
||||
description: Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано).
|
||||
@@ -1929,6 +2047,8 @@ components:
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: null — без хранилища по умолчанию
|
||||
allow_quality_upgrade:
|
||||
type: boolean
|
||||
HealthStatus:
|
||||
type: object
|
||||
required: [status]
|
||||
@@ -2036,7 +2156,7 @@ components:
|
||||
description: Где в топбаре веба поиск (по умолчанию center)
|
||||
search_collapse:
|
||||
type: boolean
|
||||
description: Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию true)
|
||||
description: Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию false)
|
||||
home_activities:
|
||||
type: boolean
|
||||
description: Показывать на главной веба выбор занятия (по умолчанию true)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
appId: app.pipu.music
|
||||
---
|
||||
- runFlow: login.yaml
|
||||
- tapOn: "Коллекция"
|
||||
- assertVisible: "Лайки"
|
||||
@@ -0,0 +1,15 @@
|
||||
appId: app.pipu.music
|
||||
---
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- assertVisible: "Адрес сервера"
|
||||
- tapOn: "Адрес сервера"
|
||||
- inputText: ${PIPU_E2E_SERVER}
|
||||
- tapOn: "Подключиться"
|
||||
- assertVisible: "Вход"
|
||||
- tapOn: "Логин"
|
||||
- inputText: ${PIPU_E2E_USER}
|
||||
- tapOn: "Пароль"
|
||||
- inputText: ${PIPU_E2E_PASSWORD}
|
||||
- tapOn: "Войти"
|
||||
- assertVisible: "Бегаю"
|
||||
@@ -0,0 +1,6 @@
|
||||
appId: app.pipu.music
|
||||
---
|
||||
- runFlow: login.yaml
|
||||
- tapOn: "Профиль"
|
||||
- assertVisible: "Проверить обновления"
|
||||
- tapOn: "Проверить обновления"
|
||||
@@ -7,7 +7,8 @@
|
||||
"start": "expo start --dev-client",
|
||||
"android": "expo run:android",
|
||||
"prebuild": "expo prebuild --platform android --clean",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
@@ -42,7 +43,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.5",
|
||||
"@types/react": "~19.0.10"
|
||||
"@types/react": "~19.0.10",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"expo": {
|
||||
"doctor": {
|
||||
|
||||
@@ -116,6 +116,16 @@ export default function ProfileScreen() {
|
||||
onChange={(v) => void setPrefs({ mobile_player: v })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label={t('settings.normalize')}>
|
||||
<Switch value={user.ui_prefs.normalize !== false} onValueChange={(v) => void setPrefs({ normalize: v })} />
|
||||
</Row>
|
||||
<Row label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}>
|
||||
<Segmented
|
||||
value={String([0, 2, 4, 6].includes(user.ui_prefs.crossfade_s ?? 0) ? (user.ui_prefs.crossfade_s ?? 0) : 0)}
|
||||
options={['0', '2', '4', '6'].map((v) => ({ value: v, label: v }))}
|
||||
onChange={(v) => void setPrefs({ crossfade_s: Number(v) })}
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export type ReleaseNeed = 'current' | 'optional' | 'required';
|
||||
|
||||
/** Нужно ли обновление: обязательное, если установленная версия ниже min_supported. */
|
||||
export function releaseNeed(installed: number, versionCode: number, minSupported: number): ReleaseNeed {
|
||||
if (versionCode <= installed) return 'current';
|
||||
return installed < minSupported ? 'required' : 'optional';
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import * as IntentLauncher from 'expo-intent-launcher';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { sha256 } from 'js-sha256';
|
||||
|
||||
import { releaseNeed } from './releaseNeed';
|
||||
|
||||
export type AndroidRelease = components['schemas']['AndroidRelease'];
|
||||
|
||||
/** Состояние самообновления (docs/mobile.md#самообновление). */
|
||||
@@ -96,11 +98,12 @@ class Updater {
|
||||
const release = unwrap(await session.client.GET('/api/v1/app/android/latest'));
|
||||
await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now()));
|
||||
const installed = installedVersionCode();
|
||||
if (release.version_code > installed) {
|
||||
const need = releaseNeed(installed, release.version_code, release.min_supported);
|
||||
if (need !== 'current') {
|
||||
this.set({
|
||||
status: 'available',
|
||||
release,
|
||||
required: installed < release.min_supported,
|
||||
required: need === 'required',
|
||||
dismissed: false,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import NetInfo from '@react-native-community/netinfo';
|
||||
import {
|
||||
EventBuffer,
|
||||
PlayAttempt,
|
||||
normalizeGain,
|
||||
unwrap,
|
||||
type AuthSession,
|
||||
type PlayContext,
|
||||
@@ -18,6 +19,9 @@ import TrackPlayer, {
|
||||
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
||||
import { downloads } from '../offline/downloads';
|
||||
import { PersistentEvents } from '../offline/events';
|
||||
import { offlineWave } from './offlineWave';
|
||||
|
||||
export { offlineWave };
|
||||
|
||||
export interface WaveInfo {
|
||||
sessionId: string;
|
||||
@@ -45,15 +49,6 @@ const WAVE_REFILL_AT = 2;
|
||||
const EARLY_SKIP_MS = 30_000;
|
||||
const SAVE_EVERY_MS = 10_000;
|
||||
|
||||
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
|
||||
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
|
||||
return tracks
|
||||
.filter((t) => t.reaction !== 'dislike')
|
||||
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
|
||||
.sort((a, b) => a.key - b.key)
|
||||
.map((x) => x.t);
|
||||
}
|
||||
|
||||
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
|
||||
|
||||
/** Ссылки API относительные — для приложения нужен полный адрес сервера. */
|
||||
@@ -159,6 +154,10 @@ class Controller {
|
||||
this.lastPositionMs = ms;
|
||||
this.update({ positionMs: ms, durationMs: Math.round(duration * 1000) || this.state.durationMs });
|
||||
if (this.state.playing && Date.now() - this.lastSave > SAVE_EVERY_MS) this.save();
|
||||
const cf = Math.min(6, Math.max(0, this.userPrefs()?.crossfade_s ?? 0));
|
||||
const left = duration - position;
|
||||
const fade = cf > 0 && left < cf ? Math.max(0, left / cf) : 1;
|
||||
void this.applyVolume(this.state.track, fade);
|
||||
});
|
||||
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, ({ index, lastPosition }) => {
|
||||
this.finishAttempt(lastPosition);
|
||||
@@ -167,6 +166,7 @@ class Controller {
|
||||
if (!track) return;
|
||||
this.attempt = new PlayAttempt(track, 0);
|
||||
this.update({ track, index, positionMs: 0, durationMs: track.duration_ms });
|
||||
void this.applyVolume(track, 1);
|
||||
this.events?.push({
|
||||
type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type,
|
||||
context_id: this.state.context?.id,
|
||||
@@ -209,6 +209,17 @@ class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
private userPrefs() {
|
||||
const st = this.session?.state;
|
||||
return st?.status === 'signed_in' ? st.user.ui_prefs : undefined;
|
||||
}
|
||||
|
||||
/** Громкость 0..1: нормализация вниз к −14 LUFS и затухание кроссфейда в конце трека. */
|
||||
private async applyVolume(track: Track | null, fade: number) {
|
||||
const gain = Math.min(1, normalizeGain(track?.features?.loudness_lufs, this.userPrefs()?.normalize !== false) * fade);
|
||||
await TrackPlayer.setVolume(gain).catch(() => undefined);
|
||||
}
|
||||
|
||||
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
|
||||
const q = await quality();
|
||||
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Track } from '@pipu/core';
|
||||
|
||||
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
|
||||
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
|
||||
return tracks
|
||||
.filter((t) => t.reaction !== 'dislike')
|
||||
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
|
||||
.sort((a, b) => a.key - b.key)
|
||||
.map((x) => x.t);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { releaseNeed } from '../src/lib/releaseNeed';
|
||||
import { offlineWave } from '../src/player/offlineWave';
|
||||
import type { Track } from '@pipu/core';
|
||||
|
||||
function track(id: string, reaction: Track['reaction']): Track {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
duration_ms: 1000,
|
||||
artists: [],
|
||||
album: { id: 'a', title: 'A', year: null, cover_url: null, cover_colors: [] },
|
||||
genres: [],
|
||||
disc_no: 1,
|
||||
track_no: 1,
|
||||
available: true,
|
||||
stream_url: null,
|
||||
reaction,
|
||||
features: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('offline wave', () => {
|
||||
it('drops dislikes and keeps likes ahead of the rest', () => {
|
||||
const tracks = [track('d', 'dislike'), track('n', null), track('l', 'like')];
|
||||
const order = offlineWave(tracks, () => 0.5).map((t) => t.id);
|
||||
expect(order).toEqual(['l', 'n']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update dialog decision', () => {
|
||||
it('marks an older install below the minimum as required', () => {
|
||||
expect(releaseNeed(10, 12, 11)).toBe('required');
|
||||
expect(releaseNeed(11, 12, 11)).toBe('optional');
|
||||
expect(releaseNeed(12, 12, 11)).toBe('current');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
});
|
||||
|
||||
test('1. bootstrap admin changes credentials and lands on home', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await expect(page.getByText(/Первый запуск/)).toBeVisible();
|
||||
await page.getByLabel('Логин').fill('admin');
|
||||
await page.getByRole('textbox', { name: 'Пароль' }).fill('admin-password');
|
||||
await page.getByRole('button', { name: 'Войти' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Смените логин и пароль' })).toBeVisible();
|
||||
await page.getByLabel('Новый логин').fill('owner');
|
||||
await page.getByRole('textbox', { name: 'Новый пароль' }).fill('a-strong-owner-pass');
|
||||
await page.getByRole('textbox', { name: 'Повторите пароль' }).fill('a-strong-owner-pass');
|
||||
await page.getByRole('button', { name: 'Сохранить и продолжить' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Бегаю' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('4. search suggest opens the artist and Enter opens search', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин').fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти' }).click();
|
||||
const box = page.getByRole('combobox', { name: 'Поиск' });
|
||||
await box.fill('кино');
|
||||
await page.getByRole('option', { name: /Кино/ }).first().click();
|
||||
await expect(page.getByRole('heading', { name: 'Кино' })).toBeVisible();
|
||||
|
||||
await box.fill('кино');
|
||||
await box.press('Enter');
|
||||
await expect(page).toHaveURL(/\/search\?q=/);
|
||||
});
|
||||
|
||||
test('settings expose crossfade and loudness normalization', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин').fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти' }).click();
|
||||
await page.getByRole('button', { name: 'Меню профиля' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Настройки' }).click();
|
||||
await expect(page.getByText('Выравнивание громкости')).toBeVisible();
|
||||
const slider = page.getByRole('slider', { name: /Кроссфейд/ });
|
||||
await slider.fill('4');
|
||||
await expect(page.getByText('Кроссфейд, 4 с')).toBeVisible();
|
||||
});
|
||||
test('9. login page switches between Russian and English', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByRole('button', { name: 'EN' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'RU' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Вход' })).toBeVisible();
|
||||
});
|
||||
@@ -8,7 +8,8 @@
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/jetbrains-mono": "^5.2.6",
|
||||
@@ -21,13 +22,16 @@
|
||||
"@pipu/i18n": "workspace:*",
|
||||
"@tanstack/react-query": "^5.80.7",
|
||||
"@tanstack/react-router": "^1.120.20",
|
||||
"@tanstack/react-virtual": "^3.14.21",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"music-metadata": "^12.2.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.5.3",
|
||||
"tus-js-client": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
@@ -35,6 +39,7 @@
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"axe-core": "^4.14.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.2.4"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
use: { baseURL: 'http://127.0.0.1:4173', ...devices['Desktop Chrome'] },
|
||||
webServer: {
|
||||
command: 'pnpm exec vite --port 4173 --strictPort',
|
||||
url: 'http://127.0.0.1:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: { VITE_E2E: '1' },
|
||||
},
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { QueryClient } from '@tanstack/react-query';
|
||||
import { createMemoryHistory } from '@tanstack/react-router';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import axe from 'axe-core';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -314,6 +315,22 @@ describe('my wave', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('home and login have no serious axe violations', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
await renderApp([alice], '/', { signedInAs: alice });
|
||||
await screen.findByRole('button', { name: 'Бегаю' });
|
||||
const home = await axe.run(document.body, { resultTypes: ['violations'] });
|
||||
expect(home.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]);
|
||||
|
||||
cleanup();
|
||||
await renderApp([], '/login');
|
||||
await screen.findByRole('heading', { name: 'Вход' });
|
||||
const login = await axe.run(document.body, { resultTypes: ['violations'] });
|
||||
expect(login.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeRedirect', () => {
|
||||
it.each([
|
||||
['/settings', '/settings'],
|
||||
|
||||
@@ -7,16 +7,17 @@ import {
|
||||
redirect,
|
||||
type ParsedLocation,
|
||||
} from '@tanstack/react-router';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import { AuditPage } from '../pages/admin/AuditPage';
|
||||
import { LibraryPage } from '../pages/admin/LibraryPage';
|
||||
import { PatternsPage } from '../pages/admin/PatternsPage';
|
||||
import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage';
|
||||
import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
|
||||
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
|
||||
import { SuggestPage } from '../pages/uploads/SuggestPage';
|
||||
import { SourcesPage } from '../pages/admin/SourcesPage';
|
||||
import { UsersPage } from '../pages/admin/UsersPage';
|
||||
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
|
||||
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
|
||||
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
|
||||
const AdminSettingsPage = lazy(() => import('../pages/admin/AdminSettingsPage').then((m) => ({ default: m.AdminSettingsPage })));
|
||||
const UploadQueuePage = lazy(() => import('../pages/admin/UploadQueuePage').then((m) => ({ default: m.UploadQueuePage })));
|
||||
const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((m) => ({ default: m.MyUploadsPage })));
|
||||
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
||||
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
|
||||
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
||||
import { AlbumPage } from '../pages/AlbumPage';
|
||||
import { ArtistPage } from '../pages/ArtistPage';
|
||||
import { CollectionPage } from '../pages/CollectionPage';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Fragment } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Fragment, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
@@ -129,82 +130,115 @@ export function TrackList({
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const { track: current, playing } = usePlayerState();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
||||
const multiDisc = Boolean(numbered && discs.size > 1);
|
||||
const virtual = tracks.length > 30 && !multiDisc;
|
||||
const virtualizer = useVirtualizer({
|
||||
count: virtual ? tracks.length : 0,
|
||||
getScrollElement: () => listRef.current?.closest('main') ?? null,
|
||||
estimateSize: () => 68,
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
const row = (tr: Track, i: number) => {
|
||||
const isCurrent = current?.id === tr.id;
|
||||
const play = () => tr.available && player.playList(tracks, i, context);
|
||||
const liked = tr.reaction === 'like';
|
||||
const meta = rowMeta?.(tr, i);
|
||||
return (
|
||||
<div
|
||||
role="listitem"
|
||||
tabIndex={tr.available ? 0 : -1}
|
||||
aria-current={isCurrent || undefined}
|
||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||
onClick={(e) => {
|
||||
if (!(e.target as HTMLElement).closest('a, button')) play();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||
}}
|
||||
>
|
||||
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
|
||||
<span className={cx(s.num, disliked && s.dimmed)}>
|
||||
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
|
||||
{tr.available && (
|
||||
<span className={s.playHint} aria-hidden>
|
||||
<Icon src={playIcon} size={20} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={cx(s.trackMain, disliked && s.dimmed)}>
|
||||
<span className={s.trackTitle}>{tr.title}</span>
|
||||
<span className={s.trackArtists}>
|
||||
<ArtistLinks artists={tr.artists} />
|
||||
{meta && <span className={s.rowMeta}> · {meta}</span>}
|
||||
</span>
|
||||
</span>
|
||||
{showAlbum && (
|
||||
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
|
||||
{tr.album.title}
|
||||
</Link>
|
||||
)}
|
||||
<span className={s.end}>
|
||||
{disliked ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.like, s.liked)}
|
||||
aria-label={t('player.undislike')}
|
||||
onClick={() => void player.react(tr, null).catch(() => undefined)}
|
||||
>
|
||||
<Icon src={undoIcon} size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.like, liked && s.liked)}
|
||||
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
||||
aria-pressed={liked}
|
||||
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
|
||||
>
|
||||
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
|
||||
</button>
|
||||
)}
|
||||
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
let lastDisc: number | null = null;
|
||||
return (
|
||||
<div className={s.tracks} role="list" aria-label={t('search.tracks')}>
|
||||
{tracks.map((tr, i) => {
|
||||
const disc = tr.disc_no ?? 1;
|
||||
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
||||
lastDisc = disc;
|
||||
const isCurrent = current?.id === tr.id;
|
||||
const play = () => tr.available && player.playList(tracks, i, context);
|
||||
const liked = tr.reaction === 'like';
|
||||
const meta = rowMeta?.(tr, i);
|
||||
return (
|
||||
<Fragment key={`${tr.id}-${i}`}>
|
||||
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||
<div
|
||||
ref={listRef}
|
||||
className={s.tracks}
|
||||
role="list"
|
||||
aria-label={t('search.tracks')}
|
||||
style={virtual ? { height: virtualizer.getTotalSize(), position: 'relative' } : undefined}
|
||||
>
|
||||
{virtual
|
||||
? virtualizer.getVirtualItems().map((vi) => (
|
||||
<div
|
||||
role="listitem"
|
||||
tabIndex={tr.available ? 0 : -1}
|
||||
aria-current={isCurrent || undefined}
|
||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||
onClick={(e) => {
|
||||
if (!(e.target as HTMLElement).closest('a, button')) play();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||
}}
|
||||
key={vi.key}
|
||||
data-index={vi.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
||||
>
|
||||
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
|
||||
<span className={cx(s.num, disliked && s.dimmed)}>
|
||||
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
|
||||
{tr.available && (
|
||||
<span className={s.playHint} aria-hidden>
|
||||
<Icon src={playIcon} size={20} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={cx(s.trackMain, disliked && s.dimmed)}>
|
||||
<span className={s.trackTitle}>{tr.title}</span>
|
||||
<span className={s.trackArtists}>
|
||||
<ArtistLinks artists={tr.artists} />
|
||||
{meta && <span className={s.rowMeta}> · {meta}</span>}
|
||||
</span>
|
||||
</span>
|
||||
{showAlbum && (
|
||||
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
|
||||
{tr.album.title}
|
||||
</Link>
|
||||
)}
|
||||
<span className={s.end}>
|
||||
{disliked ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.like, s.liked)}
|
||||
aria-label={t('player.undislike')}
|
||||
onClick={() => void player.react(tr, null).catch(() => undefined)}
|
||||
>
|
||||
<Icon src={undoIcon} size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.like, liked && s.liked)}
|
||||
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
||||
aria-pressed={liked}
|
||||
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
|
||||
>
|
||||
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
|
||||
</button>
|
||||
)}
|
||||
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
||||
</span>
|
||||
{row(tracks[vi.index]!, vi.index)}
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
))
|
||||
: tracks.map((tr, i) => {
|
||||
const disc = tr.disc_no ?? 1;
|
||||
const header = multiDisc && disc !== lastDisc;
|
||||
lastDisc = disc;
|
||||
return (
|
||||
<Fragment key={`${tr.id}-${i}`}>
|
||||
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||
{row(tr, i)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,15 +17,18 @@ export type Stage =
|
||||
| 'uploading'
|
||||
| 'done'
|
||||
| 'rejected'
|
||||
| 'error';
|
||||
| 'error'
|
||||
| 'resume';
|
||||
|
||||
export interface UploadItem {
|
||||
id: string;
|
||||
file: File;
|
||||
file: File | null;
|
||||
size: number;
|
||||
relPath: string;
|
||||
stage: Stage;
|
||||
progress: number; // 0..1 текущего этапа (хэш или загрузка)
|
||||
sha256?: string;
|
||||
tags?: { artist?: string; title?: string; album?: string; duration_ms?: number };
|
||||
trackId?: string | null;
|
||||
possibleDuplicate?: boolean;
|
||||
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
|
||||
@@ -44,6 +47,17 @@ export interface UploaderState {
|
||||
|
||||
const PARALLEL = 4;
|
||||
const CHUNK = 8 << 20;
|
||||
const PENDING_KEY = 'pipu.uploads.pending';
|
||||
|
||||
interface PendingUpload {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
relPath: string;
|
||||
sha256: string;
|
||||
tusUrl: string;
|
||||
uploadFileId: string;
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
|
||||
@@ -56,7 +70,9 @@ export class Uploader {
|
||||
private uploads = new Map<string, tus.Upload>();
|
||||
private refreshing: Promise<unknown> | null = null;
|
||||
|
||||
constructor(private readonly session: AuthSession) {}
|
||||
constructor(private readonly session: AuthSession) {
|
||||
this.restore();
|
||||
}
|
||||
|
||||
subscribe = (fn: () => void) => {
|
||||
this.listeners.add(fn);
|
||||
@@ -89,8 +105,8 @@ export class Uploader {
|
||||
continue;
|
||||
}
|
||||
// Тот же путь и размер уже в списке — не добавляем второй раз.
|
||||
if (this.state.items.some((it) => it.relPath === f.relPath && it.file.size === f.file.size)) continue;
|
||||
fresh.push({ id: `f${++seq}`, file: f.file, relPath: f.relPath, stage: 'hashing', progress: 0 });
|
||||
if (this.state.items.some((it) => it.relPath === f.relPath && it.size === f.file.size)) continue;
|
||||
fresh.push({ id: `f${++seq}`, file: f.file, size: f.file.size, relPath: f.relPath, stage: 'hashing', progress: 0 });
|
||||
}
|
||||
this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null });
|
||||
this.hashQueue.push(...fresh.map((it) => it.id));
|
||||
@@ -101,6 +117,7 @@ export class Uploader {
|
||||
if (this.state.busy) return;
|
||||
this.hashQueue = this.hashQueue.filter((x) => x !== id);
|
||||
this.set({ items: this.state.items.filter((it) => it.id !== id) });
|
||||
this.persist();
|
||||
}
|
||||
|
||||
/** Очистить список (после завершения или чтобы начать заново). */
|
||||
@@ -108,6 +125,7 @@ export class Uploader {
|
||||
if (this.state.busy) return;
|
||||
this.hashQueue = [];
|
||||
this.set({ items: [], skipped: 0, batchId: null, error: null });
|
||||
this.persist();
|
||||
}
|
||||
|
||||
private hashWorker(): Worker {
|
||||
@@ -125,6 +143,7 @@ export class Uploader {
|
||||
if (!it) continue;
|
||||
try {
|
||||
const sha256 = await this.hash(it);
|
||||
await this.readTags(it);
|
||||
this.patch(id, { sha256, stage: 'checking', progress: 1 });
|
||||
} catch (err) {
|
||||
this.patch(id, { stage: 'error', message: err instanceof Error ? err.message : String(err) });
|
||||
@@ -137,13 +156,15 @@ export class Uploader {
|
||||
}
|
||||
|
||||
private hash(it: UploadItem): Promise<string> {
|
||||
const file = it.file;
|
||||
if (!file) return Promise.reject(new Error('no file'));
|
||||
const w = this.hashWorker();
|
||||
return new Promise((resolve, reject) => {
|
||||
const onMessage = (e: MessageEvent<HashResponse>) => {
|
||||
const m = e.data;
|
||||
if (m.id !== it.id) return;
|
||||
if (m.type === 'progress') {
|
||||
this.patch(it.id, { progress: m.done / Math.max(it.file.size, 1) });
|
||||
this.patch(it.id, { progress: m.done / Math.max(it.size, 1) });
|
||||
return;
|
||||
}
|
||||
w.removeEventListener('message', onMessage);
|
||||
@@ -151,7 +172,7 @@ export class Uploader {
|
||||
else reject(new Error(m.message));
|
||||
};
|
||||
w.addEventListener('message', onMessage);
|
||||
w.postMessage({ id: it.id, file: it.file } satisfies HashRequest);
|
||||
w.postMessage({ id: it.id, file } satisfies HashRequest);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,7 +183,17 @@ export class Uploader {
|
||||
try {
|
||||
const res = unwrap(
|
||||
await this.session.client.POST('/api/v1/uploads/precheck', {
|
||||
body: { files: pending.map((it) => ({ client_id: it.id, size: it.file.size, sha256: it.sha256! })) },
|
||||
body: {
|
||||
files: pending.map((it) => ({
|
||||
client_id: it.id,
|
||||
size: it.size,
|
||||
sha256: it.sha256!,
|
||||
artist: it.tags?.artist,
|
||||
title: it.tags?.title,
|
||||
album: it.tags?.album,
|
||||
duration_ms: it.tags?.duration_ms,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const r of res.files) {
|
||||
@@ -192,9 +223,9 @@ export class Uploader {
|
||||
comment: comment.trim() || undefined,
|
||||
files: ready.map((it) => ({
|
||||
client_id: it.id,
|
||||
name: it.file.name,
|
||||
name: it.file?.name ?? it.relPath,
|
||||
rel_path: it.relPath.includes('/') ? it.relPath : undefined,
|
||||
size: it.file.size,
|
||||
size: it.size,
|
||||
sha256: it.sha256!,
|
||||
})),
|
||||
},
|
||||
@@ -209,6 +240,7 @@ export class Uploader {
|
||||
queue.push({ id: f.client_id, url: f.tus_url });
|
||||
}
|
||||
}
|
||||
this.persist();
|
||||
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
|
||||
} catch (err) {
|
||||
this.set({ error: err });
|
||||
@@ -239,9 +271,10 @@ export class Uploader {
|
||||
|
||||
private upload(id: string, url: string): Promise<void> {
|
||||
const it = this.item(id);
|
||||
if (!it) return Promise.resolve();
|
||||
if (!it?.file) return Promise.resolve();
|
||||
const file = it.file;
|
||||
return new Promise((resolve) => {
|
||||
const up = new tus.Upload(it.file, {
|
||||
const up = new tus.Upload(file, {
|
||||
uploadUrl: url,
|
||||
chunkSize: CHUNK,
|
||||
retryDelays: [0, 1000, 3000, 5000, 10000, 20000],
|
||||
@@ -264,6 +297,7 @@ export class Uploader {
|
||||
onSuccess: () => {
|
||||
this.uploads.delete(id);
|
||||
this.patch(id, { stage: 'done', progress: 1 });
|
||||
this.persist();
|
||||
resolve();
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -276,6 +310,100 @@ export class Uploader {
|
||||
up.start();
|
||||
});
|
||||
}
|
||||
|
||||
private async readTags(it: UploadItem) {
|
||||
if (!it.file) return;
|
||||
try {
|
||||
const { parseBlob } = await import('music-metadata');
|
||||
const meta = await parseBlob(it.file);
|
||||
const duration = meta.format.duration;
|
||||
this.patch(it.id, {
|
||||
tags: {
|
||||
artist: meta.common.artist,
|
||||
title: meta.common.title,
|
||||
album: meta.common.album,
|
||||
duration_ms: duration ? Math.round(duration * 1000) : undefined,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Теги — подсказка для предпроверки; без них сервер прочитает их сам.
|
||||
}
|
||||
}
|
||||
|
||||
/** Незавершённые tus-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */
|
||||
private persist() {
|
||||
const pending: PendingUpload[] = this.state.items
|
||||
.filter((it): it is UploadItem & { sha256: string; tusUrl: string; uploadFileId: string } =>
|
||||
Boolean(it.tusUrl && it.sha256 && it.uploadFileId && it.stage !== 'done'),
|
||||
)
|
||||
.map((it) => ({
|
||||
id: it.id,
|
||||
name: it.file?.name ?? it.relPath,
|
||||
size: it.size,
|
||||
relPath: it.relPath,
|
||||
sha256: it.sha256,
|
||||
tusUrl: it.tusUrl,
|
||||
uploadFileId: it.uploadFileId,
|
||||
}));
|
||||
try {
|
||||
if (pending.length) localStorage.setItem(PENDING_KEY, JSON.stringify(pending));
|
||||
else localStorage.removeItem(PENDING_KEY);
|
||||
} catch {
|
||||
// Приватный режим — докачка после перезагрузки недоступна.
|
||||
}
|
||||
}
|
||||
|
||||
private restore() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PENDING_KEY);
|
||||
if (!raw) return;
|
||||
const rows = JSON.parse(raw) as PendingUpload[];
|
||||
if (!Array.isArray(rows) || !rows.length) return;
|
||||
this.state = {
|
||||
...this.state,
|
||||
items: rows.map((r) => ({
|
||||
id: r.id,
|
||||
file: null,
|
||||
size: r.size,
|
||||
relPath: r.relPath,
|
||||
stage: 'resume' as const,
|
||||
progress: 0,
|
||||
sha256: r.sha256,
|
||||
tusUrl: r.tusUrl,
|
||||
uploadFileId: r.uploadFileId,
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
localStorage.removeItem(PENDING_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/** Приложить заново те же файлы и продолжить tus с принятого смещения. */
|
||||
async continueWith(files: PickedFile[]) {
|
||||
const waiting = this.state.items.filter((it) => it.stage === 'resume' && it.tusUrl);
|
||||
if (!waiting.length) return;
|
||||
const queue: { id: string; url: string }[] = [];
|
||||
for (const picked of files) {
|
||||
const it = waiting.find((w) => w.size === picked.file.size && (w.relPath === picked.relPath || w.relPath.endsWith(picked.file.name)));
|
||||
if (!it) continue;
|
||||
this.patch(it.id, { file: picked.file, stage: 'hashing', progress: 0 });
|
||||
const sha = await this.hash(this.item(it.id)!);
|
||||
if (sha !== it.sha256) {
|
||||
this.patch(it.id, { stage: 'error', message: 'sha256' });
|
||||
continue;
|
||||
}
|
||||
this.patch(it.id, { stage: 'uploading', progress: 0 });
|
||||
queue.push({ id: it.id, url: it.tusUrl! });
|
||||
}
|
||||
if (!queue.length) return;
|
||||
this.set({ busy: true });
|
||||
try {
|
||||
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
|
||||
} finally {
|
||||
this.set({ busy: false });
|
||||
this.persist();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let instance: { session: AuthSession; uploader: Uploader } | null = null;
|
||||
|
||||
@@ -48,6 +48,10 @@ function deviceName(): string {
|
||||
}
|
||||
|
||||
applyTheme(undefined);
|
||||
if (import.meta.env.VITE_E2E === '1') {
|
||||
const { installE2EFetch } = await import('./test/e2eBridge');
|
||||
installE2EFetch();
|
||||
}
|
||||
const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages]));
|
||||
document.documentElement.lang = i18n.language;
|
||||
i18n.on('languageChanged', (l) => {
|
||||
|
||||
@@ -143,7 +143,7 @@ function AppearanceSection() {
|
||||
<span>{t('settings.search_collapse')}</span>
|
||||
<Segmented<'on' | 'off'>
|
||||
label={t('settings.search_collapse')}
|
||||
value={user.ui_prefs.search_collapse === false ? 'off' : 'on'}
|
||||
value={user.ui_prefs.search_collapse === true ? 'on' : 'off'}
|
||||
onChange={(v) => void setPrefs({ search_collapse: v === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') },
|
||||
@@ -187,6 +187,30 @@ function AppearanceSection() {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.normalize')}</span>
|
||||
<Segmented<'on' | 'off'>
|
||||
label={t('settings.normalize')}
|
||||
value={user.ui_prefs.normalize === false ? 'off' : 'on'}
|
||||
onChange={(v) => void setPrefs({ normalize: v === 'on' })}
|
||||
options={[
|
||||
{ value: 'on', label: t('settings.on') },
|
||||
{ value: 'off', label: t('settings.off') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={6}
|
||||
step={1}
|
||||
aria-label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}
|
||||
value={user.ui_prefs.crossfade_s ?? 0}
|
||||
onChange={(e) => void setPrefs({ crossfade_s: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('language.label')}</span>
|
||||
<LanguageSwitch />
|
||||
|
||||
@@ -35,6 +35,7 @@ export function UploadQueuePage() {
|
||||
const [onlyPossible, setOnlyPossible] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [editing, setEditing] = useState<UploadFile | null>(null);
|
||||
const [batchEdit, setBatchEdit] = useState<string | null>(null);
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
@@ -131,6 +132,7 @@ export function UploadQueuePage() {
|
||||
selected={selected}
|
||||
onToggle={toggle}
|
||||
onEdit={setEditing}
|
||||
onEditBatch={setBatchEdit}
|
||||
onRetry={(id) => retry.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
@@ -158,6 +160,17 @@ export function UploadQueuePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchEdit && (
|
||||
<BatchEditDialog
|
||||
batchId={batchEdit}
|
||||
onClose={() => setBatchEdit(null)}
|
||||
onSaved={() => {
|
||||
setBatchEdit(null);
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<EditDialog
|
||||
file={editing}
|
||||
@@ -194,6 +207,7 @@ function BatchCard({
|
||||
selected,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onEditBatch,
|
||||
onRetry,
|
||||
}: {
|
||||
files: UploadFile[];
|
||||
@@ -201,6 +215,7 @@ function BatchCard({
|
||||
selected: Set<string>;
|
||||
onToggle: (ids: string[], on: boolean) => void;
|
||||
onEdit: (f: UploadFile) => void;
|
||||
onEditBatch: (batchId: string) => void;
|
||||
onRetry: (id: string) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation('admin');
|
||||
@@ -218,6 +233,12 @@ function BatchCard({
|
||||
<span>{formatDateTime(first.created_at, i18n.language)}</span>
|
||||
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
|
||||
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
|
||||
<span style={{ flex: 1 }} />
|
||||
{selectable && (
|
||||
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
|
||||
{t('uploads.edit_batch')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{files.map((f) => (
|
||||
<QueueRow
|
||||
@@ -289,6 +310,7 @@ function QueueRow({
|
||||
<div className={u.flags}>
|
||||
<UploadStatusBadge status={f.status} />
|
||||
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
|
||||
{f.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
|
||||
{f.matched_album_id ? (
|
||||
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
|
||||
) : (
|
||||
@@ -302,6 +324,9 @@ function QueueRow({
|
||||
)}
|
||||
</div>
|
||||
<div className={u.rowActions}>
|
||||
{f.stream_url && (
|
||||
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
|
||||
)}
|
||||
{editable && (
|
||||
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
|
||||
<Icon src={editIcon} size={18} />
|
||||
@@ -319,6 +344,75 @@ function QueueRow({
|
||||
|
||||
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
|
||||
|
||||
function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const [artist, setArtist] = useState('');
|
||||
const [album, setAlbum] = useState('');
|
||||
const [year, setYear] = useState('');
|
||||
const [cover, setCover] = useState<string | null | undefined>(undefined);
|
||||
const save = useMutation({
|
||||
mutationFn: async () =>
|
||||
unwrap(
|
||||
await session.client.PATCH('/api/v1/admin/uploads/batches/{id}', {
|
||||
params: { path: { id: batchId } },
|
||||
body: {
|
||||
...(artist.trim() ? { artist: artist.trim() } : {}),
|
||||
...(album.trim() ? { album: album.trim() } : {}),
|
||||
...(year.trim() ? { year: Number.parseInt(year, 10) } : {}),
|
||||
...(cover !== undefined ? { cover_base64: cover } : {}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
const pickCover = (file: File | undefined) => {
|
||||
if (!file) {
|
||||
setCover(undefined);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const data = String(reader.result ?? '');
|
||||
const comma = data.indexOf(',');
|
||||
setCover(comma >= 0 ? data.slice(comma + 1) : data);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
title={t('uploads.batch_edit_title')}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('common:form.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('uploads.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={s.sessions}>
|
||||
<p className={s.note}>{t('uploads.batch_edit_hint')}</p>
|
||||
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
|
||||
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
|
||||
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
|
||||
<label className={s.sessions}>
|
||||
<span>{t('uploads.batch_cover')}</span>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} />
|
||||
</label>
|
||||
<Button variant="ghost" onClick={() => setCover(null)}>
|
||||
{t('uploads.batch_cover_clear')}
|
||||
</Button>
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
@@ -452,7 +546,7 @@ export function UploadSettingsSection() {
|
||||
queryKey: ['sources'],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
|
||||
});
|
||||
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string } | null>(null);
|
||||
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string; upgrade: boolean } | null>(null);
|
||||
useEffect(() => {
|
||||
if (settings.data) setForm(toForm(settings.data));
|
||||
}, [settings.data]);
|
||||
@@ -468,6 +562,7 @@ export function UploadSettingsSection() {
|
||||
max_file_bytes: gb(f.maxFile),
|
||||
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
|
||||
default_source_id: f.source || null,
|
||||
allow_quality_upgrade: f.upgrade,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -507,6 +602,10 @@ export function UploadSettingsSection() {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className={s.sessions} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<input type="checkbox" checked={form.upgrade} onChange={(e) => setForm({ ...form, upgrade: e.target.checked })} />
|
||||
<span>{t('uploads.set_upgrade')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className={s.note}>{t('uploads.set_note')}</p>
|
||||
<div className={s.toolbar}>
|
||||
@@ -528,5 +627,6 @@ function toForm(st: Settings) {
|
||||
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
|
||||
maxFile: gb(st.max_file_bytes),
|
||||
source: st.default_source_id ?? '',
|
||||
upgrade: st.allow_quality_upgrade,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
@@ -31,6 +31,12 @@ export function MyUploadsPage() {
|
||||
const errorText = useErrorText();
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
useEffect(() => {
|
||||
void session.client.POST('/api/v1/uploads/seen').then(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['uploads-unseen'] });
|
||||
});
|
||||
}, [session, queryClient]);
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: ['my-uploads', filter],
|
||||
initialPageParam: undefined as string | undefined,
|
||||
|
||||
@@ -39,9 +39,10 @@ export function SuggestPage() {
|
||||
}, []);
|
||||
|
||||
const ready = st.items.filter((it) => it.stage === 'ready');
|
||||
const readyBytes = ready.reduce((n, it) => n + it.file.size, 0);
|
||||
const readyBytes = ready.reduce((n, it) => n + it.size, 0);
|
||||
const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking');
|
||||
const finished = !st.busy && st.batchId !== null;
|
||||
const resume = st.items.filter((it) => it.stage === 'resume').length;
|
||||
const failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length;
|
||||
const uploaded = st.items.filter((it) => it.stage === 'done').length;
|
||||
const free = quota.data ? Math.max(quota.data.limit_bytes - quota.data.used_bytes, 0) : null;
|
||||
@@ -96,7 +97,9 @@ export function SuggestPage() {
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
onChange={(e) => {
|
||||
uploader.add(fromInput(e.target.files));
|
||||
const picked = fromInput(e.target.files);
|
||||
if (uploader.getState().items.some((it) => it.stage === 'resume')) void uploader.continueWith(picked);
|
||||
else uploader.add(picked);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
@@ -106,12 +109,20 @@ export function SuggestPage() {
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
uploader.add(fromInput(e.target.files));
|
||||
const picked = fromInput(e.target.files);
|
||||
if (uploader.getState().items.some((it) => it.stage === 'resume')) void uploader.continueWith(picked);
|
||||
else uploader.add(picked);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{resume > 0 && (
|
||||
<section className={s.section}>
|
||||
<p>{t('uploads.resume_hint', { count: resume })}</p>
|
||||
<Button onClick={() => filesRef.current?.click()}>{t('uploads.resume_pick')}</Button>
|
||||
</section>
|
||||
)}
|
||||
{st.skipped > 0 && <p className={s.note}>{t('uploads.skipped', { count: st.skipped })}</p>}
|
||||
|
||||
{st.items.length > 0 && (
|
||||
@@ -215,7 +226,7 @@ function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean
|
||||
)}
|
||||
{note && <span className={note.cls}>{note.text}</span>}
|
||||
</div>
|
||||
<span className={u.size}>{formatBytes(item.file.size, i18n.language)}</span>
|
||||
<span className={u.size}>{formatBytes(item.size, i18n.language)}</span>
|
||||
{locked ? (
|
||||
<span />
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
EventBuffer,
|
||||
normalizeGain,
|
||||
PlayAttempt,
|
||||
PlayQueue,
|
||||
unwrap,
|
||||
@@ -80,6 +81,9 @@ function deviceId(): string {
|
||||
*/
|
||||
export class Player {
|
||||
private readonly audio: HTMLAudioElement;
|
||||
private readonly overlap: HTMLAudioElement;
|
||||
private fading = false;
|
||||
private ignoreEnded = false;
|
||||
private readonly queue = new PlayQueue();
|
||||
private readonly events: EventBuffer;
|
||||
private attempt: PlayAttempt | null = null;
|
||||
@@ -102,6 +106,8 @@ export class Player {
|
||||
) {
|
||||
this.audio = audio ?? new Audio();
|
||||
this.audio.preload = 'auto';
|
||||
this.overlap = new Audio();
|
||||
this.overlap.preload = 'auto';
|
||||
this.events = new EventBuffer({
|
||||
send: async (events) => {
|
||||
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
|
||||
@@ -324,15 +330,17 @@ export class Player {
|
||||
}
|
||||
|
||||
setVolume(v: number) {
|
||||
this.audio.volume = Math.min(Math.max(v, 0), 1);
|
||||
const volume = Math.min(Math.max(v, 0), 1);
|
||||
this.audio.muted = false;
|
||||
this.update({ volume: this.audio.volume, muted: false });
|
||||
this.update({ volume, muted: false });
|
||||
this.applyLoudness();
|
||||
this.saveLocal();
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
this.audio.muted = !this.audio.muted;
|
||||
this.update({ muted: this.audio.muted });
|
||||
this.applyLoudness();
|
||||
}
|
||||
|
||||
toggleShuffle() {
|
||||
@@ -382,6 +390,7 @@ export class Player {
|
||||
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||
this.audio.src = track.stream_url;
|
||||
if (startMs > 0) this.audio.currentTime = startMs / 1000;
|
||||
this.applyLoudness();
|
||||
this.attempt = new PlayAttempt(track, startMs);
|
||||
this.preloaded = null;
|
||||
this.events.push({
|
||||
@@ -429,6 +438,107 @@ export class Player {
|
||||
}
|
||||
}
|
||||
|
||||
private prefs() {
|
||||
const user = this.session.state.status === 'signed_in' ? this.session.state.user : null;
|
||||
return user?.ui_prefs;
|
||||
}
|
||||
|
||||
private normalizeOn() {
|
||||
return this.prefs()?.normalize !== false;
|
||||
}
|
||||
|
||||
private crossfadeSeconds() {
|
||||
return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0));
|
||||
}
|
||||
|
||||
/** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */
|
||||
private gain: GainNode | null = null;
|
||||
private wired = false;
|
||||
|
||||
private applyLoudness(mix = 1) {
|
||||
const track = this.state.track;
|
||||
const user = this.state.muted ? 0 : this.state.volume;
|
||||
const level = user * normalizeGain(track?.features?.loudness_lufs, this.normalizeOn()) * mix;
|
||||
if (!this.wired && typeof AudioContext !== 'undefined') {
|
||||
try {
|
||||
const ctx = new AudioContext();
|
||||
const src = ctx.createMediaElementSource(this.audio);
|
||||
this.gain = ctx.createGain();
|
||||
src.connect(this.gain);
|
||||
this.gain.connect(ctx.destination);
|
||||
this.audio.volume = 1;
|
||||
this.wired = true;
|
||||
} catch {
|
||||
this.wired = true;
|
||||
}
|
||||
}
|
||||
if (this.gain) this.gain.gain.value = level;
|
||||
else this.audio.volume = Math.min(1, level);
|
||||
}
|
||||
|
||||
/** В конце трека накладывает следующий на crossfade_s секунд. */
|
||||
private maybeCrossfade(positionMs: number) {
|
||||
const cf = this.crossfadeSeconds();
|
||||
if (this.fading || cf <= 0 || this.state.repeat === 'one') return;
|
||||
const dur = this.audio.duration;
|
||||
if (!Number.isFinite(dur)) return;
|
||||
const left = dur * 1000 - positionMs;
|
||||
if (left <= 0 || left > cf * 1000) return;
|
||||
const next = this.state.queue[this.state.index + 1];
|
||||
if (!next?.stream_url || !next.available) return;
|
||||
this.fading = true;
|
||||
this.overlap.src = next.stream_url;
|
||||
this.overlap.volume = 0;
|
||||
void this.overlap.play().catch(() => {
|
||||
this.fading = false;
|
||||
});
|
||||
const started = performance.now();
|
||||
const step = () => {
|
||||
if (!this.fading) return;
|
||||
const p = Math.min(1, (performance.now() - started) / (cf * 1000));
|
||||
this.applyLoudness(1 - p);
|
||||
this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p);
|
||||
if (p < 1) {
|
||||
requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
this.finishCrossfade(next);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
private finishCrossfade(next: Track) {
|
||||
const at = this.overlap.currentTime;
|
||||
this.ignoreEnded = true;
|
||||
this.audio.pause();
|
||||
this.overlap.pause();
|
||||
this.finishAttempt('play_end');
|
||||
if (!this.queue.next(true)) {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
this.update({ playing: false, positionMs: 0 });
|
||||
return;
|
||||
}
|
||||
this.syncQueue();
|
||||
const src = next.stream_url;
|
||||
if (!src) {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
this.update({ playing: false });
|
||||
return;
|
||||
}
|
||||
this.audio.src = src;
|
||||
if (at > 0) this.audio.currentTime = at;
|
||||
this.applyLoudness();
|
||||
this.attempt = new PlayAttempt(next, Math.round(at * 1000));
|
||||
this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms });
|
||||
this.updateMediaMetadata(next);
|
||||
void this.audio.play().finally(() => {
|
||||
this.fading = false;
|
||||
this.ignoreEnded = false;
|
||||
});
|
||||
}
|
||||
|
||||
private bindAudio() {
|
||||
const a = this.audio;
|
||||
a.addEventListener('playing', () => {
|
||||
@@ -451,10 +561,12 @@ export class Player {
|
||||
this.update({ positionMs: ms });
|
||||
this.updatePositionState();
|
||||
this.maybePreload(ms);
|
||||
this.maybeCrossfade(ms);
|
||||
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
||||
this.saveLocal();
|
||||
});
|
||||
a.addEventListener('ended', () => {
|
||||
if (this.ignoreEnded || this.fading) return;
|
||||
this.finishAttempt('play_end');
|
||||
if (this.queue.next(true)) {
|
||||
this.syncQueue();
|
||||
@@ -465,7 +577,6 @@ export class Player {
|
||||
}
|
||||
});
|
||||
a.addEventListener('error', () => void this.onError());
|
||||
a.addEventListener('volumechange', () => this.update({ volume: a.volume, muted: a.muted }));
|
||||
}
|
||||
|
||||
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { fakeApi, makeUser } from './fakeApi';
|
||||
|
||||
/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */
|
||||
export function installE2EFetch() {
|
||||
const api = fakeApi([
|
||||
makeUser('admin', 'admin', { must_change_credentials: true }),
|
||||
makeUser('alice', 'user'),
|
||||
]);
|
||||
const orig = window.fetch.bind(window);
|
||||
window.fetch = async (input, init) => {
|
||||
const req = new Request(input, init);
|
||||
if (new URL(req.url, window.location.origin).pathname.startsWith('/api/')) return api.fetch(req);
|
||||
return orig(input, init);
|
||||
};
|
||||
}
|
||||
@@ -153,6 +153,16 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
return tokens(user);
|
||||
case 'GET /api/v1/me/sessions':
|
||||
return json({ items: [] });
|
||||
case 'GET /api/v1/uploads/unseen':
|
||||
return json({ count: 0 });
|
||||
case 'POST /api/v1/uploads/seen':
|
||||
return new Response(null, { status: 204 });
|
||||
case 'GET /api/v1/onboarding':
|
||||
return json({ required: false, artists: [], genres: [] });
|
||||
case 'GET /api/v1/me/likes':
|
||||
case 'GET /api/v1/me/dislikes':
|
||||
case 'GET /api/v1/me/history':
|
||||
return json({ items: [], next_cursor: null, total: 0 });
|
||||
case 'GET /api/v1/admin/users': {
|
||||
if (user.role === 'user') return problem(403, 'auth.forbidden');
|
||||
const q = (url.searchParams.get('q') ?? '').toLowerCase();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Suspense, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthState, useSession, useUser } from '../app/session';
|
||||
@@ -151,9 +151,10 @@ function Shell() {
|
||||
const staff = user.role !== 'user';
|
||||
const island = user.ui_prefs.web_player === 'island';
|
||||
const searchAt = user.ui_prefs.search_position ?? 'center';
|
||||
const search = <SearchBox align={searchAt} collapsible={user.ui_prefs.search_collapse !== false} />;
|
||||
const search = <SearchBox align={searchAt} collapsible={user.ui_prefs.search_collapse === true} />;
|
||||
const counts = useCollectionCounts();
|
||||
const pending = usePendingUploads(user.role === 'admin');
|
||||
const unseen = useUnseenUploads(staff);
|
||||
|
||||
return (
|
||||
<div className={cx(s.shell, island && s.island)}>
|
||||
@@ -214,7 +215,7 @@ function Shell() {
|
||||
)}
|
||||
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
|
||||
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
|
||||
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} />
|
||||
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
|
||||
{user.role === 'admin' && (
|
||||
<>
|
||||
<NavLink
|
||||
@@ -233,7 +234,9 @@ function Shell() {
|
||||
</nav>
|
||||
|
||||
<main className={s.content}>
|
||||
<Outlet />
|
||||
<Suspense fallback={null}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
<PlayerBar variant={island ? 'island' : 'bar'} />
|
||||
@@ -261,3 +264,15 @@ function usePendingUploads(enabled: boolean) {
|
||||
});
|
||||
return q.data;
|
||||
}
|
||||
|
||||
/** Новые решения по своим загрузкам — бейдж «Мои предложения» у персонала. */
|
||||
function useUnseenUploads(enabled: boolean) {
|
||||
const session = useSession();
|
||||
const q = useQuery({
|
||||
queryKey: ['uploads-unseen'],
|
||||
enabled,
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/unseen')).count,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
return q.data || undefined;
|
||||
}
|
||||
@@ -20,5 +20,6 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['src/test/setup.ts'],
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
},
|
||||
});
|
||||
+10
-2
@@ -136,6 +136,7 @@ CREATE TABLE upload_batches ( -- одна «пачка» загруз
|
||||
id uuid PRIMARY KEY,
|
||||
author_id uuid NOT NULL REFERENCES users(id),
|
||||
comment text, -- комментарий автора для админа
|
||||
cover_path text, -- обложка пачки (JPEG/PNG/WebP), вшивается при импорте
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
@@ -160,6 +161,7 @@ CREATE TABLE upload_files (
|
||||
duplicate_of_track_id uuid REFERENCES tracks(id),
|
||||
duplicate_level text, -- sha256 | metadata | fingerprint
|
||||
possible_duplicate boolean NOT NULL DEFAULT false,
|
||||
quality_upgrade boolean NOT NULL DEFAULT false, -- тот же трек, но лучше по качеству
|
||||
target_source_id uuid REFERENCES library_sources(id),
|
||||
target_rel_path text,
|
||||
status_reason text, -- код причины: uploads.duplicate, uploads.invalid_audio, текст отклонения
|
||||
@@ -173,12 +175,18 @@ CREATE INDEX ON upload_files (status);
|
||||
CREATE INDEX ON upload_files (author_id, created_at DESC);
|
||||
CREATE INDEX ON upload_files (sha256);
|
||||
|
||||
-- Счётчик квоты (одна строка). Обновляется в той же транзакции, что и смена статуса;
|
||||
-- ночная задача сверяет его с SUM(size_bytes) по «занимающим» статусам.
|
||||
-- Счётчик квоты (одна строка). Обновляется в той же транзакции, что и смена статуса.
|
||||
-- При старте api и каждые 10 минут сверяется с SUM(size_bytes) по «занимающим» статусам.
|
||||
CREATE TABLE upload_quota (
|
||||
id boolean PRIMARY KEY DEFAULT true CHECK (id),
|
||||
used_bytes bigint NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Когда автор последний раз открыл «Мои предложения» (бейдж новых решений).
|
||||
CREATE TABLE upload_visits (
|
||||
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
seen_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
```
|
||||
|
||||
## Каталог
|
||||
|
||||
+5
-7
@@ -157,10 +157,8 @@ interface WaveButtonProps {
|
||||
- Лист обновления: «Что нового» → разрешение на установку (один раз) → загрузка → проверка SHA-256 → «Установить».
|
||||
- Офлайн-баннер при отсутствии сети.
|
||||
|
||||
## 5. Пробелы дизайна (дорабатываем при реализации)
|
||||
- **Адаптивность веба**: прототип рассчитан на десктоп (≥ 1200 px). Для < 1024 px сайдбар сворачивается в иконки,
|
||||
для < 768 px — нижняя навигация как в Android. Макетов нет — делаем по образцу Android-версии.
|
||||
- **Пустые состояния и ошибки** есть не везде (например, хранилище недоступно, нет сети в вебе) — по образцу существующих
|
||||
(иконка + короткий текст + действие).
|
||||
- **Онбординг вкуса** (выбор жанров / исполнителей при первом входе) в прототипе отсутствует — макет нужен до M6.
|
||||
- **Английская локализация**: в прототипе только русские строки; при вёрстке проверять длинные строки на обоих языках.
|
||||
## 5. Адаптив и онбординг
|
||||
- **Адаптивность веба**: при ширине < 1024 px сайдбар сворачивается в иконки, при < 768 px — нижняя навигация.
|
||||
- **Онбординг вкуса** показывается на главной, пока `GET /onboarding` отвечает `required`.
|
||||
- **Пустые состояния** — иконка, короткий текст и действие там, где список может быть пустым (поиск, коллекция, очередь).
|
||||
- Строки интерфейса есть на русском и английском; длинные подписи проверяются на обоих языках.
|
||||
+2
-1
@@ -37,7 +37,8 @@ location /_internal/cache/ { internal; alias /cache/transcode/; sendfile on;
|
||||
- Транскод трека 4 мин в Opus занимает ~1–2 с на одном ядре, поэтому делаем синхронно при первом запросе.
|
||||
Клиенты **предзагружают следующий трек очереди** (запрос `HEAD`/`Range: bytes=0-0` за 20 с до конца текущего) —
|
||||
на сервере это прогревает транскод.
|
||||
- Фоновая задача `pretranscode` для треков из выданных пачек волны (они почти наверняка будут сыграны).
|
||||
- Фоновая подготовка: выдача пачки волны вызывает `playback.Prewarm` — для качества `high` транскод
|
||||
строится сразу, не дожидаясь первого `Range` клиента.
|
||||
- Кэш транскодов — LRU с лимитом `TRANSCODE_CACHE_LIMIT_GB` (по умолчанию 50 ГБ), чистка по `atime` раз в час.
|
||||
- Пул ffmpeg ограничен (`TRANSCODE_CONCURRENCY`, по умолчанию = число ядер / 2).
|
||||
|
||||
|
||||
+9
-6
@@ -7,13 +7,13 @@
|
||||
| Unit | Go | `testing`, `testify`, табличные тесты | Политики прав (вся матрица из auth-and-roles.md), парсинг шаблонов путей и слияние с тегами, нормализация имён, подпись ссылок, расчёт `implicit_score`, рендер `write_template` и очистка имён, обратимость шаблон ↔ регэксп, учёт квоты при конкурентных резервах, переходы статусов загрузки |
|
||||
| Unit | Python | `pytest`, `hypothesis` | Формулы скоринга, context_fit, MMR, бандит, обновление сессионного вектора, правила переранжирования |
|
||||
| Unit | TS | `vitest` | `packages/core`: очередь, события (подсчёт `played_ms`, засчитанное прослушивание), single-flight refresh, офлайн-очередь |
|
||||
| Компоненты | Web | `vitest` + Testing Library | Поиск (клавиатура, Enter / выбор), плеер, MoodPicker, формы админки |
|
||||
| Компоненты | Mobile | `jest` + RNTL | Экраны входа, коллекции, диалог обновления |
|
||||
| Интеграция | Go | `testcontainers-go` (Postgres + pgvector, Redis, Meilisearch) | Репозитории, миграции, сканер на тестовой библиотеке, API-хендлеры через `httptest` |
|
||||
| Компоненты | Web | `vitest` + Testing Library + axe-core | Поиск, плеер, волна, формы админки, вход; серьёзные нарушения axe на главной и входе |
|
||||
| Компоненты | Mobile | `vitest` (`apps/mobile/test`) | Офлайн-волна и решение диалога обновления |
|
||||
| Интеграция | Go | `testcontainers-go` (Postgres + pgvector, Redis, Meilisearch) | Репозитории, миграции, сканер, API через `httptest`; `TestUserJourney` — сценарии 1–2 и скан |
|
||||
| Интеграция | Python | `testcontainers` | Чтение событий из Redis Stream, kNN в pgvector, ночной пайплайн на синтетических данных |
|
||||
| Контракт | — | `schemathesis` против запущенного `api` | Ответы соответствуют `openapi.yaml`, нет 500 на фаззинге |
|
||||
| E2E | Web | Playwright (Chromium + Firefox) | Сценарии ниже, на `deploy/compose.test.yaml` |
|
||||
| E2E | Mobile | Maestro (эмулятор) | Вход, волна, фоновое воспроизведение, скачивание, офлайн — ночной прогон, не на каждый PR |
|
||||
| Контракт | — | `tests/contract/schemathesis.sh` | Ответы соответствуют `openapi.yaml`; нужен запущенный `api` |
|
||||
| E2E | Web | Playwright (`apps/web/e2e`, Chromium, `pnpm --filter @pipu/web test:e2e`) | Сценарии 1, 4 и 9 на фейковом API |
|
||||
| E2E | Mobile | Maestro (`apps/mobile/.maestro`) | Вход, коллекция, проверка обновления. Ночной прогон (`PIPU_E2E_SERVER`, `PIPU_E2E_USER`, `PIPU_E2E_PASSWORD`), не на каждый PR |
|
||||
| Качество рекомендаций | Python | офлайн-оценка | NDCG / Recall на синтетическом датасете с известными «вкусами»; регрессия качества ломает тест |
|
||||
|
||||
## Тестовая музыка
|
||||
@@ -27,6 +27,9 @@
|
||||
|
||||
## E2E-сценарии (обязательные)
|
||||
|
||||
Сценарии 1, 4 и 9 гоняет Playwright. Сценарии входа, ролей, поиска, плеера, волны и языка — `apps/web/src/App.test.tsx`.
|
||||
Сценарии источника и скана — `TestUserJourney` в `services/api` (нужен `TEST_DATABASE_URL`).
|
||||
|
||||
1. Первый запуск: вход bootstrap-админа → принудительная смена логина / пароля → главная.
|
||||
2. Админ создаёт модератора; модератор создаёт пользователя; модератор **не может** создать модератора (UI и API).
|
||||
3. Админ добавляет `local`-источник, задаёт два шаблона, предпросмотр, скан → треки появились в поиске.
|
||||
|
||||
+12
-10
@@ -197,17 +197,19 @@
|
||||
- Настройки: `GET` / `PATCH /admin/settings/uploads` (ключ `uploads.settings` в `app_settings`).
|
||||
- Веб (`apps/web/src/features/uploads`, `pages/uploads`, `pages/admin/UploadQueuePage.tsx`):
|
||||
- «Предложить треки» (`/uploads/new`): drag-and-drop файлов и папок (FileSystemEntry API, относительные пути),
|
||||
выбор файлов / папки; SHA-256 потоком в Web Worker (`hash-wasm`); предпроверка; `POST /uploads`; `tus-js-client`
|
||||
с готовым `uploadUrl`, куски по 8 МБ, 4 файла параллельно, 401 → обновление токена и повтор. Загрузчик — синглтон
|
||||
модуля: переходы по приложению не прерывают загрузку; оборвавшиеся файлы продолжаются кнопкой
|
||||
«Продолжить загрузку» (место уже зарезервировано).
|
||||
- «Мои предложения» (`/uploads/mine`): статусы, причины отказа, отзыв до решения, квота.
|
||||
выбор файлов / папки; SHA-256 потоком в Web Worker (`hash-wasm`); теги читает `music-metadata` и отдаёт их
|
||||
в предпроверку; `POST /uploads`; `tus-js-client` с готовым `uploadUrl`, куски по 8 МБ, 4 файла параллельно,
|
||||
401 → обновление токена и повтор. Загрузчик — синглтон модуля: переходы по приложению не прерывают загрузку.
|
||||
Незавершённые tus-ссылки пишутся в `localStorage`; после перезагрузки пользователь выбирает те же файлы,
|
||||
клиент сверяет SHA-256 и продолжает с принятого смещения.
|
||||
- «Мои предложения» (`/uploads/mine`): статусы, причины отказа, отзыв до решения, квота. Бейдж — число решений
|
||||
новее `upload_visits`; открытие страницы вызывает `POST /uploads/seen`.
|
||||
- «Очередь на добавление» (`/admin/uploads`, admin): вкладки по статусам, фильтр возможных дублей, группировка
|
||||
по пачкам, правка метаданных и хранилища, массовое одобрение / отклонение с причиной, повтор импорта;
|
||||
настройки очереди — внизу страницы.
|
||||
- Пока не сделано: `allow_quality_upgrade`, правка «на всю пачку» (`PATCH /admin/uploads/batches/{id}`),
|
||||
прослушивание из staging, отдельная обложка пачки, ночная сверка счётчика квоты, чтение тегов на клиенте
|
||||
(`music-metadata`) для предпроверки метаданных, продолжение загрузки после перезагрузки страницы.
|
||||
по пачкам, правка метаданных и хранилища, правка исполнителя / альбома / года / обложки на всю пачку,
|
||||
прослушивание staging по подписанной ссылке, флаг улучшения качества, массовое одобрение / отклонение с причиной,
|
||||
повтор импорта. Настройки очереди — лимиты, хранилище и `allow_quality_upgrade`.
|
||||
- Сверка квоты: `used_bytes` обновляется в транзакции смены статуса и дополнительно пересчитывается по
|
||||
`SUM(size_bytes)` занимающих статусов при старте `api` и в цикле очистки (раз в 10 минут).
|
||||
|
||||
## 11. Очистка
|
||||
- `uploading` старше 24 ч → `expired`, файлы удаляются.
|
||||
|
||||
@@ -404,6 +404,40 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/uploads/unseen": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Сколько решений по своим предложениям с прошлого визита (moderator, admin) */
|
||||
get: operations["unseenUploadDecisions"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/uploads/seen": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Отметить свои предложения просмотренными */
|
||||
post: operations["markUploadsSeen"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/uploads/files/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -484,6 +518,48 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/uploads/files/{id}/stream": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Прослушать файл из staging (подпись, как у стрима трека)
|
||||
* @description Ссылку даёт `stream_url` файла очереди. Range поддерживается.
|
||||
*/
|
||||
get: operations["streamUpload"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/uploads/batches/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["EntityId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Правка метаданных и обложки на всю пачку (admin)
|
||||
* @description Исполнитель, альбом и год применяются к файлам в pending_review и import_failed. Обложка встраивается при импорте.
|
||||
*/
|
||||
patch: operations["updateUploadBatch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/uploads/review": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1307,6 +1383,7 @@ export interface components {
|
||||
artist?: string;
|
||||
title?: string;
|
||||
duration_ms?: number;
|
||||
album?: string;
|
||||
}[];
|
||||
};
|
||||
UploadPrecheckResult: {
|
||||
@@ -1399,6 +1476,8 @@ export interface components {
|
||||
/** @enum {string|null} */
|
||||
duplicate_level: "sha256" | "metadata" | "fingerprint" | null;
|
||||
possible_duplicate: boolean;
|
||||
/** @description Тот же трек, но лучшего качества — ждёт решения, если включён allow_quality_upgrade */
|
||||
quality_upgrade: boolean;
|
||||
/** Format: uuid */
|
||||
target_source_id: string | null;
|
||||
/** @description Путь в хранилище по write_template (null — не построился, причина в status_reason) */
|
||||
@@ -1412,6 +1491,8 @@ export interface components {
|
||||
updated_at: string;
|
||||
/** @description Только автору и пока файл в статусе uploading */
|
||||
tus_url: string | null;
|
||||
/** @description Подписанная ссылка прослушивания из staging; null, если файла уже нет */
|
||||
stream_url: string | null;
|
||||
};
|
||||
UploadFilePage: {
|
||||
items: components["schemas"]["UploadFile"][];
|
||||
@@ -1422,6 +1503,14 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
target_source_id?: string;
|
||||
};
|
||||
/** @description Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки. */
|
||||
UploadBatchPatch: {
|
||||
artist?: string;
|
||||
album?: string;
|
||||
year?: number | null;
|
||||
/** @description JPEG, PNG или WebP, не больше 8 МБ после декодирования */
|
||||
cover_base64?: string | null;
|
||||
};
|
||||
UploadReviewRequest: {
|
||||
file_ids: string[];
|
||||
/** @enum {string} */
|
||||
@@ -1446,6 +1535,8 @@ export interface components {
|
||||
max_file_bytes: number;
|
||||
/** Format: uuid */
|
||||
default_source_id: string | null;
|
||||
/** @description Тот же трек в лучшем качестве идёт на рассмотрение, а не в дубли */
|
||||
allow_quality_upgrade: boolean;
|
||||
};
|
||||
/** @description Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано). */
|
||||
UploadSettingsPatch: {
|
||||
@@ -1463,6 +1554,7 @@ export interface components {
|
||||
* @description null — без хранилища по умолчанию
|
||||
*/
|
||||
default_source_id: string | null;
|
||||
allow_quality_upgrade?: boolean;
|
||||
};
|
||||
HealthStatus: {
|
||||
/** @enum {string} */
|
||||
@@ -1538,7 +1630,7 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
search_position?: "center" | "left" | "right";
|
||||
/** @description Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию true) */
|
||||
/** @description Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию false) */
|
||||
search_collapse?: boolean;
|
||||
/** @description Показывать на главной веба выбор занятия (по умолчанию true) */
|
||||
home_activities?: boolean;
|
||||
@@ -2702,6 +2794,48 @@ export interface operations {
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
unseenUploadDecisions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Число */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
count: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
markUploadsSeen: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Отмечено */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
withdrawUpload: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2803,6 +2937,61 @@ export interface operations {
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
streamUpload: {
|
||||
parameters: {
|
||||
query: {
|
||||
exp: number;
|
||||
sig: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["EntityId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Аудио из staging */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"audio/*": string;
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
updateUploadBatch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["EntityId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["UploadBatchPatch"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Обновлённые файлы пачки */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
items: components["schemas"]["UploadFile"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
reviewUploads: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeGain } from './gain';
|
||||
|
||||
describe('normalizeGain', () => {
|
||||
it('lifts a quiet track toward -14 LUFS and clamps the extremes', () => {
|
||||
expect(normalizeGain(-14, true)).toBeCloseTo(1);
|
||||
expect(normalizeGain(-20, true)).toBeGreaterThan(1);
|
||||
expect(normalizeGain(-40, true)).toBe(4);
|
||||
expect(normalizeGain(0, true)).toBe(0.25);
|
||||
expect(normalizeGain(-20, false)).toBe(1);
|
||||
expect(normalizeGain(null, true)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Целевая громкость выравнивания (docs/playback.md). */
|
||||
export const TARGET_LUFS = -14;
|
||||
|
||||
/** Множитель громкости к −14 LUFS. Выключено или нет замера — 1. Ограничен, чтобы тихий трек не орал. */
|
||||
export function normalizeGain(lufs: number | null | undefined, enabled: boolean): number {
|
||||
if (!enabled || lufs == null || Number.isNaN(lufs)) return 1;
|
||||
const gain = 10 ** ((TARGET_LUFS - lufs) / 20);
|
||||
return Math.min(4, Math.max(0.25, gain));
|
||||
}
|
||||
@@ -21,5 +21,6 @@ export {
|
||||
type RepeatMode,
|
||||
type Track,
|
||||
} from './player';
|
||||
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
|
||||
export { normalizeGain, TARGET_LUFS } from './gain';
|
||||
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
|
||||
@@ -203,6 +203,11 @@
|
||||
"skipped_few": "{{count}} files skipped — not audio",
|
||||
"skipped_many": "{{count}} files skipped — not audio",
|
||||
"skipped_other": "{{count}} files skipped — not audio",
|
||||
"resume_hint_one": "{{count}} file was not finished. Pick it again to resume.",
|
||||
"resume_hint_few": "{{count}} files were not finished. Pick them again to resume.",
|
||||
"resume_hint_many": "{{count}} files were not finished. Pick them again to resume.",
|
||||
"resume_hint_other": "{{count}} files were not finished. Pick them again to resume.",
|
||||
"resume_pick": "Pick files and resume",
|
||||
"comment": "Comment for the administrator",
|
||||
"comment_hint": "For example: “2011 remaster”, “replacing broken files”",
|
||||
"not_enough_space": "The queue is short of space: {{need}} needed, {{free}} free — some files will be refused.",
|
||||
@@ -275,6 +280,14 @@
|
||||
"disc": "disc {{n}}",
|
||||
"edited": "edited",
|
||||
"flag_possible": "possible duplicate",
|
||||
"flag_quality": "better quality",
|
||||
"listen": "Listen to {{name}}",
|
||||
"edit_batch": "Whole batch",
|
||||
"batch_edit_title": "Edit batch",
|
||||
"batch_edit_hint": "Empty fields stay as they are. The cover is written into the files on import.",
|
||||
"batch_cover": "Batch cover",
|
||||
"batch_cover_clear": "Remove cover",
|
||||
"set_upgrade": "Accept the same track in better quality",
|
||||
"flag_existing_album": "existing album",
|
||||
"flag_new_album": "new album",
|
||||
"dup_level_sha256": "exact copy of a file",
|
||||
@@ -332,7 +345,8 @@
|
||||
"uploading": "Uploading",
|
||||
"done": "Uploaded, processing",
|
||||
"rejected": "Not accepted",
|
||||
"error": "Error"
|
||||
"error": "Error",
|
||||
"resume": "Waiting for the file to resume"
|
||||
}
|
||||
},
|
||||
"server_settings": {
|
||||
|
||||
@@ -122,7 +122,9 @@
|
||||
"search_right": "Right",
|
||||
"search_collapse": "Collapse search",
|
||||
"home_activities": "Activity picker on home",
|
||||
"home_moods": "Mood picker on home"
|
||||
"home_moods": "Mood picker on home",
|
||||
"normalize": "Loudness normalization",
|
||||
"crossfade": "Crossfade, {{seconds}} s"
|
||||
},
|
||||
"form": {
|
||||
"show_password": "Show password",
|
||||
|
||||
@@ -203,6 +203,11 @@
|
||||
"skipped_few": "Пропущено {{count}} файла — не аудио",
|
||||
"skipped_many": "Пропущено {{count}} файлов — не аудио",
|
||||
"skipped_other": "Пропущено {{count}} файла — не аудио",
|
||||
"resume_hint_one": "Не докачан {{count}} файл. Выберите его снова — загрузка продолжится.",
|
||||
"resume_hint_few": "Не докачано {{count}} файла. Выберите их снова — загрузка продолжится.",
|
||||
"resume_hint_many": "Не докачано {{count}} файлов. Выберите их снова — загрузка продолжится.",
|
||||
"resume_hint_other": "Не докачано {{count}} файла. Выберите их снова — загрузка продолжится.",
|
||||
"resume_pick": "Выбрать файлы и продолжить",
|
||||
"comment": "Комментарий для администратора",
|
||||
"comment_hint": "Например: «ремастер 2011», «замена битых файлов»",
|
||||
"not_enough_space": "В очереди не хватает места: нужно {{need}}, свободно {{free}} — часть файлов не примут.",
|
||||
@@ -275,6 +280,14 @@
|
||||
"disc": "диск {{n}}",
|
||||
"edited": "правлено",
|
||||
"flag_possible": "возможный дубль",
|
||||
"flag_quality": "лучше по качеству",
|
||||
"listen": "Слушать {{name}}",
|
||||
"edit_batch": "На всю пачку",
|
||||
"batch_edit_title": "Правка пачки",
|
||||
"batch_edit_hint": "Пустые поля не меняются. Обложка запишется в файлы при импорте.",
|
||||
"batch_cover": "Обложка пачки",
|
||||
"batch_cover_clear": "Убрать обложку",
|
||||
"set_upgrade": "Принимать тот же трек в лучшем качестве",
|
||||
"flag_existing_album": "в существующий альбом",
|
||||
"flag_new_album": "новый альбом",
|
||||
"dup_level_sha256": "точная копия файла",
|
||||
@@ -332,7 +345,8 @@
|
||||
"uploading": "Загружается",
|
||||
"done": "Загружен, обрабатывается",
|
||||
"rejected": "Не принят",
|
||||
"error": "Ошибка"
|
||||
"error": "Ошибка",
|
||||
"resume": "Ждёт файл, чтобы продолжить"
|
||||
}
|
||||
},
|
||||
"server_settings": {
|
||||
|
||||
@@ -122,7 +122,9 @@
|
||||
"search_right": "Справа",
|
||||
"search_collapse": "Сворачивать поиск",
|
||||
"home_activities": "Выбор занятия на главной",
|
||||
"home_moods": "Выбор настроения на главной"
|
||||
"home_moods": "Выбор настроения на главной",
|
||||
"normalize": "Выравнивание громкости",
|
||||
"crossfade": "Кроссфейд, {{seconds}} с"
|
||||
},
|
||||
"form": {
|
||||
"show_password": "Показать пароль",
|
||||
|
||||
Generated
+252
-10
@@ -114,6 +114,9 @@ importers:
|
||||
'@types/react':
|
||||
specifier: ~19.0.10
|
||||
version: 19.0.14
|
||||
vitest:
|
||||
specifier: ^3.2.7
|
||||
version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
|
||||
apps/web:
|
||||
dependencies:
|
||||
@@ -147,9 +150,15 @@ importers:
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.120.20
|
||||
version: 1.120.20(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.14.21
|
||||
version: 3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
|
||||
hash-wasm:
|
||||
specifier: ^4.12.0
|
||||
version: 4.12.0
|
||||
music-metadata:
|
||||
specifier: ^12.2.3
|
||||
version: 12.2.3(supports-color@10.2.2)
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.3.1
|
||||
@@ -163,6 +172,9 @@ importers:
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.1
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.64.1
|
||||
version: 1.64.1
|
||||
'@testing-library/dom':
|
||||
specifier: ^10.4.0
|
||||
version: 10.4.2
|
||||
@@ -184,15 +196,18 @@ importers:
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.5.2
|
||||
version: 4.7.0(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2))
|
||||
axe-core:
|
||||
specifier: ^4.14.0
|
||||
version: 4.14.0
|
||||
jsdom:
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0(supports-color@10.2.2)
|
||||
version: 26.1.0
|
||||
vite:
|
||||
specifier: ^6.3.5
|
||||
version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
|
||||
packages/api-client:
|
||||
dependencies:
|
||||
@@ -212,7 +227,7 @@ importers:
|
||||
devDependencies:
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
|
||||
packages/design-tokens: {}
|
||||
|
||||
@@ -735,6 +750,9 @@ packages:
|
||||
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@borewit/text-codec@0.2.2':
|
||||
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1091,6 +1109,11 @@ packages:
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@playwright/test@1.64.1':
|
||||
resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2':
|
||||
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
||||
peerDependencies:
|
||||
@@ -1419,6 +1442,12 @@ packages:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/react-virtual@3.14.21':
|
||||
resolution: {integrity: sha512-wWE/ZJDNNDp18JHWy7n5PQooqerHePVkEEu6Op0twKQ2InF9DPhYMQptQY0syDEviMd3/r8D0OUvKJibDGm9HQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/router-core@1.120.19':
|
||||
resolution: {integrity: sha512-5JUVgkxnIM3NxMwzKt0tfz2UopZVxwq6Kl7Rp33zlFJaPjpiRs46VuRjVeAvkpJd6samo1gcH1rWqmnPUmtGcw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1426,6 +1455,9 @@ packages:
|
||||
'@tanstack/store@0.7.7':
|
||||
resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==}
|
||||
|
||||
'@tanstack/virtual-core@3.19.6':
|
||||
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
|
||||
|
||||
'@testing-library/dom@10.4.2':
|
||||
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1455,6 +1487,13 @@ packages:
|
||||
peerDependencies:
|
||||
'@testing-library/dom': '>=7.21.4'
|
||||
|
||||
'@tokenizer/inflate@0.5.1':
|
||||
resolution: {integrity: sha512-56U2O6fDLFtqokwWh597IdeUgtM7j+TT9tb1soybhgA6cy163gAcVmoQr8H4Ud/CkgCkViwcuLyqtn6wYqw59Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@tokenizer/token@0.3.0':
|
||||
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
|
||||
|
||||
'@types/aria-query@5.0.4':
|
||||
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
||||
|
||||
@@ -1662,6 +1701,10 @@ packages:
|
||||
await-lock@2.2.2:
|
||||
resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==}
|
||||
|
||||
axe-core@4.14.0:
|
||||
resolution: {integrity: sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
babel-jest@29.7.0:
|
||||
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
@@ -1920,6 +1963,10 @@ packages:
|
||||
resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==}
|
||||
engines: {node: '>= 0.10.0'}
|
||||
|
||||
content-type@3.1.1:
|
||||
resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
@@ -2261,6 +2308,10 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
file-type@22.2.0:
|
||||
resolution: {integrity: sha512-XmEVJsRpQMVYC+sWFW+FKAVo2P7/lPctDdWH4UQ1r6oxhdrTobnowdM3z+ZJ93a4J8D3yDpxX0bEvRuFev0vUQ==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
fill-range@7.1.1:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2735,6 +2786,10 @@ packages:
|
||||
marky@1.3.0:
|
||||
resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
|
||||
|
||||
media-typer@2.0.0:
|
||||
resolution: {integrity: sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
memoize-one@5.2.1:
|
||||
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
|
||||
|
||||
@@ -2861,6 +2916,10 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
music-metadata@12.2.3:
|
||||
resolution: {integrity: sha512-YN0oQcaIhXyIW8lmmjErhegZhqpyO325oQu9ZjtOFXb/QfwgGjxpzIQUmEGHBpLlrCix42OmrlvUZFkfv+Eecw==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
mz@2.7.0:
|
||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||
|
||||
@@ -3043,6 +3102,16 @@ packages:
|
||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
playwright-core@1.64.1:
|
||||
resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.64.1:
|
||||
resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
plist@3.1.1:
|
||||
resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==}
|
||||
engines: {node: '>=10.4.0'}
|
||||
@@ -3518,6 +3587,10 @@ packages:
|
||||
strip-literal@3.1.0:
|
||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||
|
||||
strtok3@10.6.2:
|
||||
resolution: {integrity: sha512-BkdixFFUt7r7LsX6aGNPVyYMLjW+M1USLlKPNbZsrZEmLFh5tGqhdfckKo6xgsZIJqdDD0CZGkeCJJwNWQIKQA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
structured-headers@0.4.1:
|
||||
resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==}
|
||||
|
||||
@@ -3630,6 +3703,10 @@ packages:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
token-types@6.1.2:
|
||||
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
tough-cookie@5.1.2:
|
||||
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -3700,6 +3777,10 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uint8array-extras@1.6.0:
|
||||
resolution: {integrity: sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
@@ -3904,6 +3985,9 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
win-guid@0.2.1:
|
||||
resolution: {integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==}
|
||||
|
||||
wonka@6.3.6:
|
||||
resolution: {integrity: sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==}
|
||||
|
||||
@@ -4622,6 +4706,8 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.29.7
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
|
||||
'@borewit/text-codec@0.2.2': {}
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
@@ -5100,6 +5186,10 @@ snapshots:
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@playwright/test@1.64.1':
|
||||
dependencies:
|
||||
playwright: 1.64.1
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.0.14)(react@19.0.0)':
|
||||
dependencies:
|
||||
react: 19.0.0
|
||||
@@ -5454,6 +5544,12 @@ snapshots:
|
||||
react-dom: 19.3.1(react@19.3.1)
|
||||
use-sync-external-store: 1.7.0(react@19.3.1)
|
||||
|
||||
'@tanstack/react-virtual@3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.19.6
|
||||
react: 19.3.1
|
||||
react-dom: 19.3.1(react@19.3.1)
|
||||
|
||||
'@tanstack/router-core@1.120.19':
|
||||
dependencies:
|
||||
'@tanstack/history': 1.120.17
|
||||
@@ -5462,6 +5558,8 @@ snapshots:
|
||||
|
||||
'@tanstack/store@0.7.7': {}
|
||||
|
||||
'@tanstack/virtual-core@3.19.6': {}
|
||||
|
||||
'@testing-library/dom@10.4.2':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
@@ -5496,6 +5594,15 @@ snapshots:
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.2
|
||||
|
||||
'@tokenizer/inflate@0.5.1(supports-color@10.2.2)':
|
||||
dependencies:
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
token-types: 6.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tokenizer/token@0.3.0': {}
|
||||
|
||||
'@types/aria-query@5.0.4': {}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
@@ -5602,6 +5709,14 @@ snapshots:
|
||||
chai: 5.3.3
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/mocker@3.2.7(vite@6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.7
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
|
||||
'@vitest/mocker@3.2.7(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.7
|
||||
@@ -5715,6 +5830,8 @@ snapshots:
|
||||
|
||||
await-lock@2.2.2: {}
|
||||
|
||||
axe-core@4.14.0: {}
|
||||
|
||||
babel-jest@29.7.0(@babel/core@7.29.7):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
@@ -6043,6 +6160,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
content-type@3.1.1: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
core-js-compat@3.50.0:
|
||||
@@ -6373,6 +6492,15 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.7
|
||||
|
||||
file-type@22.2.0(supports-color@10.2.2):
|
||||
dependencies:
|
||||
'@tokenizer/inflate': 0.5.1(supports-color@10.2.2)
|
||||
strtok3: 10.6.2
|
||||
token-types: 6.1.2
|
||||
uint8array-extras: 1.6.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
@@ -6491,7 +6619,7 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http-proxy-agent@7.0.2(supports-color@10.2.2):
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
@@ -6686,13 +6814,13 @@ snapshots:
|
||||
|
||||
jsc-safe-url@0.2.4: {}
|
||||
|
||||
jsdom@26.1.0(supports-color@10.2.2):
|
||||
jsdom@26.1.0:
|
||||
dependencies:
|
||||
cssstyle: 4.6.0
|
||||
data-urls: 5.0.0
|
||||
decimal.js: 10.6.0
|
||||
html-encoding-sniffer: 4.0.0
|
||||
http-proxy-agent: 7.0.2(supports-color@10.2.2)
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6(supports-color@10.2.2)
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
nwsapi: 2.2.28
|
||||
@@ -6845,6 +6973,8 @@ snapshots:
|
||||
|
||||
marky@1.3.0: {}
|
||||
|
||||
media-typer@2.0.0: {}
|
||||
|
||||
memoize-one@5.2.1: {}
|
||||
|
||||
merge-stream@2.0.0: {}
|
||||
@@ -7069,6 +7199,21 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
music-metadata@12.2.3(supports-color@10.2.2):
|
||||
dependencies:
|
||||
'@borewit/text-codec': 0.2.2
|
||||
'@tokenizer/token': 0.3.0
|
||||
content-type: 3.1.1
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
file-type: 22.2.0(supports-color@10.2.2)
|
||||
media-typer: 2.0.0
|
||||
strtok3: 10.6.2
|
||||
token-types: 6.1.2
|
||||
uint8array-extras: 1.6.0
|
||||
win-guid: 0.2.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mz@2.7.0:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
@@ -7230,6 +7375,12 @@ snapshots:
|
||||
|
||||
pirates@4.0.7: {}
|
||||
|
||||
playwright-core@1.64.1: {}
|
||||
|
||||
playwright@1.64.1:
|
||||
dependencies:
|
||||
playwright-core: 1.64.1
|
||||
|
||||
plist@3.1.1:
|
||||
dependencies:
|
||||
'@xmldom/xmldom': 0.9.12
|
||||
@@ -7736,6 +7887,10 @@ snapshots:
|
||||
dependencies:
|
||||
js-tokens: 9.0.1
|
||||
|
||||
strtok3@10.6.2:
|
||||
dependencies:
|
||||
'@tokenizer/token': 0.3.0
|
||||
|
||||
structured-headers@0.4.1: {}
|
||||
|
||||
sucrase@3.35.0:
|
||||
@@ -7842,6 +7997,12 @@ snapshots:
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
token-types@6.1.2:
|
||||
dependencies:
|
||||
'@borewit/text-codec': 0.2.2
|
||||
'@tokenizer/token': 0.3.0
|
||||
ieee754: 1.2.1
|
||||
|
||||
tough-cookie@5.1.2:
|
||||
dependencies:
|
||||
tldts: 6.1.86
|
||||
@@ -7899,6 +8060,8 @@ snapshots:
|
||||
|
||||
typescript@5.8.3: {}
|
||||
|
||||
uint8array-extras@1.6.0: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
undici-types@8.9.0: {}
|
||||
@@ -7955,7 +8118,28 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite-node@3.2.4(@types/node@26.6.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||
vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite-node@3.2.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
@@ -7976,6 +8160,20 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite@6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0(picomatch@4.0.7)
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.31
|
||||
rollup: 4.64.10
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 22.20.5
|
||||
fsevents: 2.3.3
|
||||
lightningcss: 1.27.0
|
||||
terser: 5.51.2
|
||||
|
||||
vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
@@ -7990,7 +8188,49 @@ snapshots:
|
||||
lightningcss: 1.27.0
|
||||
terser: 5.51.2
|
||||
|
||||
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||
vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.7
|
||||
'@vitest/mocker': 3.2.7(vite@6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2))
|
||||
'@vitest/pretty-format': 3.2.7
|
||||
'@vitest/runner': 3.2.7
|
||||
'@vitest/snapshot': 3.2.7
|
||||
'@vitest/spy': 3.2.7
|
||||
'@vitest/utils': 3.2.7
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3(supports-color@10.2.2)
|
||||
expect-type: 1.4.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.7
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinyglobby: 0.2.17
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.20.5
|
||||
jsdom: 26.1.0
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.7
|
||||
@@ -8013,11 +8253,11 @@ snapshots:
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
vite-node: 3.2.4(@types/node@26.6.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||
vite-node: 3.2.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.5
|
||||
jsdom: 26.1.0(supports-color@10.2.2)
|
||||
jsdom: 26.1.0
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
@@ -8082,6 +8322,8 @@ snapshots:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
win-guid@0.2.1: {}
|
||||
|
||||
wonka@6.3.6: {}
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
|
||||
@@ -164,6 +164,9 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
|
||||
uploads.Config{StagingDir: cfg.UploadsDir, FFmpeg: cfg.FFmpegPath}, log)
|
||||
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)
|
||||
wh.Warm = pb.Prewarm
|
||||
return server.NewRouter(server.Deps{
|
||||
Log: log,
|
||||
System: sys,
|
||||
@@ -172,11 +175,10 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
|
||||
Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
|
||||
Search: search.NewHandler(meili, libStore, indexer, search.NewAffinity(pool), dto, log),
|
||||
Playback: pb,
|
||||
Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
|
||||
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log),
|
||||
Wave: wh,
|
||||
App: appupdate.NewHandler(appupdate.Config{GiteaURL: cfg.GiteaURL, Repo: cfg.GiteaRepo, Token: cfg.GiteaToken,
|
||||
Dir: cfg.UpdatesDir, MinSupported: cfg.MinAndroidVersionCode}, log),
|
||||
Uploads: uploads.NewHandler(upSvc),
|
||||
Uploads: uploads.NewHandler(upSvc, signer, cfg.XAccel),
|
||||
TUS: uploads.NewTUS(uploads.NewStore(pool), cfg.UploadsDir, upSvc.Complete, log),
|
||||
}), nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -196,6 +196,38 @@ func (s streamResponse) VisitStreamTrackResponse(w http.ResponseWriter) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prewarm заранее транскодирует пачку волны (docs/playback.md, задача pretranscode).
|
||||
func (h *Handler) Prewarm(ctx context.Context, ids []uuid.UUID) {
|
||||
for _, id := range ids {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
f, err := h.store.StreamFile(ctx, id)
|
||||
if err != nil || f.SourceStatus != "mounted" {
|
||||
continue
|
||||
}
|
||||
codec, bitrate := "", 0
|
||||
if f.Codec != nil {
|
||||
codec = *f.Codec
|
||||
}
|
||||
if f.Bitrate != nil {
|
||||
bitrate = *f.Bitrate
|
||||
}
|
||||
target := Target(QualityHigh, f.Format, codec, bitrate)
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
root, err := h.rootFor(ctx, f.SourceID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
name := CacheName(f.FileID, f.Size, target)
|
||||
if _, err := h.transcoder.Ensure(ctx, filepath.Join(root, filepath.FromSlash(f.RelPath)), name, target); err != nil {
|
||||
h.log.WarnContext(ctx, "pretranscode failed", "track_id", id, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- треки пачкой ---
|
||||
|
||||
// GetTracksBatch реализует gen.StrictServerInterface.
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package uploads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
// BatchPatch — правка на всю пачку. Nil-поля не меняют файлы.
|
||||
type BatchPatch struct {
|
||||
Artist *string
|
||||
Album *string
|
||||
Year *int
|
||||
SetYear bool
|
||||
Cover []byte
|
||||
ClearCover bool
|
||||
}
|
||||
|
||||
const maxCoverBytes = 8 << 20
|
||||
|
||||
// UpdateBatch применяет исполнителя, альбом, год и обложку к файлам пачки на рассмотрении.
|
||||
func (s *Service) UpdateBatch(ctx context.Context, id uuid.UUID, in BatchPatch) ([]File, error) {
|
||||
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files, err := s.store.PendingInBatch(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range files {
|
||||
m := Meta{}
|
||||
if f.Meta != nil {
|
||||
m = *f.Meta
|
||||
}
|
||||
if in.Artist != nil {
|
||||
m.Artist = *in.Artist
|
||||
m.AlbumArtist = *in.Artist
|
||||
}
|
||||
if in.Album != nil {
|
||||
m.Album = *in.Album
|
||||
}
|
||||
if in.SetYear {
|
||||
m.Year = in.Year
|
||||
}
|
||||
if m.Title == "" {
|
||||
m.Title = strings.TrimSuffix(f.OriginalName, path.Ext(f.OriginalName))
|
||||
}
|
||||
if m.Artist == "" || m.Title == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := s.Update(ctx, f.ID, UpdateInput{Meta: &m}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if in.ClearCover || in.Cover != nil {
|
||||
if err := s.writeCover(ctx, id, files, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s.store.PendingInBatch(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) writeCover(ctx context.Context, id uuid.UUID, files []File, in BatchPatch) error {
|
||||
prev := ""
|
||||
if len(files) > 0 && files[0].CoverPath != nil {
|
||||
prev = *files[0].CoverPath
|
||||
}
|
||||
if in.ClearCover || in.Cover == nil {
|
||||
if prev != "" {
|
||||
_ = os.Remove(s.stagingAbs(prev))
|
||||
}
|
||||
return s.store.SetCover(ctx, id, nil)
|
||||
}
|
||||
if len(in.Cover) > maxCoverBytes {
|
||||
return apperr.Validation("cover_base64", "validation.cover_too_large", "cover is larger than 8 MB", nil)
|
||||
}
|
||||
ext, ok := coverExt(in.Cover)
|
||||
if !ok {
|
||||
return apperr.Validation("cover_base64", "validation.cover_type", "cover must be jpeg, png or webp", nil)
|
||||
}
|
||||
rel := path.Join("covers", id.String()+ext)
|
||||
abs := s.stagingAbs(rel)
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(abs, in.Cover, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if prev != "" && prev != rel {
|
||||
_ = os.Remove(s.stagingAbs(prev))
|
||||
}
|
||||
return s.store.SetCover(ctx, id, &rel)
|
||||
}
|
||||
|
||||
func coverExt(b []byte) (string, bool) {
|
||||
switch {
|
||||
case len(b) >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff:
|
||||
return ".jpg", true
|
||||
case len(b) >= 8 && string(b[:8]) == "\x89PNG\r\n\x1a\n":
|
||||
return ".png", true
|
||||
case len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP":
|
||||
return ".webp", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// DecodeCover декодирует обложку из base64.
|
||||
func DecodeCover(s string) ([]byte, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, apperr.Validation("cover_base64", "validation.cover_type", "cover is not base64", nil)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Unseen — сколько решений автор ещё не открывал.
|
||||
func (s *Service) Unseen(ctx context.Context) (int, error) {
|
||||
p, err := principal(ctx, auth.PermSuggestTracks)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.store.UnseenDecisions(ctx, p.UserID)
|
||||
}
|
||||
|
||||
// MarkSeen отмечает предложения просмотренными.
|
||||
func (s *Service) MarkSeen(ctx context.Context) error {
|
||||
p, err := principal(ctx, auth.PermSuggestTracks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.MarkSeen(ctx, p.UserID, s.now())
|
||||
}
|
||||
@@ -10,17 +10,22 @@ import (
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
"pipu/api/internal/platform/sign"
|
||||
)
|
||||
|
||||
// Handler — эндпоинты очереди на добавление (часть gen.StrictServerInterface).
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
svc *Service
|
||||
signer *sign.Signer
|
||||
xAccel bool
|
||||
}
|
||||
|
||||
// NewHandler создаёт обработчик.
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
// NewHandler создаёт обработчик. signer подписывает ссылки прослушивания из staging.
|
||||
func NewHandler(svc *Service, signer *sign.Signer, xAccel bool) *Handler {
|
||||
return &Handler{svc: svc, signer: signer, xAccel: xAccel}
|
||||
}
|
||||
|
||||
func fileDTO(ctx context.Context, f File) gen.UploadFile {
|
||||
func (h *Handler) fileDTO(ctx context.Context, f File) gen.UploadFile {
|
||||
out := gen.UploadFile{
|
||||
Id: f.ID, BatchId: f.BatchID, BatchComment: f.BatchComment,
|
||||
Author: gen.UserRef{Id: f.AuthorID, Username: f.AuthorName},
|
||||
@@ -28,7 +33,7 @@ func fileDTO(ctx context.Context, f File) gen.UploadFile {
|
||||
SizeBytes: f.Size, ReceivedBytes: f.Received, Format: f.Format, Codec: f.Codec, Bitrate: f.Bitrate,
|
||||
SampleRate: f.SampleRate, BitDepth: f.BitDepth, DurationMs: f.DurationMs, Tags: f.Tags,
|
||||
MetadataEdited: f.MetadataEdited, MatchedArtistId: f.MatchedArtistID, MatchedAlbumId: f.MatchedAlbumID,
|
||||
DuplicateOfTrackId: f.DuplicateOfTrackID, PossibleDuplicate: f.PossibleDuplicate,
|
||||
DuplicateOfTrackId: f.DuplicateOfTrackID, PossibleDuplicate: f.PossibleDuplicate, QualityUpgrade: f.QualityUpgrade,
|
||||
TargetSourceId: f.TargetSourceID, TargetRelPath: f.TargetRelPath, StatusReason: f.StatusReason,
|
||||
ReviewedAt: f.ReviewedAt, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt,
|
||||
}
|
||||
@@ -47,6 +52,12 @@ func fileDTO(ctx context.Context, f File) gen.UploadFile {
|
||||
u := TusURL(f.ID)
|
||||
out.TusUrl = &u
|
||||
}
|
||||
if h.signer != nil && f.StagingPath != "" && f.Status.KeepsStaging() {
|
||||
if p, ok := auth.PrincipalFrom(ctx); ok && auth.Can(p.Role, auth.PermReviewUploads) {
|
||||
u := h.streamURL(f.ID)
|
||||
out.StreamUrl = &u
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -64,10 +75,10 @@ func metaFromDTO(m gen.UploadMeta) Meta {
|
||||
}
|
||||
}
|
||||
|
||||
func pageDTO(ctx context.Context, files []File, next string) gen.UploadFilePage {
|
||||
func (h *Handler) pageDTO(ctx context.Context, files []File, next string) gen.UploadFilePage {
|
||||
out := gen.UploadFilePage{Items: make([]gen.UploadFile, 0, len(files))}
|
||||
for _, f := range files {
|
||||
out.Items = append(out.Items, fileDTO(ctx, f))
|
||||
out.Items = append(out.Items, h.fileDTO(ctx, f))
|
||||
}
|
||||
if next != "" {
|
||||
out.NextCursor = &next
|
||||
@@ -96,7 +107,7 @@ func deref[T any](p *T, def T) T {
|
||||
func settingsDTO(s Settings) gen.UploadSettings {
|
||||
return gen.UploadSettings{
|
||||
QueueLimitBytes: s.QueueLimitBytes, PerUserLimitBytes: s.PerUserLimitBytes, MaxFileBytes: s.MaxFileBytes,
|
||||
DefaultSourceId: s.DefaultSourceID,
|
||||
DefaultSourceId: s.DefaultSourceID, AllowQualityUpgrade: s.AllowQualityUpgrade,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +185,7 @@ func (h *Handler) ListMyUploads(ctx context.Context, req gen.ListMyUploadsReques
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.ListMyUploads200JSONResponse(pageDTO(ctx, files, next)), nil
|
||||
return gen.ListMyUploads200JSONResponse(h.pageDTO(ctx, files, next)), nil
|
||||
}
|
||||
|
||||
// WithdrawUpload реализует gen.StrictServerInterface.
|
||||
@@ -195,7 +206,7 @@ func (h *Handler) ListUploads(ctx context.Context, req gen.ListUploadsRequestObj
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.ListUploads200JSONResponse(pageDTO(ctx, files, next)), nil
|
||||
return gen.ListUploads200JSONResponse(h.pageDTO(ctx, files, next)), nil
|
||||
}
|
||||
|
||||
// UpdateUpload реализует gen.StrictServerInterface.
|
||||
@@ -209,7 +220,7 @@ func (h *Handler) UpdateUpload(ctx context.Context, req gen.UpdateUploadRequestO
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.UpdateUpload200JSONResponse(fileDTO(ctx, f)), nil
|
||||
return gen.UpdateUpload200JSONResponse(h.fileDTO(ctx, f)), nil
|
||||
}
|
||||
|
||||
// RetryUploadImport реализует gen.StrictServerInterface.
|
||||
@@ -218,7 +229,7 @@ func (h *Handler) RetryUploadImport(ctx context.Context, req gen.RetryUploadImpo
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.RetryUploadImport200JSONResponse(fileDTO(ctx, f)), nil
|
||||
return gen.RetryUploadImport200JSONResponse(h.fileDTO(ctx, f)), nil
|
||||
}
|
||||
|
||||
// ReviewUploads реализует gen.StrictServerInterface.
|
||||
@@ -272,9 +283,59 @@ func (h *Handler) UpdateUploadSettings(ctx context.Context, req gen.UpdateUpload
|
||||
QueueLimitBytes: b.QueueLimitBytes, MaxFileBytes: b.MaxFileBytes,
|
||||
PerUserLimitBytes: b.PerUserLimitBytes, ClearPerUser: b.PerUserLimitBytes == nil,
|
||||
DefaultSourceID: b.DefaultSourceId, ClearDefault: b.DefaultSourceId == nil,
|
||||
AllowQualityUpgrade: b.AllowQualityUpgrade,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.UpdateUploadSettings200JSONResponse(settingsDTO(s)), nil
|
||||
}
|
||||
|
||||
// UnseenUploadDecisions реализует gen.StrictServerInterface.
|
||||
func (h *Handler) UnseenUploadDecisions(ctx context.Context, _ gen.UnseenUploadDecisionsRequestObject) (gen.UnseenUploadDecisionsResponseObject, error) {
|
||||
n, err := h.svc.Unseen(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.UnseenUploadDecisions200JSONResponse{Count: n}, nil
|
||||
}
|
||||
|
||||
// MarkUploadsSeen реализует gen.StrictServerInterface.
|
||||
func (h *Handler) MarkUploadsSeen(ctx context.Context, _ gen.MarkUploadsSeenRequestObject) (gen.MarkUploadsSeenResponseObject, error) {
|
||||
if err := h.svc.MarkSeen(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.MarkUploadsSeen204Response{}, nil
|
||||
}
|
||||
|
||||
// UpdateUploadBatch реализует gen.StrictServerInterface.
|
||||
func (h *Handler) UpdateUploadBatch(ctx context.Context, req gen.UpdateUploadBatchRequestObject) (gen.UpdateUploadBatchResponseObject, error) {
|
||||
b := req.Body
|
||||
in := BatchPatch{}
|
||||
if b.Artist != nil {
|
||||
in.Artist = b.Artist
|
||||
}
|
||||
if b.Album != nil {
|
||||
in.Album = b.Album
|
||||
}
|
||||
if b.Year != nil {
|
||||
in.SetYear = true
|
||||
in.Year = b.Year
|
||||
}
|
||||
if b.CoverBase64 != nil {
|
||||
if *b.CoverBase64 == "" {
|
||||
in.ClearCover = true
|
||||
} else {
|
||||
raw, err := DecodeCover(*b.CoverBase64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Cover = raw
|
||||
}
|
||||
}
|
||||
files, err := h.svc.UpdateBatch(ctx, req.Id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gen.UpdateUploadBatch200JSONResponse{Items: h.pageDTO(ctx, files, "").Items}, nil
|
||||
}
|
||||
@@ -138,22 +138,25 @@ type File struct {
|
||||
DuplicateOfTrackID *uuid.UUID
|
||||
DuplicateLevel *string
|
||||
PossibleDuplicate bool
|
||||
QualityUpgrade bool
|
||||
TargetSourceID *uuid.UUID
|
||||
TargetRelPath *string
|
||||
StatusReason *string
|
||||
ReviewedBy *uuid.UUID
|
||||
ReviewedAt *time.Time
|
||||
ImportedFileID *uuid.UUID
|
||||
CoverPath *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Settings — лимиты очереди и хранилище по умолчанию (app_settings, меняет админ).
|
||||
type Settings struct {
|
||||
QueueLimitBytes int64 `json:"queue_limit_bytes"`
|
||||
PerUserLimitBytes *int64 `json:"per_user_limit_bytes,omitempty"` // nil — выключен
|
||||
MaxFileBytes int64 `json:"max_file_bytes"`
|
||||
DefaultSourceID *uuid.UUID `json:"default_source_id,omitempty"`
|
||||
QueueLimitBytes int64 `json:"queue_limit_bytes"`
|
||||
PerUserLimitBytes *int64 `json:"per_user_limit_bytes,omitempty"` // nil — выключен
|
||||
MaxFileBytes int64 `json:"max_file_bytes"`
|
||||
DefaultSourceID *uuid.UUID `json:"default_source_id,omitempty"`
|
||||
AllowQualityUpgrade bool `json:"allow_quality_upgrade"`
|
||||
}
|
||||
|
||||
// DefaultSettings — значения по умолчанию (docs/uploads.md#3-квота-очереди).
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package uploads
|
||||
|
||||
import "strings"
|
||||
|
||||
// BetterQuality — загружаемый файл заметно лучше уже лежащего в библиотеке (docs/uploads.md#4).
|
||||
// Lossless вместо lossy, либо тот же класс и битрейт выше хотя бы на 32 кбит/с и на 20%.
|
||||
func BetterQuality(inCodec, inFormat string, inBitrate int, exCodec, exFormat string, exBitrate int) bool {
|
||||
inLoss, exLoss := lossless(inCodec, inFormat), lossless(exCodec, exFormat)
|
||||
if inLoss && !exLoss {
|
||||
return true
|
||||
}
|
||||
if inLoss == exLoss && inBitrate > 0 && exBitrate > 0 && inBitrate >= exBitrate+32_000 && inBitrate*5 >= exBitrate*6 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func lossless(codec, format string) bool {
|
||||
switch strings.ToLower(codec) {
|
||||
case "flac", "alac", "wavpack", "ape", "tta":
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(codec), "pcm_") {
|
||||
return true
|
||||
}
|
||||
switch strings.ToLower(format) {
|
||||
case "flac", "wav", "aiff", "aif", "wv":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package uploads
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBetterQuality(t *testing.T) {
|
||||
if !BetterQuality("flac", "flac", 900000, "mp3", "mp3", 320000) {
|
||||
t.Fatal("flac should replace mp3")
|
||||
}
|
||||
if BetterQuality("mp3", "mp3", 128000, "flac", "flac", 900000) {
|
||||
t.Fatal("mp3 is not an upgrade over flac")
|
||||
}
|
||||
if !BetterQuality("mp3", "mp3", 320000, "mp3", "mp3", 128000) {
|
||||
t.Fatal("much higher bitrate is an upgrade")
|
||||
}
|
||||
if BetterQuality("mp3", "mp3", 180000, "mp3", "mp3", 160000) {
|
||||
t.Fatal("small bitrate step is not an upgrade")
|
||||
}
|
||||
if BetterQuality("flac", "flac", 0, "flac", "flac", 0) {
|
||||
t.Fatal("same class without bitrate is not an upgrade")
|
||||
}
|
||||
}
|
||||
@@ -435,12 +435,13 @@ func (s *Service) GetSettings(ctx context.Context) (Settings, error) {
|
||||
|
||||
// SettingsInput — правка настроек; nil — не менять. ClearPerUser / ClearDefault — сбросить.
|
||||
type SettingsInput struct {
|
||||
QueueLimitBytes *int64
|
||||
PerUserLimitBytes *int64
|
||||
MaxFileBytes *int64
|
||||
DefaultSourceID *uuid.UUID
|
||||
ClearPerUser bool
|
||||
ClearDefault bool
|
||||
QueueLimitBytes *int64
|
||||
PerUserLimitBytes *int64
|
||||
MaxFileBytes *int64
|
||||
DefaultSourceID *uuid.UUID
|
||||
AllowQualityUpgrade *bool
|
||||
ClearPerUser bool
|
||||
ClearDefault bool
|
||||
}
|
||||
|
||||
// UpdateSettings меняет лимиты; уменьшение ниже занятого ничего не удаляет (docs/uploads.md#3).
|
||||
@@ -484,6 +485,9 @@ func (s *Service) UpdateSettings(ctx context.Context, in SettingsInput) (Setting
|
||||
}
|
||||
st.DefaultSourceID = in.DefaultSourceID
|
||||
}
|
||||
if in.AllowQualityUpgrade != nil {
|
||||
st.AllowQualityUpgrade = *in.AllowQualityUpgrade
|
||||
}
|
||||
if err := s.store.SaveSettings(ctx, st); err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
@@ -662,3 +666,21 @@ func (s *Service) removeStaging(f File) {
|
||||
|
||||
// TusURL — адрес tus для файла.
|
||||
func TusURL(id uuid.UUID) string { return "/api/v1/uploads/files/" + id.String() }
|
||||
|
||||
// Staged — файл очереди и абсолютный путь в staging.
|
||||
type Staged struct {
|
||||
File
|
||||
Abs string
|
||||
}
|
||||
|
||||
// OpenStaging возвращает файл, который ещё лежит в staging.
|
||||
func (s *Service) OpenStaging(ctx context.Context, id uuid.UUID) (Staged, error) {
|
||||
f, err := s.store.File(ctx, id)
|
||||
if err != nil {
|
||||
return Staged{}, notFound(err)
|
||||
}
|
||||
if f.StagingPath == "" || !f.Status.KeepsStaging() {
|
||||
return Staged{}, apperr.NotFound(CodeNotFound, "file is not in staging")
|
||||
}
|
||||
return Staged{File: f, Abs: s.stagingAbs(f.StagingPath)}, nil
|
||||
}
|
||||
@@ -230,8 +230,8 @@ func (st *Store) SetReceived(ctx context.Context, id uuid.UUID, offset int64) er
|
||||
const fileColumns = `f.id, f.batch_id, f.author_id, u.username, b.comment, f.status, f.original_name, f.client_rel_path,
|
||||
f.size_bytes, f.received_bytes, f.sha256, coalesce(f.staging_path, ''), f.format, f.codec, f.bitrate, f.sample_rate,
|
||||
f.bit_depth, f.duration_ms, f.tags, f.metadata, f.metadata_edited, f.matched_artist_id, f.matched_album_id,
|
||||
f.duplicate_of_track_id, f.duplicate_level, f.possible_duplicate, f.target_source_id, f.target_rel_path,
|
||||
f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, f.created_at, f.updated_at`
|
||||
f.duplicate_of_track_id, f.duplicate_level, f.possible_duplicate, f.quality_upgrade, f.target_source_id, f.target_rel_path,
|
||||
f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, b.cover_path, f.created_at, f.updated_at`
|
||||
|
||||
const fileFrom = ` FROM upload_files f JOIN upload_batches b ON b.id = f.batch_id JOIN users u ON u.id = f.author_id`
|
||||
|
||||
@@ -243,8 +243,8 @@ func scanFile(row pgx.Row) (File, error) {
|
||||
err := row.Scan(&f.ID, &f.BatchID, &f.AuthorID, &f.AuthorName, &f.BatchComment, &f.Status, &f.OriginalName,
|
||||
&f.ClientRelPath, &f.Size, &f.Received, &f.SHA256, &f.StagingPath, &f.Format, &f.Codec, &f.Bitrate, &f.SampleRate,
|
||||
&f.BitDepth, &f.DurationMs, &tags, &meta, &f.MetadataEdited, &f.MatchedArtistID, &f.MatchedAlbumID,
|
||||
&f.DuplicateOfTrackID, &f.DuplicateLevel, &f.PossibleDuplicate, &f.TargetSourceID, &f.TargetRelPath,
|
||||
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CreatedAt, &f.UpdatedAt)
|
||||
&f.DuplicateOfTrackID, &f.DuplicateLevel, &f.PossibleDuplicate, &f.QualityUpgrade, &f.TargetSourceID, &f.TargetRelPath,
|
||||
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CoverPath, &f.CreatedAt, &f.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return File{}, ErrNotFound
|
||||
}
|
||||
@@ -526,3 +526,91 @@ func MarkImported(ctx context.Context, tx pgx.Tx, uploadID, mediaFileID uuid.UUI
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReconcileQuota пересчитывает занятое место по статусам файлов.
|
||||
func (st *Store) ReconcileQuota(ctx context.Context) error {
|
||||
_, err := st.pool.Exec(ctx, `
|
||||
UPDATE upload_quota SET used_bytes = coalesce((
|
||||
SELECT sum(size_bytes) FROM upload_files WHERE status::text = ANY($1)
|
||||
), 0)`, occupyingStatuses)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uploads: reconcile quota: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BestAudio — лучший доступный файл трека (для сравнения качества).
|
||||
func (st *Store) BestAudio(ctx context.Context, trackID uuid.UUID) (codec, format string, bitrate int, err error) {
|
||||
err = st.pool.QueryRow(ctx, `
|
||||
SELECT coalesce(codec, ''), coalesce(format, ''), coalesce(bitrate, 0)
|
||||
FROM media_files
|
||||
WHERE track_id = $1 AND missing_since IS NULL
|
||||
ORDER BY (codec IN ('flac', 'alac', 'wavpack', 'ape', 'tta') OR codec LIKE 'pcm_%') DESC,
|
||||
bitrate DESC NULLS LAST
|
||||
LIMIT 1`, trackID).Scan(&codec, &format, &bitrate)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", 0, fmt.Errorf("uploads: best audio: %w", err)
|
||||
}
|
||||
return codec, format, bitrate, nil
|
||||
}
|
||||
|
||||
// PendingInBatch — файлы пачки, которые ещё можно править.
|
||||
func (st *Store) PendingInBatch(ctx context.Context, batchID uuid.UUID) ([]File, error) {
|
||||
rows, err := st.pool.Query(ctx, `SELECT `+fileColumns+fileFrom+`
|
||||
WHERE f.batch_id = $1 AND f.status IN ('pending_review', 'import_failed')
|
||||
ORDER BY f.created_at, f.id`, batchID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("uploads: batch files: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []File
|
||||
for rows.Next() {
|
||||
f, err := scanFile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetCover записывает путь обложки пачки (nil — снять).
|
||||
func (st *Store) SetCover(ctx context.Context, batchID uuid.UUID, path *string) error {
|
||||
tag, err := st.pool.Exec(ctx, `UPDATE upload_batches SET cover_path = $2 WHERE id = $1`, batchID, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uploads: set cover: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnseenDecisions — решения по файлам автора новее последнего визита.
|
||||
func (st *Store) UnseenDecisions(ctx context.Context, userID uuid.UUID) (int, error) {
|
||||
var n int
|
||||
err := st.pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM upload_files f
|
||||
LEFT JOIN upload_visits v ON v.user_id = f.author_id
|
||||
WHERE f.author_id = $1
|
||||
AND f.status IN ('imported', 'rejected', 'duplicate', 'invalid', 'expired', 'import_failed')
|
||||
AND f.updated_at > coalesce(v.seen_at, '-infinity')`, userID).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("uploads: unseen: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// MarkSeen отмечает визит автора.
|
||||
func (st *Store) MarkSeen(ctx context.Context, userID uuid.UUID, at time.Time) error {
|
||||
_, err := st.pool.Exec(ctx, `
|
||||
INSERT INTO upload_visits (user_id, seen_at) VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO UPDATE SET seen_at = EXCLUDED.seen_at`, userID, at)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uploads: mark seen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package uploads
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
"pipu/api/internal/platform/httpx"
|
||||
)
|
||||
|
||||
// uploadStream — отдача файла staging с Range (сам api или X-Accel-Redirect).
|
||||
type uploadStream struct {
|
||||
req *http.Request
|
||||
file string
|
||||
format string
|
||||
accel string
|
||||
xAccel bool
|
||||
}
|
||||
|
||||
func (s uploadStream) VisitStreamUploadResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", contentType(s.format))
|
||||
w.Header().Set("Cache-Control", "private, max-age=600")
|
||||
if s.xAccel && s.accel != "" {
|
||||
w.Header().Set("X-Accel-Redirect", s.accel)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(s.file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.req == nil {
|
||||
http.ServeContent(w, &http.Request{}, "", info.ModTime(), f)
|
||||
return nil
|
||||
}
|
||||
http.ServeContent(w, s.req, "", info.ModTime(), f)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) streamURL(id uuid.UUID) string {
|
||||
exp := time.Now().Add(12 * time.Hour).Unix()
|
||||
sig := h.signer.Sign("upload", id.String(), strconv.FormatInt(exp, 10))
|
||||
return "/api/v1/admin/uploads/files/" + id.String() + "/stream?exp=" + strconv.FormatInt(exp, 10) + "&sig=" + sig
|
||||
}
|
||||
|
||||
// StreamUpload реализует gen.StrictServerInterface.
|
||||
func (h *Handler) StreamUpload(ctx context.Context, req gen.StreamUploadRequestObject) (gen.StreamUploadResponseObject, error) {
|
||||
if h.signer == nil || !h.signer.Verify(req.Params.Sig, "upload", req.Id.String(), strconv.FormatInt(req.Params.Exp, 10)) || time.Now().Unix() > req.Params.Exp {
|
||||
return nil, apperr.Forbidden("uploads.link_invalid", "link is invalid or expired")
|
||||
}
|
||||
f, err := h.svc.OpenStaging(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
format := ""
|
||||
if f.Format != nil {
|
||||
format = *f.Format
|
||||
}
|
||||
return uploadStream{
|
||||
req: httpx.RequestFrom(ctx), file: f.Abs, format: format, xAccel: h.xAccel,
|
||||
accel: "/_internal/uploads/" + filepath.ToSlash(f.StagingPath),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func contentType(format string) string {
|
||||
switch format {
|
||||
case "mp3":
|
||||
return "audio/mpeg"
|
||||
case "flac":
|
||||
return "audio/flac"
|
||||
case "m4a":
|
||||
return "audio/mp4"
|
||||
case "ogg", "opus":
|
||||
return "audio/ogg"
|
||||
case "wav":
|
||||
return "audio/wav"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,16 @@ func (s *Service) Run(ctx context.Context) {
|
||||
s.reconcile(ctx)
|
||||
go s.loop(ctx, s.wakeProcess, pollEvery, func(ctx context.Context) bool { return s.processNext(ctx) })
|
||||
go s.loop(ctx, s.wakeImport, pollEvery, func(ctx context.Context) bool { return s.importNext(ctx) })
|
||||
go s.loop(ctx, nil, cleanupEvery, func(ctx context.Context) bool { s.expire(ctx); return false })
|
||||
go s.loop(ctx, nil, cleanupEvery, func(ctx context.Context) bool {
|
||||
s.expire(ctx)
|
||||
if err := s.store.ReconcileQuota(ctx); err != nil {
|
||||
s.log.Error("uploads: reconcile quota", "err", err)
|
||||
}
|
||||
return false
|
||||
})
|
||||
if err := s.store.ReconcileQuota(ctx); err != nil {
|
||||
s.log.Error("uploads: reconcile quota", "err", err)
|
||||
}
|
||||
wake(s.wakeProcess)
|
||||
wake(s.wakeImport)
|
||||
}
|
||||
@@ -98,6 +107,7 @@ type outcome struct {
|
||||
dupTrack *uuid.UUID
|
||||
dupLevel string
|
||||
possible bool
|
||||
quality bool
|
||||
place placement
|
||||
}
|
||||
|
||||
@@ -144,8 +154,12 @@ func (s *Service) analyze(ctx context.Context, f File, abs string) outcome {
|
||||
cands, err := s.store.FingerprintCandidates(ctx, probe.DurationMs, FingerprintTolerance, f.ID)
|
||||
if err == nil {
|
||||
if c, ok := bestMatch(fp.Points, cands); ok {
|
||||
o.status, o.reason, o.dupTrack, o.dupLevel = StatusDuplicate, CodeDuplicate, c.TrackID, DupFingerprint
|
||||
return o
|
||||
if s.keepAsUpgrade(ctx, &o, c.TrackID, probe) {
|
||||
// лучшее качество — дальше как обычный файл на рассмотрение
|
||||
} else {
|
||||
o.status, o.reason, o.dupTrack, o.dupLevel = StatusDuplicate, CodeDuplicate, c.TrackID, DupFingerprint
|
||||
return o
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if !errors.Is(err, library.ErrNoFPCalc) {
|
||||
@@ -183,6 +197,26 @@ func (s *Service) analyze(ctx context.Context, f File, abs string) outcome {
|
||||
return o
|
||||
}
|
||||
|
||||
// keepAsUpgrade — совпадение по отпечатку, но файл лучше библиотечного и режим замены включён.
|
||||
// true — не считать дублем, оставить на рассмотрение.
|
||||
func (s *Service) keepAsUpgrade(ctx context.Context, o *outcome, track *uuid.UUID, probe library.Probe) bool {
|
||||
if track == nil {
|
||||
return false
|
||||
}
|
||||
st, err := s.store.Settings(ctx)
|
||||
if err != nil || !st.AllowQualityUpgrade {
|
||||
return false
|
||||
}
|
||||
codec, format, bitrate, err := s.store.BestAudio(ctx, *track)
|
||||
if err != nil || !BetterQuality(probe.Codec, probe.Format, probe.Bitrate, codec, format, bitrate) {
|
||||
return false
|
||||
}
|
||||
o.quality = true
|
||||
o.dupTrack = track
|
||||
o.dupLevel = DupFingerprint
|
||||
return true
|
||||
}
|
||||
|
||||
// bestMatch — отпечаток, похожий не меньше порога library.SameRecording.
|
||||
func bestMatch(points []uint32, cands []PrintCandidate) (PrintCandidate, bool) {
|
||||
best, score := PrintCandidate{}, 0.0
|
||||
@@ -245,11 +279,11 @@ func (s *Service) saveOutcome(ctx context.Context, f File, o outcome) error {
|
||||
UPDATE upload_files SET sha256 = coalesce($2, sha256), format = NULLIF($3, ''), codec = NULLIF($4, ''),
|
||||
bitrate = NULLIF($5, 0), sample_rate = NULLIF($6, 0), bit_depth = NULLIF($7, 0), duration_ms = NULLIF($8, 0),
|
||||
tags = $9, metadata = $10, fingerprint = $11, duplicate_of_track_id = $12, duplicate_level = NULLIF($13, ''),
|
||||
possible_duplicate = $14, target_source_id = $15, target_rel_path = $16, status_reason = NULLIF($17, ''),
|
||||
possible_duplicate = $14, quality_upgrade = $20, target_source_id = $15, target_rel_path = $16, status_reason = NULLIF($17, ''),
|
||||
matched_artist_id = $18, matched_album_id = $19
|
||||
WHERE id = $1`,
|
||||
f.ID, o.sha, p.Format, p.Codec, p.Bitrate, p.SampleRate, p.BitDepth, p.DurationMs, tags, metaJSON, points,
|
||||
o.dupTrack, o.dupLevel, o.possible, o.place.SourceID, o.place.RelPath, o.reason, o.place.ArtistID, o.place.AlbumID)
|
||||
o.dupTrack, o.dupLevel, o.possible, o.place.SourceID, o.place.RelPath, o.reason, o.place.ArtistID, o.place.AlbumID, o.quality)
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -295,7 +329,7 @@ func (s *Service) importFile(ctx context.Context, f File) error {
|
||||
}
|
||||
rel := *pl.RelPath
|
||||
stagingRel, sum := f.StagingPath, f.SHA256
|
||||
if f.MetadataEdited {
|
||||
if f.MetadataEdited || (f.CoverPath != nil && *f.CoverPath != "") {
|
||||
tagged, tsum, err := s.writeTags(ctx, f, *f.Meta, path.Ext(rel))
|
||||
if err != nil {
|
||||
return fmt.Errorf("write tags: %w", err)
|
||||
@@ -352,7 +386,12 @@ func (s *Service) writeTags(ctx context.Context, f File, m Meta, ext string) (st
|
||||
}
|
||||
rel := f.StagingPath + ".tagged" + strings.ToLower(ext)
|
||||
out := s.stagingAbs(rel)
|
||||
args := []string{"-v", "error", "-y", "-i", s.stagingAbs(f.StagingPath), "-map", "0", "-c", "copy", "-map_metadata", "0"}
|
||||
args := []string{"-v", "error", "-y", "-i", s.stagingAbs(f.StagingPath)}
|
||||
if f.CoverPath != nil && *f.CoverPath != "" {
|
||||
args = append(args, "-i", s.stagingAbs(*f.CoverPath), "-map", "0:a", "-map", "1", "-c", "copy", "-disposition:v:0", "attached_pic", "-map_metadata", "0")
|
||||
} else {
|
||||
args = append(args, "-map", "0", "-c", "copy", "-map_metadata", "0")
|
||||
}
|
||||
set := func(k, v string) { args = append(args, "-metadata", k+"="+v) }
|
||||
set("title", m.Title)
|
||||
set("artist", m.Artist)
|
||||
|
||||
@@ -141,6 +141,8 @@ type Handler struct {
|
||||
catalog *library.Store
|
||||
dto func() library.DTO
|
||||
log *slog.Logger
|
||||
// Warm заранее готовит транскоды выданной пачки (docs/playback.md).
|
||||
Warm func(ctx context.Context, trackIDs []uuid.UUID)
|
||||
}
|
||||
|
||||
// NewHandler создаёт обработчик.
|
||||
@@ -328,6 +330,9 @@ func (h *Handler) serve(ctx context.Context, s session, fb []Feedback) (gen.Wave
|
||||
if err := h.record(ctx, s, picks, modelVer); err != nil {
|
||||
return gen.WaveBatch{}, err
|
||||
}
|
||||
if h.Warm != nil && len(ids) > 0 {
|
||||
go h.Warm(context.WithoutCancel(ctx), ids)
|
||||
}
|
||||
return gen.WaveBatch{SessionId: s.ID, Activity: s.Activity, Mood: s.Mood, Tracks: tracks, Fallback: fallback}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- +goose Up
|
||||
-- Остаток очереди: замена на лучшее качество, обложка пачки, визит автора (docs/uploads.md).
|
||||
ALTER TABLE upload_files ADD COLUMN quality_upgrade boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE upload_batches ADD COLUMN cover_path text;
|
||||
|
||||
CREATE TABLE upload_visits (
|
||||
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
seen_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE upload_visits;
|
||||
ALTER TABLE upload_batches DROP COLUMN cover_path;
|
||||
ALTER TABLE upload_files DROP COLUMN quality_upgrade;
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Фаззинг ответов api по OpenAPI. Сервер уже запущен; публичные ручки без токена,
|
||||
# остальные — с PIPU_API_TOKEN (Bearer).
|
||||
set -eu
|
||||
: "${PIPU_API_URL:?set PIPU_API_URL, for example http://127.0.0.1:8080}"
|
||||
root=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)
|
||||
auth=
|
||||
if [ -n "${PIPU_API_TOKEN:-}" ]; then
|
||||
auth="Authorization: Bearer ${PIPU_API_TOKEN}"
|
||||
fi
|
||||
exec schemathesis run "${root}/api/openapi.yaml" \
|
||||
--url "$PIPU_API_URL" \
|
||||
--checks all \
|
||||
--max-examples "${PIPU_SCHEMA_EXAMPLES:-20}" \
|
||||
${auth:+--header "$auth"}
|
||||
Reference in new issue
Block a user