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

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

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

No files matched your search

+2
View File
@@ -51,3 +51,5 @@ jobs:
- run: pnpm typecheck - run: pnpm typecheck
- run: pnpm test - run: pnpm test
- run: pnpm --filter @pipu/web build - run: pnpm --filter @pipu/web build
- run: pnpm --filter @pipu/web exec playwright install chromium
- run: pnpm --filter @pipu/web test:e2e
+37
View File
@@ -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
+2
View File
@@ -27,6 +27,8 @@ __pycache__/
# generated test audio # generated test audio
tests/fixtures/music/out/ tests/fixtures/music/out/
apps/web/test-results/
apps/web/playwright-report/
# os / editors # os / editors
.DS_Store .DS_Store
+123 -3
View File
@@ -451,6 +451,35 @@ paths:
$ref: "#/components/schemas/UploadFilePage" $ref: "#/components/schemas/UploadFilePage"
default: default:
$ref: "#/components/responses/Problem" $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}: /api/v1/uploads/files/{id}:
parameters: parameters:
- $ref: "#/components/parameters/EntityId" - $ref: "#/components/parameters/EntityId"
@@ -535,6 +564,65 @@ paths:
$ref: "#/components/schemas/UploadFile" $ref: "#/components/schemas/UploadFile"
default: default:
$ref: "#/components/responses/Problem" $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: /api/v1/admin/uploads/review:
post: post:
operationId: reviewUploads operationId: reviewUploads
@@ -1620,6 +1708,9 @@ components:
type: string type: string
duration_ms: duration_ms:
type: integer type: integer
album:
type: string
maxLength: 500
UploadPrecheckResult: UploadPrecheckResult:
type: object type: object
required: [files] required: [files]
@@ -1733,7 +1824,7 @@ components:
nullable: true nullable: true
UploadFile: UploadFile:
type: object 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: properties:
id: id:
type: string type: string
@@ -1807,6 +1898,9 @@ components:
enum: [sha256, metadata, fingerprint, null] enum: [sha256, metadata, fingerprint, null]
possible_duplicate: possible_duplicate:
type: boolean type: boolean
quality_upgrade:
type: boolean
description: Тот же трек, но лучшего качества — ждёт решения, если включён allow_quality_upgrade
target_source_id: target_source_id:
type: string type: string
format: uuid format: uuid
@@ -1832,6 +1926,10 @@ components:
type: string type: string
nullable: true nullable: true
description: Только автору и пока файл в статусе uploading description: Только автору и пока файл в статусе uploading
stream_url:
type: string
nullable: true
description: Подписанная ссылка прослушивания из staging; null, если файла уже нет
UploadFilePage: UploadFilePage:
type: object type: object
required: [items, next_cursor] required: [items, next_cursor]
@@ -1851,6 +1949,23 @@ components:
target_source_id: target_source_id:
type: string type: string
format: uuid 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: UploadReviewRequest:
type: object type: object
required: [file_ids, decision] required: [file_ids, decision]
@@ -1890,7 +2005,7 @@ components:
- $ref: "#/components/schemas/Problem" - $ref: "#/components/schemas/Problem"
UploadSettings: UploadSettings:
type: object 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: properties:
queue_limit_bytes: queue_limit_bytes:
type: integer type: integer
@@ -1906,6 +2021,9 @@ components:
type: string type: string
format: uuid format: uuid
nullable: true nullable: true
allow_quality_upgrade:
type: boolean
description: Тот же трек в лучшем качестве идёт на рассмотрение, а не в дубли
UploadSettingsPatch: UploadSettingsPatch:
type: object type: object
description: Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано). description: Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано).
@@ -1929,6 +2047,8 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: null — без хранилища по умолчанию description: null — без хранилища по умолчанию
allow_quality_upgrade:
type: boolean
HealthStatus: HealthStatus:
type: object type: object
required: [status] required: [status]
@@ -2036,7 +2156,7 @@ components:
description: Где в топбаре веба поиск (по умолчанию center) description: Где в топбаре веба поиск (по умолчанию center)
search_collapse: search_collapse:
type: boolean type: boolean
description: Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию true) description: Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию false)
home_activities: home_activities:
type: boolean type: boolean
description: Показывать на главной веба выбор занятия (по умолчанию true) description: Показывать на главной веба выбор занятия (по умолчанию true)
+5
View File
@@ -0,0 +1,5 @@
appId: app.pipu.music
---
- runFlow: login.yaml
- tapOn: "Коллекция"
- assertVisible: "Лайки"
+15
View File
@@ -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: "Бегаю"
+6
View File
@@ -0,0 +1,6 @@
appId: app.pipu.music
---
- runFlow: login.yaml
- tapOn: "Профиль"
- assertVisible: "Проверить обновления"
- tapOn: "Проверить обновления"
+4 -2
View File
@@ -7,7 +7,8 @@
"start": "expo start --dev-client", "start": "expo start --dev-client",
"android": "expo run:android", "android": "expo run:android",
"prebuild": "expo prebuild --platform android --clean", "prebuild": "expo prebuild --platform android --clean",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^14.1.0",
@@ -42,7 +43,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.20.5", "@types/node": "^22.20.5",
"@types/react": "~19.0.10" "@types/react": "~19.0.10",
"vitest": "^3.2.7"
}, },
"expo": { "expo": {
"doctor": { "doctor": {
+10
View File
@@ -116,6 +116,16 @@ export default function ProfileScreen() {
onChange={(v) => void setPrefs({ mobile_player: v })} onChange={(v) => void setPrefs({ mobile_player: v })}
/> />
</Row> </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>
<Section> <Section>
+7
View File
@@ -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';
}
+5 -2
View File
@@ -6,6 +6,8 @@ import * as IntentLauncher from 'expo-intent-launcher';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
import { sha256 } from 'js-sha256'; import { sha256 } from 'js-sha256';
import { releaseNeed } from './releaseNeed';
export type AndroidRelease = components['schemas']['AndroidRelease']; export type AndroidRelease = components['schemas']['AndroidRelease'];
/** Состояние самообновления (docs/mobile.md#самообновление). */ /** Состояние самообновления (docs/mobile.md#самообновление). */
@@ -96,11 +98,12 @@ class Updater {
const release = unwrap(await session.client.GET('/api/v1/app/android/latest')); const release = unwrap(await session.client.GET('/api/v1/app/android/latest'));
await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now())); await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now()));
const installed = installedVersionCode(); const installed = installedVersionCode();
if (release.version_code > installed) { const need = releaseNeed(installed, release.version_code, release.min_supported);
if (need !== 'current') {
this.set({ this.set({
status: 'available', status: 'available',
release, release,
required: installed < release.min_supported, required: need === 'required',
dismissed: false, dismissed: false,
}); });
} else { } else {
+20 -9
View File
@@ -2,6 +2,7 @@ import NetInfo from '@react-native-community/netinfo';
import { import {
EventBuffer, EventBuffer,
PlayAttempt, PlayAttempt,
normalizeGain,
unwrap, unwrap,
type AuthSession, type AuthSession,
type PlayContext, type PlayContext,
@@ -18,6 +19,9 @@ import TrackPlayer, {
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs'; import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
import { downloads } from '../offline/downloads'; import { downloads } from '../offline/downloads';
import { PersistentEvents } from '../offline/events'; import { PersistentEvents } from '../offline/events';
import { offlineWave } from './offlineWave';
export { offlineWave };
export interface WaveInfo { export interface WaveInfo {
sessionId: string; sessionId: string;
@@ -45,15 +49,6 @@ const WAVE_REFILL_AT = 2;
const EARLY_SKIP_MS = 30_000; const EARLY_SKIP_MS = 30_000;
const SAVE_EVERY_MS = 10_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)); const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
/** Ссылки API относительные — для приложения нужен полный адрес сервера. */ /** Ссылки API относительные — для приложения нужен полный адрес сервера. */
@@ -159,6 +154,10 @@ class Controller {
this.lastPositionMs = ms; this.lastPositionMs = ms;
this.update({ positionMs: ms, durationMs: Math.round(duration * 1000) || this.state.durationMs }); 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(); 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 }) => { TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, ({ index, lastPosition }) => {
this.finishAttempt(lastPosition); this.finishAttempt(lastPosition);
@@ -167,6 +166,7 @@ class Controller {
if (!track) return; if (!track) return;
this.attempt = new PlayAttempt(track, 0); this.attempt = new PlayAttempt(track, 0);
this.update({ track, index, positionMs: 0, durationMs: track.duration_ms }); this.update({ track, index, positionMs: 0, durationMs: track.duration_ms });
void this.applyVolume(track, 1);
this.events?.push({ this.events?.push({
type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type, type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type,
context_id: this.state.context?.id, 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[]> { private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
const q = await quality(); const q = await quality();
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети. // Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
+10
View File
@@ -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);
}
+38
View File
@@ -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');
});
});
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
},
});
+54
View File
@@ -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();
});
+6 -1
View File
@@ -8,7 +8,8 @@
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run" "test": "vitest run",
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@fontsource/jetbrains-mono": "^5.2.6", "@fontsource/jetbrains-mono": "^5.2.6",
@@ -21,13 +22,16 @@
"@pipu/i18n": "workspace:*", "@pipu/i18n": "workspace:*",
"@tanstack/react-query": "^5.80.7", "@tanstack/react-query": "^5.80.7",
"@tanstack/react-router": "^1.120.20", "@tanstack/react-router": "^1.120.20",
"@tanstack/react-virtual": "^3.14.21",
"hash-wasm": "^4.12.0", "hash-wasm": "^4.12.0",
"music-metadata": "^12.2.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-i18next": "^15.5.3", "react-i18next": "^15.5.3",
"tus-js-client": "^4.3.1" "tus-js-client": "^4.3.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.64.1",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
@@ -35,6 +39,7 @@
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-react": "^4.5.2",
"axe-core": "^4.14.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"vite": "^6.3.5", "vite": "^6.3.5",
"vitest": "^3.2.4" "vitest": "^3.2.4"
+13
View File
@@ -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' },
},
});
+17
View File
@@ -4,6 +4,7 @@ import { QueryClient } from '@tanstack/react-query';
import { createMemoryHistory } from '@tanstack/react-router'; import { createMemoryHistory } from '@tanstack/react-router';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import axe from 'axe-core';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { afterEach, describe, expect, it } from 'vitest'; 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', () => { describe('safeRedirect', () => {
it.each([ it.each([
['/settings', '/settings'], ['/settings', '/settings'],
+10 -9
View File
@@ -7,16 +7,17 @@ import {
redirect, redirect,
type ParsedLocation, type ParsedLocation,
} from '@tanstack/react-router'; } from '@tanstack/react-router';
import { lazy } from 'react';
import { AuditPage } from '../pages/admin/AuditPage'; const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
import { LibraryPage } from '../pages/admin/LibraryPage'; const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
import { PatternsPage } from '../pages/admin/PatternsPage'; const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage'; const AdminSettingsPage = lazy(() => import('../pages/admin/AdminSettingsPage').then((m) => ({ default: m.AdminSettingsPage })));
import { UploadQueuePage } from '../pages/admin/UploadQueuePage'; const UploadQueuePage = lazy(() => import('../pages/admin/UploadQueuePage').then((m) => ({ default: m.UploadQueuePage })));
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage'; const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((m) => ({ default: m.MyUploadsPage })));
import { SuggestPage } from '../pages/uploads/SuggestPage'; const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
import { SourcesPage } from '../pages/admin/SourcesPage'; const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
import { UsersPage } from '../pages/admin/UsersPage'; const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
import { AlbumPage } from '../pages/AlbumPage'; import { AlbumPage } from '../pages/AlbumPage';
import { ArtistPage } from '../pages/ArtistPage'; import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage'; import { CollectionPage } from '../pages/CollectionPage';
+44 -10
View File
@@ -1,6 +1,7 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { Link } from '@tanstack/react-router'; 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 { useTranslation } from 'react-i18next';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
@@ -129,21 +130,23 @@ export function TrackList({
const { t } = useTranslation(); const { t } = useTranslation();
const player = usePlayer(); const player = usePlayer();
const { track: current, playing } = usePlayerState(); const { track: current, playing } = usePlayerState();
const listRef = useRef<HTMLDivElement>(null);
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1)); const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
let lastDisc: number | null = null; const multiDisc = Boolean(numbered && discs.size > 1);
return ( const virtual = tracks.length > 30 && !multiDisc;
<div className={s.tracks} role="list" aria-label={t('search.tracks')}> const virtualizer = useVirtualizer({
{tracks.map((tr, i) => { count: virtual ? tracks.length : 0,
const disc = tr.disc_no ?? 1; getScrollElement: () => listRef.current?.closest('main') ?? null,
const header = numbered && discs.size > 1 && disc !== lastDisc; estimateSize: () => 68,
lastDisc = disc; overscan: 8,
});
const row = (tr: Track, i: number) => {
const isCurrent = current?.id === tr.id; const isCurrent = current?.id === tr.id;
const play = () => tr.available && player.playList(tracks, i, context); const play = () => tr.available && player.playList(tracks, i, context);
const liked = tr.reaction === 'like'; const liked = tr.reaction === 'like';
const meta = rowMeta?.(tr, i); const meta = rowMeta?.(tr, i);
return ( return (
<Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
<div <div
role="listitem" role="listitem"
tabIndex={tr.available ? 0 : -1} tabIndex={tr.available ? 0 : -1}
@@ -202,6 +205,37 @@ export function TrackList({
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span> <span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
</span> </span>
</div> </div>
);
};
let lastDisc: number | null = null;
return (
<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
key={vi.key}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
>
{row(tracks[vi.index]!, vi.index)}
</div>
))
: 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> </Fragment>
); );
})} })}
+140 -12
View File
@@ -17,15 +17,18 @@ export type Stage =
| 'uploading' | 'uploading'
| 'done' | 'done'
| 'rejected' | 'rejected'
| 'error'; | 'error'
| 'resume';
export interface UploadItem { export interface UploadItem {
id: string; id: string;
file: File; file: File | null;
size: number;
relPath: string; relPath: string;
stage: Stage; stage: Stage;
progress: number; // 0..1 текущего этапа (хэш или загрузка) progress: number; // 0..1 текущего этапа (хэш или загрузка)
sha256?: string; sha256?: string;
tags?: { artist?: string; title?: string; album?: string; duration_ms?: number };
trackId?: string | null; trackId?: string | null;
possibleDuplicate?: boolean; possibleDuplicate?: boolean;
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, … code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
@@ -44,6 +47,17 @@ export interface UploaderState {
const PARALLEL = 4; const PARALLEL = 4;
const CHUNK = 8 << 20; 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; let seq = 0;
@@ -56,7 +70,9 @@ export class Uploader {
private uploads = new Map<string, tus.Upload>(); private uploads = new Map<string, tus.Upload>();
private refreshing: Promise<unknown> | null = null; private refreshing: Promise<unknown> | null = null;
constructor(private readonly session: AuthSession) {} constructor(private readonly session: AuthSession) {
this.restore();
}
subscribe = (fn: () => void) => { subscribe = (fn: () => void) => {
this.listeners.add(fn); this.listeners.add(fn);
@@ -89,8 +105,8 @@ export class Uploader {
continue; continue;
} }
// Тот же путь и размер уже в списке — не добавляем второй раз. // Тот же путь и размер уже в списке — не добавляем второй раз.
if (this.state.items.some((it) => it.relPath === f.relPath && it.file.size === f.file.size)) continue; if (this.state.items.some((it) => it.relPath === f.relPath && it.size === f.file.size)) continue;
fresh.push({ id: `f${++seq}`, file: f.file, relPath: f.relPath, stage: 'hashing', progress: 0 }); 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.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null });
this.hashQueue.push(...fresh.map((it) => it.id)); this.hashQueue.push(...fresh.map((it) => it.id));
@@ -101,6 +117,7 @@ export class Uploader {
if (this.state.busy) return; if (this.state.busy) return;
this.hashQueue = this.hashQueue.filter((x) => x !== id); this.hashQueue = this.hashQueue.filter((x) => x !== id);
this.set({ items: this.state.items.filter((it) => it.id !== 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; if (this.state.busy) return;
this.hashQueue = []; this.hashQueue = [];
this.set({ items: [], skipped: 0, batchId: null, error: null }); this.set({ items: [], skipped: 0, batchId: null, error: null });
this.persist();
} }
private hashWorker(): Worker { private hashWorker(): Worker {
@@ -125,6 +143,7 @@ export class Uploader {
if (!it) continue; if (!it) continue;
try { try {
const sha256 = await this.hash(it); const sha256 = await this.hash(it);
await this.readTags(it);
this.patch(id, { sha256, stage: 'checking', progress: 1 }); this.patch(id, { sha256, stage: 'checking', progress: 1 });
} catch (err) { } catch (err) {
this.patch(id, { stage: 'error', message: err instanceof Error ? err.message : String(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> { private hash(it: UploadItem): Promise<string> {
const file = it.file;
if (!file) return Promise.reject(new Error('no file'));
const w = this.hashWorker(); const w = this.hashWorker();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const onMessage = (e: MessageEvent<HashResponse>) => { const onMessage = (e: MessageEvent<HashResponse>) => {
const m = e.data; const m = e.data;
if (m.id !== it.id) return; if (m.id !== it.id) return;
if (m.type === 'progress') { 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; return;
} }
w.removeEventListener('message', onMessage); w.removeEventListener('message', onMessage);
@@ -151,7 +172,7 @@ export class Uploader {
else reject(new Error(m.message)); else reject(new Error(m.message));
}; };
w.addEventListener('message', onMessage); 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 { try {
const res = unwrap( const res = unwrap(
await this.session.client.POST('/api/v1/uploads/precheck', { 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) { for (const r of res.files) {
@@ -192,9 +223,9 @@ export class Uploader {
comment: comment.trim() || undefined, comment: comment.trim() || undefined,
files: ready.map((it) => ({ files: ready.map((it) => ({
client_id: it.id, client_id: it.id,
name: it.file.name, name: it.file?.name ?? it.relPath,
rel_path: it.relPath.includes('/') ? it.relPath : undefined, rel_path: it.relPath.includes('/') ? it.relPath : undefined,
size: it.file.size, size: it.size,
sha256: it.sha256!, sha256: it.sha256!,
})), })),
}, },
@@ -209,6 +240,7 @@ export class Uploader {
queue.push({ id: f.client_id, url: f.tus_url }); 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))); await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
} catch (err) { } catch (err) {
this.set({ error: err }); this.set({ error: err });
@@ -239,9 +271,10 @@ export class Uploader {
private upload(id: string, url: string): Promise<void> { private upload(id: string, url: string): Promise<void> {
const it = this.item(id); const it = this.item(id);
if (!it) return Promise.resolve(); if (!it?.file) return Promise.resolve();
const file = it.file;
return new Promise((resolve) => { return new Promise((resolve) => {
const up = new tus.Upload(it.file, { const up = new tus.Upload(file, {
uploadUrl: url, uploadUrl: url,
chunkSize: CHUNK, chunkSize: CHUNK,
retryDelays: [0, 1000, 3000, 5000, 10000, 20000], retryDelays: [0, 1000, 3000, 5000, 10000, 20000],
@@ -264,6 +297,7 @@ export class Uploader {
onSuccess: () => { onSuccess: () => {
this.uploads.delete(id); this.uploads.delete(id);
this.patch(id, { stage: 'done', progress: 1 }); this.patch(id, { stage: 'done', progress: 1 });
this.persist();
resolve(); resolve();
}, },
onError: (err) => { onError: (err) => {
@@ -276,6 +310,100 @@ export class Uploader {
up.start(); 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; let instance: { session: AuthSession; uploader: Uploader } | null = null;
+4
View File
@@ -48,6 +48,10 @@ function deviceName(): string {
} }
applyTheme(undefined); applyTheme(undefined);
if (import.meta.env.VITE_E2E === '1') {
const { installE2EFetch } = await import('./test/e2eBridge');
installE2EFetch();
}
const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages])); const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages]));
document.documentElement.lang = i18n.language; document.documentElement.lang = i18n.language;
i18n.on('languageChanged', (l) => { i18n.on('languageChanged', (l) => {
+25 -1
View File
@@ -143,7 +143,7 @@ function AppearanceSection() {
<span>{t('settings.search_collapse')}</span> <span>{t('settings.search_collapse')}</span>
<Segmented<'on' | 'off'> <Segmented<'on' | 'off'>
label={t('settings.search_collapse')} 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' })} onChange={(v) => void setPrefs({ search_collapse: v === 'on' })}
options={[ options={[
{ value: 'on', label: t('settings.on') }, { value: 'on', label: t('settings.on') },
@@ -187,6 +187,30 @@ function AppearanceSection() {
]} ]}
/> />
</div> </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}> <div className={s.settingRow}>
<span>{t('language.label')}</span> <span>{t('language.label')}</span>
<LanguageSwitch /> <LanguageSwitch />
+101 -1
View File
@@ -35,6 +35,7 @@ export function UploadQueuePage() {
const [onlyPossible, setOnlyPossible] = useState(false); const [onlyPossible, setOnlyPossible] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<UploadFile | null>(null); const [editing, setEditing] = useState<UploadFile | null>(null);
const [batchEdit, setBatchEdit] = useState<string | null>(null);
const [rejecting, setRejecting] = useState(false); const [rejecting, setRejecting] = useState(false);
const list = useInfiniteQuery({ const list = useInfiniteQuery({
@@ -131,6 +132,7 @@ export function UploadQueuePage() {
selected={selected} selected={selected}
onToggle={toggle} onToggle={toggle}
onEdit={setEditing} onEdit={setEditing}
onEditBatch={setBatchEdit}
onRetry={(id) => retry.mutate(id)} onRetry={(id) => retry.mutate(id)}
/> />
))} ))}
@@ -158,6 +160,17 @@ export function UploadQueuePage() {
</div> </div>
)} )}
{batchEdit && (
<BatchEditDialog
batchId={batchEdit}
onClose={() => setBatchEdit(null)}
onSaved={() => {
setBatchEdit(null);
void refresh();
}}
/>
)}
{editing && ( {editing && (
<EditDialog <EditDialog
file={editing} file={editing}
@@ -194,6 +207,7 @@ function BatchCard({
selected, selected,
onToggle, onToggle,
onEdit, onEdit,
onEditBatch,
onRetry, onRetry,
}: { }: {
files: UploadFile[]; files: UploadFile[];
@@ -201,6 +215,7 @@ function BatchCard({
selected: Set<string>; selected: Set<string>;
onToggle: (ids: string[], on: boolean) => void; onToggle: (ids: string[], on: boolean) => void;
onEdit: (f: UploadFile) => void; onEdit: (f: UploadFile) => void;
onEditBatch: (batchId: string) => void;
onRetry: (id: string) => void; onRetry: (id: string) => void;
}) { }) {
const { t, i18n } = useTranslation('admin'); const { t, i18n } = useTranslation('admin');
@@ -218,6 +233,12 @@ function BatchCard({
<span>{formatDateTime(first.created_at, i18n.language)}</span> <span>{formatDateTime(first.created_at, i18n.language)}</span>
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, 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>} {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> </div>
{files.map((f) => ( {files.map((f) => (
<QueueRow <QueueRow
@@ -289,6 +310,7 @@ function QueueRow({
<div className={u.flags}> <div className={u.flags}>
<UploadStatusBadge status={f.status} /> <UploadStatusBadge status={f.status} />
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>} {f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
{f.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
{f.matched_album_id ? ( {f.matched_album_id ? (
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span> <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
) : ( ) : (
@@ -302,6 +324,9 @@ function QueueRow({
)} )}
</div> </div>
<div className={u.rowActions}> <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 && ( {editable && (
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}> <Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
<Icon src={editIcon} size={18} /> <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 }; 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 }) { function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']); const { t } = useTranslation(['admin', 'common']);
const session = useSession(); const session = useSession();
@@ -452,7 +546,7 @@ export function UploadSettingsSection() {
queryKey: ['sources'], queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items, 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(() => { useEffect(() => {
if (settings.data) setForm(toForm(settings.data)); if (settings.data) setForm(toForm(settings.data));
}, [settings.data]); }, [settings.data]);
@@ -468,6 +562,7 @@ export function UploadSettingsSection() {
max_file_bytes: gb(f.maxFile), max_file_bytes: gb(f.maxFile),
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null, per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
default_source_id: f.source || null, default_source_id: f.source || null,
allow_quality_upgrade: f.upgrade,
}, },
}), }),
); );
@@ -507,6 +602,10 @@ export function UploadSettingsSection() {
))} ))}
</select> </select>
</label> </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> </div>
<p className={s.note}>{t('uploads.set_note')}</p> <p className={s.note}>{t('uploads.set_note')}</p>
<div className={s.toolbar}> <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), perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
maxFile: gb(st.max_file_bytes), maxFile: gb(st.max_file_bytes),
source: st.default_source_id ?? '', source: st.default_source_id ?? '',
upgrade: st.allow_quality_upgrade,
}; };
} }
+7 -1
View File
@@ -1,7 +1,7 @@
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session'; import { useSession } from '../../app/session';
@@ -31,6 +31,12 @@ export function MyUploadsPage() {
const errorText = useErrorText(); const errorText = useErrorText();
const [filter, setFilter] = useState<Filter>('all'); 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({ const list = useInfiniteQuery({
queryKey: ['my-uploads', filter], queryKey: ['my-uploads', filter],
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
+15 -4
View File
@@ -39,9 +39,10 @@ export function SuggestPage() {
}, []); }, []);
const ready = st.items.filter((it) => it.stage === 'ready'); const 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 hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking');
const finished = !st.busy && st.batchId !== null; 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 failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length;
const uploaded = st.items.filter((it) => it.stage === 'done').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; const free = quota.data ? Math.max(quota.data.limit_bytes - quota.data.used_bytes, 0) : null;
@@ -96,7 +97,9 @@ export function SuggestPage() {
multiple multiple
accept={ACCEPT} accept={ACCEPT}
onChange={(e) => { 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 = ''; e.target.value = '';
}} }}
/> />
@@ -106,12 +109,20 @@ export function SuggestPage() {
type="file" type="file"
multiple multiple
onChange={(e) => { 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 = ''; e.target.value = '';
}} }}
/> />
</div> </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.skipped > 0 && <p className={s.note}>{t('uploads.skipped', { count: st.skipped })}</p>}
{st.items.length > 0 && ( {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>} {note && <span className={note.cls}>{note.text}</span>}
</div> </div>
<span className={u.size}>{formatBytes(item.file.size, i18n.language)}</span> <span className={u.size}>{formatBytes(item.size, i18n.language)}</span>
{locked ? ( {locked ? (
<span /> <span />
) : ( ) : (
+114 -3
View File
@@ -1,5 +1,6 @@
import { import {
EventBuffer, EventBuffer,
normalizeGain,
PlayAttempt, PlayAttempt,
PlayQueue, PlayQueue,
unwrap, unwrap,
@@ -80,6 +81,9 @@ function deviceId(): string {
*/ */
export class Player { export class Player {
private readonly audio: HTMLAudioElement; private readonly audio: HTMLAudioElement;
private readonly overlap: HTMLAudioElement;
private fading = false;
private ignoreEnded = false;
private readonly queue = new PlayQueue(); private readonly queue = new PlayQueue();
private readonly events: EventBuffer; private readonly events: EventBuffer;
private attempt: PlayAttempt | null = null; private attempt: PlayAttempt | null = null;
@@ -102,6 +106,8 @@ export class Player {
) { ) {
this.audio = audio ?? new Audio(); this.audio = audio ?? new Audio();
this.audio.preload = 'auto'; this.audio.preload = 'auto';
this.overlap = new Audio();
this.overlap.preload = 'auto';
this.events = new EventBuffer({ this.events = new EventBuffer({
send: async (events) => { send: async (events) => {
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } })); unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
@@ -324,15 +330,17 @@ export class Player {
} }
setVolume(v: number) { 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.audio.muted = false;
this.update({ volume: this.audio.volume, muted: false }); this.update({ volume, muted: false });
this.applyLoudness();
this.saveLocal(); this.saveLocal();
} }
toggleMute() { toggleMute() {
this.audio.muted = !this.audio.muted; this.audio.muted = !this.audio.muted;
this.update({ muted: this.audio.muted }); this.update({ muted: this.audio.muted });
this.applyLoudness();
} }
toggleShuffle() { toggleShuffle() {
@@ -382,6 +390,7 @@ export class Player {
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms }); this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
this.audio.src = track.stream_url; this.audio.src = track.stream_url;
if (startMs > 0) this.audio.currentTime = startMs / 1000; if (startMs > 0) this.audio.currentTime = startMs / 1000;
this.applyLoudness();
this.attempt = new PlayAttempt(track, startMs); this.attempt = new PlayAttempt(track, startMs);
this.preloaded = null; this.preloaded = null;
this.events.push({ 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() { private bindAudio() {
const a = this.audio; const a = this.audio;
a.addEventListener('playing', () => { a.addEventListener('playing', () => {
@@ -451,10 +561,12 @@ export class Player {
this.update({ positionMs: ms }); this.update({ positionMs: ms });
this.updatePositionState(); this.updatePositionState();
this.maybePreload(ms); this.maybePreload(ms);
this.maybeCrossfade(ms);
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer(); if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
this.saveLocal(); this.saveLocal();
}); });
a.addEventListener('ended', () => { a.addEventListener('ended', () => {
if (this.ignoreEnded || this.fading) return;
this.finishAttempt('play_end'); this.finishAttempt('play_end');
if (this.queue.next(true)) { if (this.queue.next(true)) {
this.syncQueue(); this.syncQueue();
@@ -465,7 +577,6 @@ export class Player {
} }
}); });
a.addEventListener('error', () => void this.onError()); a.addEventListener('error', () => void this.onError());
a.addEventListener('volumechange', () => this.update({ volume: a.volume, muted: a.muted }));
} }
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */ /** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
+15
View File
@@ -0,0 +1,15 @@
import { fakeApi, makeUser } from './fakeApi';
/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */
export function installE2EFetch() {
const api = fakeApi([
makeUser('admin', 'admin', { must_change_credentials: true }),
makeUser('alice', 'user'),
]);
const orig = window.fetch.bind(window);
window.fetch = async (input, init) => {
const req = new Request(input, init);
if (new URL(req.url, window.location.origin).pathname.startsWith('/api/')) return api.fetch(req);
return orig(input, init);
};
}
+10
View File
@@ -153,6 +153,16 @@ export function fakeApi(initial: FakeUser[]) {
return tokens(user); return tokens(user);
case 'GET /api/v1/me/sessions': case 'GET /api/v1/me/sessions':
return json({ items: [] }); 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': { case 'GET /api/v1/admin/users': {
if (user.role === 'user') return problem(403, 'auth.forbidden'); if (user.role === 'user') return problem(403, 'auth.forbidden');
const q = (url.searchParams.get('q') ?? '').toLowerCase(); const q = (url.searchParams.get('q') ?? '').toLowerCase();
+18 -3
View File
@@ -1,7 +1,7 @@
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from '@tanstack/react-router'; 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 { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session'; import { useAuthState, useSession, useUser } from '../app/session';
@@ -151,9 +151,10 @@ function Shell() {
const staff = user.role !== 'user'; const staff = user.role !== 'user';
const island = user.ui_prefs.web_player === 'island'; const island = user.ui_prefs.web_player === 'island';
const searchAt = user.ui_prefs.search_position ?? 'center'; 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 counts = useCollectionCounts();
const pending = usePendingUploads(user.role === 'admin'); const pending = usePendingUploads(user.role === 'admin');
const unseen = useUnseenUploads(staff);
return ( return (
<div className={cx(s.shell, island && s.island)}> <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="/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/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' && ( {user.role === 'admin' && (
<> <>
<NavLink <NavLink
@@ -233,7 +234,9 @@ function Shell() {
</nav> </nav>
<main className={s.content}> <main className={s.content}>
<Suspense fallback={null}>
<Outlet /> <Outlet />
</Suspense>
</main> </main>
<PlayerBar variant={island ? 'island' : 'bar'} /> <PlayerBar variant={island ? 'island' : 'bar'} />
@@ -261,3 +264,15 @@ function usePendingUploads(enabled: boolean) {
}); });
return q.data; 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;
}
+1
View File
@@ -20,5 +20,6 @@ export default defineConfig({
test: { test: {
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['src/test/setup.ts'], setupFiles: ['src/test/setup.ts'],
exclude: ['e2e/**', 'node_modules/**'],
}, },
}); });
+10 -2
View File
@@ -136,6 +136,7 @@ CREATE TABLE upload_batches ( -- одна «пачка» загруз
id uuid PRIMARY KEY, id uuid PRIMARY KEY,
author_id uuid NOT NULL REFERENCES users(id), author_id uuid NOT NULL REFERENCES users(id),
comment text, -- комментарий автора для админа comment text, -- комментарий автора для админа
cover_path text, -- обложка пачки (JPEG/PNG/WebP), вшивается при импорте
created_at timestamptz NOT NULL DEFAULT now() created_at timestamptz NOT NULL DEFAULT now()
); );
@@ -160,6 +161,7 @@ CREATE TABLE upload_files (
duplicate_of_track_id uuid REFERENCES tracks(id), duplicate_of_track_id uuid REFERENCES tracks(id),
duplicate_level text, -- sha256 | metadata | fingerprint duplicate_level text, -- sha256 | metadata | fingerprint
possible_duplicate boolean NOT NULL DEFAULT false, possible_duplicate boolean NOT NULL DEFAULT false,
quality_upgrade boolean NOT NULL DEFAULT false, -- тот же трек, но лучше по качеству
target_source_id uuid REFERENCES library_sources(id), target_source_id uuid REFERENCES library_sources(id),
target_rel_path text, target_rel_path text,
status_reason text, -- код причины: uploads.duplicate, uploads.invalid_audio, текст отклонения 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 (author_id, created_at DESC);
CREATE INDEX ON upload_files (sha256); CREATE INDEX ON upload_files (sha256);
-- Счётчик квоты (одна строка). Обновляется в той же транзакции, что и смена статуса; -- Счётчик квоты (одна строка). Обновляется в той же транзакции, что и смена статуса.
-- ночная задача сверяет его с SUM(size_bytes) по «занимающим» статусам. -- При старте api и каждые 10 минут сверяется с SUM(size_bytes) по «занимающим» статусам.
CREATE TABLE upload_quota ( CREATE TABLE upload_quota (
id boolean PRIMARY KEY DEFAULT true CHECK (id), id boolean PRIMARY KEY DEFAULT true CHECK (id),
used_bytes bigint NOT NULL DEFAULT 0 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
View File
@@ -157,10 +157,8 @@ interface WaveButtonProps {
- Лист обновления: «Что нового» → разрешение на установку (один раз) → загрузка → проверка SHA-256 → «Установить». - Лист обновления: «Что нового» → разрешение на установку (один раз) → загрузка → проверка SHA-256 → «Установить».
- Офлайн-баннер при отсутствии сети. - Офлайн-баннер при отсутствии сети.
## 5. Пробелы дизайна (дорабатываем при реализации) ## 5. Адаптив и онбординг
- **Адаптивность веба**: прототип рассчитан на десктоп (≥ 1200 px). Для < 1024 px сайдбар сворачивается в иконки, - **Адаптивность веба**: при ширине < 1024 px сайдбар сворачивается в иконки, при < 768 px — нижняя навигация.
для < 768 px — нижняя навигация как в Android. Макетов нет — делаем по образцу Android-версии. - **Онбординг вкуса** показывается на главной, пока `GET /onboarding` отвечает `required`.
- **Пустые состояния и ошибки** есть не везде (например, хранилище недоступно, нет сети в вебе) — по образцу существующих - **Пустые состояния** — иконка, короткий текст и действие там, где список может быть пустым (поиск, коллекция, очередь).
(иконка + короткий текст + действие). - Строки интерфейса есть на русском и английском; длинные подписи проверяются на обоих языках.
- **Онбординг вкуса** (выбор жанров / исполнителей при первом входе) в прототипе отсутствует — макет нужен до M6.
- **Английская локализация**: в прототипе только русские строки; при вёрстке проверять длинные строки на обоих языках.
+2 -1
View File
@@ -37,7 +37,8 @@ location /_internal/cache/ { internal; alias /cache/transcode/; sendfile on;
- Транскод трека 4 мин в Opus занимает ~1–2 с на одном ядре, поэтому делаем синхронно при первом запросе. - Транскод трека 4 мин в Opus занимает ~1–2 с на одном ядре, поэтому делаем синхронно при первом запросе.
Клиенты **предзагружают следующий трек очереди** (запрос `HEAD`/`Range: bytes=0-0` за 20 с до конца текущего) — Клиенты **предзагружают следующий трек очереди** (запрос `HEAD`/`Range: bytes=0-0` за 20 с до конца текущего) —
на сервере это прогревает транскод. на сервере это прогревает транскод.
- Фоновая задача `pretranscode` для треков из выданных пачек волны (они почти наверняка будут сыграны). - Фоновая подготовка: выдача пачки волны вызывает `playback.Prewarm` — для качества `high` транскод
строится сразу, не дожидаясь первого `Range` клиента.
- Кэш транскодов — LRU с лимитом `TRANSCODE_CACHE_LIMIT_GB` (по умолчанию 50 ГБ), чистка по `atime` раз в час. - Кэш транскодов — LRU с лимитом `TRANSCODE_CACHE_LIMIT_GB` (по умолчанию 50 ГБ), чистка по `atime` раз в час.
- Пул ffmpeg ограничен (`TRANSCODE_CONCURRENCY`, по умолчанию = число ядер / 2). - Пул ffmpeg ограничен (`TRANSCODE_CONCURRENCY`, по умолчанию = число ядер / 2).
+9 -6
View File
@@ -7,13 +7,13 @@
| Unit | Go | `testing`, `testify`, табличные тесты | Политики прав (вся матрица из auth-and-roles.md), парсинг шаблонов путей и слияние с тегами, нормализация имён, подпись ссылок, расчёт `implicit_score`, рендер `write_template` и очистка имён, обратимость шаблон ↔ регэксп, учёт квоты при конкурентных резервах, переходы статусов загрузки | | Unit | Go | `testing`, `testify`, табличные тесты | Политики прав (вся матрица из auth-and-roles.md), парсинг шаблонов путей и слияние с тегами, нормализация имён, подпись ссылок, расчёт `implicit_score`, рендер `write_template` и очистка имён, обратимость шаблон ↔ регэксп, учёт квоты при конкурентных резервах, переходы статусов загрузки |
| Unit | Python | `pytest`, `hypothesis` | Формулы скоринга, context_fit, MMR, бандит, обновление сессионного вектора, правила переранжирования | | Unit | Python | `pytest`, `hypothesis` | Формулы скоринга, context_fit, MMR, бандит, обновление сессионного вектора, правила переранжирования |
| Unit | TS | `vitest` | `packages/core`: очередь, события (подсчёт `played_ms`, засчитанное прослушивание), single-flight refresh, офлайн-очередь | | Unit | TS | `vitest` | `packages/core`: очередь, события (подсчёт `played_ms`, засчитанное прослушивание), single-flight refresh, офлайн-очередь |
| Компоненты | Web | `vitest` + Testing Library | Поиск (клавиатура, Enter / выбор), плеер, MoodPicker, формы админки | | Компоненты | Web | `vitest` + Testing Library + axe-core | Поиск, плеер, волна, формы админки, вход; серьёзные нарушения axe на главной и входе |
| Компоненты | Mobile | `jest` + RNTL | Экраны входа, коллекции, диалог обновления | | Компоненты | Mobile | `vitest` (`apps/mobile/test`) | Офлайн-волна и решение диалога обновления |
| Интеграция | Go | `testcontainers-go` (Postgres + pgvector, Redis, Meilisearch) | Репозитории, миграции, сканер на тестовой библиотеке, API-хендлеры через `httptest` | | Интеграция | Go | `testcontainers-go` (Postgres + pgvector, Redis, Meilisearch) | Репозитории, миграции, сканер, API через `httptest`; `TestUserJourney` — сценарии 1–2 и скан |
| Интеграция | Python | `testcontainers` | Чтение событий из Redis Stream, kNN в pgvector, ночной пайплайн на синтетических данных | | Интеграция | Python | `testcontainers` | Чтение событий из Redis Stream, kNN в pgvector, ночной пайплайн на синтетических данных |
| Контракт | — | `schemathesis` против запущенного `api` | Ответы соответствуют `openapi.yaml`, нет 500 на фаззинге | | Контракт | — | `tests/contract/schemathesis.sh` | Ответы соответствуют `openapi.yaml`; нужен запущенный `api` |
| E2E | Web | Playwright (Chromium + Firefox) | Сценарии ниже, на `deploy/compose.test.yaml` | | E2E | Web | Playwright (`apps/web/e2e`, Chromium, `pnpm --filter @pipu/web test:e2e`) | Сценарии 1, 4 и 9 на фейковом API |
| E2E | Mobile | Maestro (эмулятор) | Вход, волна, фоновое воспроизведение, скачивание, офлайн — ночной прогон, не на каждый PR | | E2E | Mobile | Maestro (`apps/mobile/.maestro`) | Вход, коллекция, проверка обновления. Ночной прогон (`PIPU_E2E_SERVER`, `PIPU_E2E_USER`, `PIPU_E2E_PASSWORD`), не на каждый PR |
| Качество рекомендаций | Python | офлайн-оценка | NDCG / Recall на синтетическом датасете с известными «вкусами»; регрессия качества ломает тест | | Качество рекомендаций | Python | офлайн-оценка | NDCG / Recall на синтетическом датасете с известными «вкусами»; регрессия качества ломает тест |
## Тестовая музыка ## Тестовая музыка
@@ -27,6 +27,9 @@
## E2E-сценарии (обязательные) ## E2E-сценарии (обязательные)
Сценарии 1, 4 и 9 гоняет Playwright. Сценарии входа, ролей, поиска, плеера, волны и языка — `apps/web/src/App.test.tsx`.
Сценарии источника и скана — `TestUserJourney` в `services/api` (нужен `TEST_DATABASE_URL`).
1. Первый запуск: вход bootstrap-админа → принудительная смена логина / пароля → главная. 1. Первый запуск: вход bootstrap-админа → принудительная смена логина / пароля → главная.
2. Админ создаёт модератора; модератор создаёт пользователя; модератор **не может** создать модератора (UI и API). 2. Админ создаёт модератора; модератор создаёт пользователя; модератор **не может** создать модератора (UI и API).
3. Админ добавляет `local`-источник, задаёт два шаблона, предпросмотр, скан → треки появились в поиске. 3. Админ добавляет `local`-источник, задаёт два шаблона, предпросмотр, скан → треки появились в поиске.
+12 -10
View File
@@ -197,17 +197,19 @@
- Настройки: `GET` / `PATCH /admin/settings/uploads` (ключ `uploads.settings` в `app_settings`). - Настройки: `GET` / `PATCH /admin/settings/uploads` (ключ `uploads.settings` в `app_settings`).
- Веб (`apps/web/src/features/uploads`, `pages/uploads`, `pages/admin/UploadQueuePage.tsx`): - Веб (`apps/web/src/features/uploads`, `pages/uploads`, `pages/admin/UploadQueuePage.tsx`):
- «Предложить треки» (`/uploads/new`): drag-and-drop файлов и папок (FileSystemEntry API, относительные пути), - «Предложить треки» (`/uploads/new`): drag-and-drop файлов и папок (FileSystemEntry API, относительные пути),
выбор файлов / папки; SHA-256 потоком в Web Worker (`hash-wasm`); предпроверка; `POST /uploads`; `tus-js-client` выбор файлов / папки; SHA-256 потоком в Web Worker (`hash-wasm`); теги читает `music-metadata` и отдаёт их
с готовым `uploadUrl`, куски по 8 МБ, 4 файла параллельно, 401 → обновление токена и повтор. Загрузчик — синглтон в предпроверку; `POST /uploads`; `tus-js-client` с готовым `uploadUrl`, куски по 8 МБ, 4 файла параллельно,
модуля: переходы по приложению не прерывают загрузку; оборвавшиеся файлы продолжаются кнопкой 401 → обновление токена и повтор. Загрузчик — синглтон модуля: переходы по приложению не прерывают загрузку.
«Продолжить загрузку» (место уже зарезервировано). Незавершённые tus-ссылки пишутся в `localStorage`; после перезагрузки пользователь выбирает те же файлы,
- «Мои предложения» (`/uploads/mine`): статусы, причины отказа, отзыв до решения, квота. клиент сверяет SHA-256 и продолжает с принятого смещения.
- «Мои предложения» (`/uploads/mine`): статусы, причины отказа, отзыв до решения, квота. Бейдж — число решений
новее `upload_visits`; открытие страницы вызывает `POST /uploads/seen`.
- «Очередь на добавление» (`/admin/uploads`, admin): вкладки по статусам, фильтр возможных дублей, группировка - «Очередь на добавление» (`/admin/uploads`, admin): вкладки по статусам, фильтр возможных дублей, группировка
по пачкам, правка метаданных и хранилища, массовое одобрение / отклонение с причиной, повтор импорта; по пачкам, правка метаданных и хранилища, правка исполнителя / альбома / года / обложки на всю пачку,
настройки очереди — внизу страницы. прослушивание staging по подписанной ссылке, флаг улучшения качества, массовое одобрение / отклонение с причиной,
- Пока не сделано: `allow_quality_upgrade`, правка «на всю пачку» (`PATCH /admin/uploads/batches/{id}`), повтор импорта. Настройки очереди — лимиты, хранилище и `allow_quality_upgrade`.
прослушивание из staging, отдельная обложка пачки, ночная сверка счётчика квоты, чтение тегов на клиенте - Сверка квоты: `used_bytes` обновляется в транзакции смены статуса и дополнительно пересчитывается по
(`music-metadata`) для предпроверки метаданных, продолжение загрузки после перезагрузки страницы. `SUM(size_bytes)` занимающих статусов при старте `api` и в цикле очистки (раз в 10 минут).
## 11. Очистка ## 11. Очистка
- `uploading` старше 24 ч → `expired`, файлы удаляются. - `uploading` старше 24 ч → `expired`, файлы удаляются.
+190 -1
View File
@@ -404,6 +404,40 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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}": { "/api/v1/uploads/files/{id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -484,6 +518,48 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/admin/uploads/review": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1307,6 +1383,7 @@ export interface components {
artist?: string; artist?: string;
title?: string; title?: string;
duration_ms?: number; duration_ms?: number;
album?: string;
}[]; }[];
}; };
UploadPrecheckResult: { UploadPrecheckResult: {
@@ -1399,6 +1476,8 @@ export interface components {
/** @enum {string|null} */ /** @enum {string|null} */
duplicate_level: "sha256" | "metadata" | "fingerprint" | null; duplicate_level: "sha256" | "metadata" | "fingerprint" | null;
possible_duplicate: boolean; possible_duplicate: boolean;
/** @description Тот же трек, но лучшего качества — ждёт решения, если включён allow_quality_upgrade */
quality_upgrade: boolean;
/** Format: uuid */ /** Format: uuid */
target_source_id: string | null; target_source_id: string | null;
/** @description Путь в хранилище по write_template (null — не построился, причина в status_reason) */ /** @description Путь в хранилище по write_template (null — не построился, причина в status_reason) */
@@ -1412,6 +1491,8 @@ export interface components {
updated_at: string; updated_at: string;
/** @description Только автору и пока файл в статусе uploading */ /** @description Только автору и пока файл в статусе uploading */
tus_url: string | null; tus_url: string | null;
/** @description Подписанная ссылка прослушивания из staging; null, если файла уже нет */
stream_url: string | null;
}; };
UploadFilePage: { UploadFilePage: {
items: components["schemas"]["UploadFile"][]; items: components["schemas"]["UploadFile"][];
@@ -1422,6 +1503,14 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
target_source_id?: string; 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: { UploadReviewRequest: {
file_ids: string[]; file_ids: string[];
/** @enum {string} */ /** @enum {string} */
@@ -1446,6 +1535,8 @@ export interface components {
max_file_bytes: number; max_file_bytes: number;
/** Format: uuid */ /** Format: uuid */
default_source_id: string | null; default_source_id: string | null;
/** @description Тот же трек в лучшем качестве идёт на рассмотрение, а не в дубли */
allow_quality_upgrade: boolean;
}; };
/** @description Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано). */ /** @description Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано). */
UploadSettingsPatch: { UploadSettingsPatch: {
@@ -1463,6 +1554,7 @@ export interface components {
* @description null — без хранилища по умолчанию * @description null — без хранилища по умолчанию
*/ */
default_source_id: string | null; default_source_id: string | null;
allow_quality_upgrade?: boolean;
}; };
HealthStatus: { HealthStatus: {
/** @enum {string} */ /** @enum {string} */
@@ -1538,7 +1630,7 @@ export interface components {
* @enum {string} * @enum {string}
*/ */
search_position?: "center" | "left" | "right"; search_position?: "center" | "left" | "right";
/** @description Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию true) */ /** @description Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию false) */
search_collapse?: boolean; search_collapse?: boolean;
/** @description Показывать на главной веба выбор занятия (по умолчанию true) */ /** @description Показывать на главной веба выбор занятия (по умолчанию true) */
home_activities?: boolean; home_activities?: boolean;
@@ -2702,6 +2794,48 @@ export interface operations {
default: components["responses"]["Problem"]; 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: { withdrawUpload: {
parameters: { parameters: {
query?: never; query?: never;
@@ -2803,6 +2937,61 @@ export interface operations {
default: components["responses"]["Problem"]; 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: { reviewUploads: {
parameters: { parameters: {
query?: never; query?: never;
+14
View File
@@ -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);
});
});
+9
View File
@@ -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));
}
+2 -1
View File
@@ -21,5 +21,6 @@ export {
type RepeatMode, type RepeatMode,
type Track, type Track,
} from './player'; } 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'; export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
+15 -1
View File
@@ -203,6 +203,11 @@
"skipped_few": "{{count}} files skipped — not audio", "skipped_few": "{{count}} files skipped — not audio",
"skipped_many": "{{count}} files skipped — not audio", "skipped_many": "{{count}} files skipped — not audio",
"skipped_other": "{{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": "Comment for the administrator",
"comment_hint": "For example: “2011 remaster”, “replacing broken files”", "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.", "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}}", "disc": "disc {{n}}",
"edited": "edited", "edited": "edited",
"flag_possible": "possible duplicate", "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_existing_album": "existing album",
"flag_new_album": "new album", "flag_new_album": "new album",
"dup_level_sha256": "exact copy of a file", "dup_level_sha256": "exact copy of a file",
@@ -332,7 +345,8 @@
"uploading": "Uploading", "uploading": "Uploading",
"done": "Uploaded, processing", "done": "Uploaded, processing",
"rejected": "Not accepted", "rejected": "Not accepted",
"error": "Error" "error": "Error",
"resume": "Waiting for the file to resume"
} }
}, },
"server_settings": { "server_settings": {
+3 -1
View File
@@ -122,7 +122,9 @@
"search_right": "Right", "search_right": "Right",
"search_collapse": "Collapse search", "search_collapse": "Collapse search",
"home_activities": "Activity picker on home", "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": { "form": {
"show_password": "Show password", "show_password": "Show password",
+15 -1
View File
@@ -203,6 +203,11 @@
"skipped_few": "Пропущено {{count}} файла — не аудио", "skipped_few": "Пропущено {{count}} файла — не аудио",
"skipped_many": "Пропущено {{count}} файлов — не аудио", "skipped_many": "Пропущено {{count}} файлов — не аудио",
"skipped_other": "Пропущено {{count}} файла — не аудио", "skipped_other": "Пропущено {{count}} файла — не аудио",
"resume_hint_one": "Не докачан {{count}} файл. Выберите его снова — загрузка продолжится.",
"resume_hint_few": "Не докачано {{count}} файла. Выберите их снова — загрузка продолжится.",
"resume_hint_many": "Не докачано {{count}} файлов. Выберите их снова — загрузка продолжится.",
"resume_hint_other": "Не докачано {{count}} файла. Выберите их снова — загрузка продолжится.",
"resume_pick": "Выбрать файлы и продолжить",
"comment": "Комментарий для администратора", "comment": "Комментарий для администратора",
"comment_hint": "Например: «ремастер 2011», «замена битых файлов»", "comment_hint": "Например: «ремастер 2011», «замена битых файлов»",
"not_enough_space": "В очереди не хватает места: нужно {{need}}, свободно {{free}} — часть файлов не примут.", "not_enough_space": "В очереди не хватает места: нужно {{need}}, свободно {{free}} — часть файлов не примут.",
@@ -275,6 +280,14 @@
"disc": "диск {{n}}", "disc": "диск {{n}}",
"edited": "правлено", "edited": "правлено",
"flag_possible": "возможный дубль", "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_existing_album": "в существующий альбом",
"flag_new_album": "новый альбом", "flag_new_album": "новый альбом",
"dup_level_sha256": "точная копия файла", "dup_level_sha256": "точная копия файла",
@@ -332,7 +345,8 @@
"uploading": "Загружается", "uploading": "Загружается",
"done": "Загружен, обрабатывается", "done": "Загружен, обрабатывается",
"rejected": "Не принят", "rejected": "Не принят",
"error": "Ошибка" "error": "Ошибка",
"resume": "Ждёт файл, чтобы продолжить"
} }
}, },
"server_settings": { "server_settings": {
+3 -1
View File
@@ -122,7 +122,9 @@
"search_right": "Справа", "search_right": "Справа",
"search_collapse": "Сворачивать поиск", "search_collapse": "Сворачивать поиск",
"home_activities": "Выбор занятия на главной", "home_activities": "Выбор занятия на главной",
"home_moods": "Выбор настроения на главной" "home_moods": "Выбор настроения на главной",
"normalize": "Выравнивание громкости",
"crossfade": "Кроссфейд, {{seconds}} с"
}, },
"form": { "form": {
"show_password": "Показать пароль", "show_password": "Показать пароль",
+252 -10
View File
@@ -114,6 +114,9 @@ importers:
'@types/react': '@types/react':
specifier: ~19.0.10 specifier: ~19.0.10
version: 19.0.14 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: apps/web:
dependencies: dependencies:
@@ -147,9 +150,15 @@ importers:
'@tanstack/react-router': '@tanstack/react-router':
specifier: ^1.120.20 specifier: ^1.120.20
version: 1.120.20(react-dom@19.3.1(react@19.3.1))(react@19.3.1) 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: hash-wasm:
specifier: ^4.12.0 specifier: ^4.12.0
version: 4.12.0 version: 4.12.0
music-metadata:
specifier: ^12.2.3
version: 12.2.3(supports-color@10.2.2)
react: react:
specifier: ^19.1.0 specifier: ^19.1.0
version: 19.3.1 version: 19.3.1
@@ -163,6 +172,9 @@ importers:
specifier: ^4.3.1 specifier: ^4.3.1
version: 4.3.1 version: 4.3.1
devDependencies: devDependencies:
'@playwright/test':
specifier: ^1.64.1
version: 1.64.1
'@testing-library/dom': '@testing-library/dom':
specifier: ^10.4.0 specifier: ^10.4.0
version: 10.4.2 version: 10.4.2
@@ -184,15 +196,18 @@ importers:
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.5.2 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)) 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: jsdom:
specifier: ^26.1.0 specifier: ^26.1.0
version: 26.1.0(supports-color@10.2.2) version: 26.1.0
vite: vite:
specifier: ^6.3.5 specifier: ^6.3.5
version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2) version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
vitest: vitest:
specifier: ^3.2.4 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: packages/api-client:
dependencies: dependencies:
@@ -212,7 +227,7 @@ importers:
devDependencies: devDependencies:
vitest: vitest:
specifier: ^3.2.4 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: {} packages/design-tokens: {}
@@ -735,6 +750,9 @@ packages:
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@borewit/text-codec@0.2.2':
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
'@csstools/color-helpers@5.1.0': '@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1091,6 +1109,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} 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': '@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies: peerDependencies:
@@ -1419,6 +1442,12 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 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 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': '@tanstack/router-core@1.120.19':
resolution: {integrity: sha512-5JUVgkxnIM3NxMwzKt0tfz2UopZVxwq6Kl7Rp33zlFJaPjpiRs46VuRjVeAvkpJd6samo1gcH1rWqmnPUmtGcw==} resolution: {integrity: sha512-5JUVgkxnIM3NxMwzKt0tfz2UopZVxwq6Kl7Rp33zlFJaPjpiRs46VuRjVeAvkpJd6samo1gcH1rWqmnPUmtGcw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -1426,6 +1455,9 @@ packages:
'@tanstack/store@0.7.7': '@tanstack/store@0.7.7':
resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==}
'@tanstack/virtual-core@3.19.6':
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
'@testing-library/dom@10.4.2': '@testing-library/dom@10.4.2':
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1455,6 +1487,13 @@ packages:
peerDependencies: peerDependencies:
'@testing-library/dom': '>=7.21.4' '@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': '@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@@ -1662,6 +1701,10 @@ packages:
await-lock@2.2.2: await-lock@2.2.2:
resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} 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: babel-jest@29.7.0:
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -1920,6 +1963,10 @@ packages:
resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==}
engines: {node: '>= 0.10.0'} engines: {node: '>= 0.10.0'}
content-type@3.1.1:
resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==}
engines: {node: '>=22'}
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -2261,6 +2308,10 @@ packages:
picomatch: picomatch:
optional: true optional: true
file-type@22.2.0:
resolution: {integrity: sha512-XmEVJsRpQMVYC+sWFW+FKAVo2P7/lPctDdWH4UQ1r6oxhdrTobnowdM3z+ZJ93a4J8D3yDpxX0bEvRuFev0vUQ==}
engines: {node: '>=22'}
fill-range@7.1.1: fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2735,6 +2786,10 @@ packages:
marky@1.3.0: marky@1.3.0:
resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
media-typer@2.0.0:
resolution: {integrity: sha512-kOy3OxT2HH39N70UnKgu4NWDZjLOz8W/mfyvniHjRH/DrL3f2pOfvWQ4p60offbbtDAnXWp0v9LfMIqMec269Q==}
engines: {node: '>=18'}
memoize-one@5.2.1: memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
@@ -2861,6 +2916,10 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 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: mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@@ -3043,6 +3102,16 @@ packages:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'} 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: plist@3.1.1:
resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==}
engines: {node: '>=10.4.0'} engines: {node: '>=10.4.0'}
@@ -3518,6 +3587,10 @@ packages:
strip-literal@3.1.0: strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} 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: structured-headers@0.4.1:
resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==}
@@ -3630,6 +3703,10 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'} 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: tough-cookie@5.1.2:
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
engines: {node: '>=16'} engines: {node: '>=16'}
@@ -3700,6 +3777,10 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
uint8array-extras@1.6.0:
resolution: {integrity: sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==}
engines: {node: '>=18'}
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -3904,6 +3985,9 @@ packages:
engines: {node: '>=8'} engines: {node: '>=8'}
hasBin: true hasBin: true
win-guid@0.2.1:
resolution: {integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==}
wonka@6.3.6: wonka@6.3.6:
resolution: {integrity: sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==} resolution: {integrity: sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==}
@@ -4622,6 +4706,8 @@ snapshots:
'@babel/helper-string-parser': 7.29.7 '@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
'@borewit/text-codec@0.2.2': {}
'@csstools/color-helpers@5.1.0': {} '@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)': '@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': '@pkgjs/parseargs@0.11.0':
optional: true 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)': '@radix-ui/react-compose-refs@1.1.2(@types/react@19.0.14)(react@19.0.0)':
dependencies: dependencies:
react: 19.0.0 react: 19.0.0
@@ -5454,6 +5544,12 @@ snapshots:
react-dom: 19.3.1(react@19.3.1) react-dom: 19.3.1(react@19.3.1)
use-sync-external-store: 1.7.0(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': '@tanstack/router-core@1.120.19':
dependencies: dependencies:
'@tanstack/history': 1.120.17 '@tanstack/history': 1.120.17
@@ -5462,6 +5558,8 @@ snapshots:
'@tanstack/store@0.7.7': {} '@tanstack/store@0.7.7': {}
'@tanstack/virtual-core@3.19.6': {}
'@testing-library/dom@10.4.2': '@testing-library/dom@10.4.2':
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.7
@@ -5496,6 +5594,15 @@ snapshots:
dependencies: dependencies:
'@testing-library/dom': 10.4.2 '@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/aria-query@5.0.4': {}
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
@@ -5602,6 +5709,14 @@ snapshots:
chai: 5.3.3 chai: 5.3.3
tinyrainbow: 2.0.0 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))': '@vitest/mocker@3.2.7(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2))':
dependencies: dependencies:
'@vitest/spy': 3.2.7 '@vitest/spy': 3.2.7
@@ -5715,6 +5830,8 @@ snapshots:
await-lock@2.2.2: {} await-lock@2.2.2: {}
axe-core@4.14.0: {}
babel-jest@29.7.0(@babel/core@7.29.7): babel-jest@29.7.0(@babel/core@7.29.7):
dependencies: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7
@@ -6043,6 +6160,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
content-type@3.1.1: {}
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
core-js-compat@3.50.0: core-js-compat@3.50.0:
@@ -6373,6 +6492,15 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.7 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: fill-range@7.1.1:
dependencies: dependencies:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
@@ -6491,7 +6619,7 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 toidentifier: 1.0.1
http-proxy-agent@7.0.2(supports-color@10.2.2): http-proxy-agent@7.0.2:
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
@@ -6686,13 +6814,13 @@ snapshots:
jsc-safe-url@0.2.4: {} jsc-safe-url@0.2.4: {}
jsdom@26.1.0(supports-color@10.2.2): jsdom@26.1.0:
dependencies: dependencies:
cssstyle: 4.6.0 cssstyle: 4.6.0
data-urls: 5.0.0 data-urls: 5.0.0
decimal.js: 10.6.0 decimal.js: 10.6.0
html-encoding-sniffer: 4.0.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) https-proxy-agent: 7.0.6(supports-color@10.2.2)
is-potential-custom-element-name: 1.0.1 is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.28 nwsapi: 2.2.28
@@ -6845,6 +6973,8 @@ snapshots:
marky@1.3.0: {} marky@1.3.0: {}
media-typer@2.0.0: {}
memoize-one@5.2.1: {} memoize-one@5.2.1: {}
merge-stream@2.0.0: {} merge-stream@2.0.0: {}
@@ -7069,6 +7199,21 @@ snapshots:
ms@2.1.3: {} 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: mz@2.7.0:
dependencies: dependencies:
any-promise: 1.3.0 any-promise: 1.3.0
@@ -7230,6 +7375,12 @@ snapshots:
pirates@4.0.7: {} pirates@4.0.7: {}
playwright-core@1.64.1: {}
playwright@1.64.1:
dependencies:
playwright-core: 1.64.1
plist@3.1.1: plist@3.1.1:
dependencies: dependencies:
'@xmldom/xmldom': 0.9.12 '@xmldom/xmldom': 0.9.12
@@ -7736,6 +7887,10 @@ snapshots:
dependencies: dependencies:
js-tokens: 9.0.1 js-tokens: 9.0.1
strtok3@10.6.2:
dependencies:
'@tokenizer/token': 0.3.0
structured-headers@0.4.1: {} structured-headers@0.4.1: {}
sucrase@3.35.0: sucrase@3.35.0:
@@ -7842,6 +7997,12 @@ snapshots:
toidentifier@1.0.1: {} 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: tough-cookie@5.1.2:
dependencies: dependencies:
tldts: 6.1.86 tldts: 6.1.86
@@ -7899,6 +8060,8 @@ snapshots:
typescript@5.8.3: {} typescript@5.8.3: {}
uint8array-extras@1.6.0: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici-types@8.9.0: {} undici-types@8.9.0: {}
@@ -7955,7 +8118,28 @@ snapshots:
vary@1.1.2: {} 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: dependencies:
cac: 6.7.14 cac: 6.7.14
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
@@ -7976,6 +8160,20 @@ snapshots:
- tsx - tsx
- yaml - 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): vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2):
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
@@ -7990,7 +8188,49 @@ snapshots:
lightningcss: 1.27.0 lightningcss: 1.27.0
terser: 5.51.2 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: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
'@vitest/expect': 3.2.7 '@vitest/expect': 3.2.7
@@ -8013,11 +8253,11 @@ snapshots:
tinypool: 1.1.1 tinypool: 1.1.1
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
vite: 6.4.4(@types/node@26.6.5)(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)
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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/node': 26.6.5 '@types/node': 26.6.5
jsdom: 26.1.0(supports-color@10.2.2) jsdom: 26.1.0
transitivePeerDependencies: transitivePeerDependencies:
- jiti - jiti
- less - less
@@ -8082,6 +8322,8 @@ snapshots:
siginfo: 2.0.0 siginfo: 2.0.0
stackback: 0.0.2 stackback: 0.0.2
win-guid@0.2.1: {}
wonka@6.3.6: {} wonka@6.3.6: {}
wrap-ansi@7.0.0: wrap-ansi@7.0.0:
+5 -3
View File
@@ -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) uploads.Config{StagingDir: cfg.UploadsDir, FFmpeg: cfg.FFmpegPath}, log)
upSvc.Run(ctx) 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{ return server.NewRouter(server.Deps{
Log: log, Log: log,
System: sys, 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), Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
Search: search.NewHandler(meili, libStore, indexer, search.NewAffinity(pool), dto, log), Search: search.NewHandler(meili, libStore, indexer, search.NewAffinity(pool), dto, log),
Playback: pb, Playback: pb,
Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken, Wave: wh,
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log),
App: appupdate.NewHandler(appupdate.Config{GiteaURL: cfg.GiteaURL, Repo: cfg.GiteaRepo, Token: cfg.GiteaToken, App: appupdate.NewHandler(appupdate.Config{GiteaURL: cfg.GiteaURL, Repo: cfg.GiteaRepo, Token: cfg.GiteaToken,
Dir: cfg.UpdatesDir, MinSupported: cfg.MinAndroidVersionCode}, log), 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), TUS: uploads.NewTUS(uploads.NewStore(pool), cfg.UploadsDir, upSvc.Complete, log),
}), nil }), nil
} }
File diff suppressed because it is too large. Load diff
+32
View File
@@ -196,6 +196,38 @@ func (s streamResponse) VisitStreamTrackResponse(w http.ResponseWriter) error {
return nil 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. // GetTracksBatch реализует gen.StrictServerInterface.
+141
View File
@@ -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())
}
+72 -11
View File
@@ -10,17 +10,22 @@ import (
"pipu/api/internal/api/gen" "pipu/api/internal/api/gen"
"pipu/api/internal/auth" "pipu/api/internal/auth"
"pipu/api/internal/platform/apperr" "pipu/api/internal/platform/apperr"
"pipu/api/internal/platform/sign"
) )
// Handler — эндпоинты очереди на добавление (часть gen.StrictServerInterface). // Handler — эндпоинты очереди на добавление (часть gen.StrictServerInterface).
type Handler struct { type Handler struct {
svc *Service svc *Service
signer *sign.Signer
xAccel bool
} }
// NewHandler создаёт обработчик. // NewHandler создаёт обработчик. signer подписывает ссылки прослушивания из staging.
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } 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{ out := gen.UploadFile{
Id: f.ID, BatchId: f.BatchID, BatchComment: f.BatchComment, Id: f.ID, BatchId: f.BatchID, BatchComment: f.BatchComment,
Author: gen.UserRef{Id: f.AuthorID, Username: f.AuthorName}, 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, 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, SampleRate: f.SampleRate, BitDepth: f.BitDepth, DurationMs: f.DurationMs, Tags: f.Tags,
MetadataEdited: f.MetadataEdited, MatchedArtistId: f.MatchedArtistID, MatchedAlbumId: f.MatchedAlbumID, 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, TargetSourceId: f.TargetSourceID, TargetRelPath: f.TargetRelPath, StatusReason: f.StatusReason,
ReviewedAt: f.ReviewedAt, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt, 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) u := TusURL(f.ID)
out.TusUrl = &u 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 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))} out := gen.UploadFilePage{Items: make([]gen.UploadFile, 0, len(files))}
for _, f := range files { for _, f := range files {
out.Items = append(out.Items, fileDTO(ctx, f)) out.Items = append(out.Items, h.fileDTO(ctx, f))
} }
if next != "" { if next != "" {
out.NextCursor = &next out.NextCursor = &next
@@ -96,7 +107,7 @@ func deref[T any](p *T, def T) T {
func settingsDTO(s Settings) gen.UploadSettings { func settingsDTO(s Settings) gen.UploadSettings {
return gen.UploadSettings{ return gen.UploadSettings{
QueueLimitBytes: s.QueueLimitBytes, PerUserLimitBytes: s.PerUserLimitBytes, MaxFileBytes: s.MaxFileBytes, 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 { if err != nil {
return nil, err return nil, err
} }
return gen.ListMyUploads200JSONResponse(pageDTO(ctx, files, next)), nil return gen.ListMyUploads200JSONResponse(h.pageDTO(ctx, files, next)), nil
} }
// WithdrawUpload реализует gen.StrictServerInterface. // WithdrawUpload реализует gen.StrictServerInterface.
@@ -195,7 +206,7 @@ func (h *Handler) ListUploads(ctx context.Context, req gen.ListUploadsRequestObj
if err != nil { if err != nil {
return nil, err return nil, err
} }
return gen.ListUploads200JSONResponse(pageDTO(ctx, files, next)), nil return gen.ListUploads200JSONResponse(h.pageDTO(ctx, files, next)), nil
} }
// UpdateUpload реализует gen.StrictServerInterface. // UpdateUpload реализует gen.StrictServerInterface.
@@ -209,7 +220,7 @@ func (h *Handler) UpdateUpload(ctx context.Context, req gen.UpdateUploadRequestO
if err != nil { if err != nil {
return nil, err return nil, err
} }
return gen.UpdateUpload200JSONResponse(fileDTO(ctx, f)), nil return gen.UpdateUpload200JSONResponse(h.fileDTO(ctx, f)), nil
} }
// RetryUploadImport реализует gen.StrictServerInterface. // RetryUploadImport реализует gen.StrictServerInterface.
@@ -218,7 +229,7 @@ func (h *Handler) RetryUploadImport(ctx context.Context, req gen.RetryUploadImpo
if err != nil { if err != nil {
return nil, err return nil, err
} }
return gen.RetryUploadImport200JSONResponse(fileDTO(ctx, f)), nil return gen.RetryUploadImport200JSONResponse(h.fileDTO(ctx, f)), nil
} }
// ReviewUploads реализует gen.StrictServerInterface. // ReviewUploads реализует gen.StrictServerInterface.
@@ -272,9 +283,59 @@ func (h *Handler) UpdateUploadSettings(ctx context.Context, req gen.UpdateUpload
QueueLimitBytes: b.QueueLimitBytes, MaxFileBytes: b.MaxFileBytes, QueueLimitBytes: b.QueueLimitBytes, MaxFileBytes: b.MaxFileBytes,
PerUserLimitBytes: b.PerUserLimitBytes, ClearPerUser: b.PerUserLimitBytes == nil, PerUserLimitBytes: b.PerUserLimitBytes, ClearPerUser: b.PerUserLimitBytes == nil,
DefaultSourceID: b.DefaultSourceId, ClearDefault: b.DefaultSourceId == nil, DefaultSourceID: b.DefaultSourceId, ClearDefault: b.DefaultSourceId == nil,
AllowQualityUpgrade: b.AllowQualityUpgrade,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
return gen.UpdateUploadSettings200JSONResponse(settingsDTO(s)), nil 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
}
+3
View File
@@ -138,12 +138,14 @@ type File struct {
DuplicateOfTrackID *uuid.UUID DuplicateOfTrackID *uuid.UUID
DuplicateLevel *string DuplicateLevel *string
PossibleDuplicate bool PossibleDuplicate bool
QualityUpgrade bool
TargetSourceID *uuid.UUID TargetSourceID *uuid.UUID
TargetRelPath *string TargetRelPath *string
StatusReason *string StatusReason *string
ReviewedBy *uuid.UUID ReviewedBy *uuid.UUID
ReviewedAt *time.Time ReviewedAt *time.Time
ImportedFileID *uuid.UUID ImportedFileID *uuid.UUID
CoverPath *string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
@@ -154,6 +156,7 @@ type Settings struct {
PerUserLimitBytes *int64 `json:"per_user_limit_bytes,omitempty"` // nil — выключен PerUserLimitBytes *int64 `json:"per_user_limit_bytes,omitempty"` // nil — выключен
MaxFileBytes int64 `json:"max_file_bytes"` MaxFileBytes int64 `json:"max_file_bytes"`
DefaultSourceID *uuid.UUID `json:"default_source_id,omitempty"` DefaultSourceID *uuid.UUID `json:"default_source_id,omitempty"`
AllowQualityUpgrade bool `json:"allow_quality_upgrade"`
} }
// DefaultSettings — значения по умолчанию (docs/uploads.md#3-квота-очереди). // DefaultSettings — значения по умолчанию (docs/uploads.md#3-квота-очереди).
+31
View File
@@ -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")
}
}
+22
View File
@@ -439,6 +439,7 @@ type SettingsInput struct {
PerUserLimitBytes *int64 PerUserLimitBytes *int64
MaxFileBytes *int64 MaxFileBytes *int64
DefaultSourceID *uuid.UUID DefaultSourceID *uuid.UUID
AllowQualityUpgrade *bool
ClearPerUser bool ClearPerUser bool
ClearDefault bool ClearDefault bool
} }
@@ -484,6 +485,9 @@ func (s *Service) UpdateSettings(ctx context.Context, in SettingsInput) (Setting
} }
st.DefaultSourceID = in.DefaultSourceID st.DefaultSourceID = in.DefaultSourceID
} }
if in.AllowQualityUpgrade != nil {
st.AllowQualityUpgrade = *in.AllowQualityUpgrade
}
if err := s.store.SaveSettings(ctx, st); err != nil { if err := s.store.SaveSettings(ctx, st); err != nil {
return Settings{}, err return Settings{}, err
} }
@@ -662,3 +666,21 @@ func (s *Service) removeStaging(f File) {
// TusURL — адрес tus для файла. // TusURL — адрес tus для файла.
func TusURL(id uuid.UUID) string { return "/api/v1/uploads/files/" + id.String() } 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
}
+92 -4
View File
@@ -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, 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.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.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.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, f.created_at, f.updated_at` 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` 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, 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.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.BitDepth, &f.DurationMs, &tags, &meta, &f.MetadataEdited, &f.MatchedArtistID, &f.MatchedAlbumID,
&f.DuplicateOfTrackID, &f.DuplicateLevel, &f.PossibleDuplicate, &f.TargetSourceID, &f.TargetRelPath, &f.DuplicateOfTrackID, &f.DuplicateLevel, &f.PossibleDuplicate, &f.QualityUpgrade, &f.TargetSourceID, &f.TargetRelPath,
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CreatedAt, &f.UpdatedAt) &f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CoverPath, &f.CreatedAt, &f.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return File{}, ErrNotFound return File{}, ErrNotFound
} }
@@ -526,3 +526,91 @@ func MarkImported(ctx context.Context, tx pgx.Tx, uploadID, mediaFileID uuid.UUI
} }
return nil 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
}
+92
View File
@@ -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"
}
}
+44 -5
View File
@@ -41,7 +41,16 @@ func (s *Service) Run(ctx context.Context) {
s.reconcile(ctx) s.reconcile(ctx)
go s.loop(ctx, s.wakeProcess, pollEvery, func(ctx context.Context) bool { return s.processNext(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, 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.wakeProcess)
wake(s.wakeImport) wake(s.wakeImport)
} }
@@ -98,6 +107,7 @@ type outcome struct {
dupTrack *uuid.UUID dupTrack *uuid.UUID
dupLevel string dupLevel string
possible bool possible bool
quality bool
place placement place placement
} }
@@ -144,10 +154,14 @@ func (s *Service) analyze(ctx context.Context, f File, abs string) outcome {
cands, err := s.store.FingerprintCandidates(ctx, probe.DurationMs, FingerprintTolerance, f.ID) cands, err := s.store.FingerprintCandidates(ctx, probe.DurationMs, FingerprintTolerance, f.ID)
if err == nil { if err == nil {
if c, ok := bestMatch(fp.Points, cands); ok { if c, ok := bestMatch(fp.Points, cands); ok {
if s.keepAsUpgrade(ctx, &o, c.TrackID, probe) {
// лучшее качество — дальше как обычный файл на рассмотрение
} else {
o.status, o.reason, o.dupTrack, o.dupLevel = StatusDuplicate, CodeDuplicate, c.TrackID, DupFingerprint o.status, o.reason, o.dupTrack, o.dupLevel = StatusDuplicate, CodeDuplicate, c.TrackID, DupFingerprint
return o return o
} }
} }
}
} else if !errors.Is(err, library.ErrNoFPCalc) { } else if !errors.Is(err, library.ErrNoFPCalc) {
s.log.Warn("uploads: fpcalc", "upload_id", f.ID, "err", err) s.log.Warn("uploads: fpcalc", "upload_id", f.ID, "err", err)
} }
@@ -183,6 +197,26 @@ func (s *Service) analyze(ctx context.Context, f File, abs string) outcome {
return o 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. // bestMatch — отпечаток, похожий не меньше порога library.SameRecording.
func bestMatch(points []uint32, cands []PrintCandidate) (PrintCandidate, bool) { func bestMatch(points []uint32, cands []PrintCandidate) (PrintCandidate, bool) {
best, score := PrintCandidate{}, 0.0 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, ''), 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), 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, ''), 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 matched_artist_id = $18, matched_album_id = $19
WHERE id = $1`, WHERE id = $1`,
f.ID, o.sha, p.Format, p.Codec, p.Bitrate, p.SampleRate, p.BitDepth, p.DurationMs, tags, metaJSON, points, 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 return err
}) })
} }
@@ -295,7 +329,7 @@ func (s *Service) importFile(ctx context.Context, f File) error {
} }
rel := *pl.RelPath rel := *pl.RelPath
stagingRel, sum := f.StagingPath, f.SHA256 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)) tagged, tsum, err := s.writeTags(ctx, f, *f.Meta, path.Ext(rel))
if err != nil { if err != nil {
return fmt.Errorf("write tags: %w", err) 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) rel := f.StagingPath + ".tagged" + strings.ToLower(ext)
out := s.stagingAbs(rel) 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 := func(k, v string) { args = append(args, "-metadata", k+"="+v) }
set("title", m.Title) set("title", m.Title)
set("artist", m.Artist) set("artist", m.Artist)
+5
View File
@@ -141,6 +141,8 @@ type Handler struct {
catalog *library.Store catalog *library.Store
dto func() library.DTO dto func() library.DTO
log *slog.Logger log *slog.Logger
// Warm заранее готовит транскоды выданной пачки (docs/playback.md).
Warm func(ctx context.Context, trackIDs []uuid.UUID)
} }
// NewHandler создаёт обработчик. // 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 { if err := h.record(ctx, s, picks, modelVer); err != nil {
return gen.WaveBatch{}, err 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 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;
+15
View File
@@ -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"}