Compare commits

...
15 Commits
Author SHA1 Message Date
mrleo1nid 6a1382dad5 fix(player): toggle current track without restarting playback
ci / ts / ts (packages + web) (push) Failing after 7m39s
release / android (APK в релиз) (push) Successful in 6m47s
release / desktop (Linux и Windows в релиз) (push) Successful in 15m18s
release / release (версия и заметки) (push) Successful in 9s
release / prune (старые файлы и прогоны) (push) Successful in 10s
2026-10-29 22:44:54 +03:00
mrleo1nid 5876ae29cc feat(uploads): show artist and album in batch headers
ci / module boundaries / boundaries (push) Successful in 19s
ci / ts / ts (packages + web) (push) Failing after 6m0s
2026-10-29 22:28:42 +03:00
mrleo1nid 39bf7d4b70 feat(uploads): match release groups with MusicBrainz and AcoustID
ci / module boundaries / boundaries (push) Successful in 10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / api / api (push) Successful in 58s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Failing after 25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m36s
ci / ts / ts (packages + web) (push) Failing after 6m3s
2026-10-29 20:44:59 +03:00
mrleo1nid 1e3a149aef feat(uploads): show batch format counts and quality ranges
ci / ts / ts (packages + web) (push) Failing after 5m56s
ci / module boundaries / boundaries (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m39s
2026-10-29 17:41:28 +03:00
mrleo1nid e78cab6ce6 fix(uploads): serialize status filters and resize oversized covers
ci / module boundaries / boundaries (push) Successful in 9s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m37s
ci / api / api (push) Successful in 58s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
ci / db / db (push) Successful in 6m19s
ci / ts / ts (packages + web) (push) Failing after 27m2s
2026-10-29 12:14:49 +03:00
mrleo1nid ca2a50b515 fix(uploads): load audio previews only on play
ci / module boundaries / boundaries (push) Successful in 7s
ci / ts / ts (packages + web) (push) Failing after 6m33s
2026-10-29 11:45:09 +03:00
mrleo1nid af06cf1e75 fix(uploads): pause previous audio preview when another starts
ci / ts / ts (packages + web) (push) Canceled after 3m50s
ci / module boundaries / boundaries (push) Successful in 8s
2026-10-29 11:41:11 +03:00
mrleo1nid 7d62531c02 fix(uploads): serve private previews through API and show batch cover status
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Canceled after 4m13s
ci / module boundaries / boundaries (push) Successful in 8s
ci / api / api (push) Successful in 56s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Successful in 1m7s
2026-10-29 11:34:55 +03:00
mrleo1nid 37e33c656b fix(uploads): allow signed audio previews and compact destination details
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 47s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m42s
ci / ts / ts (packages + web) (push) Failing after 5m24s
ci / api / api (push) Successful in 42s
2026-10-29 11:21:48 +03:00
mrleo1nid 30c3c85f89 fix(uploads): refresh destination previews and improve queue layout
ci / api / api (push) Successful in 50s
ci / db / db (push) Successful in 56s
ci / module boundaries / boundaries (push) Successful in 8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
ci / ts / ts (packages + web) (push) Failing after 4m28s
2026-10-29 11:10:54 +03:00
mrleo1nid eb75437708 fix(uploads): separate withdrawn proposals from default filters
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m44s
ci / ts / ts (packages + web) (push) Failing after 1m56s
2026-10-29 10:44:10 +03:00
mrleo1nid 64dd8ed05e feat(uploads): preview storage paths, detect templates, attach covers and withdraw batches
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Failing after 4m4s
ci / api / api (push) Successful in 55s
ci / db / db (push) Failing after 1m14s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
2026-10-29 10:28:29 +03:00
mrleo1nid 5e2d1e902b fix(uploads): retry failed files and report full storage
ci / db / db (push) Successful in 1m51s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m40s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 33s
ci / ts / ts (packages + web) (push) Failing after 5m16s
2026-10-29 00:39:03 +03:00
mrleo1nid b39e4dc28f fix(uploads): hash files without WebAssembly under strict CSP
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 3m17s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m12s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m42s
ci / ts / ts (packages + web) (push) Failing after 4m37s
ci / module boundaries / boundaries (push) Successful in 5s
ci / desktop / rust (desktop proxy) (push) Successful in 6m12s
2026-10-29 00:05:49 +03:00
mrleo1nid f59d1e8d03 fix(search): skip deletion of missing temporary indexes
ci / db / db (push) Successful in 52s
ci / api / api (push) Successful in 42s
2026-10-28 23:56:46 +03:00
92 changed files with 6789 additions and 622 deletions

No files matched your search

+2 -1
View File
@@ -48,6 +48,7 @@ jobs:
TEST_REDIS_URL: redis://redis:6379/0
TEST_MEILI_URL: http://meilisearch:7700
TEST_MEILI_KEY: ci-master-key-0123456789
MUSICBRAINZ_ENABLED: "false"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@@ -73,7 +74,7 @@ jobs:
working-directory: services/api
# These suites share one database; dbtest also rolls all migrations down
# and cleans orphan catalog rows. Do not run the packages concurrently.
run: go test -p 1 -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/
run: go test -p 1 -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/ ./internal/uploads/
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
working-directory: services/api
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
+225
View File
@@ -910,6 +910,12 @@ paths:
tags: [uploads]
summary: Мои предложения (moderator, admin)
parameters:
- name: batch
in: query
description: Только файлы указанной собственной пачки
schema:
type: string
format: uuid
- $ref: "#/components/parameters/UploadStatusFilter"
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/Cursor"
@@ -1094,6 +1100,105 @@ paths:
$ref: "#/components/schemas/UploadFile"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/uploads/batches/{id}/releases:
parameters:
- $ref: "#/components/parameters/EntityId"
get:
operationId: getUploadReleases
parameters:
- name: group_id
in: query
schema: {type: string, format: uuid}
tags: [uploads]
summary: Состояние фонового сопоставления пачки с изданиями MusicBrainz
responses:
"200":
description: Состояние поиска и кандидаты; текущие файлы не изменяются
content:
application/json:
schema: {$ref: "#/components/schemas/UploadReleaseReport"}
default: {$ref: "#/components/responses/Problem"}
post:
operationId: searchUploadReleases
tags: [uploads]
summary: Повторить поиск релиза, при необходимости с уточнённым запросом
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
artist: {type: string, maxLength: 500}
album: {type: string, maxLength: 500}
group_id: {type: string, format: uuid}
release_id: {type: string, format: uuid}
audio:
type: boolean
description: Явно распознать до 50 редактируемых треков через AcoustID; теги и аудио не отправляются. Требуется ключ приложения на сервере.
responses:
"202":
description: Поиск поставлен в фоновую очередь
content:
application/json:
schema: {$ref: "#/components/schemas/UploadReleaseReport"}
default: {$ref: "#/components/responses/Problem"}
/api/v1/admin/uploads/batches/{id}/releases/{release_id}/cover:
parameters:
- $ref: "#/components/parameters/EntityId"
- name: release_id
in: path
required: true
schema: {type: string, format: uuid}
get:
operationId: getUploadReleaseCover
parameters:
- name: group_id
in: query
schema: {type: string, format: uuid}
tags: [uploads]
summary: Предпросмотр обложки выбранного кандидата из Cover Art Archive
responses:
"200":
description: Ограниченное по размеру изображение; не меняет обложку пачки
content:
application/json:
schema:
type: object
required: [image_base64, mime_type, source_url, exact_release]
properties:
image_base64: {type: string}
mime_type: {type: string}
source_url: {type: string}
exact_release: {type: boolean}
default: {$ref: "#/components/responses/Problem"}
/api/v1/admin/uploads/batches/{id}/release:
parameters:
- $ref: "#/components/parameters/EntityId"
post:
operationId: applyUploadRelease
tags: [uploads]
summary: Явно применить метаданные и/или обложку выбранного издания
description: Только к совпавшим редактируемым файлам. Изменившаяся пачка или повторный поиск приводят к 409; одобрение и квота не меняются.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [release_id, apply_metadata, apply_cover]
properties:
group_id: {type: string, format: uuid}
release_id: {type: string, format: uuid}
apply_metadata: {type: boolean}
apply_cover: {type: boolean}
responses:
"200":
description: Применено к текущей пачке
content:
application/json:
schema: {$ref: "#/components/schemas/UploadReleaseReport"}
default: {$ref: "#/components/responses/Problem"}
/api/v1/admin/uploads/review:
post:
operationId: reviewUploads
@@ -2743,6 +2848,10 @@ components:
type: object
required: [files]
properties:
cover_base64:
type: string
maxLength: 11184812
description: Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки.
comment:
type: string
maxLength: 1000
@@ -2914,6 +3023,17 @@ components:
type: string
nullable: true
description: Путь в хранилище по write_template (null — не построился, причина в status_reason)
target_source_name:
type: string
nullable: true
description: Название целевого хранилища; null, если хранилище не выбрано или удалено
batch_has_cover:
type: boolean
description: У пачки есть общая обложка для записи в загружаемые треки
target_storage_path:
type: string
nullable: true
description: Полный путь назначения для админской очереди — локальный путь API или server:/export/path для NFS. Для ожидающих файлов рассчитывается по текущему шаблону.
status_reason:
type: string
nullable: true
@@ -2954,6 +3074,100 @@ components:
target_source_id:
type: string
format: uuid
ReleaseTrack:
type: object
required: [recording_id, title, artist, disc, position, duration_ms]
properties:
recording_id: {type: string}
title: {type: string}
artist: {type: string}
disc: {type: integer}
position: {type: integer}
duration_ms: {type: integer}
ReleaseInfo:
type: object
required: [id, group_id, title, artist, type, date, country, comment, has_cover, tracks]
properties:
id: {type: string}
group_id: {type: string}
title: {type: string}
artist: {type: string}
type: {type: string}
date: {type: string}
country: {type: string}
comment: {type: string}
has_cover: {type: boolean}
tracks:
type: array
items: {$ref: "#/components/schemas/ReleaseTrack"}
ReleaseCandidate:
type: object
required: [release, matches, missing, existing, unmatched, matched, total, complete, confidence]
properties:
release: {$ref: "#/components/schemas/ReleaseInfo"}
matches:
type: array
items:
type: object
required: [file_id, track_index, confidence]
properties:
file_id: {type: string, format: uuid}
track_index: {type: integer}
confidence: {type: string}
method: {type: string, enum: [metadata, acoustid]}
missing:
type: array
items: {$ref: "#/components/schemas/ReleaseTrack"}
existing:
type: array
description: Совпадения отсутствующих дорожек с библиотекой по метаданным и длительности; не подтверждают полную загруженную пачку
items: {$ref: "#/components/schemas/ReleaseTrack"}
unmatched:
type: array
items: {type: string, format: uuid}
matched: {type: integer}
total: {type: integer}
complete: {type: boolean}
confidence: {type: string}
UploadReleaseGroup:
type: object
required: [id, artist, album, file_ids, has_cover, state]
properties:
id: {type: string, format: uuid}
artist: {type: string}
album: {type: string}
file_ids:
type: array
items: {type: string, format: uuid}
has_cover: {type: boolean}
state: {type: string}
UploadReleaseReport:
type: object
required: [state, input_hash, candidates, selected_release]
properties:
group_id: {type: string, format: uuid}
groups:
type: array
items: {$ref: "#/components/schemas/UploadReleaseGroup"}
audio_available: {type: boolean}
audio_results:
type: array
items:
type: object
required: [file_id, state]
properties:
file_id: {type: string, format: uuid}
state: {type: string, enum: [matched, ambiguous, not_found, unavailable]}
recording_id: {type: string, format: uuid}
score: {type: number, minimum: 0, maximum: 1}
state:
type: string
enum: [disabled, queued, searching, ready, not_found, needs_input, error]
input_hash: {type: string}
candidates:
type: array
items: {$ref: "#/components/schemas/ReleaseCandidate"}
selected_release: {type: string, nullable: true}
UploadBatchPatch:
type: object
description: Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки.
@@ -3614,6 +3828,17 @@ components:
properties:
template:
type: string
matched:
type: integer
description: Число путей выборки, совпавших с предложенным шаблоном
total:
type: integer
description: Число исследованных файлов хранилища
examples:
type: array
items:
type: string
description: До трёх существующих путей, совпавших с шаблоном
WriteTemplateCheckRequest:
type: object
required: [template]
+1 -1
View File
@@ -13,7 +13,7 @@
"withGlobalTauri": true,
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
"security": {
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
}
},
"bundle": {
+460
View File
@@ -0,0 +1,460 @@
import { expect, test, type Page } from '@playwright/test';
function previewWav(seconds = 2) {
const dataSize = 8000 * 2 * seconds;
const wav = Buffer.alloc(44 + dataSize);
wav.write('RIFF', 0); wav.writeUInt32LE(36 + dataSize, 4); wav.write('WAVEfmt ', 8);
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
wav.write('data', 36); wav.writeUInt32LE(dataSize, 40);
return wav;
}
async function setup(page: Page, scenario: 'queue' | 'template' | 'mine' | 'release' | 'mixed', audioAvailable = true) {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('mobile-admin');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('mobile-admin-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
await page.evaluate(async ({ scenario, audioAvailable }) => {
const modulePath = '/src/test/mobileFixtures.ts';
const { mobileFixtures } = await import(modulePath);
const source = structuredClone(mobileFixtures['/api/v1/admin/sources'].items[0]);
const file = { ...structuredClone(mobileFixtures['/api/v1/admin/uploads'].items[0]), batch_has_cover: true };
const files = [
{ ...file, id: 'visible', original_name: 'visible.flac', target_source_name: source.name, target_rel_path: 'Artist/Album/01 - Song.flac', target_storage_path: 'nas:/music/Artist/Album/01 - Song.flac', stream_url: `/api/v1/admin/uploads/files/${file.id}/stream?exp=9999999999&sig=test` },
{ ...file, id: 'approved', original_name: 'approved.flac', status: 'approved' },
{ ...file, id: 'hidden', original_name: 'hidden.flac', status: 'processing' },
{ ...file, id: 'withdrawn', original_name: 'withdrawn.flac', metadata: null, status: 'withdrawn' },
{ ...file, id: 'rejected', original_name: 'rejected.flac', metadata: null, status: 'rejected' },
];
files[0]!.bitrate = 778000;
const missing = { ...file, id: 'missing', original_name: 'missing.flac', bitrate: 924000, target_source_id: null, target_source_name: null, target_rel_path: null, status_reason: 'uploads.no_target' };
const releaseId = '11111111-1111-4111-8111-111111111111';
const deluxeId = '22222222-2222-4222-8222-222222222222';
const tracks = [1, 2].map((n) => ({ recording_id: `recording-${n}`, title: `Song ${n}`, artist: 'Catalogue Artist', disc: 1, position: n, duration_ms: 180000 }));
const standard = { release: { id: releaseId, group_id: releaseId, title: 'Album', artist: 'Catalogue Artist', type: 'Album', date: '2024-01-01', country: 'RU', comment: 'Digital', has_cover: true, tracks },
matches: [{ file_id: 'visible', track_index: 0, confidence: 'high' }, { file_id: 'missing', track_index: 1, confidence: 'high' }], missing: [], existing: [], unmatched: [], matched: 2, total: 2, complete: true, confidence: 'high' };
const bonus = { ...tracks[0]!, title: 'Bonus', position: 3 };
const deluxe = { ...standard, release: { ...standard.release, id: deluxeId, comment: 'Deluxe', tracks: [...tracks, bonus] }, missing: [bonus], existing: [bonus], total: 3, complete: false };
let releaseReport = { state: 'ready', input_hash: 'original', selected_release: null as string | null, candidates: [standard, deluxe], audio_available: audioAvailable, audio_results: [] as { file_id: string; state: string }[] };
const groupA = { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', artist: 'Artist A', album: 'Album A', file_ids: ['visible'], has_cover: true, state: 'ready' };
const groupB = { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', artist: 'Artist B', album: 'Album B', file_ids: ['missing'], has_cover: false, state: 'ready' };
const groups = [groupA, groupB];
const groupReports = Object.fromEntries(groups.map((g, i) => [g.id, { ...releaseReport, group_id: g.id, groups, input_hash: g.id,
candidates: [{ ...standard, release: { ...standard.release, id: i ? deluxeId : releaseId, artist: g.artist, title: g.album, tracks: [tracks[i]] }, matches: [{ file_id: g.file_ids[0], track_index: 0, confidence: 'high' }], matched: 1, total: 1 }],
}]));
let rejectReleaseOnce = true;
let failOnce = true;
const deleted: string[] = [];
const original = window.fetch;
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } });
window.fetch = async (input, init) => {
const req = new Request(input, init);
const url = new URL(req.url);
if (scenario === 'mixed' && url.pathname.includes('/admin/uploads/batches/')) {
if (url.pathname.endsWith('/releases')) {
if (req.method === 'POST') {
const body = await req.json();
document.documentElement.dataset.releaseSearch = JSON.stringify(body);
return json({ ...groupReports[body.group_id], state: 'queued' }, 202);
}
return json(groupReports[url.searchParams.get('group_id') ?? ''] ?? { ...releaseReport, state: 'needs_input', candidates: [], groups });
}
if (url.pathname.endsWith('/cover')) {
document.documentElement.dataset.releaseCoverGroup = url.searchParams.get('group_id') ?? '';
return json({ image_base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', mime_type: 'image/png', source_url: 'https://coverartarchive.org/release/' + releaseId + '/front-1200', exact_release: true });
}
if (url.pathname.endsWith('/release') && req.method === 'POST') {
const body = await req.json();
document.documentElement.dataset.releaseApplied = JSON.stringify(body);
const target = groups.find((g) => g.id === body.group_id)!;
if (body.apply_cover) target.has_cover = true;
if (body.apply_metadata) {
const f = body.group_id === groupA.id ? files[0]! : missing;
Object.assign(f, { metadata: { artist: target.artist, albumartist: target.artist, album: target.album, title: 'Confirmed ' + target.album, genre: '' }, updated_at: new Date().toISOString() });
}
groupReports[body.group_id]!.selected_release = body.release_id;
return json(groupReports[body.group_id]);
}
}
if (scenario === 'mixed' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
if (url.pathname.endsWith('/releases') && url.pathname.includes('/admin/uploads/batches/')) {
if (scenario !== 'release') return json({ state: 'disabled', input_hash: '', selected_release: null, candidates: [] });
if (req.method === 'POST') {
const body = await req.json();
document.documentElement.dataset.releaseSearch = JSON.stringify(body);
if (body.audio) releaseReport = { ...releaseReport, audio_results: [{ file_id: 'visible', state: 'matched' }, { file_id: 'missing', state: 'ambiguous' }], candidates: [
{ ...standard, complete: false, confidence: 'medium', matches: [{ ...standard.matches[0]!, method: 'acoustid' }] } as typeof standard, deluxe,
] };
return json({ ...releaseReport, state: 'queued' }, 202);
}
return json(releaseReport);
}
if (scenario === 'release' && url.pathname.endsWith('/cover')) {
document.documentElement.dataset.releaseCoverRequests = String(Number(document.documentElement.dataset.releaseCoverRequests ?? 0) + 1);
return json({ image_base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', mime_type: 'image/png', source_url: 'https://coverartarchive.org/release-group/11111111-1111-4111-8111-111111111111/front-1200', exact_release: false });
}
if (scenario === 'release' && url.pathname.endsWith('/release') && req.method === 'POST') {
const body = await req.json();
if (body.apply_metadata && rejectReleaseOnce) { rejectReleaseOnce = false; return json({ code: 'uploads.release_stale', status: 409 }, 409); }
document.documentElement.dataset.releaseApplied = JSON.stringify(body);
if (body.apply_metadata) {
for (const [index, f] of [files[0]!, missing].entries()) Object.assign(f, { metadata: { artist: 'Catalogue Artist', albumartist: 'Catalogue Artist', title: `Song ${index + 1}`, album: 'Album', year: 2024, track: index + 1, disc: 1, genre: '' }, updated_at: new Date().toISOString() });
}
releaseReport = { ...releaseReport, input_hash: 'applied', selected_release: body.release_id };
return json(releaseReport);
}
if (scenario === 'release' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads') return json({ items: [files[0], missing], next_cursor: null });
if (scenario === 'queue' && url.pathname.startsWith('/api/v1/admin/uploads/batches/') && req.method === 'PATCH') {
const body = await req.json();
const bytes = Uint8Array.from(atob(body.cover_base64), (char) => char.charCodeAt(0));
const image = await createImageBitmap(new Blob([bytes], { type: 'image/jpeg' }));
document.documentElement.dataset.coverDimensions = `${image.width}x${image.height}`;
document.documentElement.dataset.coverBytes = String(bytes.length);
image.close();
return json({ items: [files[0], missing] });
}
if (scenario === 'queue' && url.pathname === '/api/v1/admin/uploads/files/missing' && req.method === 'PATCH') {
const body = await req.json();
Object.assign(missing, { metadata: body.metadata, target_source_id: source.id, target_source_name: source.name, target_rel_path: 'Artist/Album/02 - Fixed.flac', status_reason: null });
return json(missing);
}
if (scenario === 'template') {
if (url.pathname === '/api/v1/admin/uploads') {
const path = document.documentElement.dataset.savedTemplate ? 'Artist/Album/01. Song.flac' : 'Artist/Album/Song.flac';
return json({ items: [{ ...files[0], target_rel_path: path, target_storage_path: `nas:/music/${path}` }], next_cursor: null });
}
if (url.pathname.endsWith('/write-template/suggest')) return json({ template: '{albumartist}/{album}/{track:02}. {title}.{ext}', matched: 180, total: 200, examples: ['Кино/Группа крови/01. Группа крови.flac'] });
if (url.pathname.endsWith('/write-template/check')) return json({ ok: false, total: 50, round_trip: 0, same_path: 45, mismatches: [] });
if (url.pathname === `/api/v1/admin/sources/${source.id}` && req.method === 'PATCH') {
const body = await req.json();
document.documentElement.dataset.savedTemplate = body.write_template;
Object.assign(source, body);
return json(source);
}
if (url.pathname === `/api/v1/admin/sources/${source.id}`) return json(source);
}
if (scenario === 'mine' && url.pathname === '/api/v1/uploads/mine') {
if (url.searchParams.getAll('status').length > 1) return json({ code: 'common.bad_request', status: 400 }, 400);
if (url.searchParams.has('batch')) {
document.documentElement.dataset.batchPages = String(Number(document.documentElement.dataset.batchPages ?? 0) + 1);
return json({ items: [url.searchParams.has('cursor') ? files[2] : files[0]], next_cursor: url.searchParams.has('cursor') ? null : 'next' });
}
const statuses = url.searchParams.get('status')?.split(',') ?? [];
const visible = [files[0], files[1], files[3], files[4]].filter((f) => !statuses.length || statuses.includes(f.status));
return json({ items: visible, next_cursor: 'more' });
}
if (scenario === 'mine' && req.method === 'DELETE' && url.pathname.startsWith('/api/v1/uploads/files/')) {
const id = url.pathname.split('/').pop()!;
deleted.push(id);
document.documentElement.dataset.deleted = JSON.stringify(deleted);
if (id === 'hidden' && failOnce) { failOnce = false; return json({ code: 'uploads.wrong_status', status: 409 }, 409); }
files.find((f) => f.id === id)!.status = 'withdrawn';
return new Response(null, { status: 204 });
}
return original(input, init);
};
const target = scenario === 'queue' || scenario === 'release' || scenario === 'mixed' ? '/admin/uploads' : scenario === 'mine' ? '/uploads/mine' : `/admin/sources/${source.id}/patterns`;
history.pushState({}, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
}, { scenario, audioAvailable });
}
test('queue shows the storage and path, and recalculates after fixing placement', async ({ page }, info) => {
await setup(page, 'queue');
await expect(page.getByText('Есть обложка', { exact: true })).toBeVisible();
await expect(page.getByText('2 × FLAC', { exact: true })).toBeVisible();
await expect(page.getByText('778–924 kbps · 44.1 kHz · 16 bit', { exact: true })).toBeVisible();
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/01 - Song.flac');
await expect(page.getByText('Хранилище не выбрано', { exact: true })).toBeVisible();
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
await expect(page.getByRole('button', { name: 'Развернуть пачку', exact: true })).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true })).toBeChecked();
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).press('Enter');
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).uncheck();
await page.getByRole('button', { name: 'Выбрать хранилище', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Исполнитель', { exact: true }).fill('Artist');
await dialog.getByLabel('Название', { exact: true }).fill('Fixed');
await dialog.getByRole('combobox').selectOption({ label: 'Музыкальное хранилище' });
await dialog.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByText('Artist/Album/02 - Fixed.flac', { exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('queue-placement.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByText('Artist/Album/02 - Fixed.flac', { exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
});
test('automatically suggests a dominant template and saves a custom edit only on request', async ({ page }, info) => {
await setup(page, 'template');
const field = page.getByRole('textbox', { name: 'Шаблон пути', exact: true });
await expect(field).toHaveValue('{albumartist}/{album}/{track:02}. {title}.{ext}');
await expect(page.getByText(/180 из 200/)).toBeVisible();
await expect(page.getByText('Кино/Группа крови/01. Группа крови.flac', { exact: true })).toBeVisible();
await expect(page.locator('html')).not.toHaveAttribute('data-saved-template');
await field.fill('{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}');
const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
await section.getByRole('checkbox').check();
await section.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}');
await page.screenshot({ path: info.outputPath('auto-template.png'), fullPage: true });
});
test('selects a whole own batch across pages and reports partial withdrawal failures', async ({ page }, info) => {
await setup(page, 'mine');
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
await expect(page.locator('html')).toHaveAttribute('data-batch-pages', '2');
await expect(page.getByText(/Выбрано 2 файла/)).toBeVisible();
await page.getByRole('button', { name: 'Отозвать выбранные', exact: true }).click();
await expect(page.getByText(/Не удалось отозвать: 1/)).toBeVisible();
await expect(page.locator('html')).toHaveAttribute('data-deleted', '["visible","hidden"]');
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => page.evaluate(() => Math.max(document.documentElement.scrollWidth - innerWidth, document.querySelector('main')!.scrollWidth - document.querySelector('main')!.clientWidth))).toBeLessThanOrEqual(1);
await page.screenshot({ path: info.outputPath('bulk-withdraw-mobile.png'), fullPage: true });
await page.getByRole('button', { name: 'Отозвать выбранные', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-deleted', '["visible","hidden","hidden"]');
await expect(page.getByRole('region', { name: 'Массовый отзыв предложений' })).toHaveCount(0);
});
test('keeps withdrawn proposals in their own filter', async ({ page }) => {
await setup(page, 'mine');
await expect(page.getByText('visible.flac', { exact: true })).toBeVisible();
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Отозванные', exact: true }).click();
await expect(page.getByText('withdrawn.flac', { exact: true }).first()).toBeVisible();
await expect(page.getByText('visible.flac', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Не приняты', exact: true }).click();
await expect(page.getByText('rejected.flac', { exact: true }).first()).toBeVisible();
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Все', exact: true }).click();
await expect(page.getByText('visible.flac', { exact: true })).toBeVisible();
await expect(page.getByText('withdrawn.flac', { exact: true })).toHaveCount(0);
});
test('queue shows the current full destination after saving a changed source template', async ({ page }) => {
await setup(page, 'template');
const sourcePath = new URL(page.url()).pathname;
const navigate = async (path: string) => page.evaluate((path) => {
history.pushState({}, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}, path);
await navigate('/admin/uploads');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/Song.flac');
await navigate(sourcePath);
await page.getByRole('textbox', { name: 'Шаблон пути', exact: true }).fill('{albumartist}/{album}/{track:02}. {title}.{ext}');
const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
await section.getByRole('checkbox').check();
await section.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/{album}/{track:02}. {title}.{ext}');
await navigate('/admin/uploads');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.getByText('Artist/Album/01. Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/01. Song.flac');
await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveCount(0);
});
test('queue preview only requests audio after play', async ({ page }) => {
const wav = previewWav();
let requested = 0;
let otherRequested = 0;
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => {
requested++;
return route.fulfill({ contentType: 'audio/wav', body: wav });
});
await page.route('**/api/v1/stream/mobile-preview', (route) => {
otherRequested++;
return route.fulfill({ contentType: 'audio/wav', body: wav });
});
await setup(page, 'queue');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
const audio = page.locator('audio').first();
await expect(audio).toHaveAttribute('preload', 'none');
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
expect(requested).toBe(0);
expect(otherRequested).toBe(0);
await audio.evaluate((el: HTMLAudioElement) => el.play());
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.duration)).toBe(2);
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
expect(requested).toBeGreaterThan(0);
expect(otherRequested).toBe(0);
expect(await audio.evaluate((el: HTMLAudioElement) => el.error)).toBeNull();
});
test('queue preview explains HTTP failures and recovers on retry', async ({ page }) => {
let available = false;
// The E2E bridge mocks window.fetch; media diagnostics must reach the same
// intercepted HTTP route as the native audio element.
await page.addInitScript(() => Object.assign(window, { previewNativeFetch: window.fetch.bind(window) }));
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => available
? route.fulfill({ contentType: 'audio/wav', body: previewWav() })
: route.fulfill({ status: 403, contentType: 'text/plain', body: 'Forbidden' }));
await setup(page, 'queue');
await page.evaluate(() => {
const original = window.fetch;
const native = (window as unknown as { previewNativeFetch: typeof fetch }).previewNativeFetch;
window.fetch = (input, init) => new URL(new Request(input, init).url).pathname.endsWith('/stream')
? native(input, init) : original(input, init);
});
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
const preview = page.locator('audio').first().locator('..');
await preview.locator('audio').evaluate((el: HTMLAudioElement) => el.play().catch(() => undefined));
await expect(preview.getByRole('alert')).toHaveText('Не удалось загрузить аудио: HTTP 403.');
available = true;
await preview.getByRole('button', { name: 'Повторить', exact: true }).click();
await expect.poll(() => preview.locator('audio').evaluate((el: HTMLAudioElement) => el.duration)).toBe(2);
await expect(preview.getByRole('alert')).toHaveCount(0);
});
test('starting another queue preview pauses the previous track', async ({ page }) => {
const wav = previewWav(10);
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => route.fulfill({ contentType: 'audio/wav', body: wav }));
await page.route('**/api/v1/stream/mobile-preview', (route) => route.fulfill({ contentType: 'audio/wav', body: wav }));
await setup(page, 'queue');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
const first = page.locator('audio').nth(0);
const second = page.locator('audio').nth(1);
await first.evaluate((el: HTMLAudioElement) => el.play());
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
await second.evaluate((el: HTMLAudioElement) => el.play());
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
expect(await second.evaluate((el: HTMLAudioElement) => el.paused)).toBe(false);
expect(await first.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
await first.evaluate((el: HTMLAudioElement) => el.play());
await expect.poll(() => second.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
expect(await first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(false);
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
await expect.poll(() => first.evaluate((el: HTMLAudioElement) => el.paused)).toBe(true);
});
test('oversized album cover is reduced before saving the batch', async ({ page }) => {
await setup(page, 'queue');
const png = await page.evaluate(() => {
const canvas = document.createElement('canvas');
canvas.width = 3000; canvas.height = 1800;
canvas.getContext('2d')!.fillRect(0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png').split(',')[1]!;
});
// A valid image with trailing padding reproduces an oversized input without a large fixture in Git.
const largeCover = Buffer.concat([Buffer.from(png, 'base64'), Buffer.alloc(12 * 1024 * 1024)]);
await page.getByRole('button', { name: 'На всю пачку', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.locator('input[type=file]').setInputFiles({ name: 'Cover.png', mimeType: 'image/png', buffer: largeCover });
await dialog.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-cover-dimensions', '2048x1229');
expect(Number(await page.locator('html').getAttribute('data-cover-bytes'))).toBeLessThan(8 * 1024 * 1024);
});
test('mixed batch checks each release and applies only within the chosen group', async ({ page }, info) => {
await setup(page, 'mixed');
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
await expect(page.getByText('Artist A — Album A', { exact: true })).toBeVisible();
await expect(page.getByText('Artist B — Album B', { exact: true })).toBeVisible();
await page.getByRole('checkbox', { name: 'Выбрать загруженные строки релиза Album A', exact: true }).check();
await expect(page.getByRole('checkbox', { name: 'Выбрать visible.flac', exact: true })).toBeChecked();
await expect(page.getByRole('checkbox', { name: 'Выбрать missing.flac', exact: true })).not.toBeChecked();
await page.getByRole('button', { name: 'Релизов в пачке: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await expect(dialog.getByRole('button', { name: 'Распознать по звуку', exact: true })).toBeDisabled();
await dialog.getByRole('combobox', { name: 'Релиз в пачке' }).selectOption('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
await expect(dialog.getByText('Artist B — Album B', { exact: true })).toBeVisible();
await expect(dialog.getByText('Без обложки', { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-release-cover-group', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
await page.screenshot({ path: info.outputPath('mixed-release.png') });
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
await dialog.getByRole('checkbox', { name: 'Добавить обложку выбранного релиза', exact: true }).check();
await dialog.getByRole('button', { name: 'Применить выбранное', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-release-applied', /"group_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"/);
await dialog.getByRole('combobox', { name: 'Релиз в пачке' }).selectOption('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa');
await expect(dialog.getByText('Artist A — Album A', { exact: true })).toBeVisible();
await expect(dialog.getByText('Совпало 1 из 1', { exact: false })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await expect(dialog.getByRole('checkbox', { name: /Применить названия/ })).not.toBeChecked();
});
test('audio recognition starts only on request and keeps suggestions separate', async ({ page }) => {
await setup(page, 'release');
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await expect(page.locator('html')).not.toHaveAttribute('data-release-search');
await expect(dialog.getByText(/только аудиоотпечатки и длительности/)).toBeVisible();
await dialog.getByRole('button', { name: 'Распознать по звуку', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-release-search', '{"audio":true}');
await expect(dialog.getByText('По звуку распознано: 1 из 2 треков', { exact: true })).toBeVisible();
await expect(dialog.getByText(/Неоднозначное или недостаточно уверенное совпадение/)).toBeVisible();
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
await expect(dialog.getByText('Совпадение по звуку · AcoustID', { exact: true })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
await expect(page.locator('html')).not.toHaveAttribute('data-release-cover-requests');
});
test('audio identification without application key shows setup and stays disabled', async ({ page }) => {
await setup(page, 'release', false);
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await expect(dialog.getByRole('button', { name: 'Распознать по звуку', exact: true })).toBeDisabled();
await expect(dialog.getByText(/задайте ACOUSTID_API_KEY/)).toBeVisible();
await expect(page.locator('html')).not.toHaveAttribute('data-release-search');
});
test('release suggestions require explicit application and handle stale metadata', async ({ page }) => {
await setup(page, 'release');
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
await expect(dialog.getByText('Уверенность сопоставления: высокая', { exact: true })).toBeVisible();
const apply = dialog.getByRole('button', { name: 'Применить выбранное', exact: true });
await expect(apply).toBeDisabled();
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
await expect(page.locator('html')).not.toHaveAttribute('data-release-cover-requests');
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
await apply.click();
await expect(dialog.getByText(/Состав или теги пачки изменились/)).toBeVisible();
await expect(page.locator('html')).not.toHaveAttribute('data-release-applied');
await dialog.getByRole('textbox', { name: 'Исполнитель', exact: true }).fill('Catalogue Artist');
await dialog.getByRole('textbox', { name: 'Альбом', exact: true }).fill('Album');
await dialog.getByRole('button', { name: 'Найти / повторить поиск', exact: true }).click();
await expect(dialog.getByRole('status')).toHaveText('Найдено изданий: 2');
await expect(dialog.getByRole('combobox', { name: 'Издание' })).toBeVisible();
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('11111111-1111-4111-8111-111111111111');
await dialog.getByRole('checkbox', { name: /Применить названия/ }).check();
await expect(apply).toBeEnabled();
await apply.click();
await expect(page.locator('html')).toHaveAttribute('data-release-applied', '{"release_id":"11111111-1111-4111-8111-111111111111","apply_metadata":true,"apply_cover":false}');
await expect(dialog.getByText('Выбранное издание применено', { exact: true })).toBeVisible();
});
test('deluxe completeness and release-group artwork are shown before replacing an existing cover', async ({ page }, info) => {
await setup(page, 'release');
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('22222222-2222-4222-8222-222222222222');
await expect(dialog.getByText('Альбом · 2/3 · Неполный состав', { exact: true })).toBeVisible();
await expect(dialog.getByText('Не загружено', { exact: true })).toBeVisible();
await expect(dialog.getByText(/найдены совпадения в библиотеке/)).toBeVisible();
await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click();
await expect(dialog.getByText(/Обложка группы изданий/)).toBeVisible();
await expect(dialog.getByRole('img', { name: 'Обложка пачки' })).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
await page.screenshot({ path: info.outputPath('release-comparison.png') });
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
await dialog.getByRole('checkbox', { name: 'Заменить текущую обложку пачки этой обложкой', exact: true }).check();
await dialog.getByRole('button', { name: 'Применить выбранное', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-release-applied', '{"release_id":"22222222-2222-4222-8222-222222222222","apply_metadata":false,"apply_cover":true}');
});
+31 -11
View File
@@ -1,4 +1,5 @@
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { expect, test } from '@playwright/test';
const nginxHeaders = readFileSync(new URL('../../../deploy/nginx/security-headers.conf', import.meta.url), 'utf8');
@@ -20,6 +21,10 @@ for (const [platform, policy] of Object.entries(policies)) {
await route.fulfill({ contentType: 'application/javascript', body: `
try { new Function('return 1')(); document.documentElement.dataset.evalBlocked = 'false'; }
catch { document.documentElement.dataset.evalBlocked = 'true'; }
WebAssembly.compile(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0])).then(
() => { document.documentElement.dataset.wasmBlocked = 'false'; },
() => { document.documentElement.dataset.wasmBlocked = 'true'; },
);
` });
return;
}
@@ -27,22 +32,37 @@ for (const [platform, policy] of Object.entries(policies)) {
await route.fulfill({ response, headers: { ...response.headers(), 'Content-Security-Policy': policy } });
});
await page.goto('/csp-upload-test');
const result = await page.evaluate(() => new Promise((resolve, reject) => {
const result = await page.evaluate(async () => {
const worker = new Worker('/src/features/uploads/hash.worker.ts', { type: 'module' });
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.onmessage = ({ data }) => {
if (data.type === 'progress') return;
try {
const results = [];
// Reuse one worker for empty, known-vector and multi-chunk binary files.
const binary = Uint8Array.from({ length: (9 << 20) + 17 }, (_, i) => i % 251);
for (const [id, bytes] of [['empty', new Uint8Array()], ['abc', new TextEncoder().encode('abc')], ['binary', binary]] as const) {
results.push(await new Promise((resolve, reject) => {
const progress: number[] = [];
worker.onerror = (event) => reject(new Error(event.message));
worker.onmessage = ({ data }) => {
if (data.type === 'progress') { progress.push(data.done); return; }
resolve({ ...data, progress });
};
worker.postMessage({ id, file: new File([bytes], `${id}.flac`) });
}));
}
return results;
} finally {
worker.terminate();
resolve(data);
};
worker.postMessage({ id: 'csp-test', file: new File(['abc'], 'track.flac') });
}));
expect(result).toEqual({
id: 'csp-test', type: 'done',
sha256: 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
}
});
const binary = Uint8Array.from({ length: (9 << 20) + 17 }, (_, i) => i % 251);
expect(result).toEqual([
{ id: 'empty', type: 'done', sha256: createHash('sha256').update('').digest('hex'), progress: [] },
{ id: 'abc', type: 'done', sha256: createHash('sha256').update('abc').digest('hex'), progress: [] },
{ id: 'binary', type: 'done', sha256: createHash('sha256').update(binary).digest('hex'), progress: expect.arrayContaining([expect.any(Number)]) },
]);
// A page script observes CSP; Playwright's evaluate bypasses the eval restriction.
await page.addScriptTag({ url: '/csp-eval-test.js' });
await expect(page.locator('html')).toHaveAttribute('data-eval-blocked', 'true');
await expect(page.locator('html')).toHaveAttribute('data-wasm-blocked', 'true');
});
}
+84
View File
@@ -0,0 +1,84 @@
import { expect, test } from '@playwright/test';
test('locks the comment and retries one failed upload while another is running', async ({ page }, info) => {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('mobile-admin');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('mobile-admin-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const req = new Request(input, init);
const path = new URL(req.url).pathname;
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } });
if (path === '/api/v1/uploads/quota') return json({ limit_bytes: 1e9, used_bytes: 0, per_user_limit_bytes: null, user_used_bytes: 0, max_file_bytes: 1e9 });
if (path === '/api/v1/uploads/precheck') {
const body = await req.json();
return json({ files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, result: 'accepted' })) });
}
if (path === '/api/v1/uploads' && req.method === 'POST') {
const body = await req.json();
document.documentElement.dataset.uploadComment = body.comment;
document.documentElement.dataset.uploadReservations = String(Number(document.documentElement.dataset.uploadReservations ?? 0) + 1);
return json({ batch_id: 'test-batch', files: body.files.map((f: { client_id: string; name: string }) => ({ client_id: f.client_id, upload_file_id: f.name, tus_url: `/api/v1/uploads/files/${f.name}` })) }, 201);
}
return original(input, init);
};
history.pushState({}, '', '/uploads/new');
window.dispatchEvent(new PopStateEvent('popstate'));
});
let retry = false;
let failures = 0;
let resumedOffset: string | undefined;
let releaseSecond!: () => void;
const secondPending = new Promise<void>((resolve) => { releaseSecond = resolve; });
await page.route('**/api/v1/uploads/files/*', async (route) => {
const req = route.request();
const first = req.url().endsWith('/first.flac');
if (req.method() === 'HEAD') {
await route.fulfill({ status: 200, headers: { 'Upload-Length': '1024', 'Upload-Offset': first && failures > 0 ? '256' : '0', 'Tus-Resumable': '1.0.0' } });
return;
}
if (first && !retry) {
failures++;
await route.fulfill({ status: 507, contentType: 'application/problem+json', body: JSON.stringify({ status: 507, code: 'uploads.storage_full', detail: 'upload storage is full' }) });
return;
}
if (first) resumedOffset = req.headers()['upload-offset'];
else await secondPending;
await route.fulfill({ status: 204, headers: { 'Upload-Offset': '1024', 'Tus-Resumable': '1.0.0' } });
});
const inputs = page.locator('input[type=file]');
await inputs.first().setInputFiles(['first.flac', 'second.flac'].map((name) => ({ name, mimeType: 'audio/flac', buffer: Buffer.alloc(1024) })));
const comment = page.getByRole('textbox', { name: 'Комментарий для администратора' });
await comment.fill('Lossless album');
await expect(comment).toBeEnabled();
const send = page.getByRole('button', { name: /^Отправить/ });
await expect(send).toBeEnabled();
await send.click();
await expect(comment).toBeDisabled();
await expect(page.getByRole('button', { name: 'Очистить список' })).toBeDisabled();
await expect(page.getByText(/На сервере закончилось место для загрузок/)).toBeVisible();
const retryButton = page.getByRole('button', { name: 'Повторить загрузку first.flac', exact: true });
await expect(retryButton).toBeEnabled();
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
await page.screenshot({ path: info.outputPath('upload-retry.png'), fullPage: true });
expect(failures).toBe(1);
retry = true;
await retryButton.click();
await expect.poll(() => resumedOffset).toBe('256');
await expect(retryButton).toHaveCount(0);
await expect(comment).toBeDisabled();
await expect(page.locator('html')).toHaveAttribute('data-upload-reservations', '1');
await expect(page.locator('html')).toHaveAttribute('data-upload-comment', 'Lossless album');
releaseSecond();
await expect(page.getByText(/Готово: 2 файла/)).toBeVisible();
await page.getByRole('button', { name: 'Загрузить ещё', exact: true }).click();
await inputs.first().setInputFiles({ name: 'third.flac', mimeType: 'audio/flac', buffer: Buffer.alloc(1024) });
await expect(comment).toBeEnabled();
});
+1 -1
View File
@@ -23,7 +23,7 @@
"@tanstack/react-query": "^5.80.7",
"@tanstack/react-router": "^1.120.20",
"@tanstack/react-virtual": "^3.14.21",
"hash-wasm": "^4.12.0",
"js-sha256": "^1.0.0",
"music-metadata": "^12.2.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+35
View File
@@ -531,6 +531,41 @@ describe('search box', () => {
});
describe('player', () => {
it.each(['album', 'artist'] as const)('toggles the current %s row without restarting or replacing the listening attempt', async (kind) => {
const alice = makeUser('alice', 'user');
const id = kind === 'artist' ? '00000000-0000-4000-8000-0000000000aa' : '00000000-0000-4000-8000-0000000000bb';
const { user, api } = await renderApp([alice], `/${kind}/${id}`, { signedInAs: alice });
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="listitem"]')!;
await user.click(row);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument());
await user.keyboard('{ArrowRight}');
const progress = within(bar).getByRole('slider', { name: 'Позиция' });
expect(progress).toHaveValue('10000');
await user.click(row);
expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument();
expect(progress).toHaveValue('10000');
await user.click(row);
expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument();
expect(progress).toHaveValue('10000');
await user.keyboard('{Enter}');
expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument();
await user.keyboard('{Enter}');
expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument();
expect(progress).toHaveValue('10000');
window.dispatchEvent(new Event('pagehide'));
await waitFor(() => {
const events = api.calls.filter((c) => c.path === '/api/v1/events').flatMap((c) => (c.body as { events: { type: string; context_type?: string }[] }).events);
expect(events.filter((event) => event.type === 'play_start')).toHaveLength(1);
expect(events.find((event) => event.type === 'play_start')).toMatchObject({ context_type: kind });
expect(events.some((event) => event.type === 'skip')).toBe(false);
});
const other = screen.getAllByText('Группа крови').map((node) => node.closest('[role="listitem"]')).find(Boolean)!;
await user.click(other);
await waitFor(() => expect(within(bar).getByText('Группа крови')).toBeInTheDocument());
expect(progress).toHaveValue('0');
});
it('opens a shared track after login and copies its page URL from the player', async () => {
const alice = makeUser('alice', 'user');
const path = '/track/00000000-0000-4000-8000-000000000101';
+8 -4
View File
@@ -11,7 +11,7 @@ import { useSession } from '../app/session';
import { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player';
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons';
import { albumIcon, likeFillIcon, likeIcon, pauseIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './catalog.module.css';
@@ -143,7 +143,7 @@ export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
/**
* Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома).
* Клик по строке запускает весь список с этого трека в контексте context; Enter — то же с клавиатуры.
* Клик или Enter запускает список с выбранного трека; у текущего трека переключает паузу без перезапуска.
*/
export function TrackList({
tracks,
@@ -183,7 +183,11 @@ export function TrackList({
const row = (tr: Track, i: number) => {
const isCurrent = current?.id === tr.id;
const play = () => tr.available && player.playList(tracks, i, context);
const play = () => {
if (!tr.available) return;
if (isCurrent) player.toggle();
else player.playList(tracks, i, context);
};
const liked = tr.reaction === 'like';
const meta = rowMeta?.(tr, i);
return (
@@ -206,7 +210,7 @@ export function TrackList({
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
{tr.available && (
<span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} />
<Icon src={isCurrent && playing ? pauseIcon : playIcon} size={20} />
</span>
)}
</span>
@@ -1,4 +1,4 @@
.page { max-width: 1050px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
.page { width: 100%; min-width: 0; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
.header, .cardHead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; }
.header h1 { margin: 6px 0; font-family: var(--font-display); font-size: 30px; }
.header p, .note { color: var(--text2); font-size: 13px; line-height: 1.6; margin: 0; }
@@ -0,0 +1,69 @@
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { refreshIcon } from '../../shared/ui/icons';
import { Button } from '../../shared/ui/ui';
import u from '../../pages/uploads/uploads.module.css';
// Preview elements may belong to different batches, but only one may play.
const previews = new Set<HTMLAudioElement>();
export function AudioPreview({ url, name, active }: { url: string; name: string; active: boolean }) {
const { t } = useTranslation('admin');
const queryClient = useQueryClient();
const audio = useRef<HTMLAudioElement>(null);
const [error, setError] = useState<number | null>(null);
const [httpStatus, setHttpStatus] = useState<number | null>(null);
useEffect(() => {
const element = audio.current;
if (!element) return;
previews.add(element);
return () => { previews.delete(element); element.pause(); };
}, []);
useEffect(() => {
if (!active) audio.current?.pause();
}, [active]);
useEffect(() => { setError(null); setHttpStatus(null); }, [url]);
useEffect(() => {
if (error === null) return;
const controller = new AbortController();
// A media error alone cannot distinguish an HTTP failure from an unsupported codec.
// Probe one byte using the same signed URL, then release the response immediately.
void fetch(url, { headers: { Range: 'bytes=0-0' }, cache: 'no-store', signal: controller.signal })
.then(async (response) => {
await response.body?.cancel();
if (!controller.signal.aborted) setHttpStatus(response.ok ? null : response.status);
})
.catch(() => { /* The native media error remains visible for network failures. */ });
return () => controller.abort();
}, [url, error]);
const retry = () => {
setError(null);
setHttpStatus(null);
void queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
audio.current?.load();
void audio.current?.play().catch(() => { /* Native media errors are shown below. */ });
};
return <div className={u.preview}>
<audio ref={audio} controls preload="none" src={url}
aria-label={t('uploads.listen', { name })}
onPlay={(event) => {
if (event.currentTarget.paused) return;
for (const other of previews) {
if (other !== event.currentTarget && !other.paused) other.pause();
}
}}
onLoadedMetadata={() => { setError(null); setHttpStatus(null); }}
onError={(event) => setError(event.currentTarget.error?.code ?? 0)} />
{error !== null && <div className={u.previewError}>
<span role="alert" className={u.bad}>{httpStatus !== null
? t('uploads.preview_http_error', { status: httpStatus })
: t(error === 3 || error === 4 ? 'uploads.preview_decode_error' : 'uploads.preview_error')}</span>
<Button variant="ghost" icon={refreshIcon} onClick={retry}>{t('uploads.retry')}</Button>
</div>}
</div>;
}
@@ -2,6 +2,7 @@ import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useErrorText } from '../../shared/lib/errors';
import { useUploadsApi } from './queries';
import { coverBase64 } from './files';
import { useTranslation } from 'react-i18next';
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
@@ -13,6 +14,7 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
const [album, setAlbum] = useState('');
const [year, setYear] = useState('');
const [cover, setCover] = useState<string | null | undefined>(undefined);
const prepare = useMutation({ mutationFn: coverBase64, onSuccess: setCover });
const save = useMutation({
mutationFn: () => api.updateBatch(batchId, {
...(artist.trim() ? { artist: artist.trim() } : {}),
@@ -23,17 +25,13 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
onSuccess: onSaved,
});
const pickCover = (file: File | undefined) => {
save.reset();
prepare.reset();
setCover(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);
prepare.mutate(file);
};
return (
<Dialog
@@ -44,7 +42,7 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
<Button variant="primary" busy={save.isPending || prepare.isPending} disabled={prepare.isError} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
@@ -57,12 +55,12 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
<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])} />
<input type="file" accept="image/jpeg,image/png,image/webp" disabled={prepare.isPending || save.isPending} onChange={(e) => pickCover(e.target.files?.[0])} />
</label>
<Button variant="ghost" onClick={() => setCover(null)}>
<Button variant="ghost" disabled={prepare.isPending || save.isPending} onClick={() => { prepare.reset(); setCover(null); }}>
{t('uploads.batch_cover_clear')}
</Button>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
<FormError>{(save.error ?? prepare.error) ? errorText(save.error ?? prepare.error) : null}</FormError>
</div>
</Dialog>
);
@@ -0,0 +1,172 @@
import type { components } from '@pipu/api-client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUploadsApi } from './queries';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
import type { UploadFile } from './common';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
type Candidate = components['schemas']['ReleaseCandidate'];
export type ReleaseGroup = components['schemas']['UploadReleaseGroup'];
/** External suggestions remain separate from uploaded metadata until explicitly applied. */
export function BatchRelease({ files, onGroupsChange }: { files: UploadFile[]; onGroupsChange?: (groups: ReleaseGroup[]) => void }) {
const { t } = useTranslation('admin');
const api = useUploadsApi();
const qc = useQueryClient();
const errorText = useErrorText();
const first = files[0]!;
const id = first.batch_id;
const [groupId, setGroupId] = useState('');
const [groups, setGroups] = useState<ReleaseGroup[]>([]);
const key = ['upload-release', id, groupId];
const [open, setOpen] = useState(false);
const [artist, setArtist] = useState(first.metadata?.albumartist || first.metadata?.artist || '');
const [album, setAlbum] = useState(first.metadata?.album || '');
const [mbid, setMbid] = useState('');
const [selected, setSelected] = useState('');
const [preview, setPreview] = useState(false);
const [applyMetadata, setApplyMetadata] = useState(false);
const [applyCover, setApplyCover] = useState(false);
const version = files.map((f) => `${f.id}:${f.updated_at}`).sort().join('|');
const previousVersion = useRef(version);
useEffect(() => {
if (previousVersion.current !== version) {
previousVersion.current = version;
void qc.invalidateQueries({ queryKey: ['upload-release', id] });
}
}, [version, qc, id]);
const report = useQuery({
queryKey: key,
queryFn: () => api.releaseReport(id, groupId || undefined),
retry: false,
refetchInterval: (q) => q.state.data?.state === 'queued' || q.state.data?.state === 'searching' || (!groupId && q.state.data?.groups?.some((g) => g.state === 'queued' || g.state === 'searching')) ? 3000 : q.state.data?.state === 'error' ? 60000 : false,
});
useEffect(() => {
if (report.data?.groups) { setGroups(report.data.groups); onGroupsChange?.(report.data.groups); }
}, [report.data?.groups, onGroupsChange]);
const scopeId = report.data?.group_id || groupId;
const group = groups.find((g) => g.id === scopeId);
const needsGroup = groups.length > 1 && !scopeId;
const candidate: Candidate | undefined = report.data?.candidates.find((c) => c.release.id === selected)
?? report.data?.candidates.find((c) => c.release.id === report.data?.selected_release)
?? (report.data?.candidates.length === 1 ? report.data.candidates[0] : undefined);
const releaseId = candidate?.release.id ?? '';
useEffect(() => { setPreview(false); setApplyCover(false); setApplyMetadata(false); }, [releaseId, report.data?.input_hash]);
const cover = useQuery({
queryKey: ['upload-release-cover', id, scopeId, releaseId, report.data?.input_hash],
queryFn: () => api.releaseCover(id, releaseId, scopeId || undefined),
enabled: open && preview && !!releaseId,
retry: false,
staleTime: 3600000,
});
const search = useMutation({
mutationFn: (audio: boolean) => api.searchReleases(id, { ...(scopeId ? { group_id: scopeId } : {}), ...(audio ? { audio: true } : mbid.trim() ? { release_id: mbid.trim() } : { artist: artist.trim(), album: album.trim() }) }),
onSuccess: (data) => { setSelected(''); apply.reset(); qc.setQueryData(key, data); },
});
const apply = useMutation({
mutationFn: () => api.applyRelease(id, { ...(scopeId ? { group_id: scopeId } : {}), release_id: releaseId, apply_metadata: applyMetadata, apply_cover: applyCover }),
onSuccess: async (data) => {
qc.setQueryData(key, data);
setApplyCover(false); setApplyMetadata(false);
await qc.invalidateQueries({ queryKey: ['admin-uploads'] });
},
});
const kind = (value: string) => value === 'Album' ? t('release_lookup.album') : value === 'Single' ? t('release_lookup.single') : value === 'EP' ? 'EP' : value || t('release_lookup.release');
const stateText = needsGroup ? t('release_lookup.groups_count', { count: groups.length }) : report.data?.state === 'ready'
? candidate ? `${kind(candidate.release.type)} · ${candidate.matched}/${candidate.total} · ${t(candidate.complete ? 'release_lookup.complete' : candidate.confidence === 'high' && candidate.missing.length > 0 && !candidate.unmatched.length ? 'release_lookup.incomplete' : 'release_lookup.partial')}`
: t('release_lookup.variants', { count: report.data.candidates.length })
: t(`release_lookup.state_${report.data?.state ?? 'searching'}`);
if (report.data?.state === 'disabled') return null;
return <>
<Button variant="ghost" onClick={() => setOpen(true)} title={t('release_lookup.title')}>
{report.error ? t('release_lookup.open') : stateText}
</Button>
{open && <Dialog className={u.releaseDialog} title={t('release_lookup.title')} onClose={() => setOpen(false)} footer={
<Button variant="primary" busy={apply.isPending} disabled={needsGroup || search.isPending || !candidate || report.data?.state !== 'ready' || (!applyMetadata && !applyCover) || (applyCover && !cover.data)} onClick={() => apply.mutate()}>
{t('release_lookup.apply')}
</Button>
}>
<div className={s.sessions}>
<p className={s.note}>{t('release_lookup.intro')}</p>
{groups.length > 1 && <>
<p className={s.note}>{t('release_lookup.groups_hint')}</p>
<label className={s.sessions}>
{t('release_lookup.group')}
<select className={s.select} value={scopeId} disabled={apply.isPending || search.isPending} onChange={(e) => {
const next = groups.find((g) => g.id === e.target.value)!;
setGroupId(next.id); setArtist(next.artist); setAlbum(next.album); setMbid(''); setSelected(''); setPreview(false); setApplyMetadata(false); setApplyCover(false); search.reset(); apply.reset();
}}>
<option value="" disabled>{t('release_lookup.choose_group')}</option>
{groups.map((g) => <option key={g.id} value={g.id}>{g.artist} — {g.album} · {t('release_lookup.tracks_total', { total: g.file_ids.length })}</option>)}
</select>
</label>
{group && <p className={s.note}>{t(group.has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}</p>}
</>}
<div className={u.grid}>
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
</div>
<Field label={t('release_lookup.mbid')} value={mbid} onChange={setMbid} maxLength={36} />
<Button busy={search.isPending} disabled={needsGroup || apply.isPending || report.data?.state === 'queued' || report.data?.state === 'searching' || (!mbid.trim() && (!artist.trim() || !album.trim()))} onClick={() => search.mutate(false)}>{t('release_lookup.search')}</Button>
<Button busy={search.isPending && search.variables === true} disabled={needsGroup || !report.data?.audio_available || search.isPending || apply.isPending || report.data?.state === 'queued' || report.data?.state === 'searching'} onClick={() => search.mutate(true)}>{t('release_lookup.audio_search')}</Button>
<p className={s.note}>{t(report.data?.audio_available ? 'release_lookup.audio_privacy' : 'release_lookup.audio_setup')}</p>
<p role="status" className={s.note}>{stateText}</p>
{!!report.data?.audio_results?.length && <>
<p className={s.note}>{t('release_lookup.audio_summary', { matched: report.data.audio_results.filter((r) => r.state === 'matched').length, total: report.data.audio_results.length })}</p>
{report.data.audio_results.filter((r) => r.state !== 'matched').map((r) => <div key={r.file_id} className={u.sub}>{files.find((f) => f.id === r.file_id)?.original_name ?? r.file_id}: {t(r.state === 'ambiguous' ? 'release_lookup.audio_ambiguous' : r.state === 'not_found' ? 'release_lookup.audio_not_found' : 'release_lookup.audio_unavailable')}</div>)}
<p className={s.note}>{t('release_lookup.audio_editions')}</p>
</>}
<FormError>{(report.error ?? search.error ?? apply.error) ? errorText(report.error ?? search.error ?? apply.error) : null}</FormError>
{report.data?.state === 'ready' && report.data.candidates.length > 0 && <>
{report.data.candidates.length > 1 && <label className={s.sessions}>
{t('release_lookup.edition')}
<select className={s.select} value={releaseId} disabled={apply.isPending || search.isPending} onChange={(e) => { setSelected(e.target.value); apply.reset(); }}>
<option value="" disabled>{t('release_lookup.choose_edition')}</option>
{report.data.candidates.map((c) => <option key={c.release.id} value={c.release.id}>{c.release.artist} — {c.release.title} · {[c.release.date, c.release.country, c.release.comment, t('release_lookup.tracks_total', { total: c.total })].filter(Boolean).join(' · ')}</option>)}
</select>
</label>}
{candidate && <>
<strong>{candidate.release.artist} — {candidate.release.title}</strong>
<span className={s.note}>{[kind(candidate.release.type), candidate.release.date, candidate.release.country, candidate.release.comment].filter(Boolean).join(' · ')}</span>
<a href={`https://musicbrainz.org/release/${encodeURIComponent(releaseId)}`} target="_blank" rel="noreferrer">MusicBrainz</a>
<p className={s.note}>{t('release_lookup.confidence', { value: t(candidate.confidence === 'high' ? 'release_lookup.confidence_high' : candidate.confidence === 'medium' ? 'release_lookup.confidence_medium' : 'release_lookup.confidence_low') })}</p>
<p className={s.note}>{t('release_lookup.coverage', { matched: candidate.matched, total: candidate.total, missing: candidate.missing.length, extra: candidate.unmatched.length })}</p>
{candidate.existing.length > 0 && <p className={s.note}>{t('release_lookup.existing', { count: candidate.existing.length })}</p>}
<div className={s.tableWrap}>
<table className={s.table}>
<thead><tr><th>#</th><th>{t('release_lookup.uploaded')}</th><th>{t('release_lookup.catalogue')}</th></tr></thead>
<tbody>{candidate.release.tracks.map((track, index) => {
const match = candidate.matches.find((m) => m.track_index === index);
const file = files.find((f) => f.id === match?.file_id);
return <tr key={`${track.disc}:${track.position}:${index}`}>
<td>{track.disc}.{track.position}</td>
<td className={u.releaseCell}>{match ? file?.metadata ? `${file.metadata.artist} — ${file.metadata.title}` : file?.original_name ?? t('release_lookup.matched') : t('release_lookup.missing')}
{file?.metadata && <div className={u.sub}>{[file.metadata.albumartist, file.metadata.album, file.metadata.year, `${file.metadata.disc ?? 1}.${file.metadata.track ?? '?'}`].filter(Boolean).join(' · ')}</div>}
{match?.method === 'acoustid' && <div className={u.sub}>{t('release_lookup.audio_match')}</div>}
{match?.confidence === 'medium' && <div className={u.warn}>{t('release_lookup.tentative')}</div>}</td>
<td className={u.releaseCell}>{track.artist} — {track.title}</td>
</tr>;
})}</tbody>
</table>
</div>
<label><input type="checkbox" checked={applyMetadata} disabled={apply.isPending || search.isPending || !candidate.matches.length} onChange={(e) => setApplyMetadata(e.target.checked)} /> {t('release_lookup.apply_metadata')}</label>
<Button busy={cover.isFetching} onClick={() => setPreview(true)}>{t('release_lookup.preview_cover')}</Button>
{preview && cover.error && <FormError>{errorText(cover.error)}</FormError>}
{preview && cover.data && <>
<img className={u.releaseCover} width={180} height={180} src={`data:${cover.data.mime_type};base64,${cover.data.image_base64}`} alt={t('uploads.batch_cover')} />
<span className={s.note}>{t(cover.data.exact_release ? 'release_lookup.exact_cover' : 'release_lookup.group_cover')}</span>
<a href={cover.data.source_url} target="_blank" rel="noreferrer">Cover Art Archive</a>
<label><input type="checkbox" checked={applyCover} disabled={apply.isPending} onChange={(e) => setApplyCover(e.target.checked)} /> {t(group ? group.has_cover ? 'release_lookup.replace_group_cover' : 'release_lookup.apply_group_cover' : first.batch_has_cover ? 'release_lookup.replace_cover' : 'release_lookup.apply_cover')}</label>
</>}
{report.data.selected_release === releaseId && <p role="status" className={s.ok}>{t('release_lookup.applied')}</p>}
</>}
</>}
</div>
</Dialog>}
</>;
}
+79 -17
View File
@@ -1,6 +1,10 @@
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
import { editIcon, refreshIcon } from '../../shared/ui/icons';
import { chevronRightIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
import { useId, useState } from 'react';
import { needsFormatAttention } from './files';
import { AudioPreview } from './AudioPreview';
import { batchSummary } from './batchSummary';
import { BatchRelease, type ReleaseGroup } from './BatchRelease';
import { UploadStatusBadge, useReasonText, type UploadFile } from './common';
import { useTranslation } from 'react-i18next';
import { Button, cx, Icon } from '../../shared/ui/ui';
@@ -24,44 +28,85 @@ export function BatchCard({
onRetry: (id: string) => void;
}) {
const { t, i18n } = useTranslation('admin');
const [collapsed, setCollapsed] = useState(true);
const [releaseGroups, setReleaseGroups] = useState<ReleaseGroup[]>([]);
const contentId = useId();
const first = files[0]!;
const ids = files.map((f) => f.id);
const all = ids.every((id) => selected.has(id));
const size = files.reduce((n, f) => n + f.size_bytes, 0);
const summary = batchSummary(files);
const metadata = first.metadata;
const albumArtist = metadata?.albumartist || metadata?.artist || '';
const singleAlbum = releaseGroups.length === 1 ? releaseGroups[0]
: releaseGroups.length === 0 && metadata?.album && files.every((file) =>
file.metadata?.album.trim().toLowerCase() === metadata.album.trim().toLowerCase()
&& (file.metadata.albumartist || file.metadata.artist || '').trim().toLowerCase() === albumArtist.trim().toLowerCase())
? { artist: albumArtist, album: metadata.album } : undefined;
const albumTitle = singleAlbum?.album && singleAlbum.album.trim().toLowerCase() !== 'unknown album'
? [singleAlbum.artist.trim().toLowerCase() === 'unknown artist' ? '' : singleAlbum.artist, singleAlbum.album].filter(Boolean).join(' — ')
: undefined;
return (
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
<div className={u.batchHead}>
<Button variant="ghost" className={u.batchToggle} aria-label={t(collapsed ? 'uploads.expand_batch' : 'uploads.collapse_batch')} title={t(collapsed ? 'uploads.expand_batch' : 'uploads.collapse_batch')} aria-expanded={!collapsed} aria-controls={contentId} onClick={() => setCollapsed((value) => !value)}>
<Icon src={chevronRightIcon} size={20} className={!collapsed ? u.batchExpanded : undefined} />
</Button>
{selectable && (
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
)}
<b>{first.author.username}</b>
{albumTitle ? <div className={u.batchIdentity}>
<strong title={albumTitle}>{albumTitle}</strong>
<span>{first.author.username}</span>
</div> : <b>{first.author.username}</b>}
<span>{formatDateTime(first.created_at, i18n.language)}</span>
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
<span>{summary.formats.map(([format, count]) => `${count} × ${format || t('uploads.format_unknown')}`).join(' · ')}</span>
{summary.quality && <span>{summary.quality}</span>}
<span className={cx(s.badge, first.batch_has_cover ? u.tone_info : u.tone_muted)}>
{t(first.batch_has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}
</span>
<span style={{ flex: 1 }} />
{selectable && <BatchRelease files={files} onGroupsChange={setReleaseGroups} />}
{selectable && (
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
{t('uploads.edit_batch')}
</Button>
)}
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
</div>
<div id={contentId} hidden={collapsed}>
{(releaseGroups.length > 1 ? [
...releaseGroups.map((group) => ({ group, rows: files.filter((f) => group.file_ids.includes(f.id)) })),
{ group: undefined, rows: files.filter((f) => !releaseGroups.some((g) => g.file_ids.includes(f.id))) },
] : [{ group: undefined, rows: files }]).filter((part) => part.rows.length > 0).map((part) => <div key={part.group?.id ?? 'other'}>
{part.group && <div className={u.releaseHead}>
{selectable && <input type="checkbox" aria-label={t('release_lookup.select_group', { album: part.group.album })} checked={part.rows.every((f) => selected.has(f.id))} onChange={(e) => onToggle(part.rows.map((f) => f.id), e.target.checked)} />}
<strong>{part.group.artist} — {part.group.album}</strong>
<span>{t('release_lookup.tracks_total', { total: part.group.file_ids.length })}</span>
<span>{t(part.group.has_cover ? 'uploads.batch_cover_present' : 'uploads.batch_cover_absent')}</span>
</div>}
{part.rows.map((f) => (
<QueueRow
key={f.id}
file={f}
active={!collapsed}
selectable={selectable}
checked={selected.has(f.id)}
onCheck={(on) => onToggle([f.id], on)}
onEdit={() => onEdit(f)}
onRetry={() => onRetry(f.id)}
/>
))}
</div>)}
</div>
{files.map((f) => (
<QueueRow
key={f.id}
file={f}
selectable={selectable}
checked={selected.has(f.id)}
onCheck={(on) => onToggle([f.id], on)}
onEdit={() => onEdit(f)}
onRetry={() => onRetry(f.id)}
/>
))}
</section>
);
}
function QueueRow({
file: f,
active,
selectable,
checked,
onCheck,
@@ -69,6 +114,7 @@ function QueueRow({
onRetry,
}: {
file: UploadFile;
active: boolean;
selectable: boolean;
checked: boolean;
onCheck: (on: boolean) => void;
@@ -127,15 +173,14 @@ function QueueRow({
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
)}
</div>
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{reason && (!editable || f.target_rel_path) && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{f.duplicate_level && f.status === 'duplicate' && (
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
)}
</div>
<div className={u.rowActions}>
{f.stream_url && (
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
<AudioPreview url={f.stream_url} name={f.original_name} active={active} />
)}
{editable && (
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
@@ -148,6 +193,23 @@ function QueueRow({
</Button>
)}
</div>
{(editable || f.target_rel_path) && (
<div className={u.placement} role="group" aria-label={t('uploads.destination')}>
<span className={u.placementLabel}>{t(f.status === 'imported' ? 'uploads.destination_imported' : 'uploads.destination')}</span>
<strong>{f.target_source_name ?? t(f.target_source_id ? 'uploads.target_selected' : 'uploads.target_not_selected')}</strong>
{f.target_rel_path ? (
<code className={u.target} title={f.target_storage_path ?? undefined}>{f.target_rel_path}</code>
) : (
<>
<span className={u.warn}>{t('uploads.target_path_missing')}</span>
{reason && <span className={u.warn}>{reason}</span>}
<Button variant="secondary" onClick={onEdit}>
{t(f.target_source_id ? 'uploads.fix_destination' : 'uploads.choose_destination')}
</Button>
</>
)}
</div>
)}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { createApiClient } from '@pipu/api-client';
import { describe, expect, it } from 'vitest';
describe('upload status query serialization', () => {
it('sends comma-separated statuses for own and admin queues, including batch selection', async () => {
const urls: URL[] = [];
const client = createApiClient({ baseUrl: 'https://pipu.test', fetch: async (input) => {
urls.push(new URL((input as Request).url));
return new Response(JSON.stringify({ items: [], next_cursor: null }), { headers: { 'Content-Type': 'application/json' } });
} });
await client.GET('/api/v1/uploads/mine', { params: { query: { status: ['pending_review', 'imported', 'expired'], limit: 50 } } });
await client.GET('/api/v1/admin/uploads', { params: { query: { status: ['uploading', 'processing'], limit: 100 } } });
await client.GET('/api/v1/uploads/mine', { params: { query: { batch: '00000000-0000-4000-8000-000000000001', status: ['uploading', 'processing', 'pending_review'] } } });
expect(urls.map((url) => url.searchParams.getAll('status'))).toEqual([
['pending_review,imported,expired'], ['uploading,processing'], ['uploading,processing,pending_review'],
]);
expect(urls[0]!.searchParams.get('limit')).toBe('50');
expect(urls[2]!.searchParams.get('batch')).toBe('00000000-0000-4000-8000-000000000001');
});
});
+4
View File
@@ -8,6 +8,10 @@ type SettingsPatch = paths['/api/v1/admin/settings/uploads']['patch']['requestBo
/** The feature owns transport mapping; views only provide form values. */
export function uploadsApi(client: ApiClient) {
return {
releaseReport: async (id: string, group_id?: string) => unwrap(await client.GET('/api/v1/admin/uploads/batches/{id}/releases', { params: { path: { id }, query: group_id ? { group_id } : {} } })),
searchReleases: async (id: string, body: paths['/api/v1/admin/uploads/batches/{id}/releases']['post']['requestBody']['content']['application/json']) => unwrap(await client.POST('/api/v1/admin/uploads/batches/{id}/releases', { params: { path: { id } }, body })),
releaseCover: async (id: string, release_id: string, group_id?: string) => unwrap(await client.GET('/api/v1/admin/uploads/batches/{id}/releases/{release_id}/cover', { params: { path: { id, release_id }, query: group_id ? { group_id } : {} } })),
applyRelease: async (id: string, body: paths['/api/v1/admin/uploads/batches/{id}/release']['post']['requestBody']['content']['application/json']) => unwrap(await client.POST('/api/v1/admin/uploads/batches/{id}/release', { params: { path: { id } }, body })),
list: async (status: components['schemas']['UploadStatus'][], possible: boolean, cursor?: string) => unwrap(
await client.GET('/api/v1/admin/uploads', {
params: { query: { status, possible_duplicate: possible || undefined, cursor, limit: 100 } },
@@ -0,0 +1,21 @@
import { expect, it } from 'vitest';
import { batchSummary } from './batchSummary';
it('counts formats and combines measured quality ranges without repeating equal values', () => {
const summary = batchSummary([
{ format: 'FLAC', original_name: 'one.flac', bitrate: 778000, sample_rate: 44100, bit_depth: 16 },
{ format: 'flac', original_name: 'two.flac', bitrate: 924000, sample_rate: 44100, bit_depth: 16 },
{ format: null, original_name: 'three.mp3', bitrate: 320000, sample_rate: 48000, bit_depth: null },
]);
expect(summary.formats).toEqual([['FLAC', 2], ['MP3', 1]]);
expect(summary.quality).toBe('320–924 kbps · 44.1–48 kHz · 16 bit');
});
it('ignores missing and invalid measurements and retains fractional sample rates', () => {
expect(batchSummary([
{ format: null, original_name: 'unknown', bitrate: 0, sample_rate: null, bit_depth: null },
{ format: 'WAV', original_name: 'one.wav', bitrate: null, sample_rate: 11025, bit_depth: 24 },
{ format: 'wav', original_name: 'two.wav', bitrate: NaN, sample_rate: 96000, bit_depth: 32 },
])).toEqual({ formats: [['WAV', 2], ['', 1]], quality: '11.025–96 kHz · 24–32 bit' });
expect(batchSummary([]).quality).toBe('');
});
@@ -0,0 +1,30 @@
import type { UploadFile } from './common';
import { audioExt } from './files';
type SummaryFile = Pick<UploadFile, 'format' | 'original_name' | 'bitrate' | 'sample_rate' | 'bit_depth'>;
function range(values: (number | null)[], divisor: number, decimals: number, unit: string): string | null {
const known = values.filter((value): value is number => value !== null && Number.isFinite(value) && value > 0);
if (!known.length) return null;
const format = (value: number) => String(Number((value / divisor).toFixed(decimals)));
const min = format(Math.min(...known));
const max = format(Math.max(...known));
return `${min === max ? min : `${min}–${max}`} ${unit}`;
}
/** Only measured positive values contribute to quality ranges; absent metadata is not zero. */
export function batchSummary(files: SummaryFile[]) {
const counts = new Map<string, number>();
for (const file of files) {
const format = (file.format?.trim() || audioExt(file.original_name)).toUpperCase();
counts.set(format, (counts.get(format) ?? 0) + 1);
}
return {
formats: [...counts].sort(([a, ac], [b, bc]) => bc - ac || a.localeCompare(b)),
quality: [
range(files.map((file) => file.bitrate), 1000, 0, 'kbps'),
range(files.map((file) => file.sample_rate), 1000, 3, 'kHz'),
range(files.map((file) => file.bit_depth), 1, 0, 'bit'),
].filter(Boolean).join(' · '),
};
}
+47
View File
@@ -1,4 +1,31 @@
// Сбор файлов из drag-and-drop и выбора папки с относительными путями (они подсказывают альбом, docs/uploads.md#5).
import { ApiError } from '@pipu/core';
const MAX_COVER_BYTES = 8 * 1024 * 1024;
/** Reduce oversized artwork before base64 expansion; small covers keep their original bytes. */
async function prepareCover(file: File): Promise<Blob> {
if (file.size <= MAX_COVER_BYTES) return file;
let image: ImageBitmap;
try { image = await createImageBitmap(file); }
catch { throw new ApiError(422, 'validation.cover_type'); }
try {
const scale = Math.min(1, 2048 / Math.max(image.width, image.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(image.width * scale));
canvas.height = Math.max(1, Math.round(image.height * scale));
const context = canvas.getContext('2d');
if (!context) throw new ApiError(422, 'validation.cover_type');
context.fillStyle = '#fff';
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
for (const quality of [0.9, 0.75]) {
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality));
if (blob && blob.size <= MAX_COVER_BYTES) return blob;
}
throw new ApiError(422, 'validation.cover_too_large');
} finally { image.close(); }
}
export interface PickedFile {
file: File;
@@ -30,6 +57,26 @@ export function isAudio(name: string): boolean {
return AUDIO_EXTENSIONS.includes(ext);
}
export function isAlbumCover(name: string): boolean {
return /^(cover|folder|front)\.(jpe?g|png|webp)$/i.test(name.split(/[/\\]/).pop() ?? name);
}
export function albumFolder(relPath: string): string {
const path = relPath.replaceAll('\\', '/');
const at = path.lastIndexOf('/');
return at < 0 ? '' : path.slice(0, at);
}
export async function coverBase64(file: File): Promise<string> {
const prepared = await prepareCover(file);
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result).split(',')[1]!);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(prepared);
});
}
/** Файлы из <input type="file"> (в том числе webkitdirectory — тогда путь есть в webkitRelativePath). */
export function fromInput(list: FileList | null): PickedFile[] {
return [...(list ?? [])].map((file) => ({ file, relPath: file.webkitRelativePath || file.name }));
+4 -5
View File
@@ -1,7 +1,7 @@
/// <reference lib="webworker" />
// SHA-256 файла потоком (docs/uploads.md#4): Web Crypto не умеет считать по частям, а файлы бывают по 2 ГБ —
// hash-wasm считает чанками в воркере, не держа файл в памяти и не блокируя интерфейс.
import { createSHA256 } from 'hash-wasm';
// js-sha256 считает чанками в воркере без WebAssembly: работает и со строгим CSP у прокси.
import { sha256 } from 'js-sha256';
export interface HashRequest {
id: string;
@@ -18,8 +18,7 @@ const ctx = self as unknown as DedicatedWorkerGlobalScope;
ctx.onmessage = async (e: MessageEvent<HashRequest>) => {
const { id, file } = e.data;
try {
const h = await createSHA256();
h.init();
const h = sha256.create();
const reader = file.stream().getReader();
let done = 0;
let lastReport = 0;
@@ -33,7 +32,7 @@ ctx.onmessage = async (e: MessageEvent<HashRequest>) => {
ctx.postMessage({ id, type: 'progress', done } satisfies HashResponse);
}
}
ctx.postMessage({ id, type: 'done', sha256: h.digest('hex') } satisfies HashResponse);
ctx.postMessage({ id, type: 'done', sha256: h.hex() } satisfies HashResponse);
} catch (err) {
ctx.postMessage({ id, type: 'error', message: err instanceof Error ? err.message : String(err) } satisfies HashResponse);
}
@@ -0,0 +1,54 @@
import { AuthSession } from '@pipu/core';
import { waitFor } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import { Uploader } from './uploader';
vi.mock('music-metadata', () => ({
parseBlob: vi.fn(async (file: File) => ({
format: {}, common: file.name.startsWith('01') ? { picture: [{ data: new Uint8Array([0xff, 0xd8, 0xff, 1]), format: 'image/jpeg', type: 'Cover (front)' }] } : {},
})),
}));
vi.mock('tus-js-client', () => ({
Upload: class {
constructor(_file: File, private options: { onSuccess: () => void }) {}
start() { queueMicrotask(this.options.onSuccess); }
},
}));
afterEach(() => { vi.unstubAllGlobals(); localStorage.clear(); });
it('keeps external and embedded album covers in separate batches without skipping images', async () => {
vi.stubGlobal('Worker', class extends EventTarget {
postMessage({ id }: { id: string }) {
queueMicrotask(() => this.dispatchEvent(new MessageEvent('message', { data: { id, type: 'done', sha256: id.padEnd(64, '0') } })));
}
});
const batches: { cover_base64?: string; files: { client_id: string; rel_path: string }[] }[] = [];
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input) => {
const req = input as Request;
const body = await req.json();
const data = req.url.endsWith('/precheck')
? { files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, result: 'accepted' })) }
: (batches.push(body), { batch_id: String(batches.length), files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, upload_file_id: f.client_id, tus_url: `/files/${f.client_id}` })) });
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
} });
const uploader = new Uploader(session);
uploader.add([
{ file: new File(['external-image'], 'Cover.jpg'), relPath: 'A/Cover.jpg' },
{ file: new File(['audio'], '02.flac'), relPath: 'A/02.flac' },
{ file: new File(['audio'], '01.flac'), relPath: 'A/01.flac' },
{ file: new File(['audio'], '02.flac'), relPath: 'B/02.flac' },
{ file: new File(['audio'], '01.flac'), relPath: 'B/01.flac' },
]);
await waitFor(() => expect(uploader.ready).toHaveLength(4));
expect(uploader.getState().skipped).toBe(0);
expect(uploader.getState().items.find((f) => f.relPath === 'A/01.flac')?.coverName).toBe('Cover.jpg');
expect(uploader.getState().items.find((f) => f.relPath === 'B/01.flac')?.coverName).toBe('embedded-cover');
await uploader.start('Two albums');
expect(batches).toHaveLength(2);
expect(batches[0]!.cover_base64).toBe(btoa('external-image'));
expect(batches[1]!.cover_base64).toBe(btoa(String.fromCharCode(0xff, 0xd8, 0xff, 1)));
expect(batches[0]!.files.map((f) => f.rel_path)).toEqual(['A/01.flac', 'A/02.flac']);
expect(batches[1]!.files.map((f) => f.rel_path)).toEqual(['B/01.flac', 'B/02.flac']);
expect(uploader.getState().items.every((f) => f.stage === 'done')).toBe(true);
});
+104 -38
View File
@@ -1,7 +1,7 @@
import { unwrap, type AuthSession } from '@pipu/core';
import * as tus from 'tus-js-client';
import { isAudio, type PickedFile } from './files';
import { albumFolder, coverBase64, isAlbumCover, isAudio, type PickedFile } from './files';
import type { HashRequest, HashResponse } from './hash.worker';
// Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей
@@ -29,6 +29,7 @@ export interface UploadItem {
progress: number; // 0..1 текущего этапа (хэш или загрузка)
sha256?: string;
tags?: { artist?: string; title?: string; album?: string; duration_ms?: number };
coverName?: string;
trackId?: string | null;
possibleDuplicate?: boolean;
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
@@ -68,7 +69,11 @@ export class Uploader {
private hashQueue: string[] = [];
private hashing = false;
private uploads = new Map<string, tus.Upload>();
private transferQueue: { id: string; url: string }[] | null = null;
private transferWorkers = 0;
private transferDone: (() => void) | null = null;
private refreshing: Promise<unknown> | null = null;
private folderCovers = new Map<string, File>();
constructor(private readonly session: AuthSession) {
this.restore();
@@ -97,17 +102,21 @@ export class Uploader {
/** Добавить файлы (из перетаскивания или выбора); не-аудио отбрасываются. */
add(files: PickedFile[]) {
if (this.state.busy) return;
for (const f of files) {
if (isAlbumCover(f.file.name)) this.folderCovers.set(albumFolder(f.relPath), f.file);
}
const fresh: UploadItem[] = [];
let skipped = 0;
for (const f of files) {
if (!isAudio(f.file.name)) {
skipped++;
if (!isAlbumCover(f.file.name)) skipped++;
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, size: f.file.size, relPath: f.relPath, stage: 'hashing', progress: 0 });
}
fresh.sort((a, b) => a.relPath.localeCompare(b.relPath, undefined, { numeric: true }));
this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null });
this.hashQueue.push(...fresh.map((it) => it.id));
void this.hashNext();
@@ -124,6 +133,7 @@ export class Uploader {
clear() {
if (this.state.busy) return;
this.hashQueue = [];
this.folderCovers.clear();
this.set({ items: [], skipped: 0, batchId: null, error: null });
this.persist();
}
@@ -217,31 +227,44 @@ export class Uploader {
if (this.state.busy || !ready.length) return;
this.set({ busy: true, error: null });
try {
const res = unwrap(
await this.session.client.POST('/api/v1/uploads', {
body: {
comment: comment.trim() || undefined,
files: ready.map((it) => ({
client_id: it.id,
name: it.file?.name ?? it.relPath,
rel_path: it.relPath.includes('/') ? it.relPath : undefined,
size: it.size,
sha256: it.sha256!,
})),
},
}),
);
this.set({ batchId: res.batch_id });
const groups = new Map<string, UploadItem[]>();
for (const it of ready) {
const folder = albumFolder(it.relPath);
groups.set(folder, [...(groups.get(folder) ?? []), it]);
}
const queue: { id: string; url: string }[] = [];
for (const f of res.files) {
if (f.rejected) this.patch(f.client_id, { stage: 'rejected', code: f.rejected, trackId: f.track_id });
else if (f.tus_url && f.upload_file_id) {
this.patch(f.client_id, { uploadFileId: f.upload_file_id, tusUrl: f.tus_url, stage: 'uploading', progress: 0 });
queue.push({ id: f.client_id, url: f.tus_url });
for (const [folder, group] of groups) {
try {
const cover = this.folderCovers.get(folder);
const res = unwrap(
await this.session.client.POST('/api/v1/uploads', {
body: {
comment: comment.trim() || undefined,
...(cover ? { cover_base64: await coverBase64(cover) } : {}),
files: group.map((it) => ({
client_id: it.id,
name: it.file?.name ?? it.relPath,
rel_path: it.relPath.includes('/') ? it.relPath : undefined,
size: it.size,
sha256: it.sha256!,
})),
},
}),
);
this.set({ batchId: res.batch_id });
for (const f of res.files) {
if (f.rejected) this.patch(f.client_id, { stage: 'rejected', code: f.rejected, trackId: f.track_id });
else if (f.tus_url && f.upload_file_id) {
this.patch(f.client_id, { uploadFileId: f.upload_file_id, tusUrl: f.tus_url, stage: 'uploading', progress: 0 });
queue.push({ id: f.client_id, url: f.tus_url });
}
}
this.persist();
} catch (err) {
this.set({ error: err });
}
}
this.persist();
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
await this.transfer(queue);
} catch (err) {
this.set({ error: err });
} finally {
@@ -249,24 +272,57 @@ export class Uploader {
}
}
get canRetryFailed(): boolean {
return !this.state.busy || this.transferQueue !== null;
}
/** Повторить оборвавшиеся загрузки: место уже зарезервировано, tus продолжит с принятого смещения. */
async retryFailed() {
const failed = this.state.items.filter((it) => it.stage === 'error' && it.tusUrl);
if (this.state.busy || !failed.length) return;
async retryFailed(id?: string) {
const failed = this.state.items.filter((it) => it.stage === 'error' && it.tusUrl && it.file && (!id || it.id === id));
if (!this.canRetryFailed || !failed.length) return;
const queue = failed.map((it) => ({ id: it.id, url: it.tusUrl! }));
for (const it of failed) this.patch(it.id, { stage: 'uploading', message: undefined, code: undefined });
// При продолжающейся пачке ставим повтор в её очередь, сохраняя предел параллельности.
if (this.transferQueue) {
this.transferQueue.push(...queue);
this.pumpTransfers();
return;
}
this.set({ busy: true, error: null });
try {
const queue = failed.map((it) => {
this.patch(it.id, { stage: 'uploading', message: undefined });
return { id: it.id, url: it.tusUrl! };
});
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
await this.transfer(queue);
} finally {
this.set({ busy: false });
}
}
private async drain(queue: { id: string; url: string }[]) {
for (let next = queue.shift(); next; next = queue.shift()) await this.upload(next.id, next.url);
private transfer(queue: { id: string; url: string }[]): Promise<void> {
return new Promise((resolve) => {
this.transferQueue = queue;
this.transferDone = resolve;
this.pumpTransfers();
});
}
private pumpTransfers() {
const queue = this.transferQueue;
if (!queue) return;
while (this.transferWorkers < PARALLEL && queue.length) {
const next = queue.shift()!;
this.transferWorkers++;
void this.upload(next.id, next.url).catch((err: unknown) => {
this.patch(next.id, { stage: 'error', message: err instanceof Error ? err.message : String(err) });
}).finally(() => {
this.transferWorkers--;
this.pumpTransfers();
});
}
if (this.transferWorkers === 0) {
this.transferQueue = null;
const done = this.transferDone;
this.transferDone = null;
done?.();
}
}
private upload(id: string, url: string): Promise<void> {
@@ -291,7 +347,7 @@ export class Uploader {
return true;
}
// Сеть, перегрузка, рассинхрон смещения — повторяем (tus-js-client спросит HEAD и продолжит).
return status === undefined || status === 0 || status >= 500 || status === 409 || status === 423;
return status === undefined || status === 0 || (status >= 500 && status !== 507) || status === 409 || status === 423;
},
onProgress: (sent, total) => this.patch(id, { progress: sent / Math.max(total, 1) }),
onSuccess: () => {
@@ -302,7 +358,8 @@ export class Uploader {
},
onError: (err) => {
this.uploads.delete(id);
this.patch(id, { stage: 'error', message: err.message });
const status = (err as tus.DetailedError).originalResponse?.getStatus();
this.patch(id, { stage: 'error', message: err.message, code: status === 507 ? 'uploads.storage_full' : undefined });
resolve();
},
});
@@ -315,7 +372,12 @@ export class Uploader {
if (!it.file) return;
try {
const { parseBlob } = await import('music-metadata');
const meta = await parseBlob(it.file);
const folder = albumFolder(it.relPath);
const meta = await parseBlob(it.file, { skipCovers: this.folderCovers.has(folder) });
if (!this.folderCovers.has(folder)) {
const picture = meta.common.picture?.find((p) => p.type === 'Cover (front)') ?? meta.common.picture?.[0];
if (picture) this.folderCovers.set(folder, new File([new Uint8Array(picture.data)], 'embedded-cover', { type: picture.format }));
}
const duration = meta.format.duration;
this.patch(it.id, {
tags: {
@@ -328,6 +390,10 @@ export class Uploader {
} catch {
// Теги — подсказка для предпроверки; без них сервер прочитает их сам.
}
const folder = albumFolder(it.relPath);
const cover = this.folderCovers.get(folder);
const first = this.state.items.find((item) => albumFolder(item.relPath) === folder);
if (cover && first) this.patch(first.id, { coverName: cover.name });
}
/** Незавершённые tus-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */
@@ -398,7 +464,7 @@ export class Uploader {
if (!queue.length) return;
this.set({ busy: true });
try {
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
await this.transfer(queue);
} finally {
this.set({ busy: false });
this.persist();
+1 -1
View File
@@ -83,7 +83,7 @@ export function PatternsPage({ id }: { id: string }) {
});
return (
<div className={`${s.page} ${s.narrow}`} style={{ maxWidth: 1100 }}>
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('patterns.title', { name: source.data?.name ?? '…' })}</h1>
@@ -1,7 +1,7 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
@@ -27,30 +27,40 @@ export function WriteTemplateSection({ source }: { source: Source }) {
const [accepts, setAccepts] = useState(source.accepts_uploads);
const [template, setTemplate] = useState(source.write_template ?? '');
const [check, setCheck] = useState<Check | null>(null);
const revision = useRef(0);
useEffect(() => {
setAccepts(source.accepts_uploads);
setTemplate(source.write_template ?? '');
}, [source.accepts_uploads, source.write_template]);
const runCheck = useMutation({
mutationFn: async (tpl: string) =>
unwrap(
mutationFn: async (tpl: string) => {
const requestedRevision = revision.current;
const result = unwrap(
await session.client.POST('/api/v1/admin/sources/{id}/write-template/check', {
params: { path: { id } },
body: { template: tpl },
}),
),
onSuccess: setCheck,
});
const suggest = useMutation({
mutationFn: async () =>
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/write-template/suggest', { params: { path: { id } } }))
.template,
onSuccess: (tpl) => {
setTemplate(tpl);
runCheck.mutate(tpl);
);
return { result, requestedRevision };
},
onSuccess: ({ result, requestedRevision }) => {
if (requestedRevision === revision.current) setCheck(result);
},
});
const suggest = useMutation({
mutationFn: async (_revision: number) =>
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/write-template/suggest', { params: { path: { id } } })),
onSuccess: (result, requestedRevision) => {
if (requestedRevision !== revision.current) return;
setTemplate(result.template);
runCheck.mutate(result.template);
},
});
const suggestTemplate = suggest.mutate;
useEffect(() => {
if (!source.write_template) suggestTemplate(revision.current);
}, [id, source.write_template, suggestTemplate]);
const save = useMutation({
mutationFn: async () =>
unwrap(
@@ -62,6 +72,7 @@ export function WriteTemplateSection({ source }: { source: Source }) {
onSuccess: async (src) => {
queryClient.setQueryData(['source', id], src);
await queryClient.invalidateQueries({ queryKey: ['sources'] });
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
},
});
@@ -73,6 +84,15 @@ export function WriteTemplateSection({ source }: { source: Source }) {
<section className={s.section}>
<h2>{t('write_template.title')}</h2>
<p className={s.note}>{t('write_template.intro')}</p>
<p className={s.note}>{t('write_template.auto_hint')}</p>
{suggest.isPending && <p className={s.note} role="status">{t('write_template.detecting')}</p>}
{suggest.data && (
<div className={s.sessions}>
<p className={s.note} role="status">{t('write_template.detected', { matched: suggest.data.matched ?? 0, total: suggest.data.total ?? 0 })}</p>
<code className={l.match}>{suggest.data.template}</code>
{(suggest.data.examples ?? []).map((path) => <code key={path} className={l.match}>{path}</code>)}
</div>
)}
<label className={l.checkRow}>
<input type="checkbox" checked={accepts} onChange={(e) => setAccepts(e.target.checked)} />
<span>{t('write_template.accepts')}</span>
@@ -82,6 +102,7 @@ export function WriteTemplateSection({ source }: { source: Source }) {
label={t('write_template.template')}
value={template}
onChange={(v) => {
revision.current++;
setTemplate(v);
setCheck(null);
save.reset();
@@ -91,7 +112,7 @@ export function WriteTemplateSection({ source }: { source: Source }) {
mono
/>
<div className={s.toolbar}>
<Button busy={suggest.isPending} onClick={() => suggest.mutate()}>
<Button busy={suggest.isPending} onClick={() => suggest.mutate(revision.current)}>
{t('write_template.suggest')}
</Button>
<Button busy={runCheck.isPending} disabled={!template.trim()} onClick={() => runCheck.mutate(template)}>
+2 -1
View File
@@ -1,5 +1,6 @@
.page {
max-width: 1100px;
width: 100%;
min-width: 0;
margin: 0 auto;
display: flex;
flex-direction: column;
+107 -17
View File
@@ -1,7 +1,7 @@
import { unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
@@ -14,10 +14,11 @@ import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile,
import u from './uploads.module.css';
const FILTERS = {
all: undefined,
all: ['uploading', 'processing', 'pending_review', 'approved', 'importing', 'imported', 'import_failed', 'duplicate', 'invalid', 'rejected', 'expired'],
pending: ['uploading', 'processing', 'pending_review', 'approved', 'importing', 'import_failed'],
imported: ['imported'],
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
declined: ['rejected', 'duplicate', 'invalid', 'expired'],
withdrawn: ['withdrawn'],
} satisfies Record<string, UploadStatus[] | undefined>;
type Filter = keyof typeof FILTERS;
@@ -25,11 +26,14 @@ const WITHDRAWABLE: UploadStatus[] = ['uploading', 'processing', 'pending_review
/** «Мои предложения»: статусы своих загрузок и решения админа (docs/uploads.md#10). */
export function MyUploadsPage() {
const { t } = useTranslation(['admin', 'common']);
const { t, i18n } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [filter, setFilter] = useState<Filter>('all');
const [selected, setSelected] = useState<Map<string, string>>(new Map());
const [selectedBatches, setSelectedBatches] = useState<Set<string>>(new Set());
useEffect(() => { setSelected(new Map()); setSelectedBatches(new Set()); }, [filter]);
useEffect(() => {
void session.client.POST('/api/v1/uploads/seen').then(() => {
@@ -52,14 +56,61 @@ export function MyUploadsPage() {
q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
});
const withdraw = useMutation({
mutationFn: async (id: string) =>
unwrap(await session.client.DELETE('/api/v1/uploads/files/{id}', { params: { path: { id } } })),
onSuccess: async () => {
mutationFn: async (ids: string[]) => {
const failed: { id: string; error: unknown }[] = [];
for (const id of ids) {
try {
unwrap(await session.client.DELETE('/api/v1/uploads/files/{id}', { params: { path: { id } } }));
} catch (error) {
failed.push({ id, error });
}
}
return failed;
},
onSuccess: async (failed) => {
setSelected((cur) => new Map([...cur].filter(([id]) => failed.some((f) => f.id === id))));
setSelectedBatches(new Set());
await queryClient.invalidateQueries({ queryKey: ['my-uploads'] });
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
},
});
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
useEffect(() => {
const unavailable = new Set(list.data?.pages.flatMap((p) => p.items.filter((f) => !WITHDRAWABLE.includes(f.status)).map((f) => f.id)) ?? []);
setSelected((cur) => new Map([...cur].filter(([id]) => !unavailable.has(id))));
}, [list.data]);
const batches = useMemo(() => {
const groups = new Map<string, UploadFile[]>();
for (const file of items) groups.set(file.batch_id, [...(groups.get(file.batch_id) ?? []), file]);
return [...groups.values()];
}, [list.data]);
const toggle = (files: UploadFile[], on: boolean) => setSelected((cur) => {
const next = new Map(cur);
for (const file of files) {
if (on) next.set(file.id, file.batch_id);
else next.delete(file.id);
}
return next;
});
const selectBatch = useMutation({
mutationFn: async (batch: string) => {
const files: UploadFile[] = [];
let cursor: string | undefined;
do {
const page = unwrap(await session.client.GET('/api/v1/uploads/mine', { params: { query: { batch, status: WITHDRAWABLE, limit: 100, cursor } } }));
files.push(...page.items);
cursor = page.next_cursor ?? undefined;
} while (cursor);
return { batch, files };
},
onSuccess: ({ batch, files }) => {
toggle(files, true);
setSelectedBatches((cur) => new Set([...cur, batch]));
},
onError: (_error, batch) => setSelectedBatches((cur) => new Set([...cur].filter((id) => id !== batch))),
});
const busy = withdraw.isPending || selectBatch.isPending;
const visibleEligible = items.filter((f) => WITHDRAWABLE.includes(f.status));
return (
<div className={s.page}>
@@ -73,7 +124,7 @@ export function MyUploadsPage() {
<div className={s.toolbar}>
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
{(Object.keys(FILTERS) as Filter[]).map((f) => (
<button key={f} type="button" className={s.chip} aria-pressed={filter === f} onClick={() => setFilter(f)}>
<button key={f} type="button" className={s.chip} aria-pressed={filter === f} disabled={busy} onClick={() => setFilter(f)}>
{t(`uploads.filter_${f}`)}
</button>
))}
@@ -85,7 +136,8 @@ export function MyUploadsPage() {
</Button>
</Link>
</div>
<FormError>{(list.error ?? withdraw.error) ? errorText(list.error ?? withdraw.error) : null}</FormError>
<FormError>{(list.error ?? withdraw.error ?? selectBatch.error) ? errorText(list.error ?? withdraw.error ?? selectBatch.error) : null}</FormError>
{!!withdraw.data?.length && <FormError>{t('uploads.withdraw_failed', { count: withdraw.data.length, reason: errorText(withdraw.data[0]!.error) })}</FormError>}
{list.isSuccess && items.length === 0 && (
<EmptyState icon={addIcon} action={<Link to="/uploads/new">{t('uploads.suggest_title')}</Link>}>
{t('uploads.mine_empty')}
@@ -93,9 +145,13 @@ export function MyUploadsPage() {
)}
{items.length > 0 && (
<div className={s.tableWrap}>
<table className={s.table}>
<table className={cx(s.table, u.mineTable)}>
<thead>
<tr>
<th><input type="checkbox" aria-label={t('uploads.select_visible')} disabled={busy || !visibleEligible.length} checked={visibleEligible.length > 0 && visibleEligible.every((f) => selected.has(f.id))} onChange={(e) => {
toggle(visibleEligible, e.target.checked);
setSelectedBatches(new Set());
}} /></th>
<th>{t('uploads.col_file')}</th>
<th>{t('uploads.col_status')}</th>
<th>{t('uploads.col_size')}</th>
@@ -103,19 +159,52 @@ export function MyUploadsPage() {
<th />
</tr>
</thead>
<tbody>
{items.map((f) => (
{batches.map((files) => {
const first = files[0]!;
const eligible = files.filter((f) => WITHDRAWABLE.includes(f.status));
return <tbody key={first.batch_id}>
<tr><td colSpan={6} className={u.mineBatchCell}>
<div className={u.mineBatchHead}>
<input type="checkbox" aria-label={t('uploads.select_batch')} disabled={busy || !eligible.length} checked={selectedBatches.has(first.batch_id)} ref={(node) => {
if (node) node.indeterminate = !selectedBatches.has(first.batch_id) && eligible.some((f) => selected.has(f.id));
}} onChange={(e) => {
if (e.target.checked) {
setSelectedBatches((cur) => new Set([...cur, first.batch_id]));
selectBatch.mutate(first.batch_id);
}
else {
setSelected((cur) => new Map([...cur].filter(([, batch]) => batch !== first.batch_id)));
setSelectedBatches((cur) => new Set([...cur].filter((id) => id !== first.batch_id)));
}
}} />
<strong>{formatDateTime(first.created_at, i18n.language)}</strong>
{first.batch_comment && <span>«{first.batch_comment}»</span>}
</div>
</td></tr>
{files.map((f) => (
<MineRow
key={f.id}
file={f}
busy={withdraw.isPending && withdraw.variables === f.id}
onWithdraw={() => withdraw.mutate(f.id)}
busy={busy}
withdrawing={withdraw.isPending && withdraw.variables.includes(f.id)}
checked={selected.has(f.id)}
onCheck={(on) => {
toggle([f], on);
setSelectedBatches((cur) => new Set([...cur].filter((id) => id !== f.batch_id)));
}}
onWithdraw={() => withdraw.mutate([f.id])}
/>
))}
</tbody>
</tbody>;
})}
</table>
</div>
)}
{selected.size > 0 && <section className={u.bulk} aria-label={t('uploads.bulk_withdraw')}>
<span>{t('uploads.selected', { count: selected.size })}</span>
<Button variant="ghost" disabled={busy} onClick={() => { setSelected(new Map()); setSelectedBatches(new Set()); }}>{t('common:form.cancel')}</Button>
<Button variant="danger" busy={withdraw.isPending} disabled={selectBatch.isPending} onClick={() => withdraw.mutate([...selected.keys()])}>{t('uploads.withdraw_selected')}</Button>
</section>}
{list.hasNextPage && (
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('uploads.load_more')}
@@ -125,7 +214,7 @@ export function MyUploadsPage() {
);
}
function MineRow({ file: f, busy, onWithdraw }: { file: UploadFile; busy: boolean; onWithdraw: () => void }) {
function MineRow({ file: f, busy, withdrawing, checked, onCheck, onWithdraw }: { file: UploadFile; busy: boolean; withdrawing: boolean; checked: boolean; onCheck: (on: boolean) => void; onWithdraw: () => void }) {
const { t, i18n } = useTranslation('admin');
// Технические подробности ошибки импорта — для админа; автору достаточно причины.
const raw = f.status === 'import_failed' ? (f.status_reason?.split(':')[0] ?? null) : f.status_reason;
@@ -133,6 +222,7 @@ function MineRow({ file: f, busy, onWithdraw }: { file: UploadFile; busy: boolea
const m = f.metadata;
return (
<tr>
<td>{WITHDRAWABLE.includes(f.status) && <input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} disabled={busy} onChange={(e) => onCheck(e.target.checked)} />}</td>
<td className={u.trackCell}>
<div className={u.meta}>
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
@@ -156,7 +246,7 @@ function MineRow({ file: f, busy, onWithdraw }: { file: UploadFile; busy: boolea
<td className={s.muted}>{formatDateTime(f.created_at, i18n.language)}</td>
<td>
{WITHDRAWABLE.includes(f.status) && (
<Button variant="ghost" busy={busy} onClick={onWithdraw}>
<Button variant="ghost" busy={withdrawing} disabled={busy} onClick={onWithdraw}>
{t('uploads.withdraw')}
</Button>
)}
+19 -6
View File
@@ -14,7 +14,7 @@ import s from '../page.module.css';
import { QuotaMeter, useUploadQuota } from './common';
import u from './uploads.module.css';
const ACCEPT = AUDIO_EXTENSIONS.map((e) => `.${e}`).join(',');
const ACCEPT = [...AUDIO_EXTENSIONS, 'jpg', 'jpeg', 'png', 'webp'].map((e) => `.${e}`).join(',');
/**
* «Предложить треки» (docs/uploads.md#5): перетащить файлы или папки → SHA-256 в воркере → предпроверка дублей →
@@ -41,7 +41,7 @@ export function SuggestPage() {
const ready = st.items.filter((it) => it.stage === 'ready');
const readyBytes = ready.reduce((n, it) => n + it.size, 0);
const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking');
const finished = !st.busy && st.batchId !== null;
const finished = !st.busy && st.batchId !== null && !ready.length;
const resume = st.items.filter((it) => it.stage === 'resume').length;
const failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length;
const uploaded = st.items.filter((it) => it.stage === 'done').length;
@@ -83,6 +83,7 @@ export function SuggestPage() {
<div className={u.dropTitle}>{t('uploads.drop_title')}</div>
<p className={u.formatWarn}>{t('uploads.drop_hint')}</p>
<div className={s.note}>{t('uploads.drop_folders')}</div>
<div className={s.note}>{t('uploads.cover_hint')}</div>
<div className={u.dropActions}>
<Button disabled={st.busy} onClick={() => filesRef.current?.click()}>
{t('uploads.pick_files')}
@@ -129,7 +130,7 @@ export function SuggestPage() {
{st.items.length > 0 && (
<div className={u.files} aria-label={t('uploads.files')}>
{st.items.map((it) => (
<FileRow key={it.id} item={it} locked={st.busy || finished} onRemove={() => uploader.remove(it.id)} />
<FileRow key={it.id} item={it} locked={st.busy || finished} retryDisabled={!uploader.canRetryFailed} onRetry={() => void uploader.retryFailed(it.id)} onRemove={() => uploader.remove(it.id)} />
))}
</div>
)}
@@ -141,6 +142,7 @@ export function SuggestPage() {
<textarea
className={u.textarea}
value={comment}
disabled={st.busy}
maxLength={1000}
placeholder={t('uploads.comment_hint')}
onChange={(e) => setComment(e.target.value)}
@@ -188,7 +190,13 @@ export function SuggestPage() {
);
}
function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean; onRemove: () => void }) {
function FileRow({ item, locked, retryDisabled, onRetry, onRemove }: {
item: UploadItem;
locked: boolean;
retryDisabled: boolean;
onRetry: () => void;
onRemove: () => void;
}) {
const { t, i18n } = useTranslation(['admin', 'errors']);
const tr = t as unknown as (k: string) => string;
const showProgress = item.stage === 'hashing' || item.stage === 'uploading';
@@ -204,7 +212,7 @@ function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean
note = { text: tr(`errors:${item.code ?? 'uploads.duplicate'}`), cls: u.bad };
break;
case 'error':
note = { text: t('uploads.file_error', { message: item.message ?? '' }), cls: u.bad };
note = { text: item.code === 'uploads.storage_full' ? tr('errors:uploads.storage_full') : t('uploads.file_error', { message: item.message ?? '' }), cls: u.bad };
break;
case 'ready':
if (item.possibleDuplicate) note = { text: t('uploads.possible_dup'), cls: u.warn };
@@ -226,10 +234,15 @@ function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean
</div>
)}
{needsFormatAttention(null, item.relPath) && <span className={u.warn}>{t('uploads.format_attention')}</span>}
{item.coverName && <span className={u.sub}>{t(item.coverName === 'embedded-cover' ? 'uploads.cover_embedded' : 'uploads.cover_found', { name: item.coverName })}</span>}
{note && <span className={note.cls}>{note.text}</span>}
</div>
<span className={u.size}>{formatBytes(item.size, i18n.language)}</span>
{locked ? (
{item.stage === 'error' && item.tusUrl && item.file ? (
<Button variant="secondary" disabled={retryDisabled} aria-label={t('uploads.retry_file', { name: item.relPath })} onClick={onRetry}>
{t('uploads.retry')}
</Button>
) : locked ? (
<span />
) : (
<Button variant="ghost" aria-label={t('uploads.remove', { name: item.relPath })} onClick={onRemove}>
@@ -179,6 +179,12 @@
color: var(--text);
}
.batchIdentity { display: flex; flex-direction: column; gap: 4px; min-width: 0; max-width: min(360px, 100%); }
.batchIdentity strong { color: var(--text); font-size: 14px; overflow-wrap: anywhere; }
.batchToggle { min-width: 44px; min-height: 44px; padding: 8px; }
.batchExpanded { transform: rotate(90deg); }
.comment {
flex-basis: 100%;
color: var(--text);
@@ -218,6 +224,15 @@
align-items: center;
}
.preview { width: 300px; max-width: 100%; min-width: 0; }
.releaseCell { min-width: 140px; max-width: 300px; white-space: normal !important; overflow-wrap: anywhere; }
.releaseDialog { width: 900px; }
.releaseHead { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 12px 18px; border-bottom: 1px solid var(--line); font-size: 13px; }
.releaseHead strong { min-width: 0; overflow-wrap: anywhere; }
.releaseCover { max-width: 100%; object-fit: contain; border-radius: 10px; }
.preview audio { display: block; width: 100%; }
.previewError { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; margin-top: 6px; font-size: 12px; }
.bulk {
position: sticky;
bottom: 12px;
@@ -293,6 +308,11 @@
resize: vertical;
}
.textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 720px) {
.queueRow {
grid-template-columns: auto minmax(0, 1fr);
@@ -310,6 +330,32 @@
overflow-wrap: anywhere;
}
.placement {
grid-column: 2 / -1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface2);
overflow-wrap: anywhere;
}
.placementLabel {
font-size: 11px;
color: var(--text3);
}
.mineBatchHead {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
overflow-wrap: anywhere;
}
.trackCell {
min-width: 260px;
max-width: 480px;
@@ -322,6 +368,20 @@
}
@media (max-width: 767px) {
.mineTable, .mineTable tbody { display: block; }
.mineTable thead { display: none; }
.mineTable tr {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
gap: 10px;
padding: 14px;
border-bottom: 1px solid var(--line);
}
.mineTable td { min-width: 0; height: auto; padding: 0; border: 0; }
.mineTable td:not(:first-child) { grid-column: 2; }
.mineTable .trackCell { grid-column: 2; grid-row: 1; min-width: 0; max-width: none; }
.mineTable .mineBatchCell { grid-column: 1 / -1; }
.mineTable .ellipsis { white-space: normal; overflow-wrap: anywhere; }
.queueRow { grid-template-columns: 24px minmax(0, 1fr); gap: 10px; padding: 12px; }
.queueRow > :nth-child(3), .queueRow > .rowActions { grid-column: 2; }
.rowActions { flex-wrap: wrap; min-width: 0; }
+2
View File
@@ -472,6 +472,8 @@ export function fakeApi(initial: FakeUser[]) {
return json({ ...kino, album_count: 1, track_count: 0 });
case `GET /api/v1/artists/${kino.id}/albums`:
return json({ items: [albumSummary] });
case `GET /api/v1/artists/${kino.id}/top-tracks`:
return json({ items: albumTracks });
}
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path)) {
onboarding.set(path.split('/')[5]!, null);
+16
View File
@@ -1,4 +1,20 @@
[
{
"key": "ACOUSTID_API_KEY",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "MUSICBRAINZ_ENABLED",
"group": "api",
"type": "boolean",
"default_value": "true",
"secret": false,
"readonly": false
},
{
"key": "MANAGEMENT_DIR",
"group": "api",
+1 -2
View File
@@ -3,5 +3,4 @@
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
# hash-wasm считает SHA-256 загрузок в Web Worker; разрешаем WASM без JavaScript eval.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
+9
View File
@@ -71,6 +71,15 @@
известные дубли отбрасываются без загрузки.
2. `POST /uploads` резервирует квоту очереди → загрузка по tus в staging (`/srv/pipu/uploads`).
3. Задача `process_upload_file`: ffprobe, хэш, Chromaprint → дубль (удаление, квота освобождена) или `pending_review`.
После обработки пачки отдельная очередь PostgreSQL сопоставляет её с изданиями MusicBrainz.
Несколько альбомов получают постоянные `upload_release_groups` внутри исходной пачки: отдельные задачи
и обложки, FK из `upload_files`. `uploads` группирует по идентификаторам, альбомным тегам и папкам;
применение метаданных, инвалидирование аренды и чтение обложки ограничены выбранной группой.
По явному запросу ревьюера `internal/acoustid` распознаёт локальные Chromaprint-отпечатки;
`uploads` хранит результаты отдельно от тегов и использует recording ID при сопоставлении изданий.
Адаптер `internal/musicbrainz` отвечает за HTTP, лимит запросов и Cover Art Archive; `uploads` — за
комплектность, сравнение с библиотекой и применение выбранных администратором изменений одной транзакцией.
Сбой внешнего каталога не блокирует обработку, одобрение или импорт.
4. Админ правит метаданные и одобряет → `import_upload_file`: целевой путь по `write_template` хранилища →
`storage-agent` записывает файл → `scan_path` → трек в каталоге, поиске и очереди анализа.
+11
View File
@@ -140,9 +140,20 @@ CREATE TABLE upload_batches ( -- одна «пачка» загруз
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE upload_release_groups (
id uuid PRIMARY KEY,
batch_id uuid NOT NULL REFERENCES upload_batches(id) ON DELETE CASCADE,
grouping_key text NOT NULL, -- ключ первичной группировки, после подтверждения тегов не меняется
artist text NOT NULL DEFAULT '',
album text NOT NULL DEFAULT '',
cover_path text, -- обложка только этого релиза
UNIQUE (batch_id, grouping_key)
);
CREATE TABLE upload_files (
id uuid PRIMARY KEY,
batch_id uuid NOT NULL REFERENCES upload_batches(id) ON DELETE CASCADE,
release_group_id uuid REFERENCES upload_release_groups(id) ON DELETE SET NULL,
author_id uuid NOT NULL REFERENCES users(id),
status upload_status NOT NULL,
original_name text NOT NULL,
+2
View File
@@ -79,6 +79,8 @@
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/download` |
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
| `MUSICBRAINZ_ENABLED` | `true` | Фоновое сопоставление пачек с изданиями MusicBrainz и предложение обложек Cover Art Archive; нужен исходящий HTTPS |
| `ACOUSTID_API_KEY` | пусто | Секретный ключ приложения AcoustID для распознавания пачек по звуку; без него доступен только поиск по тегам |
| `LOG_LEVEL` | `info` | |
## TLS
+118 -7
View File
@@ -61,7 +61,7 @@
| Уровень | Когда | Как |
|---|---|---|
| 1. Точная копия файла | **До загрузки** (на клиенте) и после | SHA-256 всего файла: клиент считает в браузере (Web Crypto, потоково в Web Worker) и отправляет в предпроверке. Сравнение с `media_files.sha256` и с другими загрузками в очереди. |
| 1. Точная копия файла | **До загрузки** (на клиенте) и после | SHA-256 всего файла: клиент считает потоково в Web Worker через `js-sha256`, без WebAssembly и загрузки всего файла в память, и отправляет в предпроверке. Сравнение с `media_files.sha256` и с другими загрузками в очереди. |
| 2. Совпадение метаданных | **До загрузки** (теги читаются на клиенте библиотекой `music-metadata`) и после (на сервере по `ffprobe`) | Нормализованные `artist + title` (+ `album`, если есть) и длительность ±2 с против `tracks`. |
| 3. Акустический отпечаток | После загрузки | Chromaprint (`fpcalc`), сравнение с `track_fingerprints` библиотеки и очереди. Ловит тот же трек в другом формате / битрейте / с другими тегами. |
@@ -86,7 +86,14 @@
- Staging — локальный диск сервера `/srv/pipu/uploads/<upload_id>/<file_id>` (не NFS).
- Разрешённые форматы: `flac mp3 m4a ogg opus wav aiff wv`. Просим присылать lossless — **FLAC, WAV или AIFF** (WavPack тоже считается lossless); MP3 и остальные lossy — крайний случай: форма предупреждает об этом, а очередь на добавление помечает такой файл «обратить внимание». Проверяется не расширение, а содержимое (`ffprobe`):
должен быть ровно один аудиопоток, длительность 5 с – 3 ч. Иначе `invalid` с кодом причины.
- Допускается отдельная картинка обложки (`cover.jpg|png`) в той же пачке — привязывается к файлам той же папки.
- Картинки `cover.jpg`, `cover.png` (также `folder`/`front`, JPEG/PNG/WebP) загружаются как обложка альбома,
а не пропускаются как не-аудио. Если отдельной картинки нет, берётся встроенная обложка из первого трека,
в котором она найдена. На каждую папку альбома создаётся своя пачка с обложкой, поэтому разные альбомы
не смешиваются. Обложка до 8 МБ сохраняется вместе с резервом пачки и встраивается при импорте
в поддерживаемые контейнеры FLAC/MP3/M4A/Ogg/Opus без перекодирования аудио.
Обложки больше 8 МБ клиент автоматически уменьшает до JPEG с длинной стороной не более 2048 пикселей
перед base64-кодированием; это относится и к отдельным файлам, и к встроенной обложке, и к правке пачки.
Меньшие картинки остаются без изменений. Код HTTP 413 от прокси показывается как превышение размера запроса.
## 6. Обработка (`processing`)
@@ -126,9 +133,10 @@
| `[ ... ]` | Необязательный фрагмент: опускается целиком, если любой плейсхолдер внутри пуст, например `[{year} - ]{album}` |
**Как получить шаблон:**
- **Автоподбор**: система берёт выборку существующих путей хранилища, определяет, какой регэксп совпадает чаще всего,
и предлагает шаблон, порождающий такие же пути (группы регэкспа → плейсхолдеры, литералы между группами → текст шаблона).
Админ подтверждает или правит.
- **Автоподбор**: система сравнивает пути и метаданные до 200 существующих файлов хранилища, строит варианты
раскладки и выбирает вариант с наибольшим числом точных совпадений. Сохранённые регэкспы также используются
как кандидаты, но не обязательны. В интерфейсе показаны число совпадений и примеры реальных путей.
Если шаблон ещё не задан, предложение заполняется автоматически; администратор может изменить его и сохранить.
- **Проверка обратимости** (обязательна при сохранении): шаблон применяется к 50 существующим трекам хранилища,
полученные пути прогоняются через регэкспы этого хранилища — поля должны распознаться обратно без изменений,
а в ≥ 90% случаев путь должен совпасть с реальным путём файла. Иначе — предупреждение с примерами расхождений.
@@ -149,14 +157,114 @@
Экран «Очередь на добавление»:
- Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`.
Пачки по умолчанию свёрнуты; кнопка в заголовке раскрывает файлы, выбор всей пачки доступен и без раскрытия.
В заголовке указано наличие общей обложки пачки: «Есть обложка» / «Без обложки».
Там же показаны количества файлов по форматам и диапазоны битрейта, частоты и разрядности;
одинаковые значения не повторяются, отсутствующие измерения не учитываются как нули.
- Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество
(формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище**
(пересчитывается live при правке метаданных).
с названием хранилища и путём относительно его корня. Полный адрес назначения (локальный путь API
или `server:/export/path` для NFS) доступен при наведении на путь. Для ожидающих рассмотрения, одобренных и ошибочных импортов
предпросмотр рассчитывается при чтении очереди по текущему шаблону — тем же планировщиком, что и импорт.
Сохранение шаблона обновляет кеш очереди. Уже импортированные файлы сохраняют фактический путь.
Если рассчитать путь нельзя, показаны причина и действие для исправления вместо старого пути.
- Прослушивание staging выдаёт сам API с поддержкой Range: приватные файлы tus (`0640`, каталоги `0750`)
недоступны пользователю nginx. Выдача не зависит от `X_ACCEL`; права существующих файлов менять не нужно.
Аудио и его метаданные загружаются только после Play (`preload="none"`); раскрытие пачки не запускает запросы к файлам.
При сбое показана ошибка (включая HTTP-статус, если доступен)
и кнопка повторной попытки с обновлением подписанных ссылок.
Одновременно играет только один предпросмотр: запуск другого ставит предыдущий на паузу с сохранением позиции.
Сворачивание пачки или уход со страницы также останавливает её прослушивание.
- Действия: одобрить / отклонить (с причиной — видна автору), массовые действия для выбранных, правка «на весь альбом»
(исполнитель, альбом, год, обложка одной операцией).
- Если админ изменил метаданные — они **записываются в теги файла** при импорте (`ffmpeg -map 0 -c copy -metadata …`
без перекодирования; для FLAC/MP3/M4A/Ogg), чтобы теги и путь не противоречили друг другу.
### Сопоставление релиза и внешние обложки
После обработки всех файлов пачки API ставит её в отдельную фоновую очередь. MusicBrainz ищет
конкретные издания по сохранённому MusicBrainz Release ID либо исполнителю альбома и названию.
Внешнему сервису передаются только поисковые метаданные и идентификаторы, аудиофайлы не отправляются.
Для смешанной пачки или отсутствующих тегов показывается предложение уточнить поиск.
В заголовке пачки видно состояние поиска. Если найдено несколько изданий, администратор сначала
выбирает конкретное. Проверка сопоставляет дорожки по идентификаторам, названиям, исполнителям,
длительностям и нумерации дисков. «Полный состав» означает все дорожки выбранного издания с уверенными
совпадениями и без несопоставленных файлов. Один файл не считается синглом без типа Single в каталоге.
Несколько форматов одной песни не увеличивают число совпавших дорожек. Предположительные и неоднозначные
совпадения отмечаются отдельно. Для отсутствующих дорожек отдельно показаны совпадения с доступной
библиотекой по тегам и длительности; они не делают загруженную пачку полной.
Поиск сам не меняет теги, путь или обложку. В диалоге можно отдельно применить метаданные к совпавшим
редактируемым файлам и/или обложку. Обложка загружается из Cover Art Archive только после запроса
предпросмотра. При отсутствии обложки конкретного издания возможна обложка группы изданий с явной пометкой.
Замена уже загруженной обложки требует отдельного выбора. Применение фиксируется одной транзакцией
и отвергает изменившуюся пачку (409); статусы одобрения и квота не меняются. MusicBrainz ID записываются
в теги при импорте вместе с остальными подтверждёнными метаданными.
`MUSICBRAINZ_ENABLED=true` включает поиск (можно отключить в конфигурации сервера). Нужен исходящий HTTPS
к MusicBrainz, Cover Art Archive и archive.org для обложек. Ключ API для MusicBrainz не требуется;
запросы ограничены одним за 1,1 секунды, результат кешируется на 7 дней, просроченный кеш удаляется.
Сбой провайдера не мешает загрузке или импорту: повтор через 10 минут, не более трёх автоматических попыток,
после чего доступен ручной поиск. Очередь и кеш сохраняются в PostgreSQL, незавершённая задача
после перезапуска повторяется по истечении пятиминутной аренды.
Источники: [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API),
[Cover Art Archive API](https://musicbrainz.org/doc/Cover_Art_Archive/API).
### Несколько релизов в одной пачке
После завершения обработки всей пачки треки получают постоянные группы релизов внутри исходной пачки.
Группировка использует MusicBrainz Release ID, затем album artist + album + год. Единственный известный
Release ID подхватывается треками того же альбома без идентификатора; разные известные издания разделяются.
При неполных тегах используется исходная папка альбома. Подпапки CD1/CD2, Disc 1/2 и «Диск 1/2»
относятся к общей родительской папке. Album artist сохраняет сборники с разными исполнителями треков;
без него помогает общая папка. Файлы без альбомных тегов и без папок остаются одной группой для ручной проверки.
У групп отдельные фоновые задачи, аренды, результаты MusicBrainz/AcoustID, выбранные издания и обложки.
После первичного разделения подтверждение тегов не меняет принадлежность файлов и не объединяет группы.
Правка метаданных или отзыв файла сбрасывает только поиск его группы. Ошибка одной группы не блокирует
поиск соседних. В очереди сохраняется исходная пачка с общими действиями и вложенными заголовками релизов;
выбор строк релиза действует на загруженные строки страницы. В диалоге сначала выбирается релиз внутри пачки,
затем его издание. Проверка состава учитывает все файлы группы, включая другие страницы очереди.
HTTP: `group_id` в запросах поиска/применения и query-параметр `group_id` при чтении отчёта/обложки.
Для единственной группы параметр можно опустить; в смешанной пачке сервер требует явного выбора и проверяет
принадлежность группы пачке. Применение блокирует и проверяет только файлы выбранной группы и её задачу.
Квота и статусы файлов не меняются. При импорте используется обложка своей группы; staging-очистка сохраняет
общую обложку, пока она нужна хотя бы одной активной группе.
В одиночном релизе сохраняется исходная обложка пачки. В смешанной пачке одну общую обложку нельзя надёжно
приписать всем альбомам, поэтому группы начинают без неё; исходная обложка остаётся в пачке.
Обложку из каталога можно применить отдельно к нужному релизу. Явное изменение обложки «На всю пачку»
по-прежнему меняет обложки всех её групп.
### Распознавание по звуку
Кнопка «Распознать по звуку» запускает отдельный запрос для пачки до 50 редактируемых треков.
Нужен ключ **приложения** AcoustID: зарегистрируйте приложение на [AcoustID](https://acoustid.org/webservice),
задайте секрет `ACOUSTID_API_KEY` в конфигурации сервера и примените изменения. Пользовательский ключ
для отправки записей не нужен. При пустом ключе кнопка недоступна, поиск по тегам продолжает работать.
`fpcalc` локально вычисляет сжатый Chromaprint первых 120 секунд. В AcoustID отправляются только
отпечаток и полная длительность из ffprobe; аудиофайл, теги, имя файла и ключи пользователя не передаются.
Автоматического распознавания всех загрузок и отправки отпечатков в базу AcoustID нет.
Ключ хранится как секрет; POST не помещает его и отпечаток в URL, редиректы запрещены.
Результат используется при оценке не ниже 0,90 и отрыве от следующей **другой** записи не менее 0,05.
Для каждого файла показаны распознавание, неоднозначность, отсутствие совпадений или недоступный отпечаток.
Слабые результаты не исправляют теги. Уверенное распознавание имеет приоритет над ошибочными тегами;
для подтверждения полного состава нужна также совместимая длительность дорожки (±5 секунд).
Повтор одной записи на разных позициях остаётся неоднозначным.
MusicBrainz ищет издания по первым восьми уникальным распознанным записям (до 100 изданий на запись),
выбирает до пяти кандидатов по числу совпадений и сопоставляет с ними все файлы. Это ограниченный набор
кандидатов, не гарантия нахождения нужного издания. Отпечаток не определяет конкретный мастеринг или издание;
выбор и применение метаданных/обложки остаются за администратором. Поиск имеет общий таймаут 4 минуты,
лимит AcoustID — один запрос за 350 мс, кеш — 7 дней. Нужен исходящий HTTPS к `api.acoustid.org`.
Условия бесплатного сервиса AcoustID допускают некоммерческое использование; для коммерческого нужен
отдельный договор с провайдером (см. документацию AcoustID).
## 9. Импорт (`importing`)
Задача River `import_upload_file` (повторяемая, идемпотентная):
@@ -175,6 +283,9 @@
админ может повторить или отклонить.
## 10. Уведомления
- В «Моих предложениях» файлы сгруппированы по пачкам. Можно выбрать отдельные файлы, загруженные строки
страницы или всю собственную пачку (с загрузкой всех её страниц) и отозвать выбранные до решения администратора.
Успешные отзывы освобождают квоту; при частичной ошибке показана причина, неудавшиеся файлы остаются выбранными.
- Автор видит статусы в «Мои предложения» (раздел админки модератора), бейдж с числом решений с последнего визита.
- Аудитор загрузок видит бейдж «N на рассмотрении» в сайдбаре.
@@ -200,7 +311,7 @@
- Настройки: `GET` / `PATCH /admin/settings/uploads` (ключ `uploads.settings` в `app_settings`).
- Веб (`apps/web/src/features/uploads`, `pages/uploads`, `pages/admin/UploadQueuePage.tsx`):
- «Предложить треки» (`/uploads/new`): drag-and-drop файлов и папок (FileSystemEntry API, относительные пути),
выбор файлов / папки; SHA-256 потоком в Web Worker (`hash-wasm`); теги читает `music-metadata` и отдаёт их
выбор файлов / папки; SHA-256 потоком в Web Worker (`js-sha256`); теги читает `music-metadata` и отдаёт их
в предпроверку; `POST /uploads`; `tus-js-client` с готовым `uploadUrl`, куски по 8 МБ, 4 файла параллельно,
401 → обновление токена и повтор. Загрузчик — синглтон модуля: переходы по приложению не прерывают загрузку.
Незавершённые tus-ссылки пишутся в `localStorage`; после перезагрузки пользователь выбирает те же файлы,
+272
View File
@@ -955,6 +955,68 @@ export interface paths {
patch: operations["updateUploadBatch"];
trace?: never;
};
"/api/v1/admin/uploads/batches/{id}/releases": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
/** Состояние фонового сопоставления пачки с изданиями MusicBrainz */
get: operations["getUploadReleases"];
put?: never;
/** Повторить поиск релиза, при необходимости с уточнённым запросом */
post: operations["searchUploadReleases"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/uploads/batches/{id}/releases/{release_id}/cover": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
release_id: string;
};
cookie?: never;
};
/** Предпросмотр обложки выбранного кандидата из Cover Art Archive */
get: operations["getUploadReleaseCover"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/uploads/batches/{id}/release": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
get?: never;
put?: never;
/**
* Явно применить метаданные и/или обложку выбранного издания
* @description Только к совпавшим редактируемым файлам. Изменившаяся пачка или повторный поиск приводят к 409; одобрение и квота не меняются.
*/
post: operations["applyUploadRelease"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/uploads/review": {
parameters: {
query?: never;
@@ -2233,6 +2295,8 @@ export interface components {
}[];
};
UploadBatchRequest: {
/** @description Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки. */
cover_base64?: string;
comment?: string;
files: {
client_id: string;
@@ -2314,6 +2378,12 @@ export interface components {
target_source_id: string | null;
/** @description Путь в хранилище по write_template (null — не построился, причина в status_reason) */
target_rel_path: string | null;
/** @description Название целевого хранилища; null, если хранилище не выбрано или удалено */
target_source_name?: string | null;
/** @description У пачки есть общая обложка для записи в загружаемые треки */
batch_has_cover?: boolean;
/** @description Полный путь назначения для админской очереди — локальный путь API или server:/export/path для NFS. Для ожидающих файлов рассчитывается по текущему шаблону. */
target_storage_path?: string | null;
status_reason: string | null;
/** Format: date-time */
reviewed_at: string | null;
@@ -2335,6 +2405,74 @@ export interface components {
/** Format: uuid */
target_source_id?: string;
};
ReleaseTrack: {
recording_id: string;
title: string;
artist: string;
disc: number;
position: number;
duration_ms: number;
};
ReleaseInfo: {
id: string;
group_id: string;
title: string;
artist: string;
type: string;
date: string;
country: string;
comment: string;
has_cover: boolean;
tracks: components["schemas"]["ReleaseTrack"][];
};
ReleaseCandidate: {
release: components["schemas"]["ReleaseInfo"];
matches: {
/** Format: uuid */
file_id: string;
track_index: number;
confidence: string;
/** @enum {string} */
method?: "metadata" | "acoustid";
}[];
missing: components["schemas"]["ReleaseTrack"][];
/** @description Совпадения отсутствующих дорожек с библиотекой по метаданным и длительности; не подтверждают полную загруженную пачку */
existing: components["schemas"]["ReleaseTrack"][];
unmatched: string[];
matched: number;
total: number;
complete: boolean;
confidence: string;
};
UploadReleaseGroup: {
/** Format: uuid */
id: string;
artist: string;
album: string;
file_ids: string[];
has_cover: boolean;
state: string;
};
UploadReleaseReport: {
/** Format: uuid */
group_id?: string;
groups?: components["schemas"]["UploadReleaseGroup"][];
audio_available?: boolean;
audio_results?: {
/** Format: uuid */
file_id: string;
/** @enum {string} */
state: "matched" | "ambiguous" | "not_found" | "unavailable";
/** Format: uuid */
recording_id?: string;
score?: number;
}[];
/** @enum {string} */
state: "disabled" | "queued" | "searching" | "ready" | "not_found" | "needs_input" | "error";
input_hash: string;
candidates: components["schemas"]["ReleaseCandidate"][];
selected_release: string | null;
};
/** @description Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки. */
UploadBatchPatch: {
artist?: string;
@@ -2695,6 +2833,12 @@ export interface components {
};
WriteTemplateSuggestion: {
template: string;
/** @description Число путей выборки, совпавших с предложенным шаблоном */
matched?: number;
/** @description Число исследованных файлов хранилища */
total?: number;
/** @description До трёх существующих путей, совпавших с шаблоном */
examples?: string[];
};
WriteTemplateCheckRequest: {
template: string;
@@ -4413,6 +4557,8 @@ export interface operations {
listMyUploads: {
parameters: {
query?: {
/** @description Только файлы указанной собственной пачки */
batch?: string;
/** @description Статусы через запятую */
status?: components["parameters"]["UploadStatusFilter"];
limit?: components["parameters"]["Limit"];
@@ -4635,6 +4781,132 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
getUploadReleases: {
parameters: {
query?: {
group_id?: string;
};
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Состояние поиска и кандидаты; текущие файлы не изменяются */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UploadReleaseReport"];
};
};
default: components["responses"]["Problem"];
};
};
searchUploadReleases: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
artist?: string;
album?: string;
/** Format: uuid */
group_id?: string;
/** Format: uuid */
release_id?: string;
/** @description Явно распознать до 50 редактируемых треков через AcoustID; теги и аудио не отправляются. Требуется ключ приложения на сервере. */
audio?: boolean;
};
};
};
responses: {
/** @description Поиск поставлен в фоновую очередь */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UploadReleaseReport"];
};
};
default: components["responses"]["Problem"];
};
};
getUploadReleaseCover: {
parameters: {
query?: {
group_id?: string;
};
header?: never;
path: {
id: components["parameters"]["EntityId"];
release_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Ограниченное по размеру изображение; не меняет обложку пачки */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
image_base64: string;
mime_type: string;
source_url: string;
exact_release: boolean;
};
};
};
default: components["responses"]["Problem"];
};
};
applyUploadRelease: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** Format: uuid */
group_id?: string;
/** Format: uuid */
release_id: string;
apply_metadata: boolean;
apply_cover: boolean;
};
};
};
responses: {
/** @description Применено к текущей пачке */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UploadReleaseReport"];
};
};
default: components["responses"]["Problem"];
};
};
reviewUploads: {
parameters: {
query?: never;
+6 -1
View File
@@ -15,5 +15,10 @@ export interface ApiClientOptions {
/** Создаёт типизированный клиент публичного API (контракт — api/openapi.yaml). */
export function createApiClient(opts: ApiClientOptions = {}): ApiClient {
return createClient<paths>({ baseUrl: opts.baseUrl ?? '', fetch: opts.fetch });
return createClient<paths>({
baseUrl: opts.baseUrl ?? '',
fetch: opts.fetch,
// OpenAPI query arrays (UploadStatusFilter) use form + explode: false.
querySerializer: { array: { style: 'form', explode: false } },
});
}
+9
View File
@@ -83,6 +83,15 @@ describe('AuthSession', () => {
expect(states).toEqual(['signed_in']);
});
it('reports proxy HTML 413 as a request size error', async () => {
const s = server({ 'POST /api/v1/auth/login': () => new Response('<html>Request Entity Too Large</html>', { status: 413 }) });
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
const error = await auth.login('alice', 'password').catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).status).toBe(413);
expect((error as ApiError).code).toBe('common.request_too_large');
});
it('turns problem+json into ApiError with field errors', async () => {
const s = server({
'POST /api/v1/auth/login': () => problem(401, 'auth.invalid_credentials'),
+1 -1
View File
@@ -33,7 +33,7 @@ export function toApiError(error: unknown, response?: Response): ApiError {
if (typeof p.code === 'string') {
return new ApiError(p.status ?? response?.status ?? 0, p.code, p.params ?? {}, p.errors ?? []);
}
if (response) return new ApiError(response.status, response.status >= 500 ? 'common.internal' : 'common.bad_request');
if (response) return new ApiError(response.status, response.status === 413 ? 'common.request_too_large' : response.status >= 500 ? 'common.internal' : 'common.bad_request');
return new ApiError(0, 'common.network');
}
+86
View File
@@ -1,4 +1,63 @@
{
"release_lookup": {
"groups_count": "Releases in batch: {{count}}",
"groups_hint": "Each release is checked separately. Metadata and artwork apply only to tracks in the selected release.",
"group": "Release in batch",
"choose_group": "Choose a release",
"apply_group_cover": "Add artwork to the selected release",
"replace_group_cover": "Replace artwork of the selected release",
"select_group": "Select loaded rows of release {{album}}",
"audio_search": "Identify by audio",
"audio_privacy": "Only fingerprints and durations are sent to AcoustID, never audio files. Up to 50 tracks per request.",
"audio_setup": "Set ACOUSTID_API_KEY in server configuration and apply the changes to enable audio identification.",
"audio_summary": "Identified by audio: {{matched}} of {{total}} tracks",
"audio_editions": "A fingerprint identifies a recording, which may appear on different editions. Check the selected edition and track list.",
"audio_match": "Audio match · AcoustID",
"audio_ambiguous": "Ambiguous or low-confidence match",
"audio_not_found": "No audio match found",
"audio_unavailable": "Could not compute fingerprint: check fpcalc and file availability",
"title": "Match release",
"open": "Check release",
"intro": "MusicBrainz suggests specific editions. Review the track list and differences below. Tags and artwork only change after you select and apply them.",
"album": "Album",
"single": "Single",
"release": "Release",
"complete": "Complete track list",
"incomplete": "Incomplete track list",
"variants": "Editions found: {{count}}",
"choose_edition": "Choose a specific edition",
"partial": "Needs review",
"state_disabled": "Lookup disabled",
"state_queued": "Release queued",
"state_searching": "Finding release…",
"state_ready": "Release found",
"state_not_found": "Release not found",
"state_needs_input": "Refine release",
"state_error": "Lookup unavailable",
"mbid": "MusicBrainz Release ID (optional)",
"search": "Find / retry lookup",
"edition": "Edition",
"tracks_total": "tracks: {{total}}",
"confidence": "Match confidence: {{value}}",
"confidence_high": "high",
"confidence_medium": "medium",
"confidence_low": "low",
"coverage": "Matched {{matched}} of {{total}} tracks · Missing: {{missing}} · Unmatched files: {{extra}}",
"existing": "Metadata and duration matches were found in the library for {{count}} missing tracks. This does not make the uploaded batch complete.",
"uploaded": "Your upload",
"catalogue": "Selected edition",
"matched": "File matched",
"missing": "Not uploaded",
"tentative": "Tentative match — check manually",
"apply_metadata": "Apply titles, artists, album, year and numbering to matched files",
"preview_cover": "Preview catalogue cover",
"exact_cover": "Artwork for this edition · Cover Art Archive",
"group_cover": "Release-group artwork — an exact match to this edition is not confirmed",
"replace_cover": "Replace the batch's current artwork with this cover",
"apply_cover": "Attach this cover to the batch",
"apply": "Apply selection",
"applied": "Selected edition applied"
},
"users": {
"change_username": "Change username",
"title": "Users",
@@ -210,6 +269,9 @@
"analysis_error_unknown": "No error reason was recorded."
},
"write_template": {
"auto_hint": "If no template is saved, we suggest the most common layout in the storage. You can edit it; it is applied only when you save.",
"detecting": "Detecting the storage file layout…",
"detected": "The suggested layout matches {{matched}} of {{total}} sampled files.",
"title": "Uploads and path template",
"intro": "Approved tracks from the upload queue are placed here using a template that repeats this storage's folder layout.",
"accepts": "Accept uploads into this storage",
@@ -270,6 +332,7 @@
"failed_some_many": "{{count}} files did not upload — their space is reserved, you can continue.",
"failed_some_other": "{{count}} files did not upload — their space is reserved, you can continue.",
"retry_failed": "Continue upload",
"retry_file": "Retry upload of {{name}}",
"uploading_now": "Uploading…",
"go_mine": "My uploads →",
"more": "Upload more",
@@ -287,12 +350,25 @@
"filter_pending": "In progress",
"filter_imported": "Added",
"filter_declined": "Not accepted",
"filter_withdrawn": "Withdrawn",
"expand_batch": "Expand batch",
"collapse_batch": "Collapse batch",
"batch_cover_present": "Cover attached",
"batch_cover_absent": "No cover",
"format_unknown": "unknown format",
"preview_http_error": "Could not load audio: HTTP {{status}}.",
"preview_decode_error": "The browser could not read the audio: check file access and format support.",
"preview_error": "Could not load audio. Check your connection and retry.",
"col_file": "Track",
"col_status": "Status",
"col_size": "Size",
"col_date": "Uploaded",
"received": "{{pct}}% received — open “Suggest tracks” in the same tab to continue",
"withdraw": "Withdraw",
"select_visible": "Select eligible files on this page",
"bulk_withdraw": "Withdraw multiple submissions",
"withdraw_selected": "Withdraw selected",
"withdraw_failed": "Could not withdraw {{count}} files: {{reason}}",
"load_more": "Show more",
"queue_title": "Upload queue",
"queue_subtitle": "Check the metadata and the storage path, then approve or reject",
@@ -350,6 +426,16 @@
"meta_track": "Track number",
"meta_disc": "Disc",
"target_source": "Storage",
"cover_hint": "Album covers named cover.jpg or cover.png are uploaded with the tracks. Otherwise we use embedded artwork from the first track that contains it.",
"cover_found": "Album cover: {{name}}",
"cover_embedded": "Album cover: embedded artwork",
"destination": "Will be saved to",
"destination_imported": "Saved to",
"target_selected": "Selected storage",
"target_not_selected": "No storage selected",
"target_path_missing": "Destination path has not been calculated",
"choose_destination": "Choose storage",
"fix_destination": "Fix destination",
"target_default": "Default",
"tags_note": "Edited fields are written into the file tags on import, so path and tags stay consistent.",
"save": "Save",
+14 -1
View File
@@ -2,6 +2,7 @@
"common": {
"internal": "Something went wrong on the server. Please try again",
"bad_request": "Invalid request",
"request_too_large": "The request exceeds the server size limit. Reduce the amount of data being sent.",
"not_found": "Not found",
"method_not_allowed": "This action is not supported",
"network": "Cannot reach the server. Check your network"
@@ -26,6 +27,8 @@
"forbidden": "You don't have permission to do this"
},
"validation": {
"cover_type": "Could not read the cover. Choose a JPEG, PNG or WebP image.",
"cover_too_large": "Could not reduce the cover below 8 MB. Choose a smaller image.",
"user_archive_invalid": "Invalid user file: check the version, usernames, roles and password hashes. No changes were saved.",
"password_too_short": "The password must be at least {{min}} characters",
"password_too_long": "The password must be at most {{max}} characters",
@@ -60,7 +63,7 @@
"source_not_found": "Storage not found",
"scan_running": "A scan is already running",
"source_offline": "The storage is not connected",
"template_unavailable": "Could not suggest a template: no storage pattern converts to a template, or the storage has no files"
"template_unavailable": "Could not infer a layout from paths and metadata. Scan the storage first or enter a template manually."
},
"catalog": {
"not_found": "Not found"
@@ -80,6 +83,16 @@
"update_unavailable": "Update is unavailable"
},
"uploads": {
"release_group_required": "Choose a release within the batch",
"release_group_not_found": "Release group not found in this batch. Refresh the list.",
"audio_disabled": "Audio identification is not configured: an AcoustID application key is required",
"audio_batch_limit": "Audio identification supports batches with up to 50 editable tracks",
"metadata_disabled": "Metadata lookup is disabled in the server configuration.",
"metadata_unavailable": "The external catalogue is temporarily unavailable. Try later; uploads and current tags are preserved.",
"release_stale": "The batch contents or tags changed. Repeat the release lookup before applying.",
"release_not_found": "The selected edition is no longer a candidate for this batch. Repeat the lookup.",
"release_cover_missing": "No artwork was found for this release in Cover Art Archive.",
"storage_full": "The server has no space left for uploads. Ask an administrator to free up space, then retry the upload.",
"queue_full": "The upload queue is full",
"user_quota_full": "Your upload queue limit is exceeded",
"too_large": "The file is larger than allowed",
+86
View File
@@ -1,4 +1,63 @@
{
"release_lookup": {
"groups_count": "Релизов в пачке: {{count}}",
"groups_hint": "Каждый релиз проверяется отдельно. Метаданные и обложка применяются только к трекам выбранного релиза.",
"group": "Релиз в пачке",
"choose_group": "Выберите релиз",
"apply_group_cover": "Добавить обложку выбранного релиза",
"replace_group_cover": "Заменить обложку выбранного релиза",
"select_group": "Выбрать загруженные строки релиза {{album}}",
"audio_search": "Распознать по звуку",
"audio_privacy": "В AcoustID отправляются только аудиоотпечатки и длительности, без аудиофайлов. До 50 треков за запрос.",
"audio_setup": "Для распознавания по звуку задайте ACOUSTID_API_KEY в конфигурации сервера и примените изменения.",
"audio_summary": "По звуку распознано: {{matched}} из {{total}} треков",
"audio_editions": "Отпечаток определяет запись, но она может входить в разные издания. Проверьте выбранное издание и состав.",
"audio_match": "Совпадение по звуку · AcoustID",
"audio_ambiguous": "Неоднозначное или недостаточно уверенное совпадение",
"audio_not_found": "Совпадений по звуку не найдено",
"audio_unavailable": "Не удалось получить отпечаток: проверьте fpcalc и доступность файла",
"title": "Сопоставление релиза",
"open": "Проверить релиз",
"intro": "MusicBrainz предлагает конкретные издания. Сверьте состав и различия ниже. Теги и обложка изменятся только после вашего выбора и применения.",
"album": "Альбом",
"single": "Сингл",
"release": "Релиз",
"complete": "Полный состав",
"incomplete": "Неполный состав",
"variants": "Найдено изданий: {{count}}",
"choose_edition": "Выберите конкретное издание",
"partial": "Требует проверки",
"state_disabled": "Поиск отключён",
"state_queued": "Релиз: в очереди",
"state_searching": "Поиск релиза…",
"state_ready": "Релиз найден",
"state_not_found": "Релиз не найден",
"state_needs_input": "Уточнить релиз",
"state_error": "Поиск недоступен",
"mbid": "MusicBrainz Release ID (необязательно)",
"search": "Найти / повторить поиск",
"edition": "Издание",
"tracks_total": "дорожек: {{total}}",
"confidence": "Уверенность сопоставления: {{value}}",
"confidence_high": "высокая",
"confidence_medium": "средняя",
"confidence_low": "низкая",
"coverage": "Совпало {{matched}} из {{total}} дорожек · Не хватает: {{missing}} · Не сопоставлено файлов: {{extra}}",
"existing": "Для {{count}} отсутствующих дорожек найдены совпадения в библиотеке по тегам и длительности. Это не делает загруженную пачку полной.",
"uploaded": "Ваша загрузка",
"catalogue": "В выбранном издании",
"matched": "Файл сопоставлен",
"missing": "Не загружено",
"tentative": "Предположительное совпадение — проверьте вручную",
"apply_metadata": "Применить названия, исполнителей, альбом, год и нумерацию к совпавшим файлам",
"preview_cover": "Показать обложку из каталога",
"exact_cover": "Обложка этого издания · Cover Art Archive",
"group_cover": "Обложка группы изданий — точное соответствие выбранному изданию не подтверждено",
"replace_cover": "Заменить текущую обложку пачки этой обложкой",
"apply_cover": "Добавить эту обложку в пачку",
"apply": "Применить выбранное",
"applied": "Выбранное издание применено"
},
"users": {
"change_username": "Изменить логин",
"title": "Пользователи",
@@ -210,6 +269,9 @@
"analysis_error_unknown": "Причина ошибки не указана."
},
"write_template": {
"auto_hint": "Если шаблон ещё не задан, определим его по преобладающей структуре файлов хранилища. Предложение можно изменить вручную; оно применяется только после сохранения.",
"detecting": "Определяем структуру файлов хранилища…",
"detected": "Предложенная структура совпадает с {{matched}} из {{total}} файлов выборки.",
"title": "Приём загрузок и шаблон пути",
"intro": "Сюда раскладываются одобренные треки из очереди на добавление — по шаблону, который повторяет структуру папок этого хранилища.",
"accepts": "Принимать загрузки в это хранилище",
@@ -270,6 +332,7 @@
"failed_some_many": "Не загрузились {{count}} файлов — место за ними зарезервировано, можно продолжить.",
"failed_some_other": "Не загрузились {{count}} файла — место за ними зарезервировано, можно продолжить.",
"retry_failed": "Продолжить загрузку",
"retry_file": "Повторить загрузку {{name}}",
"uploading_now": "Загружаю…",
"go_mine": "Мои предложения →",
"more": "Загрузить ещё",
@@ -287,12 +350,25 @@
"filter_pending": "В работе",
"filter_imported": "Добавлены",
"filter_declined": "Не приняты",
"filter_withdrawn": "Отозванные",
"expand_batch": "Развернуть пачку",
"collapse_batch": "Свернуть пачку",
"batch_cover_present": "Есть обложка",
"batch_cover_absent": "Без обложки",
"format_unknown": "формат неизвестен",
"preview_http_error": "Не удалось загрузить аудио: HTTP {{status}}.",
"preview_decode_error": "Браузер не смог прочитать аудио: проверьте доступ к файлу и поддержку формата.",
"preview_error": "Не удалось загрузить аудио. Проверьте соединение и повторите попытку.",
"col_file": "Трек",
"col_status": "Статус",
"col_size": "Размер",
"col_date": "Загружен",
"received": "принято {{pct}}% — откройте «Предложить треки» в той же вкладке, чтобы продолжить",
"withdraw": "Отозвать",
"select_visible": "Выбрать доступные файлы на странице",
"bulk_withdraw": "Массовый отзыв предложений",
"withdraw_selected": "Отозвать выбранные",
"withdraw_failed": "Не удалось отозвать: {{count}}. {{reason}}",
"load_more": "Показать ещё",
"queue_title": "Очередь на добавление",
"queue_subtitle": "Проверьте метаданные и путь в хранилище, затем одобрите или отклоните",
@@ -350,6 +426,16 @@
"meta_track": "Номер трека",
"meta_disc": "Диск",
"target_source": "Хранилище",
"cover_hint": "Обложку cover.jpg или cover.png из папки альбома загрузим вместе с треками. Если её нет, возьмём встроенную обложку из первого трека, где она есть.",
"cover_found": "Обложка альбома: {{name}}",
"cover_embedded": "Обложка альбома: из тегов трека",
"destination": "Будет записано в",
"destination_imported": "Записано в",
"target_selected": "Выбранное хранилище",
"target_not_selected": "Хранилище не выбрано",
"target_path_missing": "Путь записи не рассчитан",
"choose_destination": "Выбрать хранилище",
"fix_destination": "Исправить размещение",
"target_default": "По умолчанию",
"tags_note": "Правленые поля запишутся в теги файла при импорте — путь и теги не разойдутся.",
"save": "Сохранить",
+14 -1
View File
@@ -2,6 +2,7 @@
"common": {
"internal": "Что-то пошло не так на сервере. Попробуйте ещё раз",
"bad_request": "Некорректный запрос",
"request_too_large": "Размер запроса превышает лимит сервера. Уменьшите объём отправляемых данных.",
"not_found": "Не найдено",
"method_not_allowed": "Действие не поддерживается",
"network": "Нет связи с сервером. Проверьте сеть"
@@ -26,6 +27,8 @@
"forbidden": "Недостаточно прав для этого действия"
},
"validation": {
"cover_type": "Не удалось прочитать обложку. Выберите изображение JPEG, PNG или WebP.",
"cover_too_large": "Не удалось уменьшить обложку до 8 МБ. Выберите изображение меньшего размера.",
"user_archive_invalid": "Некорректный файл пользователей: проверьте версию, логины, роли и хэши паролей. Изменения не сохранены.",
"password_too_short": "Пароль должен быть не короче {{min}} символов",
"password_too_long": "Пароль должен быть не длиннее {{max}} символов",
@@ -60,7 +63,7 @@
"source_not_found": "Хранилище не найдено",
"scan_running": "Скан уже идёт",
"source_offline": "Хранилище не подключено",
"template_unavailable": "Не удалось подобрать шаблон: ни один регэксп хранилища не переводится в шаблон или в хранилище нет файлов"
"template_unavailable": "Не удалось определить структуру по путям и метаданным. Сначала просканируйте хранилище или задайте шаблон вручную."
},
"catalog": {
"not_found": "Не найдено"
@@ -80,6 +83,16 @@
"update_unavailable": "Обновление недоступно"
},
"uploads": {
"release_group_required": "Выберите релиз внутри пачки",
"release_group_not_found": "Релиз внутри этой пачки не найден. Обновите список.",
"audio_disabled": "Распознавание по звуку не настроено: нужен ключ приложения AcoustID",
"audio_batch_limit": "Распознавание по звуку доступно для пачек до 50 редактируемых треков",
"metadata_disabled": "Поиск метаданных отключён в конфигурации сервера.",
"metadata_unavailable": "Внешний каталог временно недоступен. Попробуйте позже; загрузки и текущие теги сохранены.",
"release_stale": "Состав или теги пачки изменились. Повторите поиск релиза перед применением.",
"release_not_found": "Выбранного издания больше нет среди вариантов этой пачки. Повторите поиск.",
"release_cover_missing": "В Cover Art Archive не найдена обложка этого релиза.",
"storage_full": "На сервере закончилось место для загрузок. Попросите администратора освободить место, затем повторите загрузку.",
"queue_full": "В очереди на добавление нет места",
"user_quota_full": "Превышен ваш лимит в очереди на добавление",
"too_large": "Файл больше допустимого размера",
+18 -23
View File
@@ -122,7 +122,7 @@ importers:
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)(supports-color@10.2.2)(terser@5.51.2)
version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2)
apps/web:
dependencies:
@@ -159,9 +159,9 @@ importers:
'@tanstack/react-virtual':
specifier: ^3.14.21
version: 3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
hash-wasm:
specifier: ^4.12.0
version: 4.12.0
js-sha256:
specifier: ^1.0.0
version: 1.0.0
music-metadata:
specifier: ^12.2.3
version: 12.2.3(supports-color@10.2.2)
@@ -207,13 +207,13 @@ importers:
version: 4.14.0
jsdom:
specifier: ^26.1.0
version: 26.1.0
version: 26.1.0(supports-color@10.2.2)
vite:
specifier: ^6.3.5
version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2)
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)
packages/api-client:
dependencies:
@@ -233,7 +233,7 @@ importers:
devDependencies:
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2)
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)
packages/design-tokens: {}
@@ -2479,9 +2479,6 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hash-wasm@4.12.0:
resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
@@ -6710,8 +6707,6 @@ snapshots:
has-flag@4.0.0: {}
hash-wasm@4.12.0: {}
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
@@ -6748,7 +6743,7 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
http-proxy-agent@7.0.2:
http-proxy-agent@7.0.2(supports-color@10.2.2):
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@10.2.2)
@@ -6943,13 +6938,13 @@ snapshots:
jsc-safe-url@0.2.4: {}
jsdom@26.1.0:
jsdom@26.1.0(supports-color@10.2.2):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
http-proxy-agent: 7.0.2(supports-color@10.2.2)
https-proxy-agent: 7.0.6(supports-color@10.2.2)
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.28
@@ -8247,7 +8242,7 @@ snapshots:
vary@1.1.2: {}
vite-node@3.2.4(@types/node@22.20.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)
@@ -8268,7 +8263,7 @@ snapshots:
- tsx
- yaml
vite-node@3.2.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):
dependencies:
cac: 6.7.14
debug: 4.4.3(supports-color@10.2.2)
@@ -8317,7 +8312,7 @@ snapshots:
lightningcss: 1.27.0
terser: 5.51.2
vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(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
@@ -8340,11 +8335,11 @@ snapshots:
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)(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)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.20.5
jsdom: 26.1.0
jsdom: 26.1.0(supports-color@10.2.2)
transitivePeerDependencies:
- jiti
- less
@@ -8359,7 +8354,7 @@ snapshots:
- tsx
- yaml
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2):
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.7
@@ -8382,11 +8377,11 @@ snapshots:
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
vite-node: 3.2.4(@types/node@26.6.5)(lightningcss@1.27.0)(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)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.6.5
jsdom: 26.1.0
jsdom: 26.1.0(supports-color@10.2.2)
transitivePeerDependencies:
- jiti
- less
+1
View File
@@ -133,6 +133,7 @@ func TestUserJourney(t *testing.T) {
t.Setenv("DATABASE_URL", dbURL)
t.Setenv("REDIS_URL", os.Getenv("TEST_REDIS_URL"))
t.Setenv("BOOTSTRAP_ADMIN_PASSWORD", "admin")
t.Setenv("MUSICBRAINZ_ENABLED", "false") // public provider availability is covered by explicit live tests
cfg, err := config.Load[config.API]()
require.NoError(t, err)
cfg.LibraryHostRoot = host
+12 -2
View File
@@ -11,6 +11,7 @@ import (
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
@@ -18,12 +19,14 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"pipu/api/internal/acoustid"
"pipu/api/internal/appupdate"
"pipu/api/internal/auth"
"pipu/api/internal/buildinfo"
"pipu/api/internal/genius"
"pipu/api/internal/library"
"pipu/api/internal/management"
"pipu/api/internal/musicbrainz"
"pipu/api/internal/platform/config"
"pipu/api/internal/platform/db"
"pipu/api/internal/platform/httpx"
@@ -165,9 +168,16 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
go libSvc.Restore(ctx)
go libSvc.RunSchedule(ctx)
upSvc := uploads.NewService(uploads.NewStore(pool), libStore, libSvc, scanner, agent, prober,
upStore := uploads.NewStore(pool)
upSvc := uploads.NewService(upStore, libStore, libSvc, scanner, agent, prober,
library.FPCalc{Bin: cfg.FPCalcPath, Timeout: time.Minute},
uploads.Config{StagingDir: cfg.UploadsDir, FFmpeg: cfg.FFmpegPath}, log)
if cfg.MusicBrainzEnabled {
upSvc.SetReleaseCatalogue(musicbrainz.New(upStore, "PipuMusic/"+buildinfo.Version+" (https://gitea.hsrv.site/mrleo1nid/pipu-music)"))
if strings.TrimSpace(cfg.AcoustIDAPIKey) != "" {
upSvc.SetAudioIdentifier(acoustid.New(strings.TrimSpace(cfg.AcoustIDAPIKey), upStore))
}
}
upSvc.Run(ctx)
wh := wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
@@ -187,7 +197,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
Wave: wh,
App: appupdate.NewHandler(appupdate.Config{GiteaURL: cfg.GiteaURL, Repo: cfg.GiteaRepo, Token: cfg.GiteaToken,
Dir: cfg.UpdatesDir, MinSupported: cfg.MinAndroidVersionCode}, log),
Uploads: uploads.NewHandler(upSvc, signer, cfg.XAccel),
Uploads: uploads.NewHandler(upSvc, signer),
TUS: uploads.NewTUS(uploads.NewStore(pool), cfg.UploadsDir, upSvc.Complete, log),
}), nil
}
+126
View File
@@ -0,0 +1,126 @@
// Package acoustid identifies recordings using Chromaprint, never audio files.
package acoustid
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
type Match struct {
RecordingID string
Score float64
}
type Cache interface {
GetMetadataCache(context.Context, string) ([]byte, bool, error)
PutMetadataCache(context.Context, string, []byte, time.Duration) error
}
type Client struct {
key, endpoint string
cache Cache
http *http.Client
mu sync.Mutex
next time.Time
interval time.Duration
}
func New(key string, cache Cache) *Client {
return &Client{key: key, endpoint: "https://api.acoustid.org/v2/lookup", cache: cache, interval: 350 * time.Millisecond,
http: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
}
func (c *Client) wait(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
t := time.NewTimer(max(time.Duration(0), time.Until(c.next)))
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
c.next = time.Now().Add(c.interval)
return nil
}
func (c *Client) Lookup(ctx context.Context, fingerprint string, duration int) ([]Match, error) {
if c.key == "" || duration <= 0 || len(fingerprint) == 0 || len(fingerprint) > 65536 {
return nil, errors.New("acoustid: invalid lookup input")
}
hash := sha256.Sum256([]byte(fmt.Sprint(duration) + ":" + fingerprint))
key := "acoustid:" + hex.EncodeToString(hash[:])
if data, ok, err := c.cache.GetMetadataCache(ctx, key); err == nil && ok {
return parse(data)
}
if err := c.wait(ctx); err != nil {
return nil, err
}
body := url.Values{"client": {c.key}, "duration": {fmt.Sprint(duration)}, "fingerprint": {fingerprint}, "meta": {"recordingids"}, "format": {"json"}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, strings.NewReader(body.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "PipuMusic (https://gitea.hsrv.site/mrleo1nid/pipu-music)")
res, err := c.http.Do(req)
if err != nil {
return nil, errors.New("acoustid: request failed")
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("acoustid: HTTP %d", res.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(res.Body, (2<<20)+1))
if err != nil || len(data) > 2<<20 {
return nil, errors.New("acoustid: invalid response size")
}
matches, err := parse(data)
if err != nil {
return nil, err
}
_ = c.cache.PutMetadataCache(ctx, key, data, 7*24*time.Hour)
return matches, nil
}
func parse(data []byte) ([]Match, error) {
var response struct {
Status string `json:"status"`
Results []struct {
Score float64 `json:"score"`
Recordings []struct {
ID string `json:"id"`
} `json:"recordings"`
} `json:"results"`
}
if err := json.Unmarshal(data, &response); err != nil {
return nil, errors.New("acoustid: invalid response")
}
if response.Status != "ok" {
return nil, errors.New("acoustid: lookup rejected; check application key")
}
best := map[string]float64{}
for _, result := range response.Results {
if result.Score <= 0 || result.Score > 1 {
continue
}
for _, recording := range result.Recordings {
id, err := uuid.Parse(recording.ID)
if err == nil && id != uuid.Nil && result.Score > best[id.String()] {
best[id.String()] = result.Score
}
}
}
out := []Match{}
for id, score := range best {
out = append(out, Match{RecordingID: id, Score: score})
}
return out, nil
}
@@ -0,0 +1,83 @@
package acoustid
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
type memoryCache map[string][]byte
func (m memoryCache) GetMetadataCache(_ context.Context, k string) ([]byte, bool, error) {
b, ok := m[k]
return b, ok, nil
}
func (m memoryCache) PutMetadataCache(_ context.Context, k string, b []byte, _ time.Duration) error {
m[k] = b
return nil
}
func TestLookupPostsFingerprintCachesAndDeduplicatesRecordings(t *testing.T) {
id := uuid.NewString()
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
require.Equal(t, "POST", r.Method)
require.Empty(t, r.URL.RawQuery, "key and fingerprint must not appear in URL logs")
require.NoError(t, r.ParseForm())
require.Equal(t, "private-key", r.Form.Get("client"))
require.Equal(t, "encoded-fingerprint", r.Form.Get("fingerprint"))
require.Equal(t, "321", r.Form.Get("duration"))
require.Equal(t, "recordingids", r.Form.Get("meta"))
_, _ = w.Write([]byte(`{"status":"ok","results":[{"score":0.98,"recordings":[{"id":"` + id + `"}]},{"score":0.95,"recordings":[{"id":"` + id + `"},{"id":"invalid"}]}]}`))
}))
defer server.Close()
cache := memoryCache{}
c := New("private-key", cache)
c.endpoint = server.URL
c.interval = 0
for range 2 {
matches, err := c.Lookup(context.Background(), "encoded-fingerprint", 321)
require.NoError(t, err)
require.Equal(t, []Match{{id, .98}}, matches)
}
require.Equal(t, 1, calls)
for k := range cache {
require.NotContains(t, k, "private-key")
require.NotContains(t, k, "encoded-fingerprint")
}
}
func TestLookupRejectsProviderErrorsAndDoesNotCacheThem(t *testing.T) {
for _, body := range []string{`{"status":"error","error":{"message":"secret-key"}}`, `invalid`, strings.Repeat("x", (2<<20)+1)} {
t.Run(body[:min(20, len(body))], func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) }))
defer server.Close()
cache := memoryCache{}
c := New("secret-key", cache)
c.endpoint = server.URL
_, err := c.Lookup(context.Background(), "fp", 200)
require.Error(t, err)
require.NotContains(t, err.Error(), "secret-key")
require.Empty(t, cache)
})
}
}
func TestLookupDoesNotForwardKeyOnRedirectAndWaitIsCancelable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "https://example.com", 307) }))
defer server.Close()
c := New("key", memoryCache{})
c.endpoint = server.URL
_, err := c.Lookup(context.Background(), "fp", 200)
require.ErrorContains(t, err, "HTTP 307")
c.next = time.Now().Add(time.Hour)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = c.Lookup(ctx, "fp", 200)
require.ErrorIs(t, err, context.Canceled)
}
File diff suppressed because it is too large. Load diff
+18
View File
@@ -8,6 +8,8 @@ import (
"net/http"
"strings"
"github.com/google/uuid"
"pipu/api/internal/platform/apperr"
"pipu/api/internal/platform/httpx"
)
@@ -95,6 +97,7 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/") ||
isUploadStreamPath(r.URL.Path) ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/download/"))
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
next.ServeHTTP(w, r.WithContext(ctx))
@@ -130,6 +133,21 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
}
}
// Only the signed audio endpoint bypasses Bearer authentication. Its handler
// verifies the signature and expiry before accessing staging.
func isUploadStreamPath(urlPath string) bool {
id, ok := strings.CutPrefix(urlPath, "/api/v1/admin/uploads/files/")
if !ok {
return false
}
id, ok = strings.CutSuffix(id, "/stream")
if !ok {
return false
}
_, err := uuid.Parse(id)
return err == nil
}
func bearer(h string) (string, bool) {
const prefix = "bearer "
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
@@ -0,0 +1,38 @@
package auth_test
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
)
func TestSignedUploadStreamReachesSignatureHandlerWithoutBearer(t *testing.T) {
const file = "/api/v1/admin/uploads/files/01a18459-32bc-7589-a83d-635292a7a4e2"
for _, tc := range []struct {
method, path string
want int
}{
{http.MethodGet, file + "/stream?exp=1&sig=invalid", http.StatusForbidden},
{http.MethodHead, file + "/stream?exp=1&sig=invalid", http.StatusForbidden},
{http.MethodGet, file, http.StatusUnauthorized},
{http.MethodPatch, file + "/stream", http.StatusUnauthorized},
{http.MethodGet, file + "/stream/other", http.StatusUnauthorized},
{http.MethodGet, "/api/v1/admin/uploads", http.StatusUnauthorized},
} {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
h := auth.Middleware(nil, slog.New(slog.NewTextHandler(io.Discard, nil)))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// A signed media route still delegates authorization to its signature handler.
w.WriteHeader(http.StatusForbidden)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
require.Equal(t, tc.want, rec.Code)
})
}
}
@@ -158,11 +158,12 @@ func TestLibraryAndUploads(t *testing.T) {
assert.EqualValues(t, 1000, settings.QueueLimitBytes)
fresh := sha256.Sum256([]byte("new file"))
coverPath := "covers/album.jpg"
batch, res, err := up.CreateBatch(ctx, author, nil, []uploads.NewFile{
{ClientID: "a", Name: "a.flac", Size: 400, SHA256: fresh[:]},
{ClientID: "dup", Name: "b.flac", Size: 100, SHA256: sum[:]}, // уже в библиотеке
{ClientID: "big", Name: "c.flac", Size: 900, SHA256: []byte("cccccccccccccccccccccccccccccccc")},
}, settings)
}, settings, &coverPath)
require.NoError(t, err)
assert.NotEqual(t, uuid.Nil, batch)
byClient := map[string]uploads.Reserved{}
@@ -183,6 +184,11 @@ func TestLibraryAndUploads(t *testing.T) {
next, err := up.NextWithStatus(ctx, uploads.StatusProcessing)
require.NoError(t, err)
assert.Equal(t, fileID, next.ID)
require.NotNil(t, next.CoverPath)
assert.Equal(t, coverPath, *next.CoverPath)
coverInUse, err := up.StagingInUse(ctx)
require.NoError(t, err)
assert.True(t, coverInUse[coverPath], "active batch covers must survive staging reconciliation")
require.ErrorIs(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusUploading}, uploads.StatusExpired, nil),
uploads.ErrWrongStatus)
@@ -190,7 +196,7 @@ func TestLibraryAndUploads(t *testing.T) {
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusProcessing}, uploads.StatusPendingReview,
func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE upload_files SET metadata = '{"artist":"Кино","album":"Группа крови","disc":2}',
fingerprint = '{1,2,3}', duration_ms = 200000 WHERE id = $1`, fileID)
fingerprint = '{1,2,3}', duration_ms = 200000, target_source_id = $2 WHERE id = $1`, fileID, src.ID)
return err
}))
f, err := up.File(ctx, fileID)
@@ -198,6 +204,8 @@ func TestLibraryAndUploads(t *testing.T) {
assert.Equal(t, uploads.StatusPendingReview, f.Status)
require.NotNil(t, f.Meta)
assert.Equal(t, "Кино", f.Meta.Artist)
require.NotNil(t, f.TargetSourceName)
assert.Equal(t, src.Name, *f.TargetSourceName)
files, nextCursor, err := up.List(ctx, uploads.ListFilter{AuthorID: &author, Statuses: []uploads.Status{uploads.StatusPendingReview}, Limit: 1})
require.NoError(t, err)
@@ -256,6 +264,10 @@ func TestLibraryAndUploads(t *testing.T) {
require.NoError(t, up.ChangeStatus(ctx, fileID, []uploads.Status{uploads.StatusApproved}, uploads.StatusImporting))
require.NoError(t, up.CompleteImport(ctx, fileID, src.ID, "Кино - Группа крови.flac", fileIDs[1]))
require.ErrorIs(t, up.CompleteImport(ctx, fileID, src.ID, "ignored.flac", fileIDs[1]), uploads.ErrWrongStatus)
unusedCover, err := up.UnusedBatchCover(ctx, batch)
require.NoError(t, err)
require.NotNil(t, unusedCover)
assert.Equal(t, coverPath, *unusedCover)
used, _, err = up.Usage(ctx, author)
require.NoError(t, err)
@@ -264,7 +276,7 @@ func TestLibraryAndUploads(t *testing.T) {
secondHash := sha256.Sum256([]byte("concurrent decision"))
_, reserved, err := up.CreateBatch(ctx, author, nil, []uploads.NewFile{
{ClientID: "race", Name: "race.flac", Size: 300, SHA256: secondHash[:]},
}, settings)
}, settings, nil)
require.NoError(t, err)
require.Len(t, reserved, 1)
raceID := reserved[0].FileID
@@ -79,6 +79,37 @@ type FPCalc struct {
Timeout time.Duration
}
// EncodedFingerprint returns compressed Chromaprint for AcoustID. Use ffprobe for full duration.
func (f FPCalc) EncodedFingerprint(ctx context.Context, absPath string) (string, error) {
bin := f.Bin
if bin == "" {
bin = "fpcalc"
}
timeout := f.Timeout
if timeout <= 0 {
timeout = time.Minute
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
out, err := exec.CommandContext(ctx, bin, "-json", "-length", fmt.Sprint(FingerprintSeconds), "--", absPath).Output()
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
return "", ErrNoFPCalc
}
return "", fmt.Errorf("fpcalc: encoded fingerprint: %w", err)
}
var result struct {
Fingerprint string `json:"fingerprint"`
}
if err = json.Unmarshal(out, &result); err != nil {
return "", err
}
if len(result.Fingerprint) == 0 || len(result.Fingerprint) > 65536 || strings.ContainsAny(result.Fingerprint, " \t\r\n") {
return "", errors.New("fpcalc: invalid encoded fingerprint")
}
return result.Fingerprint, nil
}
// ErrNoFPCalc — fpcalc не установлен: отпечатки Chromaprint не считаются.
var ErrNoFPCalc = errors.New("library: fpcalc not found")
+1 -1
View File
@@ -184,7 +184,7 @@ func (h *Handler) SuggestWriteTemplate(ctx context.Context, req gen.SuggestWrite
if err != nil {
return nil, err
}
return gen.SuggestWriteTemplate200JSONResponse{Template: tpl}, nil
return gen.SuggestWriteTemplate200JSONResponse{Template: tpl.Template, Matched: &tpl.Matched, Total: &tpl.Total, Examples: &tpl.Examples}, nil
}
// CheckWriteTemplate реализует gen.StrictServerInterface.
@@ -0,0 +1,129 @@
package meta
import (
"path"
"sort"
"strconv"
"strings"
"golang.org/x/text/unicode/norm"
)
// TemplateSuggestion compares candidate layouts against the same sample of stored paths.
type TemplateSuggestion struct {
Template string
Matched int
Total int
Examples []string
}
// SuggestFromSamples also works without configured path regexes, using catalog metadata.
func SuggestFromSamples(exprs []string, samples []TemplateSample) (TemplateSuggestion, error) {
candidates := map[string]bool{}
paths := make([]string, 0, len(samples))
for _, s := range samples {
paths = append(paths, s.RelPath)
if tpl := inferTemplate(s); tpl != "" {
candidates[tpl] = true
}
}
for _, expr := range exprs {
if tpl, err := SuggestTemplate([]string{expr}, paths); err == nil {
candidates[tpl] = true
}
}
keys := make([]string, 0, len(candidates))
for tpl := range candidates {
keys = append(keys, tpl)
}
sort.Strings(keys)
best := TemplateSuggestion{Total: len(samples), Examples: []string{}}
for _, tpl := range keys {
t, err := ParseTemplate(tpl)
if err != nil {
continue
}
count := 0
var examples []string
for _, s := range samples {
if rendered, err := t.Render(s.Input); err == nil && rendered == norm.NFC.String(s.RelPath) {
count++
if len(examples) < 3 {
examples = append(examples, s.RelPath)
}
}
}
if count > best.Matched {
best.Template, best.Matched, best.Examples = tpl, count, examples
}
}
if best.Matched == 0 {
return best, ErrNoTemplate
}
return best, nil
}
func inferTemplate(s TemplateSample) string {
type token struct{ value, placeholder string }
segments := strings.Split(norm.NFC.String(s.RelPath), "/")
for i, segment := range segments {
last := i == len(segments)-1
fields := []string{"albumartist", "artist", "album", "year", "genre", "disc"}
if last {
ext := path.Ext(segment)
if ext == "" {
return ""
}
segment = strings.TrimSuffix(segment, ext)
fields = []string{"title", "albumartist", "artist", "album", "year", "track", "disc"}
}
var tokens []token
for _, field := range fields {
v := cleanValue(fieldValue(s.Input, tnode{field: field}))
if v == "" {
continue
}
tokens = append(tokens, token{v, "{" + field + "}"})
if field == "track" || field == "disc" {
n, _ := strconv.Atoi(v)
for width := 2; width <= 3; width++ {
padded := strings.Repeat("0", max(0, width-len(v))) + strconv.Itoa(n)
if padded != v {
tokens = append(tokens, token{padded, "{" + field + ":0" + strconv.Itoa(width) + "}"})
}
}
}
}
// Longest value wins; stable ties prefer album artist in folders and title in filenames.
sort.SliceStable(tokens, func(a, b int) bool { return len(tokens[a].value) > len(tokens[b].value) })
var out strings.Builder
for at := 0; at < len(segment); {
matched := false
for _, tok := range tokens {
if !strings.HasPrefix(segment[at:], tok.value) {
continue
}
end := at + len(tok.value)
if tok.value[0] >= '0' && tok.value[0] <= '9' && ((at > 0 && segment[at-1] >= '0' && segment[at-1] <= '9') || (end < len(segment) && segment[end] >= '0' && segment[end] <= '9')) {
continue
}
out.WriteString(tok.placeholder)
at = end
matched = true
break
}
if !matched {
if strings.ContainsRune("{}[]", rune(segment[at])) {
return ""
}
out.WriteByte(segment[at])
at++
}
}
segments[i] = out.String()
if last {
segments[i] += ".{ext}"
}
}
return strings.Join(segments, "/")
}
@@ -0,0 +1,22 @@
package meta
import "testing"
func TestSuggestFromSamplesUsesDominantLayoutWithoutRegexes(t *testing.T) {
samples := []TemplateSample{
{RelPath: "Кино/Группа крови (1988)/01. Группа крови.flac", Input: in(Fields{Artist: "Кино", Album: "Группа крови", Title: "Группа крови", Year: "1988", Track: "1"}, 1, "flac")},
{RelPath: "Кино/Группа крови (1988)/02. Закрой за мной дверь.flac", Input: in(Fields{Artist: "Кино", Album: "Группа крови", Title: "Закрой за мной дверь", Year: "1988", Track: "2"}, 1, "flac")},
{RelPath: "Queen/Jazz (1978)/03. Jealousy.mp3", Input: in(Fields{Artist: "Queen", Album: "Jazz", Title: "Jealousy", Year: "1978", Track: "3"}, 1, "mp3")},
{RelPath: "Other - Song.flac", Input: in(Fields{Artist: "Other", Album: "Single", Title: "Song"}, 1, "flac")},
}
got, err := SuggestFromSamples(nil, samples)
if err != nil || got.Template != "{albumartist}/{album} ({year})/{track:02}. {title}.{ext}" || got.Matched != 3 || got.Total != 4 || len(got.Examples) != 3 {
t.Fatalf("suggestion = %+v, err = %v", got, err)
}
}
func TestSuggestFromSamplesDoesNotGuessWithoutMetadata(t *testing.T) {
if _, err := SuggestFromSamples(nil, []TemplateSample{{RelPath: "unknown/file.flac"}}); err == nil {
t.Fatal("cannot infer a safe layout without matching metadata")
}
}
+2
View File
@@ -34,6 +34,7 @@ type Probe struct {
Channels int
DurationMs int
Tags meta.Fields
RawTags map[string]string
}
// Lossless сообщает, без потерь ли кодек.
@@ -160,6 +161,7 @@ func parseFFprobe(raw []byte, name string) (Probe, error) {
Track: first("track", "tracknumber"),
Disc: first("disc", "discnumber"),
}
p.RawTags = tags
return p, nil
}
@@ -0,0 +1,15 @@
package library
import (
"github.com/stretchr/testify/require"
"testing"
)
func TestProbeKeepsMusicBrainzIdentifiersForUploadLookup(t *testing.T) {
raw := []byte(`{"streams":[{"codec_type":"audio","codec_name":"flac","sample_rate":"44100","duration":"180","tags":{"MusicBrainz Track Id":"recording-id"}}],"format":{"tags":{"ARTIST":"Artist","ALBUM":"Album","TITLE":"Song","MusicBrainz Album Id":"release-id"}}}`)
probe, err := parseFFprobe(raw, "song.flac")
require.NoError(t, err)
require.Equal(t, "release-id", probe.RawTags["musicbrainz album id"])
require.Equal(t, "recording-id", probe.RawTags["musicbrainz track id"])
require.Equal(t, "Song", probe.Tags.Title)
}
+8 -8
View File
@@ -183,16 +183,16 @@ func (s *TemplateService) previewOne(ctx context.Context, root, rel string, patt
const templateSampleSize = 50
// SuggestWriteTemplate подбирает шаблон записи по существующим путям и выражениям источника.
func (s *TemplateService) SuggestWriteTemplate(ctx context.Context, id uuid.UUID) (string, error) {
func (s *TemplateService) SuggestWriteTemplate(ctx context.Context, id uuid.UUID) (meta.TemplateSuggestion, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return "", err
return meta.TemplateSuggestion{}, err
}
if _, err := s.store.GetSource(ctx, id); err != nil {
return "", notFound(err)
return meta.TemplateSuggestion{}, notFound(err)
}
rows, err := s.store.Patterns(ctx, id)
if err != nil {
return "", err
return meta.TemplateSuggestion{}, err
}
var exprs []string
for _, r := range rows {
@@ -200,13 +200,13 @@ func (s *TemplateService) SuggestWriteTemplate(ctx context.Context, id uuid.UUID
exprs = append(exprs, r.Regex)
}
}
paths, err := s.store.SampleFiles(ctx, id, 200)
samples, err := s.store.TemplateSamples(ctx, id, 200)
if err != nil {
return "", err
return meta.TemplateSuggestion{}, err
}
tpl, err := meta.SuggestTemplate(exprs, paths)
tpl, err := meta.SuggestFromSamples(exprs, samples)
if err != nil {
return "", apperr.New(http.StatusUnprocessableEntity, CodeTemplateNone, "no path pattern can be turned into a template")
return meta.TemplateSuggestion{}, apperr.New(http.StatusUnprocessableEntity, CodeTemplateNone, "no layout could be inferred from catalog paths and metadata")
}
return tpl, nil
}
+353
View File
@@ -0,0 +1,353 @@
// Package musicbrainz adapts MusicBrainz and Cover Art Archive without receiving audio files.
package musicbrainz
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
type Track struct {
RecordingID string `json:"recording_id"`
Title string `json:"title"`
Artist string `json:"artist"`
Disc int `json:"disc"`
Position int `json:"position"`
DurationMs int `json:"duration_ms"`
}
type Release struct {
ID string `json:"id"`
GroupID string `json:"group_id"`
Title string `json:"title"`
Artist string `json:"artist"`
Type string `json:"type"`
Date string `json:"date"`
Country string `json:"country"`
Comment string `json:"comment"`
HasCover bool `json:"has_cover"`
Tracks []Track `json:"tracks"`
}
type Query struct {
GroupID string `json:"group_id,omitempty"`
Artist string `json:"artist"`
Album string `json:"album"`
ReleaseID string `json:"release_id"`
Audio bool `json:"audio,omitempty"`
RecordingIDs []string `json:"-"`
}
type Cover struct {
Data []byte
MIME string
URL string
Exact bool
}
type Cache interface {
GetMetadataCache(context.Context, string) ([]byte, bool, error)
PutMetadataCache(context.Context, string, []byte, time.Duration) error
}
type Client struct {
http *http.Client
cache Cache
mu sync.Mutex
next time.Time
base string
coverBase string
interval time.Duration
userAgent string
}
func New(cache Cache, userAgent string) *Client {
return &Client{cache: cache, base: "https://musicbrainz.org/ws/2", coverBase: "https://coverartarchive.org", interval: 1100 * time.Millisecond,
userAgent: userAgent, http: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
h := req.URL.Hostname()
if len(via) > 5 || req.URL.Scheme != "https" || req.URL.Port() != "" || req.URL.User != nil ||
!(h == "musicbrainz.org" || h == "coverartarchive.org" || h == "archive.org" || strings.HasSuffix(h, ".archive.org")) {
return errors.New("metadata: redirect outside provider")
}
return nil
}}}
}
func (c *Client) wait(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
if delay := time.Until(c.next); delay > 0 {
t := time.NewTimer(delay)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
}
c.next = time.Now().Add(c.interval)
return nil
}
var ErrNotFound = errors.New("metadata: not found")
func (c *Client) get(ctx context.Context, address string, limited bool, maxBytes int64) ([]byte, error) {
if data, ok, err := c.cache.GetMetadataCache(ctx, address); err == nil && ok {
return data, nil
}
if limited {
if err := c.wait(ctx); err != nil {
return nil, err
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/json, image/jpeg, image/png, image/webp")
res, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode == http.StatusNotFound {
return nil, ErrNotFound
}
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("metadata: HTTP %d", res.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(res.Body, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, errors.New("metadata: response too large")
}
if limited && !json.Valid(data) {
return nil, errors.New("metadata: invalid JSON response")
}
if !limited {
mime := http.DetectContentType(data)
if mime != "image/jpeg" && mime != "image/png" && mime != "image/webp" {
return nil, errors.New("metadata: unsupported cover")
}
}
_ = c.cache.PutMetadataCache(ctx, address, data, 7*24*time.Hour)
return data, nil
}
type credit struct {
Name string `json:"name"`
Join string `json:"joinphrase"`
Artist struct {
Name string `json:"name"`
} `json:"artist"`
}
func artist(credits []credit) string {
var s strings.Builder
for _, a := range credits {
name := a.Name
if name == "" {
name = a.Artist.Name
}
s.WriteString(name)
s.WriteString(a.Join)
}
return s.String()
}
type releaseJSON struct {
ID string `json:"id"`
Title string `json:"title"`
Date string `json:"date"`
Country string `json:"country"`
Comment string `json:"disambiguation"`
Credits []credit `json:"artist-credit"`
Group struct {
ID string `json:"id"`
Type string `json:"primary-type"`
} `json:"release-group"`
Cover struct {
Front bool `json:"front"`
} `json:"cover-art-archive"`
Media []struct {
Position int `json:"position"`
Tracks []struct {
Position int `json:"position"`
Title string `json:"title"`
Length int `json:"length"`
Credits []credit `json:"artist-credit"`
Recording struct {
ID string `json:"id"`
Title string `json:"title"`
Length int `json:"length"`
Credits []credit `json:"artist-credit"`
} `json:"recording"`
} `json:"tracks"`
} `json:"media"`
}
func (c *Client) release(ctx context.Context, id string) (Release, error) {
if _, err := uuid.Parse(id); err != nil {
return Release{}, errors.New("metadata: invalid release ID")
}
data, err := c.get(ctx, c.base+"/release/"+id+"?fmt=json&inc=recordings%2Bartist-credits%2Brelease-groups", true, 8<<20)
if err != nil {
return Release{}, err
}
var raw releaseJSON
if err = json.Unmarshal(data, &raw); err != nil {
return Release{}, err
}
r := Release{ID: raw.ID, GroupID: raw.Group.ID, Title: raw.Title, Artist: artist(raw.Credits), Type: raw.Group.Type, Date: raw.Date, Country: raw.Country, Comment: raw.Comment, HasCover: raw.Cover.Front, Tracks: []Track{}}
if _, err = uuid.Parse(r.ID); err != nil {
return Release{}, errors.New("metadata: invalid release response")
}
for _, m := range raw.Media {
for _, t := range m.Tracks {
title := t.Title
if title == "" {
title = t.Recording.Title
}
a := artist(t.Credits)
if a == "" {
a = artist(t.Recording.Credits)
}
if a == "" {
a = r.Artist
}
length := t.Length
if length == 0 {
length = t.Recording.Length
}
r.Tracks = append(r.Tracks, Track{RecordingID: t.Recording.ID, Title: title, Artist: a, Disc: m.Position, Position: t.Position, DurationMs: length})
}
}
if len(r.Tracks) == 0 || len(r.Tracks) > 1000 {
return Release{}, errors.New("metadata: invalid track list")
}
return r, nil
}
func quote(s string) string { return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` }
func (c *Client) Search(ctx context.Context, q Query) ([]Release, error) {
if len(q.RecordingIDs) > 0 {
return c.byRecordings(ctx, q.RecordingIDs)
}
if q.ReleaseID != "" {
r, err := c.release(ctx, q.ReleaseID)
if errors.Is(err, ErrNotFound) {
return []Release{}, nil
}
return []Release{r}, err
}
params := url.Values{"fmt": {"json"}, "limit": {"5"}, "query": {"release:" + quote(q.Album) + " AND artist:" + quote(q.Artist)}}
data, err := c.get(ctx, c.base+"/release?"+params.Encode(), true, 4<<20)
if err != nil {
return nil, err
}
var found struct {
Releases []struct {
ID string `json:"id"`
} `json:"releases"`
}
if err = json.Unmarshal(data, &found); err != nil {
return nil, err
}
out := []Release{}
for _, candidate := range found.Releases {
r, err := c.release(ctx, candidate.ID)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}
// byRecordings discovers editions from a bounded sample; all batch files are matched afterwards.
func (c *Client) byRecordings(ctx context.Context, ids []string) ([]Release, error) {
votes := map[string]int{}
seen := map[string]bool{}
for _, id := range ids {
if _, err := uuid.Parse(id); err != nil {
return nil, errors.New("metadata: invalid recording ID")
}
if seen[id] {
continue
}
if len(seen) == 8 {
break
}
seen[id] = true
params := url.Values{"fmt": {"json"}, "recording": {id}, "limit": {"100"}}
data, err := c.get(ctx, c.base+"/release?"+params.Encode(), true, 4<<20)
if err != nil {
return nil, err
}
var found struct {
Releases []struct {
ID string `json:"id"`
} `json:"releases"`
}
if err = json.Unmarshal(data, &found); err != nil {
return nil, err
}
perRecording := map[string]bool{}
for _, r := range found.Releases {
if _, err := uuid.Parse(r.ID); err == nil && !perRecording[r.ID] {
votes[r.ID]++
perRecording[r.ID] = true
}
}
}
candidates := []string{}
for id := range votes {
candidates = append(candidates, id)
}
sort.Slice(candidates, func(i, j int) bool {
if votes[candidates[i]] == votes[candidates[j]] {
return candidates[i] < candidates[j]
}
return votes[candidates[i]] > votes[candidates[j]]
})
out := []Release{}
for _, id := range candidates[:min(5, len(candidates))] {
r, err := c.release(ctx, id)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, nil
}
func (c *Client) Cover(ctx context.Context, r Release) (Cover, error) {
for i, id := range []string{r.ID, r.GroupID} {
if _, err := uuid.Parse(id); err != nil {
continue
}
kind := "release"
if i == 1 {
kind = "release-group"
}
address := c.coverBase + "/" + kind + "/" + id + "/front-1200"
data, err := c.get(ctx, address, false, 8<<20)
if errors.Is(err, ErrNotFound) {
continue
}
if err != nil {
return Cover{}, err
}
mime := http.DetectContentType(data)
if mime != "image/jpeg" && mime != "image/png" && mime != "image/webp" {
return Cover{}, errors.New("metadata: unsupported cover")
}
return Cover{Data: data, MIME: mime, URL: address, Exact: i == 0}, nil
}
return Cover{}, ErrNotFound
}
@@ -0,0 +1,32 @@
//go:build integration
package musicbrainz
import (
"context"
"github.com/stretchr/testify/require"
"os"
"testing"
"time"
)
// Explicitly opt in: CI does not depend on public metadata services being online.
func TestLiveMetadataProviders(t *testing.T) {
if os.Getenv("PIPU_LIVE_METADATA_TEST") != "1" {
t.Skip("set PIPU_LIVE_METADATA_TEST=1 to query public providers")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
c := New(memoryCache{}, "PipuMusic/test (https://gitea.hsrv.site/mrleo1nid/pipu-music)")
releases, err := c.Search(ctx, Query{ReleaseID: "99b09d02-9cc9-3fed-8431-f162165a9371"})
require.NoError(t, err)
require.Len(t, releases, 1)
require.NotEmpty(t, releases[0].Title)
require.NotEmpty(t, releases[0].Artist)
require.NotEmpty(t, releases[0].Tracks)
cover, err := c.Cover(ctx, releases[0])
require.NoError(t, err)
require.NotEmpty(t, cover.Data)
require.Contains(t, cover.MIME, "image/")
t.Logf("release=%s tracks=%d cover_bytes=%d exact=%v", releases[0].ID, len(releases[0].Tracks), len(cover.Data), cover.Exact)
}
@@ -0,0 +1,122 @@
package musicbrainz
import (
"context"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
type memoryCache map[string][]byte
func (m memoryCache) GetMetadataCache(_ context.Context, key string) ([]byte, bool, error) {
b, ok := m[key]
return b, ok, nil
}
func (m memoryCache) PutMetadataCache(_ context.Context, key string, b []byte, _ time.Duration) error {
m[key] = b
return nil
}
func TestLookupParsesEditionTracksAndCachesRequests(t *testing.T) {
id := uuid.NewString()
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
require.Equal(t, "PipuTest/1 (test@example.org)", r.UserAgent())
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/release" {
require.Contains(t, r.URL.Query().Get("query"), `release:"Album"`)
_, _ = w.Write([]byte(`{"releases":[{"id":"` + id + `"}]}`))
return
}
require.Equal(t, "recordings+artist-credits+release-groups", r.URL.Query().Get("inc"))
_, _ = w.Write([]byte(`{"id":"` + id + `","title":"Album","date":"2024-01-01","country":"XE","artist-credit":[{"name":"Artist"}],"release-group":{"id":"` + uuid.NewString() + `","primary-type":"Single"},"media":[{"position":1,"tracks":[{"position":1,"recording":{"id":"recording","title":"Song","length":180000}}]}]}`))
}))
defer server.Close()
c := New(memoryCache{}, "PipuTest/1 (test@example.org)")
c.base = server.URL
c.interval = 0
for range 2 {
result, err := c.Search(context.Background(), Query{Artist: "Artist", Album: "Album"})
require.NoError(t, err)
require.Len(t, result, 1)
require.Equal(t, "Single", result[0].Type)
require.Equal(t, "Artist", result[0].Tracks[0].Artist)
require.Equal(t, 180000, result[0].Tracks[0].DurationMs)
}
require.Equal(t, 2, requests)
}
func TestRecordingLookupRanksEditionsAndBoundsDiscovery(t *testing.T) {
id, other := uuid.NewString(), uuid.NewString()
lookups, details := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/release" {
lookups++
require.NotEmpty(t, r.URL.Query().Get("recording"))
require.Equal(t, "100", r.URL.Query().Get("limit"))
if lookups == 1 {
_, _ = w.Write([]byte(`{"releases":[{"id":"` + other + `"},{"id":"` + id + `"}]}`))
} else {
_, _ = w.Write([]byte(`{"releases":[{"id":"` + id + `"}]}`))
}
return
}
details++
releaseID := strings.TrimPrefix(r.URL.Path, "/release/")
_, _ = w.Write([]byte(`{"id":"` + releaseID + `","media":[{"position":1,"tracks":[{"position":1,"title":"Song","length":180000}]}]}`))
}))
defer server.Close()
c := New(memoryCache{}, "test")
c.base = server.URL
c.interval = 0
ids := []string{}
for range 12 {
ids = append(ids, uuid.NewString())
}
ids = append([]string{ids[0]}, ids...)
releases, err := c.Search(context.Background(), Query{RecordingIDs: ids})
require.NoError(t, err)
require.Len(t, releases, 2)
require.Equal(t, id, releases[0].ID)
require.Equal(t, 8, lookups)
require.Equal(t, 2, details)
}
func TestCoverFallbackIsExplicitAndRedirectsAreRestricted(t *testing.T) {
r := Release{ID: uuid.NewString(), GroupID: uuid.NewString()}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if strings.HasPrefix(req.URL.Path, "/release/") {
w.WriteHeader(404)
return
}
_, _ = w.Write([]byte{0xff, 0xd8, 0xff, 1})
}))
defer server.Close()
c := New(memoryCache{}, "test")
c.coverBase = server.URL
cover, err := c.Cover(context.Background(), r)
require.NoError(t, err)
require.False(t, cover.Exact)
require.Contains(t, cover.URL, "/release-group/")
for _, address := range []string{"http://archive.org/a", "https://127.0.0.1/a", "https://archive.org.evil.example/a", "https://archive.org:1234/a"} {
u, _ := url.Parse(address)
require.Error(t, c.http.CheckRedirect(&http.Request{URL: u}, nil))
}
}
func TestLookupRateWaitCanBeCancelledAndResponsesAreBounded(t *testing.T) {
c := New(memoryCache{}, "test")
c.next = time.Now().Add(time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
_, err := c.Search(ctx, Query{Artist: "Artist", Album: "Album"})
require.ErrorIs(t, err, context.DeadlineExceeded)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(strings.Repeat("x", 100))) }))
defer server.Close()
_, err = c.get(context.Background(), server.URL, false, 16)
require.ErrorContains(t, err, "too large")
}
@@ -10,13 +10,15 @@ import (
// API — конфигурация публичного сервиса api.
type API struct {
ManagementDir string `env:"MANAGEMENT_DIR" envDefault:"/management"`
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"`
DatabaseURL string `env:"DATABASE_URL,required"`
RedisURL string `env:"REDIS_URL,required"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
MigrateOnStart bool `env:"MIGRATE_ON_START" envDefault:"true"`
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"15s"`
AcoustIDAPIKey string `env:"ACOUSTID_API_KEY"`
MusicBrainzEnabled bool `env:"MUSICBRAINZ_ENABLED" envDefault:"true"`
ManagementDir string `env:"MANAGEMENT_DIR" envDefault:"/management"`
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"`
DatabaseURL string `env:"DATABASE_URL,required"`
RedisURL string `env:"REDIS_URL,required"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
MigrateOnStart bool `env:"MIGRATE_ON_START" envDefault:"true"`
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"15s"`
SupportedLocales []string `env:"SUPPORTED_LOCALES" envDefault:"ru,en" envSeparator:","`
MinAndroidVersionCode int `env:"MIN_ANDROID_VERSION_CODE" envDefault:"0"`
+1 -1
View File
@@ -175,7 +175,7 @@ func (ix *Indexer) Reindex(ctx context.Context) error {
func (ix *Indexer) rebuild(ctx context.Context, s indexSpec) error {
next := s.uid + "_next"
if err := ix.meili.call(ctx, http.MethodDelete, "/indexes/"+next, nil); err != nil && !errors.Is(err, ErrMeiliNotFound) {
if err := ix.meili.deleteIndexIfExists(ctx, next); err != nil {
return err
}
if err := ix.meili.call(ctx, http.MethodPost, "/indexes", map[string]any{"uid": next, "primaryKey": "id"}); err != nil {
+16
View File
@@ -114,6 +114,22 @@ func (m *Meili) call(ctx context.Context, method, path string, body any) error {
return m.wait(ctx, t)
}
// deleteIndexIfExists не создаёт заведомо провальную задачу удаления, если индекса нет.
func (m *Meili) deleteIndexIfExists(ctx context.Context, uid string) error {
path := "/indexes/" + uid
if err := m.do(ctx, http.MethodGet, path, nil, nil); err != nil {
if errors.Is(err, ErrMeiliNotFound) {
return nil
}
return err
}
// Индекс мог исчезнуть между проверкой и выполнением асинхронной задачи.
if err := m.call(ctx, http.MethodDelete, path, nil); err != nil && !errors.Is(err, ErrMeiliNotFound) {
return err
}
return nil
}
// Healthy — Meilisearch отвечает.
func (m *Meili) Healthy(ctx context.Context) error {
return m.do(ctx, http.MethodGet, "/health", nil, nil)
@@ -5,9 +5,59 @@ import (
"errors"
"net/http"
"net/http/httptest"
"reflect"
"testing"
)
func TestDeleteIndexIfExists(t *testing.T) {
for _, tc := range []struct {
name string
lookupCode int
task string
wantErr bool
}{
{name: "missing index", lookupCode: http.StatusNotFound},
{name: "existing index", lookupCode: http.StatusOK, task: `{"status":"succeeded"}`},
{name: "lookup failure", lookupCode: http.StatusServiceUnavailable, wantErr: true},
{name: "delete failure", lookupCode: http.StatusOK, task: `{"status":"failed","error":{"code":"internal","message":"disk error"}}`, wantErr: true},
{name: "concurrent deletion", lookupCode: http.StatusOK, task: `{"status":"failed","error":{"code":"index_not_found","message":"Index disappeared"}}`},
} {
t.Run(tc.name, func(t *testing.T) {
var requests []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests = append(requests, r.Method+" "+r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch r.Method + " " + r.URL.Path {
case "GET /indexes/tracks_next":
w.WriteHeader(tc.lookupCode)
_, _ = w.Write([]byte(`{}`))
case "DELETE /indexes/tracks_next":
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"taskUid":1}`))
case "GET /tasks/1":
_, _ = w.Write([]byte(tc.task))
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusBadRequest)
}
}))
defer srv.Close()
err := NewMeili(srv.URL, "").deleteIndexIfExists(context.Background(), "tracks_next")
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr)
}
wantRequests := []string{"GET /indexes/tracks_next"}
if tc.lookupCode == http.StatusOK {
wantRequests = append(wantRequests, "DELETE /indexes/tracks_next", "GET /tasks/1")
}
if !reflect.DeepEqual(requests, wantRequests) {
t.Fatalf("requests = %v, want %v", requests, wantRequests)
}
})
}
}
// На чистом Meilisearch удаление несуществующего индекса падает в задаче, а не ответом 404.
func TestCallMapsFailedIndexTaskToNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+24
View File
@@ -26,6 +26,30 @@ type BatchPatch struct {
const maxCoverBytes = 8 << 20
// createCover writes a new immutable cover before the quota transaction; Create removes it on failure.
func (s *Service) createCover(data []byte) (*string, error) {
if data == nil {
return nil, nil
}
if len(data) > maxCoverBytes {
return nil, apperr.Validation("cover_base64", "validation.cover_too_large", "cover is larger than 8 MB", nil)
}
ext, ok := coverExt(data)
if !ok {
return nil, apperr.Validation("cover_base64", "validation.cover_type", "cover must be jpeg, png or webp", nil)
}
rel := path.Join("covers", newID().String()+ext)
abs := s.stagingAbs(rel)
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(abs, data, 0o644); err != nil {
_ = os.Remove(abs)
return nil, err
}
return &rel, nil
}
// UpdateBatch применяет исполнителя, альбом, год и обложку к файлам пачки на рассмотрении.
func (s *Service) UpdateBatch(ctx context.Context, id uuid.UUID, in BatchPatch) ([]File, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
+18 -7
View File
@@ -17,17 +17,18 @@ import (
type Handler struct {
svc *Service
signer *sign.Signer
xAccel bool
}
// NewHandler создаёт обработчик. signer подписывает ссылки прослушивания из staging.
func NewHandler(svc *Service, signer *sign.Signer, xAccel bool) *Handler {
return &Handler{svc: svc, signer: signer, xAccel: xAccel}
func NewHandler(svc *Service, signer *sign.Signer) *Handler {
return &Handler{svc: svc, signer: signer}
}
func (h *Handler) fileDTO(ctx context.Context, f File) gen.UploadFile {
hasCover := f.CoverPath != nil && *f.CoverPath != ""
out := gen.UploadFile{
Id: f.ID, BatchId: f.BatchID, BatchComment: f.BatchComment,
BatchHasCover: &hasCover,
Id: f.ID, BatchId: f.BatchID, BatchComment: f.BatchComment,
Author: gen.UserRef{Id: f.AuthorID, Username: f.AuthorName},
Status: gen.UploadStatus(f.Status), OriginalName: f.OriginalName, ClientRelPath: f.ClientRelPath,
SizeBytes: f.Size, ReceivedBytes: f.Received, Format: f.Format, Codec: f.Codec, Bitrate: f.Bitrate,
@@ -35,7 +36,9 @@ func (h *Handler) fileDTO(ctx context.Context, f File) gen.UploadFile {
MetadataEdited: f.MetadataEdited, MatchedArtistId: f.MatchedArtistID, MatchedAlbumId: f.MatchedAlbumID,
DuplicateOfTrackId: f.DuplicateOfTrackID, PossibleDuplicate: f.PossibleDuplicate, QualityUpgrade: f.QualityUpgrade,
TargetSourceId: f.TargetSourceID, TargetRelPath: f.TargetRelPath, StatusReason: f.StatusReason,
ReviewedAt: f.ReviewedAt, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt,
TargetSourceName: f.TargetSourceName,
TargetStoragePath: f.TargetStoragePath,
ReviewedAt: f.ReviewedAt, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt,
}
if out.Tags == nil {
out.Tags = map[string]string{}
@@ -154,7 +157,15 @@ func (h *Handler) CreateUploadBatch(ctx context.Context, req gen.CreateUploadBat
for _, f := range req.Body.Files {
in = append(in, CreateFile{ClientID: f.ClientId, Name: f.Name, RelPath: f.RelPath, Size: f.Size, SHA256: f.Sha256})
}
batch, res, err := h.svc.Create(ctx, req.Body.Comment, in)
var cover []byte
if req.Body.CoverBase64 != nil {
var err error
cover, err = DecodeCover(*req.Body.CoverBase64)
if err != nil {
return nil, err
}
}
batch, res, err := h.svc.Create(ctx, req.Body.Comment, in, cover)
if err != nil {
return nil, err
}
@@ -181,7 +192,7 @@ func (h *Handler) CreateUploadBatch(ctx context.Context, req gen.CreateUploadBat
// ListMyUploads реализует gen.StrictServerInterface.
func (h *Handler) ListMyUploads(ctx context.Context, req gen.ListMyUploadsRequestObject) (gen.ListMyUploadsResponseObject, error) {
files, next, err := h.svc.ListMine(ctx, statuses(req.Params.Status), deref(req.Params.Cursor, ""), deref(req.Params.Limit, 50))
files, next, err := h.svc.ListMine(ctx, statuses(req.Params.Status), deref(req.Params.Cursor, ""), deref(req.Params.Limit, 50), req.Params.Batch)
if err != nil {
return nil, err
}
+2
View File
@@ -140,6 +140,8 @@ type File struct {
PossibleDuplicate bool
QualityUpgrade bool
TargetSourceID *uuid.UUID
TargetSourceName *string
TargetStoragePath *string
TargetRelPath *string
StatusReason *string
ReviewedBy *uuid.UUID
@@ -3,6 +3,8 @@ package uploads
import (
"context"
"errors"
"path/filepath"
"strings"
"github.com/google/uuid"
@@ -47,6 +49,41 @@ type placement struct {
root string
}
// previewPlacement uses the same current configuration as importFile. Finalized
// files keep their recorded destination; reading the queue never changes a decision.
func (s *PlacementPlanner) previewPlacement(ctx context.Context, f *File) {
f.TargetStoragePath = nil
if f.Meta != nil && (f.Status == StatusPendingReview || f.Status == StatusImportFailed || f.Status == StatusApproved) {
pl := s.plan(ctx, *f, *f.Meta, f.TargetSourceID)
f.TargetSourceID, f.TargetRelPath = pl.SourceID, pl.RelPath
f.TargetSourceName = nil
if pl.SourceID != nil {
f.TargetSourceName = &pl.src.Name
}
if f.Status == StatusPendingReview || pl.Reason != "" {
f.StatusReason = nullable(pl.Reason)
}
f.MatchedArtistID, f.MatchedAlbumID = pl.ArtistID, pl.AlbumID
}
if f.TargetSourceID == nil || f.TargetRelPath == nil {
return
}
src, err := s.lib.GetSource(ctx, *f.TargetSourceID)
if err != nil {
return
}
f.TargetSourceName = &src.Name
if src.Type == library.SourceNFS {
if src.NFSServer != nil && src.NFSExport != nil {
full := *src.NFSServer + ":" + strings.TrimRight(*src.NFSExport, "/") + "/" + *f.TargetRelPath
f.TargetStoragePath = &full
}
} else if root, err := s.roots.Root(src); err == nil {
full := filepath.Join(root, filepath.FromSlash(*f.TargetRelPath))
f.TargetStoragePath = &full
}
}
// pickSource — хранилище: выбранное админом → по умолчанию → единственное принимающее загрузки.
func (s *PlacementPlanner) pickSource(ctx context.Context, chosen *uuid.UUID) (library.Source, string) {
id := chosen
@@ -0,0 +1,77 @@
package uploads
import (
"context"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/library"
)
type previewCatalog struct{ source library.Source }
func (c *previewCatalog) GetSource(context.Context, uuid.UUID) (library.Source, error) {
return c.source, nil
}
func (c *previewCatalog) ListSources(context.Context) ([]library.Source, error) {
return []library.Source{c.source}, nil
}
func (c *previewCatalog) Patterns(context.Context, uuid.UUID) ([]library.PatternRow, error) {
return nil, nil
}
type previewStore struct{}
func (previewStore) Settings(context.Context) (Settings, error) { return Settings{}, nil }
func (previewStore) MatchCatalog(context.Context, string, string) (*uuid.UUID, *uuid.UUID, int, error) {
return nil, nil, 0, nil
}
func (previewStore) BatchMaxDisc(context.Context, uuid.UUID, string) (int, error) { return 1, nil }
type previewRoot string
func (r previewRoot) Root(library.Source) (string, error) { return string(r), nil }
func TestPreviewUsesCurrentImportTemplate(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
catalog := &previewCatalog{source: library.Source{ID: uuid.New(), Name: "Base", Type: library.SourceLocal,
AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{album}/{title}.{ext}")}}
planner := &PlacementPlanner{store: previewStore{}, lib: catalog, roots: previewRoot(root)}
track := 3
f := File{Status: StatusPendingReview, OriginalName: "song.flac", Meta: &Meta{
Artist: "Artist", AlbumArtist: "Artist", Album: "Album", Title: "Song", Track: &track},
TargetSourceID: &catalog.source.ID, TargetRelPath: nullable("stale/path.flac"), StatusReason: nullable(CodeNoTarget)}
planner.previewPlacement(ctx, &f)
require.Equal(t, "Artist/Album/Song.flac", *f.TargetRelPath)
require.Equal(t, filepath.Join(root, "Artist", "Album", "Song.flac"), *f.TargetStoragePath)
require.Nil(t, f.StatusReason)
catalog.source.WriteTemplate = nullable("{albumartist}/{album}/{track:02} - {title}.{ext}")
planner.previewPlacement(ctx, &f)
planned := planner.plan(ctx, f, *f.Meta, f.TargetSourceID)
require.Equal(t, "Artist/Album/03 - Song.flac", *f.TargetRelPath)
require.Equal(t, planned.RelPath, f.TargetRelPath, "preview and import must use the same path")
catalog.source.Type = library.SourceNFS
catalog.source.NFSServer, catalog.source.NFSExport = nullable("nas"), nullable("/music/")
planner.previewPlacement(ctx, &f)
require.Equal(t, "nas:/music/Artist/Album/03 - Song.flac", *f.TargetStoragePath)
f.Status = StatusImported
catalog.source.WriteTemplate = nullable("changed/{title}.{ext}")
planner.previewPlacement(ctx, &f)
require.Equal(t, "Artist/Album/03 - Song.flac", *f.TargetRelPath, "imported files retain their actual destination")
f.Status = StatusPendingReview
catalog.source.WriteTemplate = nil
planner.previewPlacement(ctx, &f)
require.Nil(t, f.TargetRelPath, "invalid configuration must not show the stale path")
require.Nil(t, f.TargetStoragePath)
require.Equal(t, CodeTemplate, *f.StatusReason)
// Reading a preview does not write to the store or change the review status.
require.Equal(t, StatusPendingReview, f.Status)
}
@@ -0,0 +1,38 @@
package uploads
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"pipu/api/internal/api/gen"
)
type uploadQueryHandler struct {
gen.Unimplemented
statuses []gen.UploadStatus
}
func (h *uploadQueryHandler) ListMyUploads(w http.ResponseWriter, _ *http.Request, p gen.ListMyUploadsParams) {
h.statuses = *p.Status
w.WriteHeader(http.StatusNoContent)
}
func (h *uploadQueryHandler) ListUploads(w http.ResponseWriter, _ *http.Request, p gen.ListUploadsParams) {
h.statuses = *p.Status
w.WriteHeader(http.StatusNoContent)
}
func TestUploadFiltersBindCommaSeparatedStatuses(t *testing.T) {
for _, path := range []string{"/api/v1/uploads/mine", "/api/v1/admin/uploads"} {
t.Run(path, func(t *testing.T) {
h := &uploadQueryHandler{}
rec := httptest.NewRecorder()
gen.Handler(h).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path+"?status=pending_review,imported,expired&limit=50", nil))
require.Equal(t, http.StatusNoContent, rec.Code, rec.Body.String())
require.Equal(t, []gen.UploadStatus{gen.UploadStatusPendingReview, gen.UploadStatusImported, gen.UploadStatusExpired}, h.statuses)
})
}
}
@@ -0,0 +1,76 @@
package uploads
import (
"context"
"errors"
"sort"
"github.com/google/uuid"
"pipu/api/internal/acoustid"
)
const maxAudioFiles = 50
func chooseAudioMatch(id uuid.UUID, matches []acoustid.Match) AudioRecognition {
// Multiple AcoustIDs can point to the same recording. Compare distinct recordings only.
best := map[string]float64{}
for _, m := range matches {
if parsed, err := uuid.Parse(m.RecordingID); err == nil && parsed != uuid.Nil && m.Score > best[parsed.String()] && m.Score <= 1 {
best[parsed.String()] = m.Score
}
}
unique := []acoustid.Match{}
for id, score := range best {
unique = append(unique, acoustid.Match{RecordingID: id, Score: score})
}
sort.Slice(unique, func(i, j int) bool { return unique[i].Score > unique[j].Score })
r := AudioRecognition{FileID: id, State: "not_found"}
if len(unique) == 0 {
return r
}
r.Score = unique[0].Score
if unique[0].Score < .9 || len(unique) > 1 && unique[0].Score-unique[1].Score < .05 {
r.State = "ambiguous"
return r
}
r.State, r.RecordingID = "matched", unique[0].RecordingID
return r
}
// identifyAudio runs only after an explicit reviewer request. It never modifies file tags.
func (s *Service) identifyAudio(ctx context.Context, files []File) ([]AudioRecognition, map[uuid.UUID]AudioRecognition, []string, error) {
results := []AudioRecognition{}
byFile := map[uuid.UUID]AudioRecognition{}
ids := []string{}
if s.identifier == nil || s.encodedFingerprint == nil {
return results, byFile, ids, errors.New("acoustid: not configured")
}
for _, f := range releaseFiles(files) {
if f.Status != StatusPendingReview && f.Status != StatusImportFailed {
continue
}
if len(results) >= maxAudioFiles {
return results, byFile, ids, errors.New("acoustid: batch too large")
}
r := AudioRecognition{FileID: f.ID, State: "unavailable"}
if f.DurationMs != nil && *f.DurationMs > 0 && f.StagingPath != "" {
fingerprint, err := s.encodedFingerprint(ctx, s.stagingAbs(f.StagingPath))
if err == nil {
matches, err := s.identifier.Lookup(ctx, fingerprint, (*f.DurationMs+500)/1000)
if err != nil {
return results, byFile, ids, err
}
r = chooseAudioMatch(f.ID, matches)
}
}
if err := ctx.Err(); err != nil {
return results, byFile, ids, err
}
results = append(results, r)
byFile[f.ID] = r
if r.State == "matched" {
ids = append(ids, r.RecordingID)
}
}
return results, byFile, ids, nil
}
@@ -0,0 +1,90 @@
package uploads
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/acoustid"
"pipu/api/internal/musicbrainz"
)
func TestAudioRecognitionRejectsWeakAndAmbiguousRecordings(t *testing.T) {
a, b := uuid.NewString(), uuid.NewString()
for _, tc := range []struct {
matches []acoustid.Match
state string
}{
{nil, "not_found"},
{[]acoustid.Match{{RecordingID: a, Score: .89}}, "ambiguous"},
{[]acoustid.Match{{RecordingID: a, Score: .99}, {RecordingID: b, Score: .97}}, "ambiguous"},
{[]acoustid.Match{{RecordingID: a, Score: .99}, {RecordingID: a, Score: .98}, {RecordingID: b, Score: .7}}, "matched"},
{[]acoustid.Match{{RecordingID: "invalid", Score: 1}}, "not_found"},
} {
r := chooseAudioMatch(uuid.New(), tc.matches)
require.Equal(t, tc.state, r.State)
if tc.state != "matched" {
require.Empty(t, r.RecordingID)
}
}
}
func TestAudioOverridesWrongTagsButDoesNotProveEditionOrDuration(t *testing.T) {
id := uuid.NewString()
f := releaseFile("Wrong title", 9, 1, 180000)
r := musicbrainz.Release{Tracks: []musicbrainz.Track{{RecordingID: id, Title: "Correct title", Artist: "Correct artist", Position: 1, Disc: 1, DurationMs: 180000}}}
audio := map[uuid.UUID]AudioRecognition{f.ID: {FileID: f.ID, State: "matched", RecordingID: id, Score: .99}}
c := matchReleaseWithAudio([]File{f}, r, audio)
require.True(t, c.Complete)
require.Equal(t, "acoustid", c.Matches[0].Method)
require.Equal(t, "Wrong title", f.Meta.Title)
require.Empty(t, f.Tags)
r.Tracks = append(r.Tracks, r.Tracks[0])
c = matchReleaseWithAudio([]File{f}, r, audio)
require.False(t, c.Complete)
require.Empty(t, c.Matches, "same recording twice in an edition remains ambiguous")
r.Tracks = r.Tracks[:1]
r.Tracks[0].DurationMs = 195000
c = matchReleaseWithAudio([]File{f}, r, audio)
require.False(t, c.Complete)
require.Equal(t, "medium", c.Matches[0].Confidence)
r.Tracks[0].RecordingID = uuid.NewString()
c = matchReleaseWithAudio([]File{f}, r, audio)
require.Empty(t, c.Matches)
}
type testAudioIdentifier struct {
calls int
err error
id string
duration int
}
func (a *testAudioIdentifier) Lookup(_ context.Context, fp string, duration int) ([]acoustid.Match, error) {
a.calls++
a.duration = duration
return []acoustid.Match{{RecordingID: a.id, Score: .99}}, a.err
}
func TestAudioIdentificationUsesFullDurationAndSkipsUnavailableFiles(t *testing.T) {
f := releaseFile("Unknown", 1, 1, 321000)
f.StagingPath = "batch/file.flac"
provider := &testAudioIdentifier{id: uuid.NewString()}
s := &Service{cfg: Config{StagingDir: t.TempDir()}, identifier: provider, encodedFingerprint: func(context.Context, string) (string, error) { return "fp", nil }}
results, byFile, ids, err := s.identifyAudio(context.Background(), []File{f})
require.NoError(t, err)
require.Len(t, results, 1)
require.Equal(t, 321, provider.duration)
require.Equal(t, provider.id, byFile[f.ID].RecordingID)
require.Equal(t, []string{provider.id}, ids)
s.encodedFingerprint = func(context.Context, string) (string, error) { return "", errors.New("fpcalc missing") }
results, _, ids, err = s.identifyAudio(context.Background(), []File{f})
require.NoError(t, err)
require.Equal(t, "unavailable", results[0].State)
require.Empty(t, ids)
require.Equal(t, 1, provider.calls)
s.encodedFingerprint = func(context.Context, string) (string, error) { return "fp", nil }
provider.err = errors.New("provider offline")
_, _, _, err = s.identifyAudio(context.Background(), []File{f})
require.ErrorContains(t, err, "provider offline")
}
@@ -0,0 +1,144 @@
package uploads
import (
"context"
"crypto/sha256"
"encoding/hex"
"path"
"regexp"
"sort"
"strconv"
"strings"
"github.com/google/uuid"
"pipu/api/internal/library"
"pipu/api/internal/library/meta"
"pipu/api/internal/platform/apperr"
)
type ReleaseGroup struct {
ID string `json:"id"`
Artist string `json:"artist"`
Album string `json:"album"`
FileIDs []uuid.UUID `json:"file_ids"`
HasCover bool `json:"has_cover"`
State string `json:"state"`
}
type releaseGroupDraft struct {
Key, Artist, Album string
Files []uuid.UUID
}
var discDirectory = regexp.MustCompile(`(?i)^(?:cd|disc|disk|диск)[ ._-]*0*\d+(?:\s*[-(].*)?$`)
func releaseFolder(f File) string {
name := f.OriginalName
if f.ClientRelPath != nil {
name = *f.ClientRelPath
}
dir := path.Dir(strings.ReplaceAll(name, `\`, "/"))
for dir != "." && dir != "/" && discDirectory.MatchString(path.Base(dir)) {
dir = path.Dir(dir)
}
if dir == "." || dir == "/" {
return ""
}
return strings.ToLower(dir)
}
func releaseBase(f File) string {
m := f.Meta
year := ""
if m.Year != nil {
year = strconv.Itoa(*m.Year)
}
if m.Album != "" && m.Album != library.UnknownAlbum {
if m.AlbumArtist != "" && m.AlbumArtist != meta.UnknownArtist {
return "tags\x00" + meta.Key(m.AlbumArtist) + "\x00" + meta.Key(m.Album) + "\x00" + year
}
if folder := releaseFolder(f); folder != "" {
return "album-folder\x00" + folder + "\x00" + meta.Key(m.Album) + "\x00" + year
}
return "tags\x00" + meta.Key(m.Artist) + "\x00" + meta.Key(m.Album) + "\x00" + year
}
return "folder\x00" + releaseFolder(f)
}
func groupReleaseFiles(files []File) []releaseGroupDraft {
files = releaseFiles(files)
known := map[string]map[string]bool{}
for _, f := range files {
base := releaseBase(f)
if known[base] == nil {
known[base] = map[string]bool{}
}
if id := tagID(f.Tags, "musicbrainz_albumid", "musicbrainz album id", "musicbrainz release id", "musicbrainz_releaseid"); id != "" {
known[base][id] = true
}
}
groups := map[string]*releaseGroupDraft{}
for _, f := range files {
base := releaseBase(f)
id := tagID(f.Tags, "musicbrainz_albumid", "musicbrainz album id", "musicbrainz release id", "musicbrainz_releaseid")
if id == "" && len(known[base]) == 1 {
for value := range known[base] {
id = value
}
}
key := base
if id != "" {
key = "mbid\x00" + id
}
sum := sha256.Sum256([]byte(key))
key = hex.EncodeToString(sum[:])
if groups[key] == nil {
artist := f.Meta.AlbumArtist
if artist == "" {
artist = f.Meta.Artist
}
groups[key] = &releaseGroupDraft{Key: key, Artist: artist, Album: f.Meta.Album, Files: []uuid.UUID{}}
}
groups[key].Files = append(groups[key].Files, f.ID)
}
out := []releaseGroupDraft{}
for _, g := range groups {
sort.Slice(g.Files, func(i, j int) bool { return g.Files[i].String() < g.Files[j].String() })
out = append(out, *g)
}
sort.Slice(out, func(i, j int) bool { return out[i].Files[0].String() < out[j].Files[0].String() })
return out
}
// releaseScope resolves only a group belonging to this batch. A mixed batch requires explicit selection.
func (s *Service) releaseScope(ctx context.Context, batch uuid.UUID, requested string) ([]File, []ReleaseGroup, string, error) {
if err := s.store.EnsureReleaseGroups(ctx, batch); err != nil {
return nil, nil, "", notFound(err)
}
groups, err := s.store.ReleaseGroups(ctx, batch)
if err != nil {
return nil, nil, "", err
}
if requested == "" && len(groups) == 1 {
requested = groups[0].ID
}
if requested != "" {
found := false
for _, g := range groups {
found = found || g.ID == requested
}
if !found {
return nil, groups, "", apperr.NotFound("uploads.release_group_not_found", "release group does not belong to batch")
}
files, err := s.store.AllInReleaseGroup(ctx, batch, requested)
return files, groups, requested, err
}
if len(groups) > 1 {
return nil, groups, "", nil
}
files, err := s.store.AllInBatch(ctx, batch)
return files, groups, "", err
}
func firstGroup(values []string) string {
if len(values) > 0 {
return values[0]
}
return ""
}
@@ -0,0 +1,153 @@
//go:build integration
package uploads
import (
"context"
"crypto/sha256"
"encoding/json"
"log/slog"
"os"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/musicbrainz"
"pipu/api/internal/platform/db"
)
type groupedCatalogue map[string]musicbrainz.Release
func (c groupedCatalogue) Search(_ context.Context, q musicbrainz.Query) ([]musicbrainz.Release, error) {
return []musicbrainz.Release{c[q.Album]}, nil
}
func (c groupedCatalogue) Cover(context.Context, musicbrainz.Release) (musicbrainz.Cover, error) {
return musicbrainz.Cover{Data: []byte{0xff, 0xd8, 0xff, 1}, MIME: "image/jpeg", Exact: true}, nil
}
func TestReleaseGroupsIsolateJobsMetadataAndArtwork(t *testing.T) {
address := os.Getenv("TEST_DATABASE_URL")
if address == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := db.Connect(ctx, address)
require.NoError(t, err)
defer pool.Close()
require.NoError(t, db.Migrate(ctx, pool))
author := uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO users(id,username,password_hash,role) VALUES($1,$2,'x','admin')`, author, "groups-"+author.String())
require.NoError(t, err)
source, err := library.NewStore(pool).SaveSource(ctx, library.Source{ID: uuid.New(), Name: "groups-test", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{album}/{track:02} - {title}.{ext}")})
require.NoError(t, err)
store := NewStore(pool)
sumA, sumB := sha256.Sum256([]byte(uuid.NewString())), sha256.Sum256([]byte(uuid.NewString()))
cover := "covers/original.jpg"
batch, reserved, err := store.CreateBatch(ctx, author, nil, []NewFile{{ClientID: "a", Name: "a.flac", Size: 10, SHA256: sumA[:]}, {ClientID: "b", Name: "b.flac", Size: 10, SHA256: sumB[:]}}, DefaultSettings, &cover)
require.NoError(t, err)
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM upload_batches WHERE id=$1`, batch)
_ = store.ReconcileQuota(ctx)
}()
_, err = pool.Exec(ctx, `UPDATE upload_release_jobs SET state='not_found' WHERE batch_id<>$1`, batch)
require.NoError(t, err)
for i, f := range reserved {
m := Meta{Artist: "Artist", AlbumArtist: "Artist", Album: []string{"Album A", "Album B"}[i], Title: "Song"}
raw, _ := json.Marshal(m)
_, err = pool.Exec(ctx, `UPDATE upload_files SET status='pending_review',metadata=$2,duration_ms=180000 WHERE id=$1`, f.FileID, raw)
require.NoError(t, err)
}
catalogue := groupedCatalogue{}
for _, album := range []string{"Album A", "Album B"} {
catalogue[album] = musicbrainz.Release{ID: uuid.NewString(), Title: album, Artist: "Artist", Type: "Album", Tracks: []musicbrainz.Track{{Title: "Song", Artist: "Artist", Disc: 1, Position: 1, DurationMs: 180000}}}
}
svc := NewService(store, &previewCatalog{source: source}, previewRoot(t.TempDir()), nil, nil, nil, library.FPCalc{}, Config{StagingDir: t.TempDir()}, slog.New(slog.DiscardHandler))
svc.SetReleaseCatalogue(catalogue)
review := auth.WithPrincipal(ctx, auth.Principal{UserID: author, Role: auth.RoleAdmin, ReviewsUploads: true})
root, err := svc.GetReleaseReport(review, batch)
require.NoError(t, err)
require.Len(t, root.Groups, 2)
require.Equal(t, "needs_input", root.State)
group := map[string]string{}
for _, g := range root.Groups {
group[g.Album] = g.ID
require.False(t, g.HasCover, "ambiguous original batch artwork must not be copied to every album")
}
a, b := group["Album A"], group["Album B"]
_, err = svc.SearchReleases(review, batch, musicbrainz.Query{})
require.ErrorContains(t, err, "release_group_required")
_, err = svc.GetReleaseReport(review, batch, uuid.NewString())
require.ErrorContains(t, err, "release_group_not_found")
require.True(t, svc.lookupNextRelease(ctx))
require.True(t, svc.lookupNextRelease(ctx))
reportA, err := svc.GetReleaseReport(review, batch, a)
require.NoError(t, err)
require.True(t, reportA.Candidates[0].Complete)
reportB, err := svc.GetReleaseReport(review, batch, b)
require.NoError(t, err)
require.True(t, reportB.Candidates[0].Complete)
_, err = svc.ApplyRelease(review, batch, catalogue["Album B"].ID, true, false, a)
require.ErrorContains(t, err, "release_not_found")
used, _, err := store.Usage(ctx, author)
require.NoError(t, err)
_, err = svc.ApplyRelease(review, batch, catalogue["Album B"].ID, true, true, b)
require.NoError(t, err)
aFiles, err := store.AllInReleaseGroup(ctx, batch, a)
require.NoError(t, err)
require.Nil(t, aFiles[0].CoverPath)
require.Equal(t, "Album A", aFiles[0].Meta.Album)
require.Empty(t, aFiles[0].Tags["musicbrainz_albumid"])
bFiles, err := store.AllInReleaseGroup(ctx, batch, b)
require.NoError(t, err)
require.NotNil(t, bFiles[0].CoverPath)
require.Equal(t, catalogue["Album B"].ID, bFiles[0].Tags["musicbrainz_albumid"])
kept, err := store.StagingInUse(ctx)
require.NoError(t, err)
require.True(t, kept[*bFiles[0].CoverPath])
afterA, err := svc.GetReleaseReport(review, batch, a)
require.NoError(t, err)
require.Equal(t, reportA.InputHash, afterA.InputHash)
require.Nil(t, afterA.SelectedRelease)
newUsed, _, err := store.Usage(ctx, author)
require.NoError(t, err)
require.Equal(t, used, newUsed)
// A job lease for A survives a metadata edit in B.
_, err = svc.SearchReleases(review, batch, musicbrainz.Query{GroupID: a})
require.NoError(t, err)
job, err := store.ClaimRelease(ctx)
require.NoError(t, err)
require.Equal(t, a, job.GroupKey)
edited := *bFiles[0].Meta
edited.Title = "Edited B"
require.NoError(t, store.UpdateMetadata(ctx, bFiles[0].ID, edited, true, placement{}))
require.NoError(t, store.SaveRelease(ctx, job, reportA))
afterA, err = store.ReleaseReport(ctx, batch, a)
require.NoError(t, err)
require.Equal(t, "ready", afterA.State)
afterB, err := store.ReleaseReport(ctx, batch, b)
require.NoError(t, err)
require.Equal(t, "queued", afterB.State)
require.NoError(t, store.EnsureReleaseGroups(ctx, batch))
groups, err := store.ReleaseGroups(ctx, batch)
require.NoError(t, err)
require.Len(t, groups, 2)
// Explicit whole-batch cover editing still affects every subgroup.
shared := "covers/shared.jpg"
require.NoError(t, store.SetCover(ctx, batch, &shared))
aFiles, err = store.AllInReleaseGroup(ctx, batch, a)
require.NoError(t, err)
require.Equal(t, shared, *aFiles[0].CoverPath)
bFiles, err = store.AllInReleaseGroup(ctx, batch, b)
require.NoError(t, err)
require.Equal(t, shared, *bFiles[0].CoverPath)
require.NoError(t, store.ChangeStatus(ctx, bFiles[0].ID, []Status{StatusPendingReview}, StatusWithdrawn))
unused, err := store.UnusedReleaseCovers(ctx, batch)
require.NoError(t, err)
require.Empty(t, unused, "shared artwork is still needed by group A")
require.NoError(t, store.ChangeStatus(ctx, aFiles[0].ID, []Status{StatusPendingReview}, StatusWithdrawn))
unused, err = store.UnusedReleaseCovers(ctx, batch)
require.NoError(t, err)
require.Contains(t, unused, shared)
}
@@ -0,0 +1,140 @@
package uploads
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// EnsureReleaseGroups assigns a stable membership once the complete batch has been processed.
// Metadata confirmation cannot accidentally merge two groups or change another group's job.
func (st *Store) EnsureReleaseGroups(ctx context.Context, batch uuid.UUID) error {
tx, err := st.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
var cover *string
if err = tx.QueryRow(ctx, `SELECT cover_path FROM upload_batches WHERE id=$1 FOR UPDATE`, batch).Scan(&cover); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
var pending bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM upload_files WHERE batch_id=$1 AND status IN ('uploading','processing'))`, batch).Scan(&pending); err != nil {
return err
}
if pending {
return tx.Commit(ctx)
}
rows, err := tx.Query(ctx, `SELECT `+fileColumns+fileFrom+` WHERE f.batch_id=$1 AND f.release_group_id IS NULL ORDER BY f.id FOR UPDATE OF f`, batch)
if err != nil {
return err
}
files := []File{}
for rows.Next() {
f, e := scanFile(rows)
if e != nil {
rows.Close()
return e
}
files = append(files, f)
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
drafts := groupReleaseFiles(files)
var existing int
if err = tx.QueryRow(ctx, `SELECT count(*) FROM upload_release_groups WHERE batch_id=$1`, batch).Scan(&existing); err != nil {
return err
}
for _, draft := range drafts {
group := newID()
var groupCover *string
if existing == 0 && len(drafts) == 1 {
groupCover = cover
}
err = tx.QueryRow(ctx, `INSERT INTO upload_release_groups(id,batch_id,grouping_key,artist,album,cover_path) VALUES($1,$2,$3,$4,$5,$6)
ON CONFLICT(batch_id,grouping_key) DO UPDATE SET grouping_key=EXCLUDED.grouping_key RETURNING id`, group, batch, draft.Key, draft.Artist, draft.Album, groupCover).Scan(&group)
if err != nil {
return err
}
if _, err = tx.Exec(ctx, `UPDATE upload_files SET release_group_id=$2 WHERE batch_id=$1 AND id=ANY($3::uuid[]) AND release_group_id IS NULL`, batch, group, draft.Files); err != nil {
return err
}
if _, err = tx.Exec(ctx, `INSERT INTO upload_release_jobs(batch_id,group_key) VALUES($1,$2) ON CONFLICT DO NOTHING`, batch, group.String()); err != nil {
return err
}
}
if _, err = tx.Exec(ctx, `DELETE FROM upload_release_jobs WHERE batch_id=$1 AND group_key=''`, batch); err != nil {
return err
}
return tx.Commit(ctx)
}
func (st *Store) ReleaseGroups(ctx context.Context, batch uuid.UUID) ([]ReleaseGroup, error) {
rows, err := st.pool.Query(ctx, `SELECT g.id::text,g.artist,g.album,g.cover_path IS NOT NULL,coalesce(j.state,'queued'),array_agg(f.id ORDER BY f.id)
FROM upload_release_groups g JOIN upload_files f ON f.release_group_id=g.id
LEFT JOIN upload_release_jobs j ON j.batch_id=g.batch_id AND j.group_key=g.id::text
WHERE g.batch_id=$1 AND f.status NOT IN ('withdrawn','rejected','expired','invalid')
GROUP BY g.id,j.state ORDER BY min(f.id::text)`, batch)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ReleaseGroup{}
for rows.Next() {
var g ReleaseGroup
if err = rows.Scan(&g.ID, &g.Artist, &g.Album, &g.HasCover, &g.State, &g.FileIDs); err != nil {
return nil, err
}
out = append(out, g)
}
return out, rows.Err()
}
func (st *Store) resolveGroupKey(ctx context.Context, batch uuid.UUID, key string) (string, error) {
if key != "" {
return key, nil
}
err := st.pool.QueryRow(ctx, `SELECT CASE WHEN count(*)=1 THEN min(id::text) ELSE '' END FROM upload_release_groups WHERE batch_id=$1`, batch).Scan(&key)
return key, err
}
func (st *Store) AllInReleaseGroup(ctx context.Context, batch uuid.UUID, group string) ([]File, error) {
rows, err := st.pool.Query(ctx, `SELECT `+fileColumns+fileFrom+` WHERE f.batch_id=$1 AND f.release_group_id::text=$2 ORDER BY f.id`, batch, group)
if err != nil {
return nil, err
}
defer rows.Close()
out := []File{}
for rows.Next() {
f, e := scanFile(rows)
if e != nil {
return nil, e
}
out = append(out, f)
}
if err = rows.Err(); err != nil {
return nil, err
}
if len(out) == 0 {
return nil, ErrNotFound
}
return out, nil
}
func (st *Store) UnusedReleaseCovers(ctx context.Context, batch uuid.UUID) ([]string, error) {
rows, err := st.pool.Query(ctx, `SELECT DISTINCT cover_path FROM upload_release_groups g WHERE g.batch_id=$1 AND g.cover_path IS NOT NULL
AND NOT EXISTS(SELECT 1 FROM upload_files f WHERE f.release_group_id=g.id AND f.status::text=ANY($2))
AND NOT EXISTS(SELECT 1 FROM upload_files active LEFT JOIN upload_release_groups ag ON ag.id=active.release_group_id
JOIN upload_batches ab ON ab.id=active.batch_id WHERE active.status::text=ANY($2)
AND (CASE WHEN active.release_group_id IS NULL THEN ab.cover_path ELSE ag.cover_path END)=g.cover_path)`, batch, occupyingStatuses)
if err != nil {
return nil, err
}
defer rows.Close()
return pgx.CollectRows(rows, pgx.RowTo[string])
}
@@ -0,0 +1,71 @@
package uploads
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/library"
)
func TestReleaseGroupsSeparateAlbumsAndPreserveDiscsAndCompilations(t *testing.T) {
one := releaseFile("One", 1, 1, 180000)
two := releaseFile("Two", 1, 2, 180000)
other := releaseFile("Other", 1, 1, 180000)
other.Meta.Album = "Another album"
groups := groupReleaseFiles([]File{one, two, other})
require.Len(t, groups, 2)
var album []uuid.UUID
for _, g := range groups {
if g.Album == "Album" {
album = g.Files
}
}
require.ElementsMatch(t, []uuid.UUID{one.ID, two.ID}, album)
// Album artist, rather than each track artist, keeps compilations together.
one.Meta.Artist = "Singer one"
two.Meta.Artist = "Singer two"
one.Meta.AlbumArtist = "Various Artists"
two.Meta.AlbumArtist = "Various Artists"
require.Len(t, groupReleaseFiles([]File{one, two}), 1)
// Without album artist, folder context still keeps the two discs of a compilation together.
one.Meta.AlbumArtist = ""
two.Meta.AlbumArtist = ""
one.ClientRelPath = nullable(`Collection\Compilation\CD1\01.flac`)
two.ClientRelPath = nullable("Collection/Compilation/Disc 02/01.flac")
require.Len(t, groupReleaseFiles([]File{one, two}), 1)
}
func TestReleaseGroupsUseFoldersWhenAlbumTagsAreMissing(t *testing.T) {
one := releaseFile("One", 1, 1, 180000)
one.Meta.Album = library.UnknownAlbum
two := releaseFile("Two", 1, 2, 180000)
two.Meta.Album = ""
other := releaseFile("Other", 1, 1, 180000)
other.Meta.Album = library.UnknownAlbum
one.ClientRelPath = nullable("Artist/First/CD 1/01.flac")
two.ClientRelPath = nullable("Artist/First/Диск 2/01.flac")
other.ClientRelPath = nullable("Artist/Second/01.flac")
require.Len(t, groupReleaseFiles([]File{one, two, other}), 2)
other.Status = StatusWithdrawn
require.Len(t, groupReleaseFiles([]File{one, two, other}), 1)
// Unknown root files stay a single explicitly reviewable group, never invented albums per file.
one.ClientRelPath = nil
two.ClientRelPath = nil
require.Len(t, groupReleaseFiles([]File{one, two}), 1)
}
func TestReleaseGroupsRespectEditionIdentifiersAndKnownMissingTags(t *testing.T) {
one := releaseFile("One", 1, 1, 180000)
two := releaseFile("Two", 2, 1, 180000)
a, b := uuid.NewString(), uuid.NewString()
one.Tags = map[string]string{"musicbrainz_albumid": a}
groups := groupReleaseFiles([]File{one, two})
require.Len(t, groups, 1, "missing ID inherits the sole known edition of the same album")
key := groups[0].Key
two.Tags = map[string]string{"musicbrainz_albumid": b}
require.Len(t, groupReleaseFiles([]File{one, two}), 2, "different known editions must remain separate")
two.Tags = map[string]string{"musicbrainz_albumid": a}
two.Meta.Album = "Different spelling"
groups = groupReleaseFiles([]File{two, one})
require.Len(t, groups, 1)
require.Equal(t, key, groups[0].Key)
}
@@ -0,0 +1,81 @@
package uploads
import (
"context"
"encoding/base64"
"encoding/json"
"strings"
"pipu/api/internal/api/gen"
"pipu/api/internal/musicbrainz"
)
func releaseDTO(report ReleaseReport) (gen.UploadReleaseReport, error) {
raw, err := json.Marshal(report)
if err != nil {
return gen.UploadReleaseReport{}, err
}
var out gen.UploadReleaseReport
err = json.Unmarshal(raw, &out)
return out, err
}
func (h *Handler) GetUploadReleases(ctx context.Context, req gen.GetUploadReleasesRequestObject) (gen.GetUploadReleasesResponseObject, error) {
group := ""
if req.Params.GroupId != nil {
group = req.Params.GroupId.String()
}
report, err := h.svc.GetReleaseReport(ctx, req.Id, group)
if err != nil {
return nil, err
}
out, err := releaseDTO(report)
if err != nil {
return nil, err
}
return gen.GetUploadReleases200JSONResponse(out), nil
}
func (h *Handler) SearchUploadReleases(ctx context.Context, req gen.SearchUploadReleasesRequestObject) (gen.SearchUploadReleasesResponseObject, error) {
q := musicbrainz.Query{Artist: strings.TrimSpace(deref(req.Body.Artist, "")), Album: strings.TrimSpace(deref(req.Body.Album, ""))}
q.Audio = deref(req.Body.Audio, false)
if req.Body.GroupId != nil {
q.GroupID = req.Body.GroupId.String()
}
if req.Body.ReleaseId != nil {
q.ReleaseID = req.Body.ReleaseId.String()
}
report, err := h.svc.SearchReleases(ctx, req.Id, q)
if err != nil {
return nil, err
}
out, err := releaseDTO(report)
if err != nil {
return nil, err
}
return gen.SearchUploadReleases202JSONResponse(out), nil
}
func (h *Handler) GetUploadReleaseCover(ctx context.Context, req gen.GetUploadReleaseCoverRequestObject) (gen.GetUploadReleaseCoverResponseObject, error) {
group := ""
if req.Params.GroupId != nil {
group = req.Params.GroupId.String()
}
cover, err := h.svc.ReleaseCover(ctx, req.Id, req.ReleaseId.String(), group)
if err != nil {
return nil, err
}
return gen.GetUploadReleaseCover200JSONResponse{ImageBase64: base64.StdEncoding.EncodeToString(cover.Data), MimeType: cover.MIME, SourceUrl: cover.URL, ExactRelease: cover.Exact}, nil
}
func (h *Handler) ApplyUploadRelease(ctx context.Context, req gen.ApplyUploadReleaseRequestObject) (gen.ApplyUploadReleaseResponseObject, error) {
group := ""
if req.Body.GroupId != nil {
group = req.Body.GroupId.String()
}
report, err := h.svc.ApplyRelease(ctx, req.Id, req.Body.ReleaseId.String(), req.Body.ApplyMetadata, req.Body.ApplyCover, group)
if err != nil {
return nil, err
}
out, err := releaseDTO(report)
if err != nil {
return nil, err
}
return gen.ApplyUploadRelease200JSONResponse(out), nil
}
@@ -0,0 +1,184 @@
//go:build integration
package uploads
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"log/slog"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/musicbrainz"
"pipu/api/internal/platform/db"
)
type testReleaseCatalogue struct {
release musicbrainz.Release
searches int
err error
}
func (c *testReleaseCatalogue) Search(context.Context, musicbrainz.Query) ([]musicbrainz.Release, error) {
c.searches++
return []musicbrainz.Release{c.release}, c.err
}
func (c *testReleaseCatalogue) Cover(context.Context, musicbrainz.Release) (musicbrainz.Cover, error) {
return musicbrainz.Cover{Data: []byte{0xff, 0xd8, 0xff, 1}, MIME: "image/jpeg", Exact: true}, nil
}
func TestReleaseJobsAndAtomicApplication(t *testing.T) {
address := os.Getenv("TEST_DATABASE_URL")
if address == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := db.Connect(ctx, address)
require.NoError(t, err)
defer pool.Close()
require.NoError(t, db.Migrate(ctx, pool))
author := uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO users(id,username,password_hash,role) VALUES($1,$2,'x','admin')`, author, "release-"+author.String())
require.NoError(t, err)
source, err := library.NewStore(pool).SaveSource(ctx, library.Source{ID: uuid.New(), Name: "release-test", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, AcceptsUploads: true, WriteTemplate: nullable("{albumartist}/{album}/{track:02} - {title}.{ext}")})
require.NoError(t, err)
store := NewStore(pool)
oldCover := "covers/original.jpg"
sum1, sum2 := sha256.Sum256([]byte(uuid.NewString())), sha256.Sum256([]byte(uuid.NewString()))
batch, reserved, err := store.CreateBatch(ctx, author, nil, []NewFile{{ClientID: "1", Name: "one.flac", Size: 10, SHA256: sum1[:]}, {ClientID: "2", Name: "two.flac", Size: 10, SHA256: sum2[:]}}, DefaultSettings, &oldCover)
require.NoError(t, err)
require.Len(t, reserved, 2)
// Isolate from batches produced by other integration tests.
_, err = pool.Exec(ctx, `UPDATE upload_release_jobs SET state='not_found' WHERE batch_id<>$1`, batch)
require.NoError(t, err)
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM upload_batches WHERE id=$1`, batch)
_ = store.ReconcileQuota(ctx)
}()
provider := &testReleaseCatalogue{release: musicbrainz.Release{ID: uuid.NewString(), Artist: "Artist", Title: "Album", Type: "Single", Date: "2024-01-01", Tracks: []musicbrainz.Track{
{Title: "Song 1", Artist: "Artist", Disc: 1, Position: 1, DurationMs: 180000},
{Title: "Song 2", Artist: "Artist", Disc: 1, Position: 2, DurationMs: 190000},
}}}
svc := NewService(store, &previewCatalog{source: source}, previewRoot(t.TempDir()), nil, nil, nil, library.FPCalc{}, Config{StagingDir: t.TempDir()}, slog.New(slog.DiscardHandler))
svc.SetReleaseCatalogue(provider)
review := auth.WithPrincipal(ctx, auth.Principal{UserID: author, Role: auth.RoleAdmin, ReviewsUploads: true})
for i, r := range reserved {
require.NoError(t, store.ChangeStatus(ctx, r.FileID, []Status{StatusUploading}, StatusProcessing))
m := Meta{Artist: "Artist", AlbumArtist: "Artist", Album: "Album", Title: "SONG " + string(rune('1'+i))}
n := i + 1
m.Track = &n
raw, _ := json.Marshal(m)
duration := 180000 + i*10000
require.NoError(t, store.Transition(ctx, r.FileID, []Status{StatusProcessing}, StatusPendingReview, func(tx pgx.Tx) error {
_, e := tx.Exec(ctx, `UPDATE upload_files SET metadata=$2,duration_ms=$3 WHERE id=$1`, r.FileID, raw, duration)
return e
}))
if i == 0 {
_, err = store.ClaimRelease(ctx)
require.ErrorIs(t, err, ErrNotFound, "lookup waits until the whole batch is processed")
}
}
job, err := store.ClaimRelease(ctx)
require.NoError(t, err)
require.Equal(t, batch, job.ID)
files, err := store.AllInBatch(ctx, batch)
require.NoError(t, err)
edited := *files[0].Meta
year := 2025
edited.Year = &year
require.NoError(t, store.UpdateMetadata(ctx, files[0].ID, edited, true, placement{}))
require.NoError(t, store.SaveRelease(ctx, job, ReleaseReport{State: "ready", InputHash: "stale", Candidates: []ReleaseCandidate{}}))
report, err := store.ReleaseReport(ctx, batch)
require.NoError(t, err)
require.Equal(t, "queued", report.State, "an old worker lease cannot overwrite an edit")
require.True(t, svc.lookupNextRelease(ctx))
report, err = svc.GetReleaseReport(review, batch)
require.NoError(t, err)
require.Equal(t, "ready", report.State)
require.True(t, report.Candidates[0].Complete)
_, err = svc.SearchReleases(review, batch, musicbrainz.Query{Audio: true})
require.ErrorContains(t, err, "audio_disabled")
identifier := &testAudioIdentifier{}
svc.SetAudioIdentifier(identifier)
provider.release.Tracks[0].RecordingID, provider.release.Tracks[1].RecordingID = uuid.NewString(), uuid.NewString()
svc.encodedFingerprint = func(_ context.Context, filename string) (string, error) {
if strings.Contains(filename, reserved[0].FileID.String()) {
identifier.id = provider.release.Tracks[0].RecordingID
} else {
identifier.id = provider.release.Tracks[1].RecordingID
}
return "encoded", nil
}
_, err = svc.SearchReleases(review, batch, musicbrainz.Query{Audio: true})
require.NoError(t, err)
require.True(t, svc.lookupNextRelease(ctx))
report, err = svc.GetReleaseReport(review, batch)
require.NoError(t, err)
require.True(t, report.AudioAvailable)
require.Len(t, report.AudioResults, 2)
require.Equal(t, "acoustid", report.Candidates[0].Matches[0].Method)
require.True(t, report.Candidates[0].Complete)
before, err := store.AllInBatch(ctx, batch)
require.NoError(t, err)
require.Equal(t, "SONG 1", before[0].Meta.Title)
// Looking up the release never changes tags or cover.
require.Equal(t, oldCover, *before[0].CoverPath)
used, _, err := store.Usage(ctx, author)
require.NoError(t, err)
applied, err := svc.ApplyRelease(review, batch, provider.release.ID, true, false)
require.NoError(t, err)
require.Equal(t, provider.release.ID, *applied.SelectedRelease)
persisted, err := store.ReleaseReport(ctx, batch)
require.NoError(t, err)
require.Len(t, persisted.AudioResults, 2, "metadata update trigger must not erase confirmed recognition")
after, err := store.AllInBatch(ctx, batch)
require.NoError(t, err)
require.Equal(t, "Song 1", after[0].Meta.Title)
require.Equal(t, oldCover, *after[0].CoverPath)
require.Equal(t, StatusPendingReview, after[0].Status)
require.Equal(t, provider.release.ID, after[0].Tags["musicbrainz_albumid"])
require.NotNil(t, after[0].TargetRelPath)
require.Contains(t, *after[0].TargetRelPath, "01 - Song 1.flac")
newUsed, _, err := store.Usage(ctx, author)
require.NoError(t, err)
require.Equal(t, used, newUsed)
_, err = svc.ApplyRelease(review, batch, provider.release.ID, false, true)
require.NoError(t, err)
after, err = store.AllInBatch(ctx, batch)
require.NoError(t, err)
require.NotEqual(t, oldCover, *after[0].CoverPath)
// A queued manual lookup makes the old candidate inapplicable immediately.
_, err = svc.SearchReleases(review, batch, musicbrainz.Query{})
require.NoError(t, err)
_, err = svc.ApplyRelease(review, batch, provider.release.ID, true, false)
require.ErrorContains(t, err, "release_stale")
provider.err = errors.New("provider offline")
require.True(t, svc.lookupNextRelease(ctx))
report, err = store.ReleaseReport(ctx, batch)
require.NoError(t, err)
require.Equal(t, "error", report.State)
_, err = store.ClaimRelease(ctx)
require.ErrorIs(t, err, ErrNotFound, "failures back off instead of blocking upload processing")
_, err = svc.GetReleaseReport(ctx, batch)
require.Error(t, err, "review permission is mandatory")
key := "integration:" + uuid.NewString()
require.NoError(t, store.PutMetadataCache(ctx, key, []byte("cached"), time.Hour))
data, ok, err := store.GetMetadataCache(ctx, key)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, "cached", string(data))
require.NoError(t, store.PutMetadataCache(ctx, key, data, -time.Second))
require.NoError(t, store.ExpireMetadataCache(ctx))
_, ok, err = store.GetMetadataCache(ctx, key)
require.NoError(t, err)
require.False(t, ok)
}
@@ -0,0 +1,201 @@
package uploads
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"math"
"sort"
"strings"
"github.com/google/uuid"
"pipu/api/internal/library"
"pipu/api/internal/library/meta"
"pipu/api/internal/musicbrainz"
)
type ReleaseMatch struct {
FileID uuid.UUID `json:"file_id"`
TrackIndex int `json:"track_index"`
Confidence string `json:"confidence"`
Method string `json:"method,omitempty"`
}
type ReleaseCandidate struct {
Release musicbrainz.Release `json:"release"`
Matches []ReleaseMatch `json:"matches"`
Missing []musicbrainz.Track `json:"missing"`
Existing []musicbrainz.Track `json:"existing"`
Unmatched []uuid.UUID `json:"unmatched"`
Matched int `json:"matched"`
Total int `json:"total"`
Complete bool `json:"complete"`
Confidence string `json:"confidence"`
}
type ReleaseReport struct {
GroupID string `json:"group_id,omitempty"`
Groups []ReleaseGroup `json:"groups,omitempty"`
State string `json:"state"`
InputHash string `json:"input_hash"`
Candidates []ReleaseCandidate `json:"candidates"`
SelectedRelease *string `json:"selected_release"`
AudioAvailable bool `json:"audio_available"`
AudioResults []AudioRecognition `json:"audio_results,omitempty"`
}
type AudioRecognition struct {
FileID uuid.UUID `json:"file_id"`
State string `json:"state"`
RecordingID string `json:"recording_id,omitempty"`
Score float64 `json:"score,omitempty"`
}
func releaseFiles(files []File) []File {
out := []File{}
for _, f := range files {
if f.Meta != nil && (f.Status == StatusPendingReview || f.Status == StatusImportFailed || f.Status == StatusDuplicate || f.Status == StatusImported || f.Status == StatusApproved || f.Status == StatusImporting) {
out = append(out, f)
}
}
return out
}
func releaseHash(files []File) string {
type entry struct {
ID uuid.UUID
Meta *Meta
Duration *int
Tags map[string]string
}
entries := []entry{}
for _, f := range releaseFiles(files) {
entries = append(entries, entry{f.ID, f.Meta, f.DurationMs, f.Tags})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].ID.String() < entries[j].ID.String() })
b, _ := json.Marshal(entries)
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func tagID(tags map[string]string, keys ...string) string {
for _, key := range keys {
if id, err := uuid.Parse(strings.TrimSpace(tags[key])); err == nil {
return id.String()
}
}
return ""
}
func batchReleaseQuery(files []File) (musicbrainz.Query, bool) {
q := musicbrainz.Query{}
for _, f := range releaseFiles(files) {
if id := tagID(f.Tags, "musicbrainz_albumid", "musicbrainz album id", "musicbrainz release id", "musicbrainz_releaseid"); id != "" {
if q.ReleaseID != "" && q.ReleaseID != id {
return musicbrainz.Query{}, false
}
q.ReleaseID = id
}
}
if q.ReleaseID != "" {
return q, true
}
for _, f := range releaseFiles(files) {
m := f.Meta
a := m.AlbumArtist
if a == "" {
a = m.Artist
}
if a == "" || m.Album == "" || m.Album == library.UnknownAlbum || len(a) > 500 || len(m.Album) > 500 {
return q, false
}
if q.Artist != "" && (meta.Key(q.Artist) != meta.Key(a) || meta.Key(q.Album) != meta.Key(m.Album)) {
return q, false
}
q.Artist, q.Album = a, m.Album
}
return q, q.Artist != ""
}
func trackScore(f File, t musicbrainz.Track, albumArtist string) int {
if f.Meta == nil {
return 0
}
m := f.Meta
delta := math.MaxFloat64
if f.DurationMs != nil && *f.DurationMs > 0 && t.DurationMs > 0 {
delta = math.Abs(float64(*f.DurationMs - t.DurationMs))
}
if id := tagID(f.Tags, "musicbrainz_trackid", "musicbrainz track id", "musicbrainz recording id", "musicbrainz_recordingid"); id != "" && id == t.RecordingID {
if delta <= 5000 {
return 100
}
return 70
}
title := meta.Key(m.Title) == meta.Key(t.Title) && meta.Key(t.Title) != ""
artistMatch := meta.Key(m.Artist) == meta.Key(t.Artist) || m.AlbumArtist != "" && meta.Key(m.AlbumArtist) == meta.Key(albumArtist)
if title && delta <= 3000 {
if !artistMatch {
return 70
}
return 95
}
if title && (t.DurationMs == 0 || f.DurationMs == nil) {
return 70
}
if delta <= 2000 && m.Track != nil && *m.Track == t.Position && (m.Disc == nil && t.Disc == 1 || m.Disc != nil && *m.Disc == t.Disc) && meta.Key(m.Artist) == meta.Key(t.Artist) {
return 60
}
return 0
}
func matchRelease(files []File, r musicbrainz.Release) ReleaseCandidate {
return matchReleaseWithAudio(files, r, nil)
}
func matchReleaseWithAudio(files []File, r musicbrainz.Release, audio map[uuid.UUID]AudioRecognition) ReleaseCandidate {
c := ReleaseCandidate{Release: r, Matches: []ReleaseMatch{}, Missing: []musicbrainz.Track{}, Existing: []musicbrainz.Track{}, Unmatched: []uuid.UUID{}, Total: len(r.Tracks), Confidence: "low"}
covered := map[int]bool{}
strong := true
for _, f := range releaseFiles(files) {
index, best, tied := -1, 0, false
for i, t := range r.Tracks {
score := trackScore(f, t, r.Artist)
if a, ok := audio[f.ID]; ok && a.State == "matched" {
score = 0
if a.RecordingID == t.RecordingID {
score = 70
if f.DurationMs != nil && t.DurationMs > 0 && math.Abs(float64(*f.DurationMs-t.DurationMs)) <= 5000 {
score = 100
}
}
}
if score > best {
index, best, tied = i, score, false
} else if score > 0 && score == best {
tied = true
}
}
if index < 0 || tied {
c.Unmatched = append(c.Unmatched, f.ID)
strong = false
continue
}
confidence := "high"
if best < 90 {
confidence = "medium"
strong = false
}
method := "metadata"
if a, ok := audio[f.ID]; ok && a.State == "matched" {
method = "acoustid"
}
c.Matches = append(c.Matches, ReleaseMatch{FileID: f.ID, TrackIndex: index, Confidence: confidence, Method: method})
covered[index] = true
}
for i, t := range r.Tracks {
if !covered[i] {
c.Missing = append(c.Missing, t)
}
}
c.Matched = len(covered)
c.Complete = strong && c.Total > 0 && c.Matched == c.Total && len(c.Unmatched) == 0
if strong && c.Matched > 0 {
c.Confidence = "high"
} else if c.Matched > 0 {
c.Confidence = "medium"
}
return c
}
@@ -0,0 +1,95 @@
package uploads
import (
"context"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/musicbrainz"
"testing"
)
func TestReleaseOperationsRequireReviewPermission(t *testing.T) {
s := &Service{}
for _, ctx := range []context.Context{context.Background(), auth.WithPrincipal(context.Background(), auth.Principal{UserID: uuid.New(), Role: auth.RoleAdmin})} {
_, err := s.GetReleaseReport(ctx, uuid.New())
require.Error(t, err)
_, err = s.SearchReleases(ctx, uuid.New(), musicbrainz.Query{})
require.Error(t, err)
_, err = s.ReleaseCover(ctx, uuid.New(), uuid.NewString())
require.Error(t, err)
_, err = s.ApplyRelease(ctx, uuid.New(), uuid.NewString(), true, true)
require.Error(t, err)
}
}
func releaseFile(title string, number, disc, duration int) File {
return File{ID: uuid.New(), Status: StatusPendingReview, Meta: &Meta{Artist: "Artist", AlbumArtist: "Artist", Album: "Album", Title: title, Track: &number, Disc: &disc}, DurationMs: &duration}
}
func TestReleaseCompletenessUsesTracksAndEditions(t *testing.T) {
r := musicbrainz.Release{ID: uuid.NewString(), Artist: "Artist", Type: "Single", Tracks: []musicbrainz.Track{
{Title: "One", Artist: "Artist", Disc: 1, Position: 1, DurationMs: 180000},
{Title: "Two", Artist: "Artist", Disc: 2, Position: 1, DurationMs: 200000},
}}
files := []File{releaseFile("One", 1, 1, 180100), releaseFile("Two", 1, 2, 199000)}
c := matchRelease(files, r)
require.True(t, c.Complete)
require.Equal(t, 2, c.Matched)
require.Equal(t, "high", c.Confidence)
// Two encodings of the same song cover one catalogue slot, never two.
c = matchRelease([]File{files[0], releaseFile("One", 1, 1, 180000)}, r)
require.False(t, c.Complete)
require.Equal(t, 1, c.Matched)
require.Len(t, c.Missing, 1)
// A deluxe edition with another track must remain incomplete.
deluxe := r
deluxe.Tracks = append(append([]musicbrainz.Track{}, r.Tracks...), musicbrainz.Track{Title: "Bonus", Artist: "Artist", Disc: 2, Position: 2, DurationMs: 220000})
require.False(t, matchRelease(files, deluxe).Complete)
// A one-track upload does not turn the two-track single into a full single.
require.False(t, matchRelease(files[:1], r).Complete)
}
func TestReleaseMatchingDoesNotClaimAmbiguousOrWeakMatchesAreComplete(t *testing.T) {
r := musicbrainz.Release{Artist: "Artist", Tracks: []musicbrainz.Track{{Title: "Intro", Artist: "Artist", Disc: 1, Position: 1, DurationMs: 12000}, {Title: "Intro", Artist: "Artist", Disc: 2, Position: 1, DurationMs: 12000}}}
c := matchRelease([]File{releaseFile("Intro", 1, 1, 12000)}, r)
require.False(t, c.Complete)
require.Len(t, c.Unmatched, 1)
r.Tracks = r.Tracks[:1]
f := releaseFile("Untitled", 1, 1, 12000)
c = matchRelease([]File{f}, r)
require.False(t, c.Complete)
require.Equal(t, "medium", c.Confidence)
f.Meta.Title = "Intro"
f.Meta.Artist = "Someone Else"
f.Meta.AlbumArtist = "Someone Else"
require.False(t, matchRelease([]File{f}, r).Complete)
f = releaseFile("Intro", 1, 1, 12000)
f.DurationMs = nil
require.False(t, matchRelease([]File{f}, r).Complete)
}
func TestReleaseQueryPrioritizesIDsAndRejectsMixedAlbums(t *testing.T) {
a, b := releaseFile("One", 1, 1, 180000), releaseFile("Two", 2, 1, 180000)
b.Meta.Album = "Another Album"
_, ok := batchReleaseQuery([]File{a, b})
require.False(t, ok)
id := uuid.NewString()
a.Tags = map[string]string{"musicbrainz_albumid": id}
a.Meta.Album = library.UnknownAlbum
q, ok := batchReleaseQuery([]File{a, b})
require.True(t, ok)
require.Equal(t, id, q.ReleaseID)
b.Tags = map[string]string{"musicbrainz_albumid": uuid.NewString()}
_, ok = batchReleaseQuery([]File{a, b})
require.False(t, ok)
}
func TestReleaseFingerprintInvalidatesEditsButNotApproval(t *testing.T) {
f := releaseFile("One", 1, 1, 180000)
files := []File{f}
hash := releaseHash(files)
files[0].Status = StatusApproved
require.Equal(t, hash, releaseHash(files))
files[0].Meta.Title = "Changed"
require.NotEqual(t, hash, releaseHash(files))
files[0].Status = StatusWithdrawn
require.NotEqual(t, hash, releaseHash(files))
}
@@ -0,0 +1,255 @@
package uploads
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"pipu/api/internal/library/meta"
"pipu/api/internal/musicbrainz"
"pipu/api/internal/platform/apperr"
)
func (st *Store) GetMetadataCache(ctx context.Context, key string) ([]byte, bool, error) {
var data []byte
err := st.pool.QueryRow(ctx, `SELECT payload FROM metadata_lookup_cache WHERE cache_key=$1 AND expires_at>now()`, key).Scan(&data)
if errors.Is(err, pgx.ErrNoRows) {
return nil, false, nil
}
return data, err == nil, err
}
func (st *Store) PutMetadataCache(ctx context.Context, key string, data []byte, ttl time.Duration) error {
_, err := st.pool.Exec(ctx, `INSERT INTO metadata_lookup_cache(cache_key,payload,expires_at) VALUES($1,$2,now()+$3*interval '1 second')
ON CONFLICT(cache_key) DO UPDATE SET payload=EXCLUDED.payload,expires_at=EXCLUDED.expires_at`, key, data, ttl.Seconds())
return err
}
func (st *Store) ExpireMetadataCache(ctx context.Context) error {
_, err := st.pool.Exec(ctx, `DELETE FROM metadata_lookup_cache WHERE expires_at<now()`)
return err
}
func (st *Store) ExistingReleaseTracks(ctx context.Context, tracks []musicbrainz.Track) ([]musicbrainz.Track, error) {
out := []musicbrainz.Track{}
if len(tracks) == 0 {
return out, nil
}
artists, titles := []string{}, []string{}
durations := []int32{}
for _, t := range tracks {
artists = append(artists, meta.Key(t.Artist))
titles = append(titles, meta.Key(t.Title))
durations = append(durations, int32(t.DurationMs))
}
rows, err := st.pool.Query(ctx, `SELECT DISTINCT e.n::int FROM unnest($1::text[],$2::text[],$3::int[]) WITH ORDINALITY e(artist,title,duration,n)
JOIN tracks t ON t.title_norm=e.title AND abs(t.duration_ms-e.duration)<=2000
JOIN track_artists ta ON ta.track_id=t.id JOIN artists a ON a.id=ta.artist_id AND a.name_norm=e.artist
WHERE e.duration>0 AND EXISTS(SELECT 1 FROM media_files mf JOIN library_sources s ON s.id=mf.source_id
WHERE mf.track_id=t.id AND mf.missing_since IS NULL AND mf.scan_error IS NULL AND NOT library_path_excluded(mf.rel_path,s.excluded_dirs))
ORDER BY e.n::int`, artists, titles, durations)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var i int
if err = rows.Scan(&i); err != nil {
return nil, err
}
out = append(out, tracks[i-1])
}
return out, rows.Err()
}
func (st *Store) AllInBatch(ctx context.Context, id uuid.UUID) ([]File, error) {
rows, err := st.pool.Query(ctx, `SELECT `+fileColumns+fileFrom+` WHERE f.batch_id=$1 ORDER BY f.id`, id)
if err != nil {
return nil, err
}
defer rows.Close()
out := []File{}
for rows.Next() {
f, err := scanFile(rows)
if err != nil {
return nil, err
}
out = append(out, f)
}
if err = rows.Err(); err != nil {
return nil, err
}
if len(out) == 0 {
return nil, ErrNotFound
}
return out, nil
}
func (st *Store) ReleaseReport(ctx context.Context, id uuid.UUID, scope ...string) (ReleaseReport, error) {
r := ReleaseReport{State: "queued", Candidates: []ReleaseCandidate{}}
key, err := st.resolveGroupKey(ctx, id, firstGroup(scope))
if err != nil {
return r, err
}
r.GroupID = key
var raw []byte
var audio []byte
err = st.pool.QueryRow(ctx, `SELECT state,input_hash,candidates,selected_release::text,audio_results FROM upload_release_jobs WHERE batch_id=$1 AND group_key=$2`, id, key).Scan(&r.State, &r.InputHash, &raw, &r.SelectedRelease, &audio)
if errors.Is(err, pgx.ErrNoRows) {
return r, nil
}
if err != nil {
return r, err
}
err = json.Unmarshal(raw, &r.Candidates)
if err == nil {
err = json.Unmarshal(audio, &r.AudioResults)
}
return r, err
}
func (st *Store) QueueRelease(ctx context.Context, id uuid.UUID, q musicbrainz.Query) error {
key, err := st.resolveGroupKey(ctx, id, q.GroupID)
if err != nil {
return err
}
raw, _ := json.Marshal(q)
_, err = st.pool.Exec(ctx, `INSERT INTO upload_release_jobs(batch_id,request,group_key) VALUES($1,$2,$3)
ON CONFLICT(batch_id,group_key) DO UPDATE SET state='queued',request=EXCLUDED.request,audio_results='[]',lease=NULL,attempts=0,selected_release=NULL,updated_at=now()`, id, raw, key)
return err
}
type releaseJob struct {
ID, Lease uuid.UUID
GroupKey string
Query musicbrainz.Query
}
func (st *Store) ClaimRelease(ctx context.Context) (releaseJob, error) {
j := releaseJob{Lease: newID()}
// Empty-key jobs bootstrap memberships, then disappear. Actual lookups have independent leases.
var batch uuid.UUID
err := st.pool.QueryRow(ctx, `SELECT j.batch_id FROM upload_release_jobs j WHERE j.group_key='' AND j.state='queued'
AND NOT EXISTS(SELECT 1 FROM upload_files f WHERE f.batch_id=j.batch_id AND f.status IN ('uploading','processing')) ORDER BY j.updated_at LIMIT 1`).Scan(&batch)
if err == nil {
if err = st.EnsureReleaseGroups(ctx, batch); err != nil {
return j, err
}
} else if !errors.Is(err, pgx.ErrNoRows) {
return j, err
}
var raw []byte
err = st.pool.QueryRow(ctx, `UPDATE upload_release_jobs SET state='searching',lease=$1,attempts=attempts+1,updated_at=now()
WHERE (batch_id,group_key)=(SELECT j.batch_id,j.group_key FROM upload_release_jobs j
WHERE j.group_key<>'' AND (j.state='queued' OR j.state='error' AND j.attempts<3 AND j.updated_at<now()-interval '10 minutes'
OR j.state='searching' AND j.updated_at<now()-interval '5 minutes')
AND EXISTS(SELECT 1 FROM upload_files f WHERE f.batch_id=j.batch_id AND f.release_group_id::text=j.group_key AND f.status IN ('pending_review','import_failed'))
AND NOT EXISTS(SELECT 1 FROM upload_files f WHERE f.batch_id=j.batch_id AND f.status IN ('uploading','processing'))
ORDER BY j.updated_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING batch_id,group_key,request`, j.Lease).Scan(&j.ID, &j.GroupKey, &raw)
if errors.Is(err, pgx.ErrNoRows) {
return j, ErrNotFound
}
if err != nil {
return j, err
}
err = json.Unmarshal(raw, &j.Query)
return j, err
}
func (st *Store) SaveRelease(ctx context.Context, j releaseJob, r ReleaseReport) error {
raw, _ := json.Marshal(r.Candidates)
audio := r.AudioResults
if audio == nil {
audio = []AudioRecognition{}
}
encoded, _ := json.Marshal(audio)
_, err := st.pool.Exec(ctx, `UPDATE upload_release_jobs SET state=$3,input_hash=$4,candidates=$5,audio_results=$6,lease=NULL,updated_at=now()
WHERE batch_id=$1 AND lease=$2 AND group_key=$7`, j.ID, j.Lease, r.State, r.InputHash, raw, encoded, j.GroupKey)
return err
}
// ApplyRelease locks the complete batch and rejects stale suggestions. No network
// or filesystem IO occurs inside the transaction; decisions and quota stay intact.
func (st *Store) ApplyRelease(ctx context.Context, batch uuid.UUID, expected string, changes []releaseChange, cover *string, next ReleaseReport, reviewer uuid.UUID) error {
key, err := st.resolveGroupKey(ctx, batch, next.GroupID)
if err != nil {
return err
}
tx, err := st.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
var id uuid.UUID
if err = tx.QueryRow(ctx, `SELECT id FROM upload_batches WHERE id=$1 FOR UPDATE`, batch).Scan(&id); err != nil {
return err
}
rows, err := tx.Query(ctx, `SELECT `+fileColumns+fileFrom+` WHERE f.batch_id=$1 AND f.release_group_id::text=$2 ORDER BY f.id FOR UPDATE OF f`, batch, key)
if err != nil {
return err
}
current := []File{}
editable := false
for rows.Next() {
f, e := scanFile(rows)
if e != nil {
rows.Close()
return e
}
current = append(current, f)
editable = editable || f.Status == StatusPendingReview || f.Status == StatusImportFailed
}
err = rows.Err()
rows.Close()
if err != nil {
return err
}
if !editable {
return ErrWrongStatus
}
if releaseHash(current) != expected {
return apperr.Conflict("uploads.release_stale", "batch changed during review")
}
var state, hash string
if err = tx.QueryRow(ctx, `SELECT state,input_hash FROM upload_release_jobs WHERE batch_id=$1 AND group_key=$2 FOR UPDATE`, batch, key).Scan(&state, &hash); err != nil {
return err
}
if state != "ready" || hash != expected {
return apperr.Conflict("uploads.release_stale", "release lookup changed during review")
}
for _, c := range changes {
metadata, _ := json.Marshal(c.Meta)
tags, _ := json.Marshal(c.Tags)
result, e := tx.Exec(ctx, `UPDATE upload_files SET metadata=$2,tags=$3,metadata_edited=true,target_source_id=$4,target_rel_path=$5,
status_reason=$6,matched_artist_id=$7,matched_album_id=$8,updated_at=now() WHERE id=$1 AND release_group_id::text=$9 AND status IN ('pending_review','import_failed')`,
c.File.ID, metadata, tags, c.Placement.SourceID, c.Placement.RelPath, nullable(c.Placement.Reason), c.Placement.ArtistID, c.Placement.AlbumID, key)
if e != nil {
return e
}
if result.RowsAffected() != 1 {
return ErrWrongStatus
}
}
if cover != nil {
if _, err = tx.Exec(ctx, `UPDATE upload_release_groups SET cover_path=$2 WHERE batch_id=$1 AND id::text=$3`, batch, *cover, key); err != nil {
return err
}
}
if len(changes) > 0 {
m := changes[0].Meta
if _, err = tx.Exec(ctx, `UPDATE upload_release_groups SET artist=$3,album=$4 WHERE batch_id=$1 AND id::text=$2`, batch, key, m.AlbumArtist, m.Album); err != nil {
return err
}
}
raw, _ := json.Marshal(next.Candidates)
audio := next.AudioResults
if audio == nil {
audio = []AudioRecognition{}
}
encoded, _ := json.Marshal(audio)
if _, err = tx.Exec(ctx, `UPDATE upload_release_jobs SET state='ready',input_hash=$2,candidates=$3,selected_release=$4,audio_results=$5,lease=NULL,updated_at=now() WHERE batch_id=$1 AND group_key=$6`, batch, next.InputHash, raw, next.SelectedRelease, encoded, key); err != nil {
return err
}
if err = tx.Commit(ctx); err != nil {
return err
}
st.Audit(ctx, &reviewer, "upload.release.apply", batch, map[string]any{"group": key, "release": next.SelectedRelease, "files": len(changes), "cover": cover != nil})
return nil
}
+298
View File
@@ -0,0 +1,298 @@
package uploads
import (
"context"
"errors"
"net/http"
"os"
"sort"
"strconv"
"time"
"github.com/google/uuid"
"pipu/api/internal/auth"
"pipu/api/internal/musicbrainz"
"pipu/api/internal/platform/apperr"
)
func (s *Service) lookupNextRelease(ctx context.Context) bool {
job, err := s.store.ClaimRelease(ctx)
if errors.Is(err, ErrNotFound) {
return false
}
if err != nil {
s.log.Error("uploads: release job", "err", err)
return false
}
run, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()
files, err := s.store.AllInReleaseGroup(run, job.ID, job.GroupKey)
report := ReleaseReport{State: "error", Candidates: []ReleaseCandidate{}, GroupID: job.GroupKey}
if err == nil {
report.InputHash = releaseHash(files)
q := job.Query
var audio map[uuid.UUID]AudioRecognition
if q.Audio {
report.AudioResults, audio, q.RecordingIDs, err = s.identifyAudio(run, files)
}
valid := true
if !q.Audio && q.Artist == "" && q.Album == "" && q.ReleaseID == "" {
q, valid = batchReleaseQuery(files)
}
if err != nil {
report.State = "error"
} else if q.Audio && len(q.RecordingIDs) == 0 {
report.State = "not_found"
} else if !q.Audio && (!valid || q.ReleaseID == "" && (q.Artist == "" || q.Album == "")) {
report.State = "needs_input"
} else {
var releases []musicbrainz.Release
releases, err = s.catalogue.Search(run, q)
if err == nil {
report.State = "not_found"
for _, release := range releases {
candidate := matchReleaseWithAudio(files, release, audio)
candidate.Existing, err = s.store.ExistingReleaseTracks(run, candidate.Missing)
if err != nil {
break
}
report.Candidates = append(report.Candidates, candidate)
}
if len(report.Candidates) > 0 {
report.State = "ready"
}
sort.SliceStable(report.Candidates, func(i, j int) bool {
a, b := report.Candidates[i], report.Candidates[j]
if a.Complete != b.Complete {
return a.Complete
}
if a.Matched != b.Matched {
return a.Matched > b.Matched
}
return len(a.Unmatched) < len(b.Unmatched)
})
}
}
}
if err != nil {
report.State = "error"
report.Candidates = []ReleaseCandidate{}
s.log.Warn("uploads: release lookup", "batch_id", job.ID, "err", err)
}
if err = s.store.SaveRelease(ctx, job, report); err != nil {
s.log.Error("uploads: save release lookup", "err", err)
}
return true
}
func (s *Service) GetReleaseReport(ctx context.Context, id uuid.UUID, scope ...string) (ReleaseReport, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return ReleaseReport{}, err
}
files, err := s.store.AllInBatch(ctx, id)
if err != nil {
return ReleaseReport{}, notFound(err)
}
files, groups, key, err := s.releaseScope(ctx, id, firstGroup(scope))
if err != nil {
return ReleaseReport{}, err
}
if s.catalogue == nil {
return ReleaseReport{State: "disabled", Candidates: []ReleaseCandidate{}, GroupID: key, Groups: groups}, nil
}
if len(groups) > 1 && key == "" {
return ReleaseReport{State: "needs_input", Candidates: []ReleaseCandidate{}, Groups: groups, AudioAvailable: s.identifier != nil}, nil
}
report, err := s.store.ReleaseReport(ctx, id, key)
if err != nil {
return report, err
}
if report.InputHash != "" && report.InputHash != releaseHash(files) && report.State != "queued" && report.State != "searching" {
if err = s.store.QueueRelease(ctx, id, musicbrainz.Query{GroupID: key}); err != nil {
return report, err
}
report = ReleaseReport{State: "queued", Candidates: []ReleaseCandidate{}, GroupID: key}
}
report.AudioAvailable = s.identifier != nil
report.Groups = groups
return report, nil
}
func (s *Service) SearchReleases(ctx context.Context, id uuid.UUID, q musicbrainz.Query) (ReleaseReport, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return ReleaseReport{}, err
}
if s.catalogue == nil {
return ReleaseReport{}, apperr.Conflict("uploads.metadata_disabled", "external metadata is disabled")
}
if q.Audio && s.identifier == nil {
return ReleaseReport{}, apperr.Conflict("uploads.audio_disabled", "AcoustID application key is not configured")
}
files, groups, key, err := s.releaseScope(ctx, id, q.GroupID)
if err != nil {
return ReleaseReport{}, notFound(err)
}
if key == "" {
return ReleaseReport{}, apperr.Conflict("uploads.release_group_required", "choose a release group")
}
q.GroupID = key
editable := false
for _, f := range files {
editable = editable || f.Status == StatusPendingReview || f.Status == StatusImportFailed
}
if !editable {
return ReleaseReport{}, notFound(ErrWrongStatus)
}
if q.Audio {
count := 0
for _, f := range releaseFiles(files) {
if f.Status == StatusPendingReview || f.Status == StatusImportFailed {
count++
}
}
if count > maxAudioFiles {
return ReleaseReport{}, apperr.Validation("audio", "uploads.audio_batch_limit", "at most 50 editable tracks per audio lookup", nil)
}
q = musicbrainz.Query{Audio: true, GroupID: key}
}
if len(q.Artist) > 500 || len(q.Album) > 500 {
return ReleaseReport{}, apperr.Validation("query", "common.bad_request", "query too long", nil)
}
if q.ReleaseID != "" {
if _, err = uuid.Parse(q.ReleaseID); err != nil {
return ReleaseReport{}, apperr.Validation("release_id", "common.bad_request", "invalid release ID", nil)
}
}
if err = s.store.QueueRelease(ctx, id, q); err != nil {
return ReleaseReport{}, err
}
return ReleaseReport{State: "queued", Candidates: []ReleaseCandidate{}, AudioAvailable: s.identifier != nil, GroupID: key, Groups: groups}, nil
}
func (s *Service) releaseCandidate(ctx context.Context, batch uuid.UUID, release string, scope ...string) (ReleaseCandidate, []File, ReleaseReport, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return ReleaseCandidate{}, nil, ReleaseReport{}, err
}
files, groups, key, err := s.releaseScope(ctx, batch, firstGroup(scope))
if err != nil {
return ReleaseCandidate{}, nil, ReleaseReport{}, notFound(err)
}
if len(groups) > 1 && key == "" {
return ReleaseCandidate{}, nil, ReleaseReport{}, apperr.Conflict("uploads.release_group_required", "choose a release group")
}
report, err := s.store.ReleaseReport(ctx, batch, key)
if err != nil {
return ReleaseCandidate{}, files, report, err
}
report.Groups = groups
if report.State != "ready" || report.InputHash != releaseHash(files) {
return ReleaseCandidate{}, files, report, apperr.Conflict("uploads.release_stale", "metadata changed; repeat release lookup")
}
for _, candidate := range report.Candidates {
if candidate.Release.ID == release {
return candidate, files, report, nil
}
}
return ReleaseCandidate{}, files, report, apperr.NotFound("uploads.release_not_found", "release is not in this batch's candidates")
}
func (s *Service) ReleaseCover(ctx context.Context, batch uuid.UUID, release string, scope ...string) (musicbrainz.Cover, error) {
c, _, _, err := s.releaseCandidate(ctx, batch, release, scope...)
if err != nil {
return musicbrainz.Cover{}, err
}
if s.catalogue == nil {
return musicbrainz.Cover{}, apperr.Conflict("uploads.metadata_disabled", "external metadata is disabled")
}
cover, err := s.catalogue.Cover(ctx, c.Release)
if errors.Is(err, musicbrainz.ErrNotFound) {
return cover, apperr.NotFound("uploads.release_cover_missing", "release has no cover")
}
if err != nil {
return cover, apperr.New(http.StatusServiceUnavailable, "uploads.metadata_unavailable", "cover provider is unavailable")
}
return cover, err
}
type releaseChange struct {
File File
Meta Meta
Placement placement
Tags map[string]string
}
func (s *Service) ApplyRelease(ctx context.Context, batch uuid.UUID, release string, metadata, cover bool, scope ...string) (ReleaseReport, error) {
candidate, files, report, err := s.releaseCandidate(ctx, batch, release, scope...)
if err != nil {
return report, err
}
if !metadata && !cover {
return report, apperr.Validation("apply_metadata", "common.bad_request", "choose metadata or cover", nil)
}
var coverPath *string
if cover {
image, err := s.ReleaseCover(ctx, batch, release, report.GroupID)
if err != nil {
return report, err
}
coverPath, err = s.createCover(image.Data)
if err != nil {
return report, err
}
}
committed := false
defer func() {
if !committed && coverPath != nil {
_ = os.Remove(s.stagingAbs(*coverPath))
}
}()
changes := []releaseChange{}
byID := map[uuid.UUID]int{}
for i, f := range files {
byID[f.ID] = i
}
for _, match := range candidate.Matches {
i := byID[match.FileID]
f := files[i]
if !metadata || f.Meta == nil || f.Status != StatusPendingReview && f.Status != StatusImportFailed {
continue
}
t := candidate.Release.Tracks[match.TrackIndex]
m := *f.Meta
m.Artist, m.AlbumArtist, m.Album, m.Title = t.Artist, candidate.Release.Artist, candidate.Release.Title, t.Title
m.Disc, m.Track = &t.Disc, &t.Position
if len(candidate.Release.Date) >= 4 {
if y, e := strconv.Atoi(candidate.Release.Date[:4]); e == nil {
m.Year = &y
}
}
m = normalizeMeta(m)
tags := map[string]string{}
for k, v := range f.Tags {
tags[k] = v
}
tags["musicbrainz_albumid"], tags["musicbrainz_releasegroupid"], tags["musicbrainz_trackid"] = candidate.Release.ID, candidate.Release.GroupID, t.RecordingID
changes = append(changes, releaseChange{f, m, s.plan(ctx, f, m, f.TargetSourceID), tags})
files[i].Meta, files[i].Tags = &m, tags
}
if metadata && len(changes) == 0 {
return report, apperr.Conflict(CodeWrongStatus, "no matched editable tracks")
}
report.SelectedRelease = &release
next := report
next.InputHash = releaseHash(files)
reviewer, _ := principal(ctx, auth.PermReviewUploads)
if err = s.store.ApplyRelease(ctx, batch, report.InputHash, changes, coverPath, next, reviewer.UserID); err != nil {
return report, notFound(err)
}
committed = true
next.AudioAvailable = s.identifier != nil
for i, g := range next.Groups {
if g.ID == next.GroupID {
if cover {
next.Groups[i].HasCover = true
}
if metadata {
next.Groups[i].Artist = candidate.Release.Artist
next.Groups[i].Album = candidate.Release.Title
}
}
}
return next, nil
}
+70 -12
View File
@@ -13,9 +13,11 @@ import (
"github.com/google/uuid"
"pipu/api/internal/acoustid"
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/library/meta"
"pipu/api/internal/musicbrainz"
"pipu/api/internal/platform/apperr"
)
@@ -37,9 +39,28 @@ type Service struct {
cfg Config
log *slog.Logger
wakeProcess chan struct{}
wakeImport chan struct{}
now func() time.Time
wakeProcess chan struct{}
wakeImport chan struct{}
now func() time.Time
catalogue ReleaseCatalogue
identifier AudioIdentifier
encodedFingerprint func(context.Context, string) (string, error)
}
type ReleaseCatalogue interface {
Search(context.Context, musicbrainz.Query) ([]musicbrainz.Release, error)
Cover(context.Context, musicbrainz.Release) (musicbrainz.Cover, error)
}
func (s *Service) SetReleaseCatalogue(c ReleaseCatalogue) { s.catalogue = c }
type AudioIdentifier interface {
Lookup(context.Context, string, int) ([]acoustid.Match, error)
}
func (s *Service) SetAudioIdentifier(c AudioIdentifier) {
s.identifier = c
s.encodedFingerprint = s.fpcalc.EncodedFingerprint
}
// NewService создаёт сервис очереди.
@@ -178,7 +199,7 @@ type CreateFile struct {
}
// Create создаёт пачку и резервирует место; не поместившиеся и дубли отклоняются с кодом.
func (s *Service) Create(ctx context.Context, comment *string, files []CreateFile) (uuid.UUID, []Reserved, error) {
func (s *Service) Create(ctx context.Context, comment *string, files []CreateFile, cover []byte) (uuid.UUID, []Reserved, error) {
p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil {
return uuid.Nil, nil, err
@@ -209,10 +230,25 @@ func (s *Service) Create(ctx context.Context, comment *string, files []CreateFil
}
accepted = append(accepted, NewFile{ClientID: f.ClientID, Name: name, RelPath: cleanClientPath(f.RelPath), Size: f.Size, SHA256: sum})
}
batch, res, err := s.store.CreateBatch(ctx, p.UserID, comment, accepted, st)
coverPath, err := s.createCover(cover)
if err != nil {
return uuid.Nil, nil, err
}
keepCover := false
defer func() {
if coverPath != nil && !keepCover {
_ = os.Remove(s.stagingAbs(*coverPath))
}
}()
batch, res, err := s.store.CreateBatch(ctx, p.UserID, comment, accepted, st, coverPath)
if err != nil {
return uuid.Nil, nil, err
}
for _, r := range res {
if r.Rejected == "" {
keepCover = true
}
}
return batch, append(res, rejected...), nil
}
@@ -240,12 +276,12 @@ func (s *Service) Complete(ctx context.Context, f File) error {
// --- Списки ---
// ListMine — предложения текущего пользователя.
func (s *Service) ListMine(ctx context.Context, statuses []Status, cursor string, limit int) ([]File, string, error) {
func (s *Service) ListMine(ctx context.Context, statuses []Status, cursor string, limit int, batch *uuid.UUID) ([]File, string, error) {
p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil {
return nil, "", err
}
return s.list(ctx, ListFilter{AuthorID: &p.UserID, Statuses: statuses, Cursor: cursor, Limit: limit})
return s.list(ctx, ListFilter{AuthorID: &p.UserID, BatchID: batch, Statuses: statuses, Cursor: cursor, Limit: limit})
}
// ListAll — вся очередь (админ).
@@ -253,7 +289,14 @@ func (s *Service) ListAll(ctx context.Context, fl ListFilter) ([]File, string, e
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return nil, "", err
}
return s.list(ctx, fl)
files, next, err := s.list(ctx, fl)
if err != nil {
return nil, "", err
}
for i := range files {
s.previewPlacement(ctx, &files[i])
}
return files, next, nil
}
func (s *Service) list(ctx context.Context, fl ListFilter) ([]File, string, error) {
@@ -279,7 +322,7 @@ func (s *Service) Withdraw(ctx context.Context, id uuid.UUID) error {
if err := s.store.ChangeStatus(ctx, id, []Status{StatusUploading, StatusProcessing, StatusPendingReview}, StatusWithdrawn); err != nil {
return notFound(err)
}
s.removeStaging(f)
s.removeStaging(ctx, f)
return nil
}
@@ -324,7 +367,11 @@ func (s *Service) Update(ctx context.Context, id uuid.UUID, in UpdateInput) (Fil
if err := s.store.UpdateMetadata(ctx, id, m, edited, pl); err != nil {
return File{}, notFound(err)
}
return s.store.File(ctx, id)
updated, err := s.store.File(ctx, id)
if err == nil {
s.previewPlacement(ctx, &updated)
}
return updated, err
}
func normalizeMeta(m Meta) Meta {
@@ -380,7 +427,7 @@ func (s *Service) reviewOne(ctx context.Context, reviewer, id uuid.UUID, approve
if err := s.store.Decide(ctx, id, reviewer, false, reason); err != nil {
return notFound(err)
}
s.removeStaging(f)
s.removeStaging(ctx, f)
s.store.Audit(ctx, &reviewer, "upload.reject", id, map[string]any{"reason": reason})
return nil
}
@@ -542,7 +589,7 @@ func reuseExisting(root, rel string) string {
}
// removeStaging удаляет файл staging (и временные копии с тегами).
func (s *Service) removeStaging(f File) {
func (s *Service) removeStaging(ctx context.Context, f File) {
if f.StagingPath == "" {
return
}
@@ -552,6 +599,17 @@ func (s *Service) removeStaging(f File) {
_ = os.Remove(m)
}
_ = os.Remove(filepath.Dir(p)) // каталог пачки, если опустел
// A mixed batch can retain an unassigned original cover even when this file has none.
if s.store != nil {
if covers, err := s.store.UnusedReleaseCovers(ctx, f.BatchID); err == nil {
for _, cover := range covers {
_ = os.Remove(s.stagingAbs(cover))
}
}
if cover, err := s.store.UnusedBatchCover(ctx, f.BatchID); err == nil && cover != nil {
_ = os.Remove(s.stagingAbs(*cover))
}
}
}
// TusURL — адрес tus для файла.
+33 -9
View File
@@ -98,7 +98,7 @@ type Reserved struct {
// CreateBatch создаёт пачку и резервирует место под файлы: проверка «занято + запрошено ≤ лимит»
// под блокировкой строки счётчика (docs/uploads.md#3). Дубли по SHA-256 отклоняются, не занимая места.
func (st *Store) CreateBatch(ctx context.Context, author uuid.UUID, comment *string, files []NewFile, s Settings) (uuid.UUID, []Reserved, error) {
func (st *Store) CreateBatch(ctx context.Context, author uuid.UUID, comment *string, files []NewFile, s Settings, coverPath *string) (uuid.UUID, []Reserved, error) {
tx, err := st.pool.Begin(ctx)
if err != nil {
return uuid.Nil, nil, fmt.Errorf("uploads: create batch: %w", err)
@@ -116,8 +116,8 @@ func (st *Store) CreateBatch(ctx context.Context, author uuid.UUID, comment *str
}
batchID := newID()
if _, err := tx.Exec(ctx, `INSERT INTO upload_batches (id, author_id, comment) VALUES ($1, $2, $3)`,
batchID, author, comment); err != nil {
if _, err := tx.Exec(ctx, `INSERT INTO upload_batches (id, author_id, comment, cover_path) VALUES ($1, $2, $3, $4)`,
batchID, author, comment, coverPath); err != nil {
return uuid.Nil, nil, fmt.Errorf("uploads: insert batch: %w", err)
}
out := make([]Reserved, 0, len(files))
@@ -231,9 +231,11 @@ const fileColumns = `f.id, f.batch_id, f.author_id, u.username, b.comment, f.sta
f.size_bytes, f.received_bytes, f.sha256, coalesce(f.staging_path, ''), f.format, f.codec, f.bitrate, f.sample_rate,
f.bit_depth, f.duration_ms, f.tags, f.metadata, f.metadata_edited, f.matched_artist_id, f.matched_album_id,
f.duplicate_of_track_id, f.duplicate_level, f.possible_duplicate, f.quality_upgrade, f.target_source_id, f.target_rel_path,
f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, b.cover_path, f.created_at, f.updated_at`
f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, CASE WHEN f.release_group_id IS NULL THEN b.cover_path ELSE rg.cover_path END, f.created_at, f.updated_at, src.name`
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
LEFT JOIN upload_release_groups rg ON rg.id=f.release_group_id
LEFT JOIN library_sources src ON src.id = f.target_source_id`
func scanFile(row pgx.Row) (File, error) {
var (
@@ -244,7 +246,7 @@ func scanFile(row pgx.Row) (File, error) {
&f.ClientRelPath, &f.Size, &f.Received, &f.SHA256, &f.StagingPath, &f.Format, &f.Codec, &f.Bitrate, &f.SampleRate,
&f.BitDepth, &f.DurationMs, &tags, &meta, &f.MetadataEdited, &f.MatchedArtistID, &f.MatchedAlbumID,
&f.DuplicateOfTrackID, &f.DuplicateLevel, &f.PossibleDuplicate, &f.QualityUpgrade, &f.TargetSourceID, &f.TargetRelPath,
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CoverPath, &f.CreatedAt, &f.UpdatedAt)
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CoverPath, &f.CreatedAt, &f.UpdatedAt, &f.TargetSourceName)
if errors.Is(err, pgx.ErrNoRows) {
return File{}, ErrNotFound
}
@@ -348,7 +350,11 @@ func (st *Store) NextWithStatus(ctx context.Context, s Status) (File, error) {
// StagingInUse — пути staging файлов, которым он ещё нужен (сверка при старте).
func (st *Store) StagingInUse(ctx context.Context) (map[string]bool, error) {
rows, err := st.pool.Query(ctx, `SELECT staging_path FROM upload_files WHERE status::text = ANY($1) AND staging_path IS NOT NULL`,
rows, err := st.pool.Query(ctx, `SELECT staging_path FROM upload_files WHERE status::text = ANY($1) AND staging_path IS NOT NULL
UNION SELECT b.cover_path FROM upload_batches b JOIN upload_files f ON f.batch_id = b.id
WHERE f.status::text = ANY($1) AND b.cover_path IS NOT NULL
UNION SELECT g.cover_path FROM upload_release_groups g JOIN upload_files f ON f.release_group_id=g.id
WHERE f.status::text = ANY($1) AND g.cover_path IS NOT NULL`,
occupyingStatuses)
if err != nil {
return nil, fmt.Errorf("uploads: staging in use: %w", err)
@@ -364,6 +370,17 @@ func (st *Store) StagingInUse(ctx context.Context) (map[string]bool, error) {
return out, nil
}
// UnusedBatchCover returns a cover only after every file in its batch has released staging.
func (st *Store) UnusedBatchCover(ctx context.Context, batch uuid.UUID) (*string, error) {
var cover *string
err := st.pool.QueryRow(ctx, `SELECT b.cover_path FROM upload_batches b WHERE b.id = $1
AND NOT EXISTS (SELECT 1 FROM upload_files f WHERE f.batch_id = b.id AND f.status::text = ANY($2))`, batch, occupyingStatuses).Scan(&cover)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return cover, err
}
// Stale — недокачанные файлы старше before.
func (st *Store) Stale(ctx context.Context, before time.Time) ([]uuid.UUID, error) {
rows, err := st.pool.Query(ctx, `SELECT id FROM upload_files WHERE status = 'uploading' AND updated_at < $1`, before)
@@ -579,12 +596,19 @@ func (st *Store) PendingInBatch(ctx context.Context, batchID uuid.UUID) ([]File,
// 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)
tag, err := st.pool.Exec(ctx, `WITH updated AS (UPDATE upload_batches SET cover_path = $2 WHERE id = $1 RETURNING id)
UPDATE upload_release_groups SET cover_path=$2 WHERE batch_id IN (SELECT id FROM updated)`, batchID, path)
if err != nil {
return fmt.Errorf("uploads: set cover: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
var exists bool
if err = st.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM upload_batches WHERE id=$1)`, batchID).Scan(&exists); err != nil {
return err
}
if !exists {
return ErrNotFound
}
}
return nil
}
+3 -11
View File
@@ -4,7 +4,6 @@ import (
"context"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
@@ -15,23 +14,17 @@ import (
"pipu/api/internal/platform/httpx"
)
// uploadStream — отдача файла staging с Range (сам api или X-Accel-Redirect).
// uploadStream serves private staging files through the API with Range support.
// Nginx runs as another user and cannot read tus files (0750 directories, 0640 files).
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
@@ -69,8 +62,7 @@ func (h *Handler) StreamUpload(ctx context.Context, req gen.StreamUploadRequestO
format = *f.Format
}
return uploadStream{
req: httpx.RequestFrom(ctx), file: f.Abs, format: format, xAccel: h.xAccel,
accel: "/_internal/uploads/" + filepath.ToSlash(f.StagingPath),
req: httpx.RequestFrom(ctx), file: f.Abs, format: format,
}, nil
}
@@ -0,0 +1,57 @@
package uploads
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/sign"
)
func TestPrivateUploadStreamIsServedByAPIWithRange(t *testing.T) {
file := filepath.Join(t.TempDir(), "audio")
require.NoError(t, os.WriteFile(file, []byte("fLaC0123456789"), 0600))
req := httptest.NewRequest(http.MethodGet, "/audio", nil)
req.Header.Set("Range", "bytes=4-7")
rec := httptest.NewRecorder()
require.NoError(t, (uploadStream{req: req, file: file, format: "flac"}).VisitStreamUploadResponse(rec))
require.Equal(t, http.StatusPartialContent, rec.Code)
require.Equal(t, "audio/flac", rec.Header().Get("Content-Type"))
require.Equal(t, "bytes 4-7/14", rec.Header().Get("Content-Range"))
require.Equal(t, "0123", rec.Body.String())
require.Empty(t, rec.Header().Get("X-Accel-Redirect"))
rec = httptest.NewRecorder()
require.NoError(t, (uploadStream{req: httptest.NewRequest(http.MethodGet, "/audio", nil), file: file, format: "flac"}).VisitStreamUploadResponse(rec))
require.Equal(t, http.StatusOK, rec.Code)
require.Empty(t, rec.Header().Get("X-Accel-Redirect"))
require.Equal(t, "fLaC0123456789", rec.Body.String())
require.Equal(t, "audio/flac", rec.Header().Get("Content-Type"))
}
func TestUploadStreamRejectsInvalidAndExpiredLinksBeforeReadingStaging(t *testing.T) {
signer, err := sign.New("")
require.NoError(t, err)
h := NewHandler(nil, signer)
id := uuid.New()
for _, expired := range []bool{false, true} {
exp := time.Now().Add(time.Hour).Unix()
sig := "invalid"
if expired {
exp = time.Now().Add(-time.Hour).Unix()
sig = signer.Sign("upload", id.String(), strconv.FormatInt(exp, 10))
}
res, err := h.StreamUpload(context.Background(), gen.StreamUploadRequestObject{Id: id, Params: gen.StreamUploadParams{Exp: exp, Sig: sig}})
require.Nil(t, res)
require.ErrorContains(t, err, "link is invalid or expired")
}
}
+5
View File
@@ -10,6 +10,7 @@ import (
"path/filepath"
"strconv"
"sync"
"syscall"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
@@ -162,6 +163,10 @@ func (t *TUS) patch(w http.ResponseWriter, r *http.Request) {
}
// Обрыв посреди куска: принятое сохранено, клиент спросит HEAD и продолжит.
t.log.WarnContext(r.Context(), "tus: write interrupted", "upload_id", f.ID, "offset", now, "err", werr)
if errors.Is(werr, syscall.ENOSPC) || errors.Is(werr, syscall.EDQUOT) {
httpx.WriteProblem(w, http.StatusInsufficientStorage, "uploads.storage_full", "upload storage is full")
return
}
httpx.WriteProblem(w, http.StatusInternalServerError, "common.internal", "write interrupted")
return
}
@@ -3,12 +3,14 @@ package uploads
import (
"bytes"
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"syscall"
"testing"
"github.com/go-chi/chi/v5"
@@ -19,6 +21,59 @@ import (
"pipu/api/internal/auth"
)
type failingReader struct{ err error }
func TestBatchCoverPresenceInFileDTO(t *testing.T) {
h := NewHandler(nil, nil)
for _, tc := range []struct {
path *string
want bool
}{
{nil, false}, {nullable(""), false}, {nullable("covers/album.jpg"), true},
} {
got := h.fileDTO(context.Background(), File{CoverPath: tc.path})
require.NotNil(t, got.BatchHasCover)
require.Equal(t, tc.want, *got.BatchHasCover)
}
}
func (r failingReader) Read([]byte) (int, error) { return 0, r.err }
func TestTusInterruptedWriteCanResume(t *testing.T) {
for _, tc := range []struct {
name string
err error
status int
code string
}{
{"disk full", &os.PathError{Op: "write", Path: "/uploads/file", Err: syscall.ENOSPC}, http.StatusInsufficientStorage, "uploads.storage_full"},
{"disk quota", syscall.EDQUOT, http.StatusInsufficientStorage, "uploads.storage_full"},
{"connection interrupted", io.ErrUnexpectedEOF, http.StatusInternalServerError, "common.internal"},
} {
t.Run(tc.name, func(t *testing.T) {
e := newTusEnv(t, 10)
req := httptest.NewRequest(http.MethodPatch, TusURL(e.files.f.ID), io.MultiReader(bytes.NewBufferString("hello"), failingReader{tc.err}))
req.Header.Set("Tus-Resumable", tusVersion)
req.Header.Set("Content-Type", "application/offset+octet-stream")
req.Header.Set("Upload-Offset", "0")
req = req.WithContext(auth.WithPrincipal(req.Context(), auth.Principal{UserID: e.author, Role: auth.RoleModerator}))
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
require.Equal(t, tc.status, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), tc.code)
assert.NotContains(t, rec.Body.String(), "/uploads/file")
assert.EqualValues(t, 5, e.files.f.Received)
assert.Equal(t, 0, e.complete)
assert.Equal(t, "5", e.do(e.author, http.MethodHead, 0, "").Header().Get("Upload-Offset"))
require.Equal(t, http.StatusNoContent, e.do(e.author, http.MethodPatch, 5, "world").Code)
assert.Equal(t, 1, e.complete)
got, err := os.ReadFile(filepath.Join(e.dir, e.files.f.StagingPath))
require.NoError(t, err)
assert.Equal(t, "helloworld", string(got))
})
}
}
type fakeFiles struct{ f File }
func (m *fakeFiles) File(_ context.Context, id uuid.UUID) (File, error) {
@@ -141,6 +196,26 @@ func TestExtFor(t *testing.T) {
}
}
func TestInitialAlbumCover(t *testing.T) {
s := &Service{cfg: Config{StagingDir: t.TempDir()}}
if cover, err := s.createCover(nil); err != nil || cover != nil {
t.Fatalf("missing cover = %v, %v", cover, err)
}
if _, err := s.createCover([]byte("not an image")); err == nil {
t.Fatal("invalid cover must be rejected")
}
data := []byte{0xff, 0xd8, 0xff, 1}
rel, err := s.createCover(data)
require.NoError(t, err)
require.NotNil(t, rel)
got, err := os.ReadFile(s.stagingAbs(*rel))
require.NoError(t, err)
assert.Equal(t, data, got)
second, err := s.createCover(data)
require.NoError(t, err)
assert.NotEqual(t, *rel, *second, "album batches must not overwrite each other's cover")
}
func TestReuseExistingFolders(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "Кино", "1988 - Группа крови"), 0o755))
+19 -3
View File
@@ -41,6 +41,9 @@ func (s *Service) Run(ctx context.Context) {
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)
if err := s.store.ExpireMetadataCache(ctx); err != nil {
s.log.Warn("uploads: expire metadata cache", "err", err)
}
if err := s.store.ReconcileQuota(ctx); err != nil {
s.log.Error("uploads: reconcile quota", "err", err)
}
@@ -51,6 +54,9 @@ func (s *Service) Run(ctx context.Context) {
}
wake(s.wakeProcess)
wake(s.wakeImport)
if s.catalogue != nil {
go s.loop(ctx, nil, pollEvery, func(ctx context.Context) bool { return s.lookupNextRelease(ctx) })
}
}
// loop вызывает step, пока тот находит работу, затем ждёт сигнала или таймера.
@@ -116,7 +122,7 @@ func (s *Service) process(ctx context.Context, f File) error {
return err
}
if o.status != StatusPendingReview {
s.removeStaging(f)
s.removeStaging(ctx, f)
}
s.log.Info("upload processed", "upload_id", f.ID, "status", o.status, "reason", o.reason)
return nil
@@ -133,6 +139,11 @@ func (s *Service) analyze(ctx context.Context, f File, abs string) outcome {
return outcome{status: StatusInvalid, reason: CodeInvalidAudio}
}
o := outcome{probe: probe, tags: tagsMap(probe.Tags)}
for key, value := range probe.RawTags {
if strings.HasPrefix(key, "musicbrainz") {
o.tags[key] = value
}
}
if o.sha, err = library.FileSHA256(abs); err != nil {
return outcome{status: StatusInvalid, reason: CodeInvalidAudio, probe: probe}
}
@@ -326,7 +337,7 @@ func (s *Service) importFile(ctx context.Context, f File) error {
if err := s.store.CompleteImport(ctx, f.ID, pl.src.ID, rel, fileID); err != nil {
return err
}
s.removeStaging(f)
s.removeStaging(ctx, f)
s.store.Audit(ctx, f.ReviewedBy, "upload.import", f.ID, map[string]any{"source_id": pl.src.ID, "rel_path": rel})
s.log.Info("upload imported", "upload_id", f.ID, "source_id", pl.src.ID, "rel_path", rel)
return nil
@@ -355,6 +366,11 @@ func (s *Service) writeTags(ctx context.Context, f File, m Meta, ext string) (st
set("album_artist", m.AlbumArtist)
set("album", m.Album)
set("genre", m.Genre)
for _, key := range []string{"musicbrainz_albumid", "musicbrainz_releasegroupid", "musicbrainz_trackid"} {
if value := f.Tags[key]; value != "" {
set(key, value)
}
}
num := func(p *int) string {
if p == nil {
return ""
@@ -397,7 +413,7 @@ func (s *Service) expire(ctx context.Context) {
continue
}
if err := s.store.ChangeStatus(ctx, id, []Status{StatusUploading}, StatusExpired); err == nil {
s.removeStaging(f)
s.removeStaging(ctx, f)
}
}
}
@@ -0,0 +1,58 @@
-- +goose Up
CREATE TABLE metadata_lookup_cache (
cache_key text PRIMARY KEY,
payload bytea NOT NULL,
expires_at timestamptz NOT NULL
);
CREATE INDEX metadata_lookup_cache_expiry ON metadata_lookup_cache(expires_at);
CREATE TABLE upload_release_groups (
id uuid PRIMARY KEY,
batch_id uuid NOT NULL REFERENCES upload_batches(id) ON DELETE CASCADE,
grouping_key text NOT NULL,
artist text NOT NULL DEFAULT '',
album text NOT NULL DEFAULT '',
cover_path text,
UNIQUE(batch_id,grouping_key)
);
ALTER TABLE upload_files ADD COLUMN release_group_id uuid REFERENCES upload_release_groups(id) ON DELETE SET NULL;
CREATE INDEX upload_files_release_group ON upload_files(release_group_id);
CREATE TABLE upload_release_jobs (
batch_id uuid NOT NULL REFERENCES upload_batches(id) ON DELETE CASCADE,
group_key text NOT NULL DEFAULT '',
state text NOT NULL DEFAULT 'queued' CHECK (state IN ('queued','searching','ready','not_found','needs_input','error')),
request jsonb NOT NULL DEFAULT '{}',
input_hash text NOT NULL DEFAULT '',
candidates jsonb NOT NULL DEFAULT '[]',
audio_results jsonb NOT NULL DEFAULT '[]',
selected_release uuid,
lease uuid,
attempts int NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY(batch_id,group_key)
);
-- +goose StatementBegin
CREATE FUNCTION queue_upload_release_lookup() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.status IN ('pending_review','import_failed','withdrawn') AND
(OLD.status IS DISTINCT FROM NEW.status OR OLD.metadata IS DISTINCT FROM NEW.metadata OR
OLD.tags IS DISTINCT FROM NEW.tags OR OLD.duration_ms IS DISTINCT FROM NEW.duration_ms) THEN
INSERT INTO upload_release_jobs(batch_id,group_key) VALUES (NEW.batch_id,coalesce(NEW.release_group_id::text,''))
ON CONFLICT (batch_id,group_key) DO UPDATE SET state='queued', request='{}', audio_results='[]', lease=NULL, attempts=0,
selected_release=NULL, updated_at=now();
END IF;
RETURN NEW;
END;
$$;
-- +goose StatementEnd
CREATE TRIGGER upload_release_lookup_changed AFTER UPDATE OF status, metadata, tags, duration_ms ON upload_files
FOR EACH ROW EXECUTE FUNCTION queue_upload_release_lookup();
INSERT INTO upload_release_jobs(batch_id)
SELECT DISTINCT batch_id FROM upload_files WHERE status IN ('pending_review','import_failed');
-- +goose Down
DROP TRIGGER upload_release_lookup_changed ON upload_files;
DROP FUNCTION queue_upload_release_lookup();
DROP TABLE upload_release_jobs;
ALTER TABLE upload_files DROP COLUMN release_group_id;
DROP TABLE upload_release_groups;
DROP TABLE metadata_lookup_cache;