Compare commits

...
25 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
mrleo1nid 66d3897f66 feat(admin): group navigation into media, uploads and system
ci / ts / ts (packages + web) (push) Failing after 4m33s
release / android (APK в релиз) (push) Successful in 4m45s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 8s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m14s
release / prune (старые файлы и прогоны) (push) Successful in 12s
2026-10-28 23:47:17 +03:00
mrleo1nid 17bb0e1846 fix(uploads): allow WebAssembly hashing under CSP
ci / ts / ts (packages + web) (push) Failing after 4m29s
ci / desktop / rust (desktop proxy) (push) Successful in 5m54s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 3m23s
2026-10-28 23:34:42 +03:00
mrleo1nidandClaude Sonnet 5.5 17c85493b7 fix(library): always split comma-separated artists even if glued name exists
ci / api / api (push) Successful in 34s
ci / db / db (push) Successful in 42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 15:26:49 +03:00
mrleo1nidandClaude Sonnet 5.5 75f9c57eca fix(library): split compound album artist and re-split artists on rescan
ci / api / api (push) Successful in 37s
ci / db / db (push) Successful in 55s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:29:50 +03:00
mrleo1nidandClaude Sonnet 5.5 8a007be036 feat(web): show spinner on play button while track is loading
ci / ts / ts (packages + web) (push) Successful in 3m15s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:23:19 +03:00
mrleo1nidandClaude Sonnet 5.5 f77148df2c fix(web): show notification count as menu badge and mark read on open
ci / ts / ts (packages + web) (push) Successful in 2m52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:56:05 +03:00
mrleo1nidandClaude Sonnet 5.5 139dd226fa feat(genius): fetch artist avatars by name and match names with transliteration suffix
ci / api / api (push) Successful in 27s
ci / db / db (push) Successful in 53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:50:55 +03:00
mrleo1nidandClaude Sonnet 5.5 795bb7de46 fix(library): split comma-separated main artists
ci / module boundaries / boundaries (push) Successful in 8s
ci / api / api (push) Successful in 1m17s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 3m43s
ci / db / db (push) Canceled after 11m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:39:32 +03:00
mrleo1nid 11b890c2e7 docs: record architecture and correctness review 2026-10-24 02:16:05 +03:00
mrleo1nid 59983ed1b3 fix(social): complete shared listening request lifecycle 2026-10-24 02:16:00 +03:00
121 changed files with 7611 additions and 669 deletions

No files matched your search

+2 -1
View File
@@ -48,6 +48,7 @@ jobs:
TEST_REDIS_URL: redis://redis:6379/0 TEST_REDIS_URL: redis://redis:6379/0
TEST_MEILI_URL: http://meilisearch:7700 TEST_MEILI_URL: http://meilisearch:7700
TEST_MEILI_KEY: ci-master-key-0123456789 TEST_MEILI_KEY: ci-master-key-0123456789
MUSICBRAINZ_ENABLED: "false"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
@@ -73,7 +74,7 @@ jobs:
working-directory: services/api working-directory: services/api
# These suites share one database; dbtest also rolls all migrations down # These suites share one database; dbtest also rolls all migrations down
# and cleans orphan catalog rows. Do not run the packages concurrently. # 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) - name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
working-directory: services/api working-directory: services/api
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/ run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
+225
View File
@@ -910,6 +910,12 @@ paths:
tags: [uploads] tags: [uploads]
summary: Мои предложения (moderator, admin) summary: Мои предложения (moderator, admin)
parameters: parameters:
- name: batch
in: query
description: Только файлы указанной собственной пачки
schema:
type: string
format: uuid
- $ref: "#/components/parameters/UploadStatusFilter" - $ref: "#/components/parameters/UploadStatusFilter"
- $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/Cursor" - $ref: "#/components/parameters/Cursor"
@@ -1094,6 +1100,105 @@ paths:
$ref: "#/components/schemas/UploadFile" $ref: "#/components/schemas/UploadFile"
default: default:
$ref: "#/components/responses/Problem" $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: /api/v1/admin/uploads/review:
post: post:
operationId: reviewUploads operationId: reviewUploads
@@ -2743,6 +2848,10 @@ components:
type: object type: object
required: [files] required: [files]
properties: properties:
cover_base64:
type: string
maxLength: 11184812
description: Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки.
comment: comment:
type: string type: string
maxLength: 1000 maxLength: 1000
@@ -2914,6 +3023,17 @@ components:
type: string type: string
nullable: true nullable: true
description: Путь в хранилище по write_template (null — не построился, причина в status_reason) 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: status_reason:
type: string type: string
nullable: true nullable: true
@@ -2954,6 +3074,100 @@ components:
target_source_id: target_source_id:
type: string type: string
format: uuid 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: UploadBatchPatch:
type: object type: object
description: Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки. description: Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки.
@@ -3614,6 +3828,17 @@ components:
properties: properties:
template: template:
type: string type: string
matched:
type: integer
description: Число путей выборки, совпавших с предложенным шаблоном
total:
type: integer
description: Число исследованных файлов хранилища
examples:
type: array
items:
type: string
description: До трёх существующих путей, совпавших с шаблоном
WriteTemplateCheckRequest: WriteTemplateCheckRequest:
type: object type: object
required: [template] required: [template]
@@ -38,7 +38,7 @@ export function ListenTogetherButton({ person }: { person: SocialPerson }) {
function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) { function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) {
const { t } = useTranslation(); const { api, key } = useListening(); const cache = useQueryClient(); const state = usePlayerState(); const errorText = useErrorText(); const { t } = useTranslation(); const { api, key } = useListening(); const cache = useQueryClient(); const state = usePlayerState(); const errorText = useErrorText();
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [id] = useState(() => eventId(Date.now())); const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [id] = useState(() => eventId(Date.now()));
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } }); const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void cache.invalidateQueries({ queryKey: key }); } });
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}><Button title={t('form.close')} variant="ghost" onPress={close} /><H1>{t('social.together')}</H1><Muted>{person.display_name}</Muted><Muted>{t('social.stream_choice')}</Muted> return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}><Button title={t('form.close')} variant="ghost" onPress={close} /><H1>{t('social.together')}</H1><Muted>{person.display_name}</Muted><Muted>{t('social.stream_choice')}</Muted>
<Button title={t('social.join_stream')} variant={kind === 'join' ? 'primary' : 'ghost'} disabled={!person.activity || action.isPending || action.isSuccess} onPress={() => setKind('join')} /> <Button title={t('social.join_stream')} variant={kind === 'join' ? 'primary' : 'ghost'} disabled={!person.activity || action.isPending || action.isSuccess} onPress={() => setKind('join')} />
<Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} /> <Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} />
+73
View File
@@ -0,0 +1,73 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username = 'mobile-admin', target = '/') {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
await page.getByLabel('Логин', { exact: true }).fill(username);
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
}
test('admin groups related pages and remembers collapsed sections', async ({ page }, info) => {
await login(page);
const nav = page.getByRole('navigation', { name: 'Навигация' });
await nav.getByRole('button', { name: 'Админка', exact: true }).click();
await expect(nav.getByRole('link', { name: 'Пользователи', exact: true })).toBeVisible();
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
await expect(nav.getByRole('button', { name: title, exact: true })).toHaveAttribute('aria-expanded', 'false');
}
await nav.getByRole('button', { name: 'Система', exact: true }).click();
const system = nav.getByRole('group', { name: 'Система', exact: true });
await expect(system.getByRole('link')).toHaveCount(4);
for (const title of ['Обновления', 'Конфигурация', 'Настройки', 'Журнал']) {
await expect(system.getByRole('link', { name: title, exact: true })).toBeVisible();
}
await nav.screenshot({ path: info.outputPath('admin-groups.png') });
// The fake API resets authentication on a full document load; sign in again.
await login(page);
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'true');
await nav.getByRole('button', { name: 'Система', exact: true }).click();
await login(page);
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'false');
await login(page, 'mobile-admin', '/admin/audit');
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toBeVisible();
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toHaveAttribute('data-status', 'active');
await nav.getByRole('button', { name: 'Медиатека', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link')).toHaveCount(2);
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link')).toHaveCount(3);
});
test('moderator sees only permitted groups and pages', async ({ page }) => {
await login(page, 'moderator', '/admin/library');
const nav = page.getByRole('navigation', { name: 'Навигация' });
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link', { name: 'Библиотека' })).toBeVisible();
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveCount(0);
await expect(nav.getByRole('link', { name: 'Хранилища' })).toHaveCount(0);
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link', { name: 'Очередь', exact: true })).toBeVisible();
});
test('mobile menu exposes grouped pages and closes after navigation', async ({ page }, info) => {
await page.setViewportSize({ width: 390, height: 844 });
await login(page);
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Навигация' });
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
await expect(dialog.getByRole('heading', { name: title, exact: true })).toBeVisible();
}
await dialog.screenshot({ path: info.outputPath('admin-groups-mobile.png') });
await dialog.getByRole('group', { name: 'Система', exact: true }).getByRole('link', { name: 'Обновления', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/admin\/updates$/);
});
test('tablet icon sidebar keeps all admin destinations accessible', async ({ page }) => {
await page.setViewportSize({ width: 900, height: 900 });
await login(page);
const nav = page.getByRole('navigation', { name: 'Навигация' });
for (const title of ['Библиотека', 'Хранилища', 'Предложить треки', 'Очередь', 'Журнал']) {
await expect(nav.getByRole('link', { name: title, exact: true })).toBeVisible();
}
});
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from '@playwright/test';
for (const scenario of ['pending', 'automatic', 'restricted', 'incoming']) {
test(`listening request lifecycle: ${scenario}`, async ({ page }, info) => {
await page.addInitScript(value => {
localStorage.setItem('pipu.locale', 'ru');
localStorage.setItem('pipu.e2e.listening', value);
}, scenario);
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
const banner = page.getByRole('region', { name: 'Слушать вместе', exact: true });
await expect(banner).toHaveCount(0);
if (scenario === 'incoming') {
await expect(page.getByRole('button', { name: 'Принять', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('pending-invite.png'), fullPage: true });
await page.getByRole('button', { name: 'Принять', exact: true }).click();
} else {
await page.getByRole('button', { name: 'Слушать вместе', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Слушать вместе', exact: true });
await expect(dialog.getByRole('radio', { name: 'Подключиться к другу', exact: true })).toBeChecked();
await dialog.getByRole('button', { name: 'Отправить', exact: true }).click();
if (scenario === 'restricted') {
await expect(dialog.getByRole('alert')).toContainText('Совместное прослушивание недоступно');
await expect(dialog.getByRole('alert')).not.toContainText('Чат доступен только друзьям');
await page.screenshot({ path: info.outputPath('request-restricted.png'), fullPage: true });
await dialog.getByRole('button', { name: 'Закрыть', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(banner).toHaveCount(0);
return;
}
await expect(dialog).toHaveCount(0);
if (scenario === 'pending') {
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toBeVisible();
await expect(banner).toHaveCount(0);
await page.getByRole('button', { name: 'Отмена', exact: true }).click();
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toHaveCount(0);
await expect(banner).toHaveCount(0);
return;
}
}
await expect(banner).toContainText('Слушаете вместе с moderator');
await expect(page.getByRole('contentinfo', { name: 'Плеер', exact: true }).getByRole('button', { name: 'Следующий', exact: true })).toBeDisabled();
await banner.getByRole('button', { name: 'Выйти из прослушивания', exact: true }).click();
await expect(banner).toHaveCount(0);
});
}
+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}');
});
+68
View File
@@ -0,0 +1,68 @@
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');
const desktopConfig = JSON.parse(readFileSync(new URL('../../desktop/src-tauri/tauri.conf.json', import.meta.url), 'utf8'));
const policies: Record<string, string> = {
web: nginxHeaders.match(/add_header Content-Security-Policy "([^"]+)" always;/)![1],
desktop: desktopConfig.app.security.csp,
};
for (const [platform, policy] of Object.entries(policies)) {
test(`upload hash worker runs under ${platform} CSP`, async ({ page }) => {
// Apply the deployed policy to both the document and worker responses.
await page.route('**/*', async (route) => {
if (new URL(route.request().url()).pathname === '/csp-upload-test') {
await route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>Upload CSP</title>', headers: { 'Content-Security-Policy': policy } });
return;
}
if (new URL(route.request().url()).pathname === '/csp-eval-test.js') {
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;
}
const response = await route.fetch();
await route.fulfill({ response, headers: { ...response.headers(), 'Content-Security-Policy': policy } });
});
await page.goto('/csp-upload-test');
const result = await page.evaluate(async () => {
const worker = new Worker('/src/features/uploads/hash.worker.ts', { type: 'module' });
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();
}
});
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-query": "^5.80.7",
"@tanstack/react-router": "^1.120.20", "@tanstack/react-router": "^1.120.20",
"@tanstack/react-virtual": "^3.14.21", "@tanstack/react-virtual": "^3.14.21",
"hash-wasm": "^4.12.0", "js-sha256": "^1.0.0",
"music-metadata": "^12.2.3", "music-metadata": "^12.2.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
+102
View File
@@ -15,6 +15,73 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup); afterEach(cleanup);
describe('social', () => { describe('social', () => {
it.each([false, true])('closes a join dialog and follows only after admission (automatic=%s)', async (automatic) => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient, session } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.activityVisibility.set(bob.id, 'friends');
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 1000 });
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: automatic ? 'friends' : 'nobody', autoplay: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(dialog).not.toBeInTheDocument());
if (!automatic) {
expect(api.listeningMembers.has(alice.id)).toBe(false);
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
expect(api.listeningRequests.size).toBe(1);
const request = [...api.listeningRequests.values()][0]!;
const room = [...api.listeningRooms.values()][0]!;
api.listeningMembers.set(alice.id, room.id);
api.listeningRequests.delete(request.id);
refreshListening(session);
}
await screen.findByText('Слушаете вместе с bob');
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
await waitFor(() => expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument());
});
it('closes a pending invitation without announcing a stream and allows cancellation', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient, router } = await renderApp([alice, bob], `/track/${albumTracks[0]!.id}`, { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: 'nobody', autoplay: true });
await router.navigate({ to: '/friends' });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(dialog).not.toBeInTheDocument());
expect(api.listeningMembers.has(bob.id)).toBe(false);
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: 'Отмена' }));
await waitFor(() => expect(api.listeningRequests.size).toBe(0));
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
});
it('explains listening restrictions instead of reporting a chat error and can close after rejection', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.activityVisibility.set(bob.id, 'friends');
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Совместное прослушивание недоступно');
expect(within(dialog).getByRole('alert')).not.toHaveTextContent('Чат доступен только друзьям');
expect(api.listeningRequests.size).toBe(0);
expect(api.listeningRooms.size).toBe(0);
await user.click(within(dialog).getByRole('button', { name: 'Закрыть' }));
expect(dialog).not.toBeInTheDocument();
});
it('enables listening together only while a friend is online, independently of music privacy', async () => { it('enables listening together only while a friend is online, independently of music privacy', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user'); const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice }); const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
@@ -464,6 +531,41 @@ describe('search box', () => {
}); });
describe('player', () => { 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 () => { it('opens a shared track after login and copies its page URL from the player', async () => {
const alice = makeUser('alice', 'user'); const alice = makeUser('alice', 'user');
const path = '/track/00000000-0000-4000-8000-000000000101'; 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 { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context'; import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player'; 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 { cx, Icon } from '../shared/ui/ui';
import s from './catalog.module.css'; import s from './catalog.module.css';
@@ -143,7 +143,7 @@ export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
/** /**
* Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома). * Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома).
* Клик по строке запускает весь список с этого трека в контексте context; Enter — то же с клавиатуры. * Клик или Enter запускает список с выбранного трека; у текущего трека переключает паузу без перезапуска.
*/ */
export function TrackList({ export function TrackList({
tracks, tracks,
@@ -183,7 +183,11 @@ export function TrackList({
const row = (tr: Track, i: number) => { const row = (tr: Track, i: number) => {
const isCurrent = current?.id === tr.id; const isCurrent = current?.id === tr.id;
const play = () => tr.available && player.playList(tracks, i, context); const play = () => {
if (!tr.available) return;
if (isCurrent) player.toggle();
else player.playList(tracks, i, context);
};
const liked = tr.reaction === 'like'; const liked = tr.reaction === 'like';
const meta = rowMeta?.(tr, i); const meta = rowMeta?.(tr, i);
return ( return (
@@ -206,7 +210,7 @@ export function TrackList({
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />} {numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
{tr.available && ( {tr.available && (
<span className={s.playHint} aria-hidden> <span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} /> <Icon src={isCurrent && playing ? pauseIcon : playIcon} size={20} />
</span> </span>
)} )}
</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, .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 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; } .header p, .note { color: var(--text2); font-size: 13px; line-height: 1.6; margin: 0; }
+1 -1
View File
@@ -42,7 +42,7 @@ function ListenTogetherDialog({ person, close }: { person: SocialPerson; close:
const { api, key } = useListening(); const state = usePlayerState(); const { api, key } = useListening(); const state = usePlayerState();
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite');
const [id] = useState(uuidv7); const [id] = useState(uuidv7);
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } }); const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void cache.invalidateQueries({ queryKey: key }); } });
return <Dialog title={t('social.together')} onClose={close}><strong>{person.display_name}</strong><fieldset className={s.privacy} disabled={action.isPending || action.isSuccess}><legend>{t('social.stream_choice')}</legend> return <Dialog title={t('social.together')} onClose={close}><strong>{person.display_name}</strong><fieldset className={s.privacy} disabled={action.isPending || action.isSuccess}><legend>{t('social.stream_choice')}</legend>
<label className={s.choice}><input type="radio" checked={kind === 'join'} disabled={!person.activity} onChange={() => setKind('join')} />{t('social.join_stream')}</label> <label className={s.choice}><input type="radio" checked={kind === 'join'} disabled={!person.activity} onChange={() => setKind('join')} />{t('social.join_stream')}</label>
<label className={s.choice}><input type="radio" checked={kind === 'invite'} disabled={!state.track || state.listening?.locked} onChange={() => setKind('invite')} />{t('social.invite_stream')}</label> <label className={s.choice}><input type="radio" checked={kind === 'invite'} disabled={!state.track || state.listening?.locked} onChange={() => setKind('invite')} />{t('social.invite_stream')}</label>
@@ -29,7 +29,7 @@ export function NotificationsDialog({ onClose, onOpen }: { onClose: () => void;
{shown.map(event => { {shown.map(event => {
const date = new Date(event.created_at); const date = new Date(event.created_at);
return <article key={event.id} className={`${s.row} ${!event.read ? s.unread : ''}`}> return <article key={event.id} className={`${s.row} ${!event.read ? s.unread : ''}`}>
<button type="button" className={s.open} aria-label={t('social.open_notification', { name: event.actor_name })} onClick={() => onOpen(event.actor_id)}> <button type="button" className={s.open} aria-label={t('social.open_notification', { name: event.actor_name })} onClick={() => { if (!event.read) read.mutate(event.id); onOpen(event.actor_id); }}>
<span className={s.avatar} aria-hidden>{event.actor_name.slice(0, 1).toUpperCase()}</span> <span className={s.avatar} aria-hidden>{event.actor_name.slice(0, 1).toUpperCase()}</span>
<span className={s.content}><strong>{event.actor_name}</strong><span>{t(`social.kind_${event.kind}`)}</span> <span className={s.content}><strong>{event.actor_name}</strong><span>{t(`social.kind_${event.kind}`)}</span>
<time dateTime={event.created_at} title={date.toLocaleString(i18n.language)}>{date.toLocaleString(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</time> <time dateTime={event.created_at} title={date.toLocaleString(i18n.language)}>{date.toLocaleString(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</time>
+1 -1
View File
@@ -86,7 +86,7 @@ export function useSocialProfileMenu() {
return { return {
items: [ items: [
{ label: t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings'), icon: currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon, onSelect: () => setDialog('privacy') }, { label: t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings'), icon: currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon, onSelect: () => setDialog('privacy') },
{ label: `${t('social.notifications')}${inbox.data?.unread ? ` ${inbox.data.unread}` : ''}`, icon: inboxIcon, onSelect: () => setDialog('notifications') }, { label: t('social.notifications'), badge: inbox.data?.unread, icon: inboxIcon, onSelect: () => setDialog('notifications') },
], ],
dialogs: <> dialogs: <>
{dialog === 'privacy' && <Dialog title={t('social.privacy_settings')} onClose={closeDialog} className={s.preferencesDialog}> {dialog === 'privacy' && <Dialog title={t('social.privacy_settings')} onClose={closeDialog} className={s.preferencesDialog}>
@@ -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 { useState } from 'react';
import { useErrorText } from '../../shared/lib/errors'; import { useErrorText } from '../../shared/lib/errors';
import { useUploadsApi } from './queries'; import { useUploadsApi } from './queries';
import { coverBase64 } from './files';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui'; import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
import s from '../../pages/page.module.css'; import s from '../../pages/page.module.css';
@@ -13,6 +14,7 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
const [album, setAlbum] = useState(''); const [album, setAlbum] = useState('');
const [year, setYear] = useState(''); const [year, setYear] = useState('');
const [cover, setCover] = useState<string | null | undefined>(undefined); const [cover, setCover] = useState<string | null | undefined>(undefined);
const prepare = useMutation({ mutationFn: coverBase64, onSuccess: setCover });
const save = useMutation({ const save = useMutation({
mutationFn: () => api.updateBatch(batchId, { mutationFn: () => api.updateBatch(batchId, {
...(artist.trim() ? { artist: artist.trim() } : {}), ...(artist.trim() ? { artist: artist.trim() } : {}),
@@ -23,17 +25,13 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
onSuccess: onSaved, onSuccess: onSaved,
}); });
const pickCover = (file: File | undefined) => { const pickCover = (file: File | undefined) => {
save.reset();
prepare.reset();
setCover(undefined);
if (!file) { if (!file) {
setCover(undefined);
return; return;
} }
const reader = new FileReader(); prepare.mutate(file);
reader.onload = () => {
const data = String(reader.result ?? '');
const comma = data.indexOf(',');
setCover(comma >= 0 ? data.slice(comma + 1) : data);
};
reader.readAsDataURL(file);
}; };
return ( return (
<Dialog <Dialog
@@ -44,7 +42,7 @@ export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string
<Button variant="ghost" onClick={onClose}> <Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')} {t('common:form.cancel')}
</Button> </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')} {t('uploads.save')}
</Button> </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} /> <Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
<label className={s.sessions}> <label className={s.sessions}>
<span>{t('uploads.batch_cover')}</span> <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> </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')} {t('uploads.batch_cover_clear')}
</Button> </Button>
<FormError>{save.error ? errorText(save.error) : null}</FormError> <FormError>{(save.error ?? prepare.error) ? errorText(save.error ?? prepare.error) : null}</FormError>
</div> </div>
</Dialog> </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 { 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 { 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 { UploadStatusBadge, useReasonText, type UploadFile } from './common';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, cx, Icon } from '../../shared/ui/ui'; import { Button, cx, Icon } from '../../shared/ui/ui';
@@ -24,44 +28,85 @@ export function BatchCard({
onRetry: (id: string) => void; onRetry: (id: string) => void;
}) { }) {
const { t, i18n } = useTranslation('admin'); const { t, i18n } = useTranslation('admin');
const [collapsed, setCollapsed] = useState(true);
const [releaseGroups, setReleaseGroups] = useState<ReleaseGroup[]>([]);
const contentId = useId();
const first = files[0]!; const first = files[0]!;
const ids = files.map((f) => f.id); const ids = files.map((f) => f.id);
const all = ids.every((id) => selected.has(id)); const all = ids.every((id) => selected.has(id));
const size = files.reduce((n, f) => n + f.size_bytes, 0); 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 ( return (
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}> <section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
<div className={u.batchHead}> <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 && ( {selectable && (
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} /> <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>{formatDateTime(first.created_at, i18n.language)}</span>
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span> <span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>} <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 }} /> <span style={{ flex: 1 }} />
{selectable && <BatchRelease files={files} onGroupsChange={setReleaseGroups} />}
{selectable && ( {selectable && (
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}> <Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
{t('uploads.edit_batch')} {t('uploads.edit_batch')}
</Button> </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> </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> </section>
); );
} }
function QueueRow({ function QueueRow({
file: f, file: f,
active,
selectable, selectable,
checked, checked,
onCheck, onCheck,
@@ -69,6 +114,7 @@ function QueueRow({
onRetry, onRetry,
}: { }: {
file: UploadFile; file: UploadFile;
active: boolean;
selectable: boolean; selectable: boolean;
checked: boolean; checked: boolean;
onCheck: (on: boolean) => void; 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> m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
)} )}
</div> </div>
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>} {reason && (!editable || f.target_rel_path) && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{f.duplicate_level && f.status === 'duplicate' && ( {f.duplicate_level && f.status === 'duplicate' && (
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span> <span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
)} )}
</div> </div>
<div className={u.rowActions}> <div className={u.rowActions}>
{f.stream_url && ( {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 && ( {editable && (
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}> <Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
@@ -148,6 +193,23 @@ function QueueRow({
</Button> </Button>
)} )}
</div> </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> </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. */ /** The feature owns transport mapping; views only provide form values. */
export function uploadsApi(client: ApiClient) { export function uploadsApi(client: ApiClient) {
return { 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( list: async (status: components['schemas']['UploadStatus'][], possible: boolean, cursor?: string) => unwrap(
await client.GET('/api/v1/admin/uploads', { await client.GET('/api/v1/admin/uploads', {
params: { query: { status, possible_duplicate: possible || undefined, cursor, limit: 100 } }, 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). // Сбор файлов из 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 { export interface PickedFile {
file: File; file: File;
@@ -30,6 +57,26 @@ export function isAudio(name: string): boolean {
return AUDIO_EXTENSIONS.includes(ext); 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). */ /** Файлы из <input type="file"> (в том числе webkitdirectory — тогда путь есть в webkitRelativePath). */
export function fromInput(list: FileList | null): PickedFile[] { export function fromInput(list: FileList | null): PickedFile[] {
return [...(list ?? [])].map((file) => ({ file, relPath: file.webkitRelativePath || file.name })); return [...(list ?? [])].map((file) => ({ file, relPath: file.webkitRelativePath || file.name }));
+4 -5
View File
@@ -1,7 +1,7 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
// SHA-256 файла потоком (docs/uploads.md#4): Web Crypto не умеет считать по частям, а файлы бывают по 2 ГБ — // SHA-256 файла потоком (docs/uploads.md#4): Web Crypto не умеет считать по частям, а файлы бывают по 2 ГБ —
// hash-wasm считает чанками в воркере, не держа файл в памяти и не блокируя интерфейс. // js-sha256 считает чанками в воркере без WebAssembly: работает и со строгим CSP у прокси.
import { createSHA256 } from 'hash-wasm'; import { sha256 } from 'js-sha256';
export interface HashRequest { export interface HashRequest {
id: string; id: string;
@@ -18,8 +18,7 @@ const ctx = self as unknown as DedicatedWorkerGlobalScope;
ctx.onmessage = async (e: MessageEvent<HashRequest>) => { ctx.onmessage = async (e: MessageEvent<HashRequest>) => {
const { id, file } = e.data; const { id, file } = e.data;
try { try {
const h = await createSHA256(); const h = sha256.create();
h.init();
const reader = file.stream().getReader(); const reader = file.stream().getReader();
let done = 0; let done = 0;
let lastReport = 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: '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) { } catch (err) {
ctx.postMessage({ id, type: 'error', message: err instanceof Error ? err.message : String(err) } satisfies HashResponse); 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 { unwrap, type AuthSession } from '@pipu/core';
import * as tus from 'tus-js-client'; 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'; import type { HashRequest, HashResponse } from './hash.worker';
// Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей // Загрузка пачки в очередь на добавление (docs/uploads.md#5): SHA-256 в воркере → предпроверка дублей
@@ -29,6 +29,7 @@ export interface UploadItem {
progress: number; // 0..1 текущего этапа (хэш или загрузка) progress: number; // 0..1 текущего этапа (хэш или загрузка)
sha256?: string; sha256?: string;
tags?: { artist?: string; title?: string; album?: string; duration_ms?: number }; tags?: { artist?: string; title?: string; album?: string; duration_ms?: number };
coverName?: string;
trackId?: string | null; trackId?: string | null;
possibleDuplicate?: boolean; possibleDuplicate?: boolean;
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, … code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
@@ -68,7 +69,11 @@ export class Uploader {
private hashQueue: string[] = []; private hashQueue: string[] = [];
private hashing = false; private hashing = false;
private uploads = new Map<string, tus.Upload>(); 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 refreshing: Promise<unknown> | null = null;
private folderCovers = new Map<string, File>();
constructor(private readonly session: AuthSession) { constructor(private readonly session: AuthSession) {
this.restore(); this.restore();
@@ -97,17 +102,21 @@ export class Uploader {
/** Добавить файлы (из перетаскивания или выбора); не-аудио отбрасываются. */ /** Добавить файлы (из перетаскивания или выбора); не-аудио отбрасываются. */
add(files: PickedFile[]) { add(files: PickedFile[]) {
if (this.state.busy) return; 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[] = []; const fresh: UploadItem[] = [];
let skipped = 0; let skipped = 0;
for (const f of files) { for (const f of files) {
if (!isAudio(f.file.name)) { if (!isAudio(f.file.name)) {
skipped++; if (!isAlbumCover(f.file.name)) skipped++;
continue; continue;
} }
// Тот же путь и размер уже в списке — не добавляем второй раз. // Тот же путь и размер уже в списке — не добавляем второй раз.
if (this.state.items.some((it) => it.relPath === f.relPath && it.size === f.file.size)) continue; if (this.state.items.some((it) => it.relPath === f.relPath && it.size === f.file.size)) continue;
fresh.push({ id: `f${++seq}`, file: f.file, size: f.file.size, relPath: f.relPath, stage: 'hashing', progress: 0 }); fresh.push({ id: `f${++seq}`, file: f.file, size: f.file.size, relPath: f.relPath, stage: 'hashing', progress: 0 });
} }
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.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null });
this.hashQueue.push(...fresh.map((it) => it.id)); this.hashQueue.push(...fresh.map((it) => it.id));
void this.hashNext(); void this.hashNext();
@@ -124,6 +133,7 @@ export class Uploader {
clear() { clear() {
if (this.state.busy) return; if (this.state.busy) return;
this.hashQueue = []; this.hashQueue = [];
this.folderCovers.clear();
this.set({ items: [], skipped: 0, batchId: null, error: null }); this.set({ items: [], skipped: 0, batchId: null, error: null });
this.persist(); this.persist();
} }
@@ -217,31 +227,44 @@ export class Uploader {
if (this.state.busy || !ready.length) return; if (this.state.busy || !ready.length) return;
this.set({ busy: true, error: null }); this.set({ busy: true, error: null });
try { try {
const res = unwrap( const groups = new Map<string, UploadItem[]>();
await this.session.client.POST('/api/v1/uploads', { for (const it of ready) {
body: { const folder = albumFolder(it.relPath);
comment: comment.trim() || undefined, groups.set(folder, [...(groups.get(folder) ?? []), it]);
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 queue: { id: string; url: string }[] = []; const queue: { id: string; url: string }[] = [];
for (const f of res.files) { for (const [folder, group] of groups) {
if (f.rejected) this.patch(f.client_id, { stage: 'rejected', code: f.rejected, trackId: f.track_id }); try {
else if (f.tus_url && f.upload_file_id) { const cover = this.folderCovers.get(folder);
this.patch(f.client_id, { uploadFileId: f.upload_file_id, tusUrl: f.tus_url, stage: 'uploading', progress: 0 }); const res = unwrap(
queue.push({ id: f.client_id, url: f.tus_url }); 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 this.transfer(queue);
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
} catch (err) { } catch (err) {
this.set({ error: err }); this.set({ error: err });
} finally { } finally {
@@ -249,24 +272,57 @@ export class Uploader {
} }
} }
get canRetryFailed(): boolean {
return !this.state.busy || this.transferQueue !== null;
}
/** Повторить оборвавшиеся загрузки: место уже зарезервировано, tus продолжит с принятого смещения. */ /** Повторить оборвавшиеся загрузки: место уже зарезервировано, tus продолжит с принятого смещения. */
async retryFailed() { async retryFailed(id?: string) {
const failed = this.state.items.filter((it) => it.stage === 'error' && it.tusUrl); const failed = this.state.items.filter((it) => it.stage === 'error' && it.tusUrl && it.file && (!id || it.id === id));
if (this.state.busy || !failed.length) return; 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 }); this.set({ busy: true, error: null });
try { try {
const queue = failed.map((it) => { await this.transfer(queue);
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)));
} finally { } finally {
this.set({ busy: false }); this.set({ busy: false });
} }
} }
private async drain(queue: { id: string; url: string }[]) { private transfer(queue: { id: string; url: string }[]): Promise<void> {
for (let next = queue.shift(); next; next = queue.shift()) await this.upload(next.id, next.url); 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> { private upload(id: string, url: string): Promise<void> {
@@ -291,7 +347,7 @@ export class Uploader {
return true; return true;
} }
// Сеть, перегрузка, рассинхрон смещения — повторяем (tus-js-client спросит HEAD и продолжит). // Сеть, перегрузка, рассинхрон смещения — повторяем (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) }), onProgress: (sent, total) => this.patch(id, { progress: sent / Math.max(total, 1) }),
onSuccess: () => { onSuccess: () => {
@@ -302,7 +358,8 @@ export class Uploader {
}, },
onError: (err) => { onError: (err) => {
this.uploads.delete(id); 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(); resolve();
}, },
}); });
@@ -315,7 +372,12 @@ export class Uploader {
if (!it.file) return; if (!it.file) return;
try { try {
const { parseBlob } = await import('music-metadata'); 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; const duration = meta.format.duration;
this.patch(it.id, { this.patch(it.id, {
tags: { tags: {
@@ -328,6 +390,10 @@ export class Uploader {
} catch { } 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-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */ /** Незавершённые tus-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */
@@ -398,7 +464,7 @@ export class Uploader {
if (!queue.length) return; if (!queue.length) return;
this.set({ busy: true }); this.set({ busy: true });
try { try {
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue))); await this.transfer(queue);
} finally { } finally {
this.set({ busy: false }); this.set({ busy: false });
this.persist(); this.persist();
+1 -1
View File
@@ -83,7 +83,7 @@ export function PatternsPage({ id }: { id: string }) {
}); });
return ( return (
<div className={`${s.page} ${s.narrow}`} style={{ maxWidth: 1100 }}> <div className={s.page}>
<div className={s.head}> <div className={s.head}>
<div> <div>
<h1>{t('patterns.title', { name: source.data?.name ?? '…' })}</h1> <h1>{t('patterns.title', { name: source.data?.name ?? '…' })}</h1>
@@ -1,7 +1,7 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session'; import { useSession } from '../../app/session';
@@ -27,30 +27,40 @@ export function WriteTemplateSection({ source }: { source: Source }) {
const [accepts, setAccepts] = useState(source.accepts_uploads); const [accepts, setAccepts] = useState(source.accepts_uploads);
const [template, setTemplate] = useState(source.write_template ?? ''); const [template, setTemplate] = useState(source.write_template ?? '');
const [check, setCheck] = useState<Check | null>(null); const [check, setCheck] = useState<Check | null>(null);
const revision = useRef(0);
useEffect(() => { useEffect(() => {
setAccepts(source.accepts_uploads); setAccepts(source.accepts_uploads);
setTemplate(source.write_template ?? ''); setTemplate(source.write_template ?? '');
}, [source.accepts_uploads, source.write_template]); }, [source.accepts_uploads, source.write_template]);
const runCheck = useMutation({ const runCheck = useMutation({
mutationFn: async (tpl: string) => mutationFn: async (tpl: string) => {
unwrap( const requestedRevision = revision.current;
const result = unwrap(
await session.client.POST('/api/v1/admin/sources/{id}/write-template/check', { await session.client.POST('/api/v1/admin/sources/{id}/write-template/check', {
params: { path: { id } }, params: { path: { id } },
body: { template: tpl }, body: { template: tpl },
}), }),
), );
onSuccess: setCheck, return { result, requestedRevision };
}); },
const suggest = useMutation({ onSuccess: ({ result, requestedRevision }) => {
mutationFn: async () => if (requestedRevision === revision.current) setCheck(result);
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/write-template/suggest', { params: { path: { id } } }))
.template,
onSuccess: (tpl) => {
setTemplate(tpl);
runCheck.mutate(tpl);
}, },
}); });
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({ const save = useMutation({
mutationFn: async () => mutationFn: async () =>
unwrap( unwrap(
@@ -62,6 +72,7 @@ export function WriteTemplateSection({ source }: { source: Source }) {
onSuccess: async (src) => { onSuccess: async (src) => {
queryClient.setQueryData(['source', id], src); queryClient.setQueryData(['source', id], src);
await queryClient.invalidateQueries({ queryKey: ['sources'] }); 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}> <section className={s.section}>
<h2>{t('write_template.title')}</h2> <h2>{t('write_template.title')}</h2>
<p className={s.note}>{t('write_template.intro')}</p> <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}> <label className={l.checkRow}>
<input type="checkbox" checked={accepts} onChange={(e) => setAccepts(e.target.checked)} /> <input type="checkbox" checked={accepts} onChange={(e) => setAccepts(e.target.checked)} />
<span>{t('write_template.accepts')}</span> <span>{t('write_template.accepts')}</span>
@@ -82,6 +102,7 @@ export function WriteTemplateSection({ source }: { source: Source }) {
label={t('write_template.template')} label={t('write_template.template')}
value={template} value={template}
onChange={(v) => { onChange={(v) => {
revision.current++;
setTemplate(v); setTemplate(v);
setCheck(null); setCheck(null);
save.reset(); save.reset();
@@ -91,7 +112,7 @@ export function WriteTemplateSection({ source }: { source: Source }) {
mono mono
/> />
<div className={s.toolbar}> <div className={s.toolbar}>
<Button busy={suggest.isPending} onClick={() => suggest.mutate()}> <Button busy={suggest.isPending} onClick={() => suggest.mutate(revision.current)}>
{t('write_template.suggest')} {t('write_template.suggest')}
</Button> </Button>
<Button busy={runCheck.isPending} disabled={!template.trim()} onClick={() => runCheck.mutate(template)}> <Button busy={runCheck.isPending} disabled={!template.trim()} onClick={() => runCheck.mutate(template)}>
+2 -1
View File
@@ -1,5 +1,6 @@
.page { .page {
max-width: 1100px; width: 100%;
min-width: 0;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+107 -17
View File
@@ -1,7 +1,7 @@
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session'; import { useSession } from '../../app/session';
@@ -14,10 +14,11 @@ import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile,
import u from './uploads.module.css'; import u from './uploads.module.css';
const FILTERS = { 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'], pending: ['uploading', 'processing', 'pending_review', 'approved', 'importing', 'import_failed'],
imported: ['imported'], imported: ['imported'],
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'], declined: ['rejected', 'duplicate', 'invalid', 'expired'],
withdrawn: ['withdrawn'],
} satisfies Record<string, UploadStatus[] | undefined>; } satisfies Record<string, UploadStatus[] | undefined>;
type Filter = keyof typeof FILTERS; type Filter = keyof typeof FILTERS;
@@ -25,11 +26,14 @@ const WITHDRAWABLE: UploadStatus[] = ['uploading', 'processing', 'pending_review
/** «Мои предложения»: статусы своих загрузок и решения админа (docs/uploads.md#10). */ /** «Мои предложения»: статусы своих загрузок и решения админа (docs/uploads.md#10). */
export function MyUploadsPage() { export function MyUploadsPage() {
const { t } = useTranslation(['admin', 'common']); const { t, i18n } = useTranslation(['admin', 'common']);
const session = useSession(); const session = useSession();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const errorText = useErrorText(); const errorText = useErrorText();
const [filter, setFilter] = useState<Filter>('all'); 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(() => { useEffect(() => {
void session.client.POST('/api/v1/uploads/seen').then(() => { 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, q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
}); });
const withdraw = useMutation({ const withdraw = useMutation({
mutationFn: async (id: string) => mutationFn: async (ids: string[]) => {
unwrap(await session.client.DELETE('/api/v1/uploads/files/{id}', { params: { path: { id } } })), const failed: { id: string; error: unknown }[] = [];
onSuccess: async () => { 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: ['my-uploads'] });
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] }); await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
}, },
}); });
const items = list.data?.pages.flatMap((p) => p.items) ?? []; 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 ( return (
<div className={s.page}> <div className={s.page}>
@@ -73,7 +124,7 @@ export function MyUploadsPage() {
<div className={s.toolbar}> <div className={s.toolbar}>
<div className={s.chips} role="group" aria-label={t('uploads.filter')}> <div className={s.chips} role="group" aria-label={t('uploads.filter')}>
{(Object.keys(FILTERS) as Filter[]).map((f) => ( {(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}`)} {t(`uploads.filter_${f}`)}
</button> </button>
))} ))}
@@ -85,7 +136,8 @@ export function MyUploadsPage() {
</Button> </Button>
</Link> </Link>
</div> </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 && ( {list.isSuccess && items.length === 0 && (
<EmptyState icon={addIcon} action={<Link to="/uploads/new">{t('uploads.suggest_title')}</Link>}> <EmptyState icon={addIcon} action={<Link to="/uploads/new">{t('uploads.suggest_title')}</Link>}>
{t('uploads.mine_empty')} {t('uploads.mine_empty')}
@@ -93,9 +145,13 @@ export function MyUploadsPage() {
)} )}
{items.length > 0 && ( {items.length > 0 && (
<div className={s.tableWrap}> <div className={s.tableWrap}>
<table className={s.table}> <table className={cx(s.table, u.mineTable)}>
<thead> <thead>
<tr> <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_file')}</th>
<th>{t('uploads.col_status')}</th> <th>{t('uploads.col_status')}</th>
<th>{t('uploads.col_size')}</th> <th>{t('uploads.col_size')}</th>
@@ -103,19 +159,52 @@ export function MyUploadsPage() {
<th /> <th />
</tr> </tr>
</thead> </thead>
<tbody> {batches.map((files) => {
{items.map((f) => ( 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 <MineRow
key={f.id} key={f.id}
file={f} file={f}
busy={withdraw.isPending && withdraw.variables === f.id} busy={busy}
onWithdraw={() => withdraw.mutate(f.id)} 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> </table>
</div> </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 && ( {list.hasNextPage && (
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}> <Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('uploads.load_more')} {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 { t, i18n } = useTranslation('admin');
// Технические подробности ошибки импорта — для админа; автору достаточно причины. // Технические подробности ошибки импорта — для админа; автору достаточно причины.
const raw = f.status === 'import_failed' ? (f.status_reason?.split(':')[0] ?? null) : f.status_reason; 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; const m = f.metadata;
return ( return (
<tr> <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}> <td className={u.trackCell}>
<div className={u.meta}> <div className={u.meta}>
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span> <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 className={s.muted}>{formatDateTime(f.created_at, i18n.language)}</td>
<td> <td>
{WITHDRAWABLE.includes(f.status) && ( {WITHDRAWABLE.includes(f.status) && (
<Button variant="ghost" busy={busy} onClick={onWithdraw}> <Button variant="ghost" busy={withdrawing} disabled={busy} onClick={onWithdraw}>
{t('uploads.withdraw')} {t('uploads.withdraw')}
</Button> </Button>
)} )}
+19 -6
View File
@@ -14,7 +14,7 @@ import s from '../page.module.css';
import { QuotaMeter, useUploadQuota } from './common'; import { QuotaMeter, useUploadQuota } from './common';
import u from './uploads.module.css'; 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 в воркере → предпроверка дублей → * «Предложить треки» (docs/uploads.md#5): перетащить файлы или папки → SHA-256 в воркере → предпроверка дублей →
@@ -41,7 +41,7 @@ export function SuggestPage() {
const ready = st.items.filter((it) => it.stage === 'ready'); const ready = st.items.filter((it) => it.stage === 'ready');
const readyBytes = ready.reduce((n, it) => n + it.size, 0); const readyBytes = ready.reduce((n, it) => n + it.size, 0);
const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking'); const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking');
const finished = !st.busy && st.batchId !== null; const finished = !st.busy && st.batchId !== null && !ready.length;
const resume = st.items.filter((it) => it.stage === 'resume').length; const resume = st.items.filter((it) => it.stage === 'resume').length;
const failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length; const failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length;
const uploaded = st.items.filter((it) => it.stage === 'done').length; const uploaded = st.items.filter((it) => it.stage === 'done').length;
@@ -83,6 +83,7 @@ export function SuggestPage() {
<div className={u.dropTitle}>{t('uploads.drop_title')}</div> <div className={u.dropTitle}>{t('uploads.drop_title')}</div>
<p className={u.formatWarn}>{t('uploads.drop_hint')}</p> <p className={u.formatWarn}>{t('uploads.drop_hint')}</p>
<div className={s.note}>{t('uploads.drop_folders')}</div> <div className={s.note}>{t('uploads.drop_folders')}</div>
<div className={s.note}>{t('uploads.cover_hint')}</div>
<div className={u.dropActions}> <div className={u.dropActions}>
<Button disabled={st.busy} onClick={() => filesRef.current?.click()}> <Button disabled={st.busy} onClick={() => filesRef.current?.click()}>
{t('uploads.pick_files')} {t('uploads.pick_files')}
@@ -129,7 +130,7 @@ export function SuggestPage() {
{st.items.length > 0 && ( {st.items.length > 0 && (
<div className={u.files} aria-label={t('uploads.files')}> <div className={u.files} aria-label={t('uploads.files')}>
{st.items.map((it) => ( {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> </div>
)} )}
@@ -141,6 +142,7 @@ export function SuggestPage() {
<textarea <textarea
className={u.textarea} className={u.textarea}
value={comment} value={comment}
disabled={st.busy}
maxLength={1000} maxLength={1000}
placeholder={t('uploads.comment_hint')} placeholder={t('uploads.comment_hint')}
onChange={(e) => setComment(e.target.value)} 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 { t, i18n } = useTranslation(['admin', 'errors']);
const tr = t as unknown as (k: string) => string; const tr = t as unknown as (k: string) => string;
const showProgress = item.stage === 'hashing' || item.stage === 'uploading'; 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 }; note = { text: tr(`errors:${item.code ?? 'uploads.duplicate'}`), cls: u.bad };
break; break;
case 'error': 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; break;
case 'ready': case 'ready':
if (item.possibleDuplicate) note = { text: t('uploads.possible_dup'), cls: u.warn }; 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> </div>
)} )}
{needsFormatAttention(null, item.relPath) && <span className={u.warn}>{t('uploads.format_attention')}</span>} {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>} {note && <span className={note.cls}>{note.text}</span>}
</div> </div>
<span className={u.size}>{formatBytes(item.size, i18n.language)}</span> <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 /> <span />
) : ( ) : (
<Button variant="ghost" aria-label={t('uploads.remove', { name: item.relPath })} onClick={onRemove}> <Button variant="ghost" aria-label={t('uploads.remove', { name: item.relPath })} onClick={onRemove}>
@@ -179,6 +179,12 @@
color: var(--text); 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 { .comment {
flex-basis: 100%; flex-basis: 100%;
color: var(--text); color: var(--text);
@@ -218,6 +224,15 @@
align-items: center; 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 { .bulk {
position: sticky; position: sticky;
bottom: 12px; bottom: 12px;
@@ -293,6 +308,11 @@
resize: vertical; resize: vertical;
} }
.textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.queueRow { .queueRow {
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
@@ -310,6 +330,32 @@
overflow-wrap: anywhere; 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 { .trackCell {
min-width: 260px; min-width: 260px;
max-width: 480px; max-width: 480px;
@@ -322,6 +368,20 @@
} }
@media (max-width: 767px) { @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 { grid-template-columns: 24px minmax(0, 1fr); gap: 10px; padding: 12px; }
.queueRow > :nth-child(3), .queueRow > .rowActions { grid-column: 2; } .queueRow > :nth-child(3), .queueRow > .rowActions { grid-column: 2; }
.rowActions { flex-wrap: wrap; min-width: 0; } .rowActions { flex-wrap: wrap; min-width: 0; }
+1 -1
View File
@@ -40,7 +40,7 @@ function fixture() {
expect(overlap.volume).toBeGreaterThan(0); expect(overlap.volume).toBeGreaterThan(0);
}; };
const follow = async () => { const follow = async () => {
snapshot = { ...snapshot, room: { id: 'room', owner_id: 'host', owner_name: 'Host', device_id: 'host-device', track_id: remote.id, position_ms: 1000, playing: true, updated_at: snapshot.server_time, participants: [] } }; snapshot = { ...snapshot, room: { id: 'room', owner_id: 'host', owner_name: 'Host', device_id: 'host-device', track_id: remote.id, position_ms: 1000, playing: true, updated_at: snapshot.server_time, participants: [{ id: 'host', name: 'Host' }, { id: 'guest', name: 'Guest' }] } };
player.start(); player.start();
await vi.waitFor(() => expect(player.getState()).toMatchObject({ track: { id: 'remote' }, playing: true, listening: { locked: true } })); await vi.waitFor(() => expect(player.getState()).toMatchObject({ track: { id: 'remote' }, playing: true, listening: { locked: true } }));
}; };
+15
View File
@@ -420,6 +420,21 @@
background: var(--hover); background: var(--hover);
} }
.menuBadge {
margin-left: auto;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--accent-fill);
color: var(--on-accent, #fff);
font-size: 11px;
font-weight: 600;
line-height: 20px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.menuDanger { .menuDanger {
color: var(--danger); color: var(--danger);
} }
+2 -1
View File
@@ -204,7 +204,7 @@ export function Dialog({
); );
} }
export type MenuItem = { label: string; icon?: string; danger?: boolean; onSelect: () => void }; export type MenuItem = { label: string; icon?: string; danger?: boolean; badge?: number; onSelect: () => void };
/** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */ /** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */
export function Menu({ export function Menu({
@@ -268,6 +268,7 @@ export function Menu({
> >
{it.icon && <Icon src={it.icon} size={18} />} {it.icon && <Icon src={it.icon} size={18} />}
{it.label} {it.label}
{it.badge ? <span className={s.menuBadge}>{it.badge > 99 ? '99+' : it.badge}</span> : null}
</button> </button>
))} ))}
</div> </div>
+16 -1
View File
@@ -1,4 +1,4 @@
import { fakeApi, makeUser } from './fakeApi'; import { albumTracks, fakeApi, makeUser } from './fakeApi';
import { mobileFixtures } from './mobileFixtures'; import { mobileFixtures } from './mobileFixtures';
/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */ /** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */
@@ -14,6 +14,21 @@ export function installE2EFetch() {
const alice = [...api.users.values()].find(u => u.username === 'alice')!; const alice = [...api.users.values()].find(u => u.username === 'alice')!;
const friend = [...api.users.values()].find(u => u.username === 'moderator')!; const friend = [...api.users.values()].find(u => u.username === 'moderator')!;
api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true }); api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
const listeningScenario = localStorage.getItem('pipu.e2e.listening');
if (listeningScenario) {
const device = crypto.randomUUID();
api.presence.set(friend.id, Date.now());
api.activityVisibility.set(friend.id, 'friends');
api.activity.set(friend.id, { device_id: device, track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
api.listeningPreferences.set(friend.id, { requests: listeningScenario === 'restricted' ? 'nobody' : 'friends', auto_accept: listeningScenario === 'automatic' ? 'friends' : 'nobody', autoplay: true });
if (listeningScenario === 'incoming') {
const roomId = crypto.randomUUID();
const requestId = crypto.randomUUID();
api.listeningRooms.set(roomId, { id: roomId, owner_id: friend.id, owner_name: friend.username, device_id: device, track_id: albumTracks[0]!.id, position_ms: 0, playing: false, updated_at: new Date().toISOString(), participants: [] });
api.listeningMembers.set(friend.id, roomId);
api.listeningRequests.set(requestId, { id: requestId, owner_id: friend.id, guest_id: alice.id, sender_id: friend.id, sender_name: friend.username, recipient_id: alice.id, kind: 'invite', expires_at: new Date(Date.now() + 120_000).toISOString() });
}
}
if (localStorage.getItem('pipu.e2e.notifications') === '1') { if (localStorage.getItem('pipu.e2e.notifications') === '1') {
const kinds = ['message', 'friend_request', 'friend_accepted', 'music'] as const; const kinds = ['message', 'friend_request', 'friend_accepted', 'music'] as const;
for (let index = 0; index < 28; index++) api.notifications.push({ for (let index = 0; index < 28; index++) api.notifications.push({
+8 -1
View File
@@ -260,8 +260,13 @@ export function fakeApi(initial: FakeUser[]) {
if(op==='join'||op==='invite'){ if(op==='join'||op==='invite'){
const host=op==='join'?target:user.id; const guest=op==='join'?user.id:target; const prefs=listeningPreferences.get(target)??defaultListening; const host=op==='join'?target:user.id; const guest=op==='join'?user.id:target; const prefs=listeningPreferences.get(target)??defaultListening;
const friend=!!friendships.get(pair(user.id,target))?.accepted; const friend=!!friendships.get(pair(user.id,target))?.accepted;
if(isBlocked(user.id,target)||!(prefs.requests==='everyone'||prefs.requests==='friends'&&friend))return problem(403,'social.unavailable'); if(isBlocked(user.id,target)||!(prefs.requests==='everyone'||prefs.requests==='friends'&&friend))return problem(403,'social.listening_unavailable');
if ((presence.get(target) ?? 0) <= Date.now() - 45_000) return problem(409, 'social.person_offline'); if ((presence.get(target) ?? 0) <= Date.now() - 45_000) return problem(409, 'social.person_offline');
const guestRoom = listeningRooms.get(listeningMembers.get(guest) ?? '');
const hostRoom = listeningRooms.get(listeningMembers.get(host) ?? '');
if (guestRoom && guestRoom.owner_id !== host || hostRoom && hostRoom.owner_id !== host) return problem(409, 'social.listening_busy');
if (guestRoom?.owner_id === host || listeningRequests.has(String(body.id))) return new Response(null, { status: 204 });
if ([...listeningRequests.values()].some(r => r.owner_id === host && r.guest_id === guest)) return problem(409, 'social.listening_pending');
let room=[...listeningRooms.values()].find(r=>r.owner_id===host); let room=[...listeningRooms.values()].find(r=>r.owner_id===host);
const source=activity.get(host);if(!room){room={id:crypto.randomUUID(),owner_id:host,owner_name:users.get(host)?.username??host,device_id:String(op==='join'?source?.device_id:body.device_id),track_id:String(op==='join'?source?.track_id:body.track_id),position_ms:Number(op==='join'?source?.position_ms:body.position_ms??0),playing:true,updated_at:new Date().toISOString(),participants:[]};listeningRooms.set(room.id,room);listeningMembers.set(host,room.id);} const source=activity.get(host);if(!room){room={id:crypto.randomUUID(),owner_id:host,owner_name:users.get(host)?.username??host,device_id:String(op==='join'?source?.device_id:body.device_id),track_id:String(op==='join'?source?.track_id:body.track_id),position_ms:Number(op==='join'?source?.position_ms:body.position_ms??0),playing:true,updated_at:new Date().toISOString(),participants:[]};listeningRooms.set(room.id,room);listeningMembers.set(host,room.id);}
if(prefs.auto_accept==='everyone'||prefs.auto_accept==='friends'&&friend)listeningMembers.set(guest,room.id); if(prefs.auto_accept==='everyone'||prefs.auto_accept==='friends'&&friend)listeningMembers.set(guest,room.id);
@@ -467,6 +472,8 @@ export function fakeApi(initial: FakeUser[]) {
return json({ ...kino, album_count: 1, track_count: 0 }); return json({ ...kino, album_count: 1, track_count: 0 });
case `GET /api/v1/artists/${kino.id}/albums`: case `GET /api/v1/artists/${kino.id}/albums`:
return json({ items: [albumSummary] }); 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)) { if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path)) {
onboarding.set(path.split('/')[5]!, null); onboarding.set(path.split('/')[5]!, null);
+21
View File
@@ -170,6 +170,22 @@
display: none; display: none;
} }
.navSubsection {
min-height: 40px;
margin: 2px 0;
font-size: 13px;
font-weight: 600;
letter-spacing: normal;
text-transform: none;
color: var(--text2);
}
.navSubgroup > .navItem {
margin-left: 12px;
padding-left: 12px;
border-left: 1px solid var(--line);
}
.navItem { .navItem {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -295,6 +311,9 @@
.navGroupFolded { .navGroupFolded {
display: contents; display: contents;
} }
.navSubsection { min-height: 0; }
.navSubgroup > .navItem { margin-left: 0; padding-left: 0; border-left: 0; }
} }
.mobileBottom { display: none; } .mobileBottom { display: none; }
@@ -358,6 +377,8 @@
border: 0; pointer-events: none; border: 0; pointer-events: none;
} }
.mobileMenu .navSectionTitle { display: block; } .mobileMenu .navSectionTitle { display: block; }
.mobileMenu .navSubsection { color: var(--text2); font-size: 13px; }
.mobileMenu .navSubgroup > .navItem { margin-left: 12px; border-left: 1px solid var(--line); }
.mobileMenu .navChevron { display: none; } .mobileMenu .navChevron { display: none; }
.content, .island .content { padding: 20px 16px 24px; } .content, .island .content { padding: 20px 16px 24px; }
} }
+47 -27
View File
@@ -2,7 +2,7 @@ import { queueIcon } from '../shared/ui/icons';
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'; import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { Suspense, useCallback, useState, type ReactNode } from 'react'; import { Suspense, useCallback, useEffect, useId, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session'; import { useAuthState, useSession, useUser } from '../app/session';
@@ -110,6 +110,8 @@ function NavSection({
children, children,
defaultFolded = false, defaultFolded = false,
mobile = false, mobile = false,
nested = false,
paths,
}: { }: {
id: string; id: string;
title: string; title: string;
@@ -117,12 +119,21 @@ function NavSection({
children: ReactNode; children: ReactNode;
defaultFolded?: boolean; defaultFolded?: boolean;
mobile?: boolean; mobile?: boolean;
nested?: boolean;
paths?: readonly string[];
}) { }) {
const groupId = useId();
const pathname = useRouterState({ select: (state) => state.location.pathname });
const active = paths?.some((path) => pathname === path || pathname.startsWith(`${path}/`)) ?? false;
const [folded, setFolded] = useState(() => { const [folded, setFolded] = useState(() => {
if (active) return false;
if (readIds(FOLDED_KEY).includes(id)) return true; if (readIds(FOLDED_KEY).includes(id)) return true;
if (readIds(TOUCHED_KEY).includes(id)) return false; if (readIds(TOUCHED_KEY).includes(id)) return false;
return defaultFolded; return defaultFolded;
}); });
useEffect(() => {
if (active) setFolded(false);
}, [active, pathname]);
const toggle = () => { const toggle = () => {
const next = !folded; const next = !folded;
setFolded(next); setFolded(next);
@@ -138,15 +149,15 @@ function NavSection({
return ( return (
<> <>
{mobile ? ( {mobile ? (
<h3 className={s.navSection}><span className={s.navSectionTitle}>{title}</span></h3> <h3 className={cx(s.navSection, nested && s.navSubsection)}><span className={s.navSectionTitle}>{title}</span></h3>
) : ( ) : (
<button type="button" className={s.navSection} aria-expanded={!folded} onClick={toggle}> <button type="button" className={cx(s.navSection, nested && s.navSubsection)} aria-expanded={!folded} aria-controls={groupId} onClick={toggle}>
<span className={s.navSectionTitle}>{title}</span> <span className={s.navSectionTitle}>{title}</span>
{folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null} {folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
<Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} /> <Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} />
</button> </button>
)} )}
<div className={cx(s.navGroup, !mobile && folded && s.navGroupFolded)}>{children}</div> <div id={groupId} role="group" aria-label={title} className={cx(s.navGroup, nested && s.navSubgroup, !mobile && folded && s.navGroupFolded)}>{children}</div>
</> </>
); );
} }
@@ -178,6 +189,7 @@ function Shell() {
const reviews = user.reviews_uploads; const reviews = user.reviews_uploads;
const pending = usePendingUploads(reviews); const pending = usePendingUploads(reviews);
const unseen = useUnseenUploads(staff); const unseen = useUnseenUploads(staff);
const uploadBadge = (reviews ? pending ?? 0 : 0) + (unseen ?? 0);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const closeMobile = useCallback(() => setMobileOpen(false), []); const closeMobile = useCallback(() => setMobileOpen(false), []);
const navigation = (mobile = false) => ( const navigation = (mobile = false) => (
@@ -208,30 +220,38 @@ function Shell() {
)} )}
{!staff && user.manages_updates && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />} {!staff && user.manages_updates && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
{staff && ( {staff && (
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded> <NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={uploadBadge} paths={['/admin', '/uploads']} defaultFolded>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} /> <NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
{user.role === 'admin' && ( <NavSection mobile={mobile} nested id="admin-media" title={t('nav.admin_media')} paths={['/admin/library', '/admin/sources']} defaultFolded>
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} /> <NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
)} {user.role === 'admin' && (
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} /> <NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} /> )}
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} /> </NavSection>
{reviews && ( <NavSection mobile={mobile} nested id="admin-uploads" title={t('nav.admin_uploads')} badge={uploadBadge} paths={['/uploads', '/admin/uploads']} defaultFolded>
<NavLink <NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
to="/admin/uploads" <NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
label={t('nav.upload_queue')} {reviews && (
icon={inboxIcon} <NavLink
activeIcon={inboxFillIcon} to="/admin/uploads"
badge={pending} label={t('nav.upload_queue')}
/> icon={inboxIcon}
)} activeIcon={inboxFillIcon}
{(user.role === 'admin' || user.manages_updates) && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />} badge={pending}
{user.role === 'admin' && ( />
<> )}
<NavLink to="/admin/config" label={t('nav.server_config')} icon={tuneIcon} activeIcon={tuneFillIcon} /> </NavSection>
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} /> {(user.role === 'admin' || user.manages_updates) && (
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} /> <NavSection mobile={mobile} nested id="admin-system" title={t('nav.admin_system')} paths={['/admin/updates', '/admin/config', '/admin/settings', '/admin/audit']} defaultFolded>
</> <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />
{user.role === 'admin' && (
<>
<NavLink to="/admin/config" label={t('nav.server_config')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
</>
)}
</NavSection>
)} )}
</NavSection> </NavSection>
)} )}
+22 -1
View File
@@ -259,7 +259,28 @@ a.source:hover {
} }
.play[aria-busy='true'] { .play[aria-busy='true'] {
opacity: 0.7; opacity: 0.85;
}
.playSpinner {
width: 22px;
height: 22px;
border: 3px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: playSpin 0.7s linear infinite;
}
@keyframes playSpin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.playSpinner {
animation-duration: 2s;
}
} }
.progress { .progress {
+3 -3
View File
@@ -143,7 +143,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
aria-busy={st.loading || undefined} aria-busy={st.loading || undefined}
disabled={locked} onClick={() => player.toggle()} disabled={locked} onClick={() => player.toggle()}
> >
<Icon src={st.playing ? pauseIcon : playIcon} size={28} /> {st.loading ? <span className={s.playSpinner} aria-hidden /> : <Icon src={st.playing ? pauseIcon : playIcon} size={28} />}
</button> </button>
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}> <button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
<Icon src={nextIcon} size={26} /> <Icon src={nextIcon} size={26} />
@@ -335,8 +335,8 @@ function NowPlayingSheet({
<button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}> <button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
<Icon src={prevIcon} size={28} /> <Icon src={prevIcon} size={28} />
</button> </button>
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} disabled={locked} onClick={() => player.toggle()}> <button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} aria-busy={st.loading || undefined} disabled={locked} onClick={() => player.toggle()}>
<Icon src={st.playing ? pauseIcon : playIcon} size={32} /> {st.loading ? <span className={s.playSpinner} aria-hidden /> : <Icon src={st.playing ? pauseIcon : playIcon} size={32} />}
</button> </button>
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}> <button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
<Icon src={nextIcon} size={28} /> <Icon src={nextIcon} size={28} />
+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", "key": "MANAGEMENT_DIR",
"group": "api", "group": "api",
+9
View File
@@ -14,6 +14,15 @@
## 2. Пользователи и роли ## 2. Пользователи и роли
В меню «Админка» пользователи доступны отдельным пунктом, остальные страницы собраны в подразделы:
- **Медиатека:** библиотека и хранилища.
- **Загрузки:** предложить треки, мои предложения и очередь на рассмотрение.
- **Система:** обновления, конфигурация, настройки и журнал.
На компьютере подразделы можно сворачивать; браузер запоминает выбор. Подраздел текущей страницы
раскрывается при переходе. На телефоне группы показаны заголовками в меню «Ещё». Видимость страниц зависит от прав.
«Администрирование → Пользователи». «Администрирование → Пользователи».
| Роль | Что может | | Роль | Что может |
+9
View File
@@ -71,6 +71,15 @@
известные дубли отбрасываются без загрузки. известные дубли отбрасываются без загрузки.
2. `POST /uploads` резервирует квоту очереди → загрузка по tus в staging (`/srv/pipu/uploads`). 2. `POST /uploads` резервирует квоту очереди → загрузка по tus в staging (`/srv/pipu/uploads`).
3. Задача `process_upload_file`: ffprobe, хэш, Chromaprint → дубль (удаление, квота освобождена) или `pending_review`. 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` хранилища → 4. Админ правит метаданные и одобряет → `import_upload_file`: целевой путь по `write_template` хранилища →
`storage-agent` записывает файл → `scan_path` → трек в каталоге, поиске и очереди анализа. `storage-agent` записывает файл → `scan_path` → трек в каталоге, поиске и очереди анализа.
+11
View File
@@ -140,9 +140,20 @@ CREATE TABLE upload_batches ( -- одна «пачка» загруз
created_at timestamptz NOT NULL DEFAULT now() 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 ( CREATE TABLE upload_files (
id uuid PRIMARY KEY, id uuid PRIMARY KEY,
batch_id uuid NOT NULL REFERENCES upload_batches(id) ON DELETE CASCADE, 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), author_id uuid NOT NULL REFERENCES users(id),
status upload_status NOT NULL, status upload_status NOT NULL,
original_name text 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` | | `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/download` |
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища | | `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) | | `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
| `MUSICBRAINZ_ENABLED` | `true` | Фоновое сопоставление пачек с изданиями MusicBrainz и предложение обложек Cover Art Archive; нужен исходящий HTTPS |
| `ACOUSTID_API_KEY` | пусто | Секретный ключ приложения AcoustID для распознавания пачек по звуку; без него доступен только поиск по тегам |
| `LOG_LEVEL` | `info` | | | `LOG_LEVEL` | `info` | |
## TLS ## TLS
+106
View File
@@ -0,0 +1,106 @@
# Project Review — 2026-10-24 (1)
## Вывод и область проверки
Модульный монолит, contract-first API и общий core соответствуют масштабу проекта. После предыдущего рефакторинга у library/uploads/player появились полезные границы. Перестраивать проект целиком ради SOLID оснований нет. Приоритет — изоляция асинхронных операций при смене аккаунта, авторизация записей и согласованность состояния рекомендаций.
Проверены карта документации и архитектура, локальные AGENTS, выбранные потоки auth, playback/events, wave, social/listening, uploads/placement, library/schedule, web persistence/dialogs, Android offline/events/downloads/onboarding, desktop proxy, Python wave/API и текущий diff. Это статическое ревью перечисленных потоков с целевыми тестами, а не построчная проверка каждого файла или полный security-аудит. Физический Android, реальные аудиопотоки, NFS и производительность под нагрузкой не проверялись. Замечания ниже подтверждены чтением кода; отдельные новые воспроизводящие тесты для них в этой работе не добавлялись.
## Prior Reviews Summary
В каталоге были два отчёта: `2026-10-22-review-1.md` и `2026-10-21-review-1.md`. Все 17 задач отмечены `[x]`; закрытые задачи повторно не открывались.
### Still Open (carried forward)
Нет. Перенесено: 0.
### Resolved Since Last Review
Нет ранее открытых задач для перепроверки. Подтверждено новых закрытий: 0.
## Phase 1: Code Quality
### SOLID
**F7, P3 — WaveService собирает инфраструктуру и выполняет сценарий.** Конструктор сам создаёт IndexHolder, SessionStore и RankerHolder, `batch` открывает PostgreSQL-соединения, загружает профиль, меняет Redis-сессию, вызывает алгоритм и пишет обучающие признаки. Изолированная проверка сценария требует подмены конкретных инфраструктурных деталей. Передавать узкие порты каталога/профиля, сессии и impression sink из `_serve`, оставив SQL/Redis в адаптерах. Не нужно оборачивать каждую математическую функцию интерфейсом. Источник: `services/recommender/pipu_rec/wave/service.py:58`, `:79`, `:112`; composition root: `pipu_rec/cli.py:_serve`.
Доказанных нарушений LSP в просмотренных реализациях не найдено. Остаточная длина Player и наличие конкретных адаптеров в composition root сами по себе не считаются дефектами. Закрытые замечания предыдущего SOLID-ревью не дублируются.
### Performance
**F5, P2 — размер исключений растёт без ограничения и нарушает внутренний контракт.** Go `servedIDs` загружает все показы сессии и передаёт весь массив как `exclude_ids`, а Python BatchRequest ограничивает его 1000 элементами. После превышения 1000 показов запросы этой сессии получают 422, и Go каждый раз переходит на fallback. Это подтверждённый конфликт границ, а не результат нагрузочного замера. Согласовать bounded history/хранение исключений на стороне рекомендателя, сохранив политику неповторов. Источники: `services/api/internal/wave/wave.go:291`, `:349`; `services/recommender/pipu_rec/wave/service.py:40`.
Иных измеренных проблем производительности не установлено.
### Correctness & Bugs
**F1, P1 — незавершённая загрузка Android продолжает работу в новом аккаунте.** `attach` меняет `this.owner/server` и очищает active; `run` захватывает старую session, но после сетевого await строит путь через изменяемый `this.file` и URL через `this.abs`, затем без проверки поколения снова вставляет DownloadResumable в active. Сценарий: медленный `/tracks/batch` → logout/смена сервера → старый ответ. Старая задача может писать в каталог нового владельца и занимать его очередь; её finally может удалить active новой задачи с тем же track ID. Проверка owner внутри `status` не защищает создание загрузки, progress callback и файловые операции. Захватывать контекст задачи (owner/server/пути), проверять поколение после await и удалять active только по идентичности задачи. Источник: `apps/mobile/src/offline/downloads.ts:84`, `:228`, `:235`, `:261`.
**F3, P2 — поздний refresh восстанавливает устаревшую авторизацию.** `doRefresh` без проверки версии вызывает `apply`; logout/signOut и новый login не инвалидируют уже отправленный refresh. Если успешный refresh был обработан сервером до logout, но его ответ задержался, он может после signOut вернуть локальный signed_in и записать старые токены в RefreshStore. Аналогично ответ старого refresh может перезаписать новый login. Нужны поколение auth-операций и отбрасывание устаревшего результата до изменения access/store/state. Источник: `packages/core/src/auth.ts:143`, `:159`, `:167`, `:197`.
### Code Quality
Отдельных задач по косметике, размеру файлов и magic numbers не создано: они уступают проверенным нарушениям поведения.
## Phase 2: Logical Consistency
### Domain & Application Layer
**F2, P1 — событие пользователя может изменить исход чужой wave-сессии.** Клиент передаёт произвольный UUID в context_id; Accept передаёт его в MarkImpression, где UPDATE ограничен только sessionID/trackID. Проверки `wave_sessions.user_id = p.UserID` нет. Зная UUID чужой сессии и трека, авторизованный клиент может записать like/dislike/skip в чужой показ, влияя на метрики и обучающие данные. UUID усложняет угадывание, но не заменяет авторизацию. Передавать userID и ограничивать запись владельцем сессии в той же SQL-операции; добавить тест изоляции двух пользователей. Источники: `services/api/internal/playback/events.go:75`, `services/api/internal/playback/store.go:354`; контракт `api/openapi.yaml:PlayEvent` разрешает context_id как строку.
### Data Flow
Сохранены положительные границы: core не импортирует платформенные реализации, uploads use cases отделены от pgx, read-проекции могут делать межмодульные JOIN. Целевая проверка `pnpm check:boundaries` проходит; это не доказательство отсутствия всех циклов импортов.
### State Management
**F4, P2 — drain реакций может перезаписать более свежий выбор.** `drain` читает snapshot pending_reactions и отправляет его без координации с `react`. Возможен порядок: drain прочитал старый like → react успешно записал новый dislike и удалил pending → drain позже отправил старый like. Условие `reaction IS ?` защищает удаление SQLite-строки, но не порядок запросов к серверу. Сериализовать операции по track ID либо добавить версию и согласовать запись на сервере. Источник: `apps/mobile/src/offline/events.ts:50`, `:65`, `:77`, `:82`.
**F6, P2 — параллельные запросы теряют обновления Redis-сессии волны.** API запускает batch через `asyncio.to_thread`; каждый batch отдельно делает get → меняет session → put. Два запроса одной сессии могут прочитать одинаковый snapshot, выбрать пересекающиеся треки и последней записью затереть feedback/served/recent_artists первого. PostgreSQL-обновление tracks_served не защищает Redis и выбор кандидатов. Нужна сериализация/версионирование всей операции по session_id с учётом нескольких процессов, а не только локальный thread lock. Источники: `services/recommender/pipu_rec/api/app.py:wave_batch`, `pipu_rec/wave/service.py:84`, `:106`, `pipu_rec/wave/store.py:SessionStore`.
### Consistency
Конфликт внутреннего контракта Go/Python описан как F5; отдельная дублирующая задача не создаётся.
## Phase 3: UI/UX
### Usability
**F8, P2 — Android молча пропускает onboarding при ошибке его загрузки.** После завершения запроса с ошибкой `query.isPending` становится false, data отсутствует, и `!query.data?.required` возвращает основной интерфейс без ошибки и retry. Для нового пользователя временный сбой выглядит как отсутствие настройки рекомендаций. Разделить unknown/error и подтверждённое required=false; предусмотреть retry и осознанный вход в offline-режим без блокирования скачанной музыки. Источник: `apps/mobile/src/ui/Onboarding.tsx:29`.
### Visual & Consistency
Новых подтверждённых визуальных замечаний нет. Старые закрытые mobile-web задачи не переоткрывались. В этой работе браузер использовался для функциональных E2E; полного визуального аудита всех экранов не было.
### Interaction & Feedback
В текущих висящих изменениях завершён lifecycle listening: закрытие диалога после отправки, отсутствие стрима до admission, отдельная ошибка listening, явный запуск на втором устройстве ведущего, защита от позднего GET после выхода и сохранение refresh во время запроса. Дополнительно исправлено освобождение пустой скрытой комнаты после отмены/истечения последнего запроса; музыка источника продолжает играть. Эти исправленные в рамках сдачи дефекты не входят в открытые Tasks.
### Accessibility
У просмотренного web Dialog есть именование, focus trap, Escape и возврат фокуса; у Android Field/Button есть доступные имена/роли. Новых доказанных accessibility-дефектов не заявлено. Проверка screen reader и всех контрастов не проводилась.
## Tasks
Новых открытых задач: 8. Перенесено: 0. Ранее открытых, исправленных после прошлых ревью: 0. Исправление всех найденных замечаний не входило в просьбу завершить уже висящие изменения listening.
- [ ] 1. [Bug] [P1, F1] Изолировать Download jobs по поколению owner/server, включая URL, пути, progress и active cleanup — `apps/mobile/src/offline/downloads.ts` line 261.
- [ ] 2. [Logic] [P1, F2] Проверять владельца wave-сессии при MarkImpression и покрыть изоляцию пользователей — `services/api/internal/playback/store.go` line 354.
- [ ] 3. [Bug] [P2, F3] Инвалидировать поздние refresh/apply после logout и нового login — `packages/core/src/auth.ts` line 197.
- [ ] 4. [Bug] [P2, F4] Согласовать порядок drain и новых реакций по track ID — `apps/mobile/src/offline/events.ts` line 82.
- [ ] 5. [Performance] [P2, F5] Согласовать bounded exclude history Go/Python, исключить постоянный fallback после 1000 показов — `services/api/internal/wave/wave.go` line 349.
- [ ] 6. [Logic] [P2, F6] Защитить read-modify-write wave-сессии от параллельных batch — `services/recommender/pipu_rec/wave/service.py` line 84.
- [ ] 7. [UX] [P2, F8] Отделить ошибку onboarding от required=false и дать retry/offline-путь — `apps/mobile/src/ui/Onboarding.tsx` line 29.
- [ ] 8. [SOLID] [P3, F7] Передавать узкие IO-порты в WaveService из composition root — `services/recommender/pipu_rec/wave/service.py` line 58.
## Проверки и Git
- До изменений этого ревью: HEAD `cb22f15`; после успешного `git fetch origin` — HEAD и origin/main совпадали, ahead=0, behind=0.
- В рабочем дереве изначально: 13 изменённых tracked-файлов listening/тестов/переводов/docs, новый `apps/web/e2e/listening.spec.ts`, а также отдельные `scripts/share-stats.py`, `artifacts/wave-palette/`, `services/api/api.exe`. Последние три не менялись и не включаются в коммит listening.
- Core: 69 тестов прошли, в том числе новый тест освобождения пустой комнаты.
- Web: App + player/listening — 51 тест прошёл в итоговом прогоне. В первом запуске при параллельных проверках упал lossless-hint test; изолированно и в повторном прогоне проходит. Стабильность этого теста не считается доказанной одним повтором.
- Mobile: 6 тестов прошли. Web/mobile/core typecheck и i18n check прошли (1111 ключей × 2 языка).
- `pnpm check:boundaries` прошёл. `git diff --check` прошёл.
- Go: unit-тесты social/playback/wave и vet social прошли. Integration `TestActivityRoomsAndChatPrivacy` собирается, но **SKIP**: TEST_DATABASE_URL не задан. Docker daemon недоступен. Транзакционные сценарии на реальной БД этой проверкой не подтверждены.
- Playwright listening: 4 сценария прошли. Используется fake API; ошибки аудиопрокси ECONNREFUSED в сценариях не являются проверкой воспроизведения настоящего аудио.
- Публичный OpenAPI не менялся, генерация не требуется. Реальный Android/background и PostgreSQL остаются ограничениями проверки.
+8
View File
@@ -89,6 +89,14 @@ React Query; Android останавливает polling при уходе при
«Подключиться» отправляет запрос хозяину и оставляет управление у него. «Подключиться» отправляет запрос хозяину и оставляет управление у него.
«Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем. «Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем.
Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину. Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину.
Комната резервируется на время ожидания подтверждения. До принятия запроса
слушатель не становится участником, а баннер совместного прослушивания не показывается.
Исходное устройство ведущего поддерживает резервирование, не прерывая свою музыку.
После успешной отправки окно закрывается; ожидающий запрос можно отменить в разделе общения.
После отмены или истечения последнего запроса исходное устройство освобождает
пустую комнату при следующем опросе, не прерывая свою музыку.
Повторный запрос уже подключённого слушателя не создаёт нового ожидания.
Второе устройство ведущего не включает звук автоматически и при закрытии не завершает стрим.
Настройки приёма запросов/приглашений и автоматического принятия независимы, Настройки приёма запросов/приглашений и автоматического принятия независимы,
обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории. обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории.
Дополнительная настройка управляет запуском звука при подключении. Дополнительная настройка управляет запуском звука при подключении.
+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`. | | 2. Совпадение метаданных | **До загрузки** (теги читаются на клиенте библиотекой `music-metadata`) и после (на сервере по `ffprobe`) | Нормализованные `artist + title` (+ `album`, если есть) и длительность ±2 с против `tracks`. |
| 3. Акустический отпечаток | После загрузки | Chromaprint (`fpcalc`), сравнение с `track_fingerprints` библиотеки и очереди. Ловит тот же трек в другом формате / битрейте / с другими тегами. | | 3. Акустический отпечаток | После загрузки | Chromaprint (`fpcalc`), сравнение с `track_fingerprints` библиотеки и очереди. Ловит тот же трек в другом формате / битрейте / с другими тегами. |
@@ -86,7 +86,14 @@
- Staging — локальный диск сервера `/srv/pipu/uploads/<upload_id>/<file_id>` (не NFS). - 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`): - Разрешённые форматы: `flac mp3 m4a ogg opus wav aiff wv`. Просим присылать lossless — **FLAC, WAV или AIFF** (WavPack тоже считается lossless); MP3 и остальные lossy — крайний случай: форма предупреждает об этом, а очередь на добавление помечает такой файл «обратить внимание». Проверяется не расширение, а содержимое (`ffprobe`):
должен быть ровно один аудиопоток, длительность 5 с – 3 ч. Иначе `invalid` с кодом причины. должен быть ровно один аудиопоток, длительность 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`) ## 6. Обработка (`processing`)
@@ -126,9 +133,10 @@
| `[ ... ]` | Необязательный фрагмент: опускается целиком, если любой плейсхолдер внутри пуст, например `[{year} - ]{album}` | | `[ ... ]` | Необязательный фрагмент: опускается целиком, если любой плейсхолдер внутри пуст, например `[{year} - ]{album}` |
**Как получить шаблон:** **Как получить шаблон:**
- **Автоподбор**: система берёт выборку существующих путей хранилища, определяет, какой регэксп совпадает чаще всего, - **Автоподбор**: система сравнивает пути и метаданные до 200 существующих файлов хранилища, строит варианты
и предлагает шаблон, порождающий такие же пути (группы регэкспа → плейсхолдеры, литералы между группами → текст шаблона). раскладки и выбирает вариант с наибольшим числом точных совпадений. Сохранённые регэкспы также используются
Админ подтверждает или правит. как кандидаты, но не обязательны. В интерфейсе показаны число совпадений и примеры реальных путей.
Если шаблон ещё не задан, предложение заполняется автоматически; администратор может изменить его и сохранить.
- **Проверка обратимости** (обязательна при сохранении): шаблон применяется к 50 существующим трекам хранилища, - **Проверка обратимости** (обязательна при сохранении): шаблон применяется к 50 существующим трекам хранилища,
полученные пути прогоняются через регэкспы этого хранилища — поля должны распознаться обратно без изменений, полученные пути прогоняются через регэкспы этого хранилища — поля должны распознаться обратно без изменений,
а в ≥ 90% случаев путь должен совпасть с реальным путём файла. Иначе — предупреждение с примерами расхождений. а в ≥ 90% случаев путь должен совпасть с реальным путём файла. Иначе — предупреждение с примерами расхождений.
@@ -149,14 +157,114 @@
Экран «Очередь на добавление»: Экран «Очередь на добавление»:
- Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`. - Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`.
Пачки по умолчанию свёрнуты; кнопка в заголовке раскрывает файлы, выбор всей пачки доступен и без раскрытия.
В заголовке указано наличие общей обложки пачки: «Есть обложка» / «Без обложки».
Там же показаны количества файлов по форматам и диапазоны битрейта, частоты и разрядности;
одинаковые значения не повторяются, отсутствующие измерения не учитываются как нули.
- Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество - Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество
(формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище** (формат / битрейт / 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 …` - Если админ изменил метаданные — они **записываются в теги файла** при импорте (`ffmpeg -map 0 -c copy -metadata …`
без перекодирования; для FLAC/MP3/M4A/Ogg), чтобы теги и путь не противоречили друг другу. без перекодирования; для 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`) ## 9. Импорт (`importing`)
Задача River `import_upload_file` (повторяемая, идемпотентная): Задача River `import_upload_file` (повторяемая, идемпотентная):
@@ -175,6 +283,9 @@
админ может повторить или отклонить. админ может повторить или отклонить.
## 10. Уведомления ## 10. Уведомления
- В «Моих предложениях» файлы сгруппированы по пачкам. Можно выбрать отдельные файлы, загруженные строки
страницы или всю собственную пачку (с загрузкой всех её страниц) и отозвать выбранные до решения администратора.
Успешные отзывы освобождают квоту; при частичной ошибке показана причина, неудавшиеся файлы остаются выбранными.
- Автор видит статусы в «Мои предложения» (раздел админки модератора), бейдж с числом решений с последнего визита. - Автор видит статусы в «Мои предложения» (раздел админки модератора), бейдж с числом решений с последнего визита.
- Аудитор загрузок видит бейдж «N на рассмотрении» в сайдбаре. - Аудитор загрузок видит бейдж «N на рассмотрении» в сайдбаре.
@@ -200,7 +311,7 @@
- Настройки: `GET` / `PATCH /admin/settings/uploads` (ключ `uploads.settings` в `app_settings`). - Настройки: `GET` / `PATCH /admin/settings/uploads` (ключ `uploads.settings` в `app_settings`).
- Веб (`apps/web/src/features/uploads`, `pages/uploads`, `pages/admin/UploadQueuePage.tsx`): - Веб (`apps/web/src/features/uploads`, `pages/uploads`, `pages/admin/UploadQueuePage.tsx`):
- «Предложить треки» (`/uploads/new`): drag-and-drop файлов и папок (FileSystemEntry API, относительные пути), - «Предложить треки» (`/uploads/new`): drag-and-drop файлов и папок (FileSystemEntry API, относительные пути),
выбор файлов / папки; SHA-256 потоком в Web Worker (`hash-wasm`); теги читает `music-metadata` и отдаёт их выбор файлов / папки; SHA-256 потоком в Web Worker (`js-sha256`); теги читает `music-metadata` и отдаёт их
в предпроверку; `POST /uploads`; `tus-js-client` с готовым `uploadUrl`, куски по 8 МБ, 4 файла параллельно, в предпроверку; `POST /uploads`; `tus-js-client` с готовым `uploadUrl`, куски по 8 МБ, 4 файла параллельно,
401 → обновление токена и повтор. Загрузчик — синглтон модуля: переходы по приложению не прерывают загрузку. 401 → обновление токена и повтор. Загрузчик — синглтон модуля: переходы по приложению не прерывают загрузку.
Незавершённые tus-ссылки пишутся в `localStorage`; после перезагрузки пользователь выбирает те же файлы, Незавершённые tus-ссылки пишутся в `localStorage`; после перезагрузки пользователь выбирает те же файлы,
+272
View File
@@ -955,6 +955,68 @@ export interface paths {
patch: operations["updateUploadBatch"]; patch: operations["updateUploadBatch"];
trace?: never; 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": { "/api/v1/admin/uploads/review": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2233,6 +2295,8 @@ export interface components {
}[]; }[];
}; };
UploadBatchRequest: { UploadBatchRequest: {
/** @description Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки. */
cover_base64?: string;
comment?: string; comment?: string;
files: { files: {
client_id: string; client_id: string;
@@ -2314,6 +2378,12 @@ export interface components {
target_source_id: string | null; target_source_id: string | null;
/** @description Путь в хранилище по write_template (null — не построился, причина в status_reason) */ /** @description Путь в хранилище по write_template (null — не построился, причина в status_reason) */
target_rel_path: string | null; 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; status_reason: string | null;
/** Format: date-time */ /** Format: date-time */
reviewed_at: string | null; reviewed_at: string | null;
@@ -2335,6 +2405,74 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
target_source_id?: string; 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 снимает обложку пачки. */ /** @description Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки. */
UploadBatchPatch: { UploadBatchPatch: {
artist?: string; artist?: string;
@@ -2695,6 +2833,12 @@ export interface components {
}; };
WriteTemplateSuggestion: { WriteTemplateSuggestion: {
template: string; template: string;
/** @description Число путей выборки, совпавших с предложенным шаблоном */
matched?: number;
/** @description Число исследованных файлов хранилища */
total?: number;
/** @description До трёх существующих путей, совпавших с шаблоном */
examples?: string[];
}; };
WriteTemplateCheckRequest: { WriteTemplateCheckRequest: {
template: string; template: string;
@@ -4413,6 +4557,8 @@ export interface operations {
listMyUploads: { listMyUploads: {
parameters: { parameters: {
query?: { query?: {
/** @description Только файлы указанной собственной пачки */
batch?: string;
/** @description Статусы через запятую */ /** @description Статусы через запятую */
status?: components["parameters"]["UploadStatusFilter"]; status?: components["parameters"]["UploadStatusFilter"];
limit?: components["parameters"]["Limit"]; limit?: components["parameters"]["Limit"];
@@ -4635,6 +4781,132 @@ export interface operations {
default: components["responses"]["Problem"]; 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: { reviewUploads: {
parameters: { parameters: {
query?: never; query?: never;
+6 -1
View File
@@ -15,5 +15,10 @@ export interface ApiClientOptions {
/** Создаёт типизированный клиент публичного API (контракт — api/openapi.yaml). */ /** Создаёт типизированный клиент публичного API (контракт — api/openapi.yaml). */
export function createApiClient(opts: ApiClientOptions = {}): ApiClient { 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']); 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 () => { it('turns problem+json into ApiError with field errors', async () => {
const s = server({ const s = server({
'POST /api/v1/auth/login': () => problem(401, 'auth.invalid_credentials'), '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') { if (typeof p.code === 'string') {
return new ApiError(p.status ?? response?.status ?? 0, p.code, p.params ?? {}, p.errors ?? []); 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'); return new ApiError(0, 'common.network');
} }
+44
View File
@@ -31,4 +31,48 @@ describe('shared listening',()=>{
it('pauses on connection loss and on room termination',async()=>{ it('pauses on connection loss and on room termination',async()=>{
const e=setup();await e.coordinator.poll(true);e.get.mockRejectedValueOnce(new TypeError('offline'));e.advance(11000);await e.coordinator.poll(true);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({disconnected:true}));e.setState({...e.state,room:null});await e.coordinator.poll(true);expect(e.coordinator.locked).toBe(false);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null}));e.coordinator.dispose(); const e=setup();await e.coordinator.poll(true);e.get.mockRejectedValueOnce(new TypeError('offline'));e.advance(11000);await e.coordinator.poll(true);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({disconnected:true}));e.setState({...e.state,room:null});await e.coordinator.poll(true);expect(e.coordinator.locked).toBe(false);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null}));e.coordinator.dispose();
}); });
it('keeps a reserved source room alive without announcing a stream before approval',async()=>{
const e=setup(host,device);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]},requests:[{id:'request',owner_id:host,sender_id:guest,sender_name:'Guest',recipient_id:host,kind:'join',expires_at:'2026-10-23T00:02:00Z'}]});
await e.coordinator.poll(true);
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
expect(e.put).toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();expect(e.adapter.apply).not.toHaveBeenCalled();e.coordinator.dispose();
});
it('releases an empty source reservation after its final request is cancelled or expires',async()=>{
const e=setup(host,device);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]}});
await e.coordinator.poll(true);
expect(e.post).toHaveBeenCalledWith('/api/v1/me/social/listening/actions',expect.objectContaining({body:expect.objectContaining({action:'end',target_id:room.id})}));
expect(e.put).not.toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
e.coordinator.dispose();expect(e.post).toHaveBeenCalledTimes(1);
});
it('does not take over another source device while its request is pending',async()=>{
const e=setup(host);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]}});
await e.coordinator.poll(true);
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();e.coordinator.dispose();expect(e.post).not.toHaveBeenCalled();
});
it('does not start a pending guest request with no admitted room',async()=>{
const e=setup();e.setState({...e.state,room:null,requests:[{id:'request',owner_id:host,sender_id:guest,sender_name:'Guest',recipient_id:host,kind:'join',expires_at:'2026-10-23T00:02:00Z'}]});
await e.coordinator.poll(true);expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.coordinator.locked).toBe(false);e.coordinator.dispose();
});
it('requires explicit audio on a second owner device and does not end the source when it closes',async()=>{
const e=setup(host);await e.coordinator.poll(true);
expect(e.adapter.apply).toHaveBeenLastCalledWith(expect.objectContaining({playing:false}),3000,expect.any(Function));
e.coordinator.beginAudio();await vi.waitFor(()=>expect(e.adapter.apply).toHaveBeenLastCalledWith(expect.objectContaining({playing:true}),3000,expect.any(Function)));
e.coordinator.dispose();expect(e.post).not.toHaveBeenCalled();
});
it('discards a room lookup that arrives after a local exit',async()=>{
const e=setup();let resolve!:(value:{data:ListeningState;response:Response})=>void;
e.get.mockImplementationOnce(()=>new Promise(done=>{resolve=done;}));
const pending=e.coordinator.poll(true);e.coordinator.leaveLocally();resolve({data:e.state,response:new Response()});await pending;
expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null}));e.coordinator.dispose();
});
it('queues a refresh requested while a state lookup is in flight',async()=>{
const e=setup();let resolve!:(value:{data:ListeningState;response:Response})=>void;
e.get.mockImplementationOnce(()=>new Promise(done=>{resolve=done;}));
const pending=e.coordinator.poll(true);e.coordinator.refresh();
e.setState({...e.state,room:null});resolve({data:{...e.state,room:{...room}},response:new Response()});await pending;
await vi.waitFor(()=>expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null})));
expect(e.get).toHaveBeenCalledTimes(2);e.coordinator.dispose();
});
}); });
+34 -7
View File
@@ -22,6 +22,7 @@ export class ListeningCoordinator {
private generation = 0; private generation = 0;
private sequence = 0; private sequence = 0;
private busy = false; private busy = false;
private refreshPending = false;
private closed = false; private closed = false;
private lastPoll = 0; private lastPoll = 0;
private lastSuccess = 0; private lastSuccess = 0;
@@ -46,9 +47,12 @@ export class ListeningCoordinator {
if (!room || !this.source(room)) return; if (!room || !this.source(room)) return;
const state = this.adapter.read(); const state = this.adapter.read();
const key = `${state.track?.id}:${state.playing}`; const key = `${state.track?.id}:${state.playing}`;
if (key !== this.key) { this.key = key; void this.poll(true); } if (key !== this.key) { this.key = key; this.refresh(); }
}
refresh() {
if (this.busy) { this.refreshPending = true; return; }
void this.poll(true);
} }
refresh() { void this.poll(true); }
allowJoining() { this.ignored.clear(); } allowJoining() { this.ignored.clear(); }
beginAudio() { if (this.snapshot?.room) this.enabled.add(this.snapshot.room.id); this.refresh(); } beginAudio() { if (this.snapshot?.room) this.enabled.add(this.snapshot.room.id); this.refresh(); }
leaveLocally() { leaveLocally() {
@@ -66,9 +70,10 @@ export class ListeningCoordinator {
if (this.closed || this.busy || (!force && !this.snapshot?.room && this.now() - this.lastPoll < 10_000)) return; if (this.closed || this.busy || (!force && !this.snapshot?.room && this.now() - this.lastPoll < 10_000)) return;
if (this.session.state?.status !== 'signed_in') return; if (this.session.state?.status !== 'signed_in') return;
this.busy = true; this.lastPoll = this.now(); this.busy = true; this.lastPoll = this.now();
const requestGeneration = this.generation;
try { try {
const state = unwrap(await this.session.client.GET('/api/v1/me/social/listening')); const state = unwrap(await this.session.client.GET('/api/v1/me/social/listening'));
if (this.closed) return; if (this.closed || requestGeneration !== this.generation) return;
this.lastSuccess = this.now(); this.lastSuccess = this.now();
const previous = this.snapshot?.room; const previous = this.snapshot?.room;
this.snapshot = state; this.snapshot = state;
@@ -80,14 +85,32 @@ export class ListeningCoordinator {
await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'leave', target_id: room.id } }); await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'leave', target_id: room.id } });
return; return;
} }
// The server reserves a room while a request is pending. Only admitted
// participants turn it into a shared session; keep the source heartbeat
// alive without displaying a stream or taking over another device.
const shared = room.participants.some(person => person.id !== room.owner_id);
if (!shared && !this.source(room)) {
if (this.locked) { this.generation++; this.adapter.pause(); }
this.emit(null);
return;
}
if (this.source(room)) { if (this.source(room)) {
this.emit(room); // A cancelled or expired reservation must not keep an invisible room
// alive forever and prevent its owner from joining another stream.
if (!shared && !state.requests.some(request => request.owner_id === room.owner_id)) {
unwrap(await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'end', target_id: room.id } }));
this.snapshot = { ...state, room: null };
this.emit(null);
return;
}
this.emit(shared ? room : null);
const playback = this.adapter.read(); const playback = this.adapter.read();
unwrap(await this.session.client.PUT('/api/v1/me/social/listening/state', { body: { room_id: room.id, device_id: this.deviceId, sequence: ++this.sequence, track_id: playback.track?.id, playing: playback.playing && !!playback.track, position_ms: Math.max(0, Math.round(playback.positionMs)) } })); unwrap(await this.session.client.PUT('/api/v1/me/social/listening/state', { body: { room_id: room.id, device_id: this.deviceId, sequence: ++this.sequence, track_id: playback.track?.id, playing: playback.playing && !!playback.track, position_ms: Math.max(0, Math.round(playback.positionMs)) } }));
return; return;
} }
if (!this.locked) this.adapter.pause(); if (!this.locked) this.adapter.pause();
const needsStart = !state.preferences.autoplay && !this.enabled.has(room.id); const ownOtherDevice = room.owner_id === this.session.state.user.id;
const needsStart = (!state.preferences.autoplay || ownOtherDevice) && !this.enabled.has(room.id);
this.emit(room, needsStart); this.emit(room, needsStart);
const version = this.generation; const version = this.generation;
const elapsed = room.playing ? Math.max(0, Date.parse(state.server_time) - Date.parse(room.updated_at)) : 0; const elapsed = room.playing ? Math.max(0, Date.parse(state.server_time) - Date.parse(room.updated_at)) : 0;
@@ -95,7 +118,10 @@ export class ListeningCoordinator {
} catch { } catch {
if (this.closed) return; if (this.closed) return;
if (this.locked && this.now() - this.lastSuccess > 10_000) { this.adapter.pause(); this.emit(this.snapshot?.room ?? null, false, true); } if (this.locked && this.now() - this.lastSuccess > 10_000) { this.adapter.pause(); this.emit(this.snapshot?.room ?? null, false, true); }
} finally { this.busy = false; } } finally {
this.busy = false;
if (this.refreshPending && !this.closed) { this.refreshPending = false; this.refresh(); }
}
} }
dispose() { dispose() {
if (this.closed) return; if (this.closed) return;
@@ -104,7 +130,8 @@ export class ListeningCoordinator {
clients.get(this.session)?.delete(this); clients.get(this.session)?.delete(this);
const room = this.snapshot?.room; const room = this.snapshot?.room;
if (this.locked) this.adapter.pause(); if (this.locked) this.adapter.pause();
if (room) void this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: this.source(room) ? 'end' : 'leave', target_id: room.id } }).catch(() => undefined); const ownAccount = this.session.state.status === 'signed_in' && room?.owner_id === this.session.state.user.id;
if (room && (this.source(room) || !ownAccount)) void this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: this.source(room) ? 'end' : 'leave', target_id: room.id } }).catch(() => undefined);
} }
} }
+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": { "users": {
"change_username": "Change username", "change_username": "Change username",
"title": "Users", "title": "Users",
@@ -210,6 +269,9 @@
"analysis_error_unknown": "No error reason was recorded." "analysis_error_unknown": "No error reason was recorded."
}, },
"write_template": { "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", "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.", "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", "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_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.", "failed_some_other": "{{count}} files did not upload — their space is reserved, you can continue.",
"retry_failed": "Continue upload", "retry_failed": "Continue upload",
"retry_file": "Retry upload of {{name}}",
"uploading_now": "Uploading…", "uploading_now": "Uploading…",
"go_mine": "My uploads →", "go_mine": "My uploads →",
"more": "Upload more", "more": "Upload more",
@@ -287,12 +350,25 @@
"filter_pending": "In progress", "filter_pending": "In progress",
"filter_imported": "Added", "filter_imported": "Added",
"filter_declined": "Not accepted", "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_file": "Track",
"col_status": "Status", "col_status": "Status",
"col_size": "Size", "col_size": "Size",
"col_date": "Uploaded", "col_date": "Uploaded",
"received": "{{pct}}% received — open “Suggest tracks” in the same tab to continue", "received": "{{pct}}% received — open “Suggest tracks” in the same tab to continue",
"withdraw": "Withdraw", "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", "load_more": "Show more",
"queue_title": "Upload queue", "queue_title": "Upload queue",
"queue_subtitle": "Check the metadata and the storage path, then approve or reject", "queue_subtitle": "Check the metadata and the storage path, then approve or reject",
@@ -350,6 +426,16 @@
"meta_track": "Track number", "meta_track": "Track number",
"meta_disc": "Disc", "meta_disc": "Disc",
"target_source": "Storage", "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", "target_default": "Default",
"tags_note": "Edited fields are written into the file tags on import, so path and tags stay consistent.", "tags_note": "Edited fields are written into the file tags on import, so path and tags stay consistent.",
"save": "Save", "save": "Save",
+3
View File
@@ -50,6 +50,9 @@
"main": "Navigation", "main": "Navigation",
"home": "Home", "home": "Home",
"admin": "Admin", "admin": "Admin",
"admin_media": "Media library",
"admin_uploads": "Uploads",
"admin_system": "System",
"users": "Users", "users": "Users",
"audit": "Audit log", "audit": "Audit log",
"library": "Library", "library": "Library",
+15 -1
View File
@@ -2,6 +2,7 @@
"common": { "common": {
"internal": "Something went wrong on the server. Please try again", "internal": "Something went wrong on the server. Please try again",
"bad_request": "Invalid request", "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", "not_found": "Not found",
"method_not_allowed": "This action is not supported", "method_not_allowed": "This action is not supported",
"network": "Cannot reach the server. Check your network" "network": "Cannot reach the server. Check your network"
@@ -26,6 +27,8 @@
"forbidden": "You don't have permission to do this" "forbidden": "You don't have permission to do this"
}, },
"validation": { "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.", "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_short": "The password must be at least {{min}} characters",
"password_too_long": "The password must be at most {{max}} characters", "password_too_long": "The password must be at most {{max}} characters",
@@ -60,7 +63,7 @@
"source_not_found": "Storage not found", "source_not_found": "Storage not found",
"scan_running": "A scan is already running", "scan_running": "A scan is already running",
"source_offline": "The storage is not connected", "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": { "catalog": {
"not_found": "Not found" "not_found": "Not found"
@@ -80,6 +83,16 @@
"update_unavailable": "Update is unavailable" "update_unavailable": "Update is unavailable"
}, },
"uploads": { "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", "queue_full": "The upload queue is full",
"user_quota_full": "Your upload queue limit is exceeded", "user_quota_full": "Your upload queue limit is exceeded",
"too_large": "The file is larger than allowed", "too_large": "The file is larger than allowed",
@@ -124,6 +137,7 @@
"target_changed": "The source changed after checking. Check for updates again." "target_changed": "The source changed after checking. Check for updates again."
}, },
"social": { "social": {
"listening_unavailable": "Shared listening is unavailable. This person does not accept your requests or invitations, or is no longer available. Request preferences are in the profile menu → Privacy and communication.",
"person_offline": "This person is offline. Try inviting them again when they connect", "person_offline": "This person is offline. Try inviting them again when they connect",
"invalid": "Check your message or action", "invalid": "Check your message or action",
"unavailable": "Chats are available to friends only. The friendship may have ended or the person is unavailable", "unavailable": "Chats are available to friends only. The friendship may have ended or the person is unavailable",
+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": { "users": {
"change_username": "Изменить логин", "change_username": "Изменить логин",
"title": "Пользователи", "title": "Пользователи",
@@ -210,6 +269,9 @@
"analysis_error_unknown": "Причина ошибки не указана." "analysis_error_unknown": "Причина ошибки не указана."
}, },
"write_template": { "write_template": {
"auto_hint": "Если шаблон ещё не задан, определим его по преобладающей структуре файлов хранилища. Предложение можно изменить вручную; оно применяется только после сохранения.",
"detecting": "Определяем структуру файлов хранилища…",
"detected": "Предложенная структура совпадает с {{matched}} из {{total}} файлов выборки.",
"title": "Приём загрузок и шаблон пути", "title": "Приём загрузок и шаблон пути",
"intro": "Сюда раскладываются одобренные треки из очереди на добавление — по шаблону, который повторяет структуру папок этого хранилища.", "intro": "Сюда раскладываются одобренные треки из очереди на добавление — по шаблону, который повторяет структуру папок этого хранилища.",
"accepts": "Принимать загрузки в это хранилище", "accepts": "Принимать загрузки в это хранилище",
@@ -270,6 +332,7 @@
"failed_some_many": "Не загрузились {{count}} файлов — место за ними зарезервировано, можно продолжить.", "failed_some_many": "Не загрузились {{count}} файлов — место за ними зарезервировано, можно продолжить.",
"failed_some_other": "Не загрузились {{count}} файла — место за ними зарезервировано, можно продолжить.", "failed_some_other": "Не загрузились {{count}} файла — место за ними зарезервировано, можно продолжить.",
"retry_failed": "Продолжить загрузку", "retry_failed": "Продолжить загрузку",
"retry_file": "Повторить загрузку {{name}}",
"uploading_now": "Загружаю…", "uploading_now": "Загружаю…",
"go_mine": "Мои предложения →", "go_mine": "Мои предложения →",
"more": "Загрузить ещё", "more": "Загрузить ещё",
@@ -287,12 +350,25 @@
"filter_pending": "В работе", "filter_pending": "В работе",
"filter_imported": "Добавлены", "filter_imported": "Добавлены",
"filter_declined": "Не приняты", "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_file": "Трек",
"col_status": "Статус", "col_status": "Статус",
"col_size": "Размер", "col_size": "Размер",
"col_date": "Загружен", "col_date": "Загружен",
"received": "принято {{pct}}% — откройте «Предложить треки» в той же вкладке, чтобы продолжить", "received": "принято {{pct}}% — откройте «Предложить треки» в той же вкладке, чтобы продолжить",
"withdraw": "Отозвать", "withdraw": "Отозвать",
"select_visible": "Выбрать доступные файлы на странице",
"bulk_withdraw": "Массовый отзыв предложений",
"withdraw_selected": "Отозвать выбранные",
"withdraw_failed": "Не удалось отозвать: {{count}}. {{reason}}",
"load_more": "Показать ещё", "load_more": "Показать ещё",
"queue_title": "Очередь на добавление", "queue_title": "Очередь на добавление",
"queue_subtitle": "Проверьте метаданные и путь в хранилище, затем одобрите или отклоните", "queue_subtitle": "Проверьте метаданные и путь в хранилище, затем одобрите или отклоните",
@@ -350,6 +426,16 @@
"meta_track": "Номер трека", "meta_track": "Номер трека",
"meta_disc": "Диск", "meta_disc": "Диск",
"target_source": "Хранилище", "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": "По умолчанию", "target_default": "По умолчанию",
"tags_note": "Правленые поля запишутся в теги файла при импорте — путь и теги не разойдутся.", "tags_note": "Правленые поля запишутся в теги файла при импорте — путь и теги не разойдутся.",
"save": "Сохранить", "save": "Сохранить",
+3
View File
@@ -50,6 +50,9 @@
"main": "Навигация", "main": "Навигация",
"home": "Главная", "home": "Главная",
"admin": "Админка", "admin": "Админка",
"admin_media": "Медиатека",
"admin_uploads": "Загрузки",
"admin_system": "Система",
"users": "Пользователи", "users": "Пользователи",
"audit": "Журнал", "audit": "Журнал",
"library": "Библиотека", "library": "Библиотека",
+15 -1
View File
@@ -2,6 +2,7 @@
"common": { "common": {
"internal": "Что-то пошло не так на сервере. Попробуйте ещё раз", "internal": "Что-то пошло не так на сервере. Попробуйте ещё раз",
"bad_request": "Некорректный запрос", "bad_request": "Некорректный запрос",
"request_too_large": "Размер запроса превышает лимит сервера. Уменьшите объём отправляемых данных.",
"not_found": "Не найдено", "not_found": "Не найдено",
"method_not_allowed": "Действие не поддерживается", "method_not_allowed": "Действие не поддерживается",
"network": "Нет связи с сервером. Проверьте сеть" "network": "Нет связи с сервером. Проверьте сеть"
@@ -26,6 +27,8 @@
"forbidden": "Недостаточно прав для этого действия" "forbidden": "Недостаточно прав для этого действия"
}, },
"validation": { "validation": {
"cover_type": "Не удалось прочитать обложку. Выберите изображение JPEG, PNG или WebP.",
"cover_too_large": "Не удалось уменьшить обложку до 8 МБ. Выберите изображение меньшего размера.",
"user_archive_invalid": "Некорректный файл пользователей: проверьте версию, логины, роли и хэши паролей. Изменения не сохранены.", "user_archive_invalid": "Некорректный файл пользователей: проверьте версию, логины, роли и хэши паролей. Изменения не сохранены.",
"password_too_short": "Пароль должен быть не короче {{min}} символов", "password_too_short": "Пароль должен быть не короче {{min}} символов",
"password_too_long": "Пароль должен быть не длиннее {{max}} символов", "password_too_long": "Пароль должен быть не длиннее {{max}} символов",
@@ -60,7 +63,7 @@
"source_not_found": "Хранилище не найдено", "source_not_found": "Хранилище не найдено",
"scan_running": "Скан уже идёт", "scan_running": "Скан уже идёт",
"source_offline": "Хранилище не подключено", "source_offline": "Хранилище не подключено",
"template_unavailable": "Не удалось подобрать шаблон: ни один регэксп хранилища не переводится в шаблон или в хранилище нет файлов" "template_unavailable": "Не удалось определить структуру по путям и метаданным. Сначала просканируйте хранилище или задайте шаблон вручную."
}, },
"catalog": { "catalog": {
"not_found": "Не найдено" "not_found": "Не найдено"
@@ -80,6 +83,16 @@
"update_unavailable": "Обновление недоступно" "update_unavailable": "Обновление недоступно"
}, },
"uploads": { "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": "В очереди на добавление нет места", "queue_full": "В очереди на добавление нет места",
"user_quota_full": "Превышен ваш лимит в очереди на добавление", "user_quota_full": "Превышен ваш лимит в очереди на добавление",
"too_large": "Файл больше допустимого размера", "too_large": "Файл больше допустимого размера",
@@ -124,6 +137,7 @@
"target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз." "target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз."
}, },
"social": { "social": {
"listening_unavailable": "Совместное прослушивание недоступно. Пользователь не принимает запросы или приглашения от вас либо больше недоступен. Настройки приёма находятся в меню профиля → «Приватность и общение».",
"person_offline": "Пользователь не в сети. Повторите приглашение, когда он подключится", "person_offline": "Пользователь не в сети. Повторите приглашение, когда он подключится",
"invalid": "Проверьте сообщение или действие", "invalid": "Проверьте сообщение или действие",
"unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен", "unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен",
+18 -23
View File
@@ -122,7 +122,7 @@ importers:
version: 19.0.14 version: 19.0.14
vitest: vitest:
specifier: ^3.2.7 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: apps/web:
dependencies: dependencies:
@@ -159,9 +159,9 @@ importers:
'@tanstack/react-virtual': '@tanstack/react-virtual':
specifier: ^3.14.21 specifier: ^3.14.21
version: 3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1) version: 3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
hash-wasm: js-sha256:
specifier: ^4.12.0 specifier: ^1.0.0
version: 4.12.0 version: 1.0.0
music-metadata: music-metadata:
specifier: ^12.2.3 specifier: ^12.2.3
version: 12.2.3(supports-color@10.2.2) version: 12.2.3(supports-color@10.2.2)
@@ -207,13 +207,13 @@ importers:
version: 4.14.0 version: 4.14.0
jsdom: jsdom:
specifier: ^26.1.0 specifier: ^26.1.0
version: 26.1.0 version: 26.1.0(supports-color@10.2.2)
vite: vite:
specifier: ^6.3.5 specifier: ^6.3.5
version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2) version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
vitest: vitest:
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(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: packages/api-client:
dependencies: dependencies:
@@ -233,7 +233,7 @@ importers:
devDependencies: devDependencies:
vitest: vitest:
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(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: {} packages/design-tokens: {}
@@ -2479,9 +2479,6 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
hash-wasm@4.12.0:
resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==}
hasown@2.0.4: hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -6710,8 +6707,6 @@ snapshots:
has-flag@4.0.0: {} has-flag@4.0.0: {}
hash-wasm@4.12.0: {}
hasown@2.0.4: hasown@2.0.4:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
@@ -6748,7 +6743,7 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 toidentifier: 1.0.1
http-proxy-agent@7.0.2: http-proxy-agent@7.0.2(supports-color@10.2.2):
dependencies: dependencies:
agent-base: 7.1.4 agent-base: 7.1.4
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
@@ -6943,13 +6938,13 @@ snapshots:
jsc-safe-url@0.2.4: {} jsc-safe-url@0.2.4: {}
jsdom@26.1.0: jsdom@26.1.0(supports-color@10.2.2):
dependencies: dependencies:
cssstyle: 4.6.0 cssstyle: 4.6.0
data-urls: 5.0.0 data-urls: 5.0.0
decimal.js: 10.6.0 decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0 html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2(supports-color@10.2.2)
https-proxy-agent: 7.0.6(supports-color@10.2.2) https-proxy-agent: 7.0.6(supports-color@10.2.2)
is-potential-custom-element-name: 1.0.1 is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.28 nwsapi: 2.2.28
@@ -8247,7 +8242,7 @@ snapshots:
vary@1.1.2: {} 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: dependencies:
cac: 6.7.14 cac: 6.7.14
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
@@ -8268,7 +8263,7 @@ snapshots:
- tsx - tsx
- yaml - 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: dependencies:
cac: 6.7.14 cac: 6.7.14
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
@@ -8317,7 +8312,7 @@ snapshots:
lightningcss: 1.27.0 lightningcss: 1.27.0
terser: 5.51.2 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: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
'@vitest/expect': 3.2.7 '@vitest/expect': 3.2.7
@@ -8340,11 +8335,11 @@ snapshots:
tinypool: 1.1.1 tinypool: 1.1.1
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2) 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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/node': 22.20.5 '@types/node': 22.20.5
jsdom: 26.1.0 jsdom: 26.1.0(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- jiti - jiti
- less - less
@@ -8359,7 +8354,7 @@ snapshots:
- tsx - tsx
- yaml - 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: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
'@vitest/expect': 3.2.7 '@vitest/expect': 3.2.7
@@ -8382,11 +8377,11 @@ snapshots:
tinypool: 1.1.1 tinypool: 1.1.1
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
vite: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2) vite: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
vite-node: 3.2.4(@types/node@26.6.5)(lightningcss@1.27.0)(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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/node': 26.6.5 '@types/node': 26.6.5
jsdom: 26.1.0 jsdom: 26.1.0(supports-color@10.2.2)
transitivePeerDependencies: transitivePeerDependencies:
- jiti - jiti
- less - less
+1
View File
@@ -133,6 +133,7 @@ func TestUserJourney(t *testing.T) {
t.Setenv("DATABASE_URL", dbURL) t.Setenv("DATABASE_URL", dbURL)
t.Setenv("REDIS_URL", os.Getenv("TEST_REDIS_URL")) t.Setenv("REDIS_URL", os.Getenv("TEST_REDIS_URL"))
t.Setenv("BOOTSTRAP_ADMIN_PASSWORD", "admin") 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]() cfg, err := config.Load[config.API]()
require.NoError(t, err) require.NoError(t, err)
cfg.LibraryHostRoot = host cfg.LibraryHostRoot = host
+12 -2
View File
@@ -11,6 +11,7 @@ import (
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings"
"syscall" "syscall"
"time" "time"
@@ -18,12 +19,14 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"pipu/api/internal/acoustid"
"pipu/api/internal/appupdate" "pipu/api/internal/appupdate"
"pipu/api/internal/auth" "pipu/api/internal/auth"
"pipu/api/internal/buildinfo" "pipu/api/internal/buildinfo"
"pipu/api/internal/genius" "pipu/api/internal/genius"
"pipu/api/internal/library" "pipu/api/internal/library"
"pipu/api/internal/management" "pipu/api/internal/management"
"pipu/api/internal/musicbrainz"
"pipu/api/internal/platform/config" "pipu/api/internal/platform/config"
"pipu/api/internal/platform/db" "pipu/api/internal/platform/db"
"pipu/api/internal/platform/httpx" "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.Restore(ctx)
go libSvc.RunSchedule(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}, library.FPCalc{Bin: cfg.FPCalcPath, Timeout: time.Minute},
uploads.Config{StagingDir: cfg.UploadsDir, FFmpeg: cfg.FFmpegPath}, log) 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) upSvc.Run(ctx)
wh := wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken, 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, Wave: wh,
App: appupdate.NewHandler(appupdate.Config{GiteaURL: cfg.GiteaURL, Repo: cfg.GiteaRepo, Token: cfg.GiteaToken, App: appupdate.NewHandler(appupdate.Config{GiteaURL: cfg.GiteaURL, Repo: cfg.GiteaRepo, Token: cfg.GiteaToken,
Dir: cfg.UpdatesDir, MinSupported: cfg.MinAndroidVersionCode}, log), Dir: cfg.UpdatesDir, MinSupported: cfg.MinAndroidVersionCode}, log),
Uploads: uploads.NewHandler(upSvc, signer, cfg.XAccel), Uploads: uploads.NewHandler(upSvc, signer),
TUS: uploads.NewTUS(uploads.NewStore(pool), cfg.UploadsDir, upSvc.Complete, log), TUS: uploads.NewTUS(uploads.NewStore(pool), cfg.UploadsDir, upSvc.Complete, log),
}), nil }), 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" "net/http"
"strings" "strings"
"github.com/google/uuid"
"pipu/api/internal/platform/apperr" "pipu/api/internal/platform/apperr"
"pipu/api/internal/platform/httpx" "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) && 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/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/") || strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/") ||
isUploadStreamPath(r.URL.Path) ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/download/")) strings.HasPrefix(r.URL.Path, "/api/v1/app/download/"))
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover { if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
next.ServeHTTP(w, r.WithContext(ctx)) 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) { func bearer(h string) (string, bool) {
const prefix = "bearer " const prefix = "bearer "
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) { 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) assert.EqualValues(t, 1000, settings.QueueLimitBytes)
fresh := sha256.Sum256([]byte("new file")) fresh := sha256.Sum256([]byte("new file"))
coverPath := "covers/album.jpg"
batch, res, err := up.CreateBatch(ctx, author, nil, []uploads.NewFile{ batch, res, err := up.CreateBatch(ctx, author, nil, []uploads.NewFile{
{ClientID: "a", Name: "a.flac", Size: 400, SHA256: fresh[:]}, {ClientID: "a", Name: "a.flac", Size: 400, SHA256: fresh[:]},
{ClientID: "dup", Name: "b.flac", Size: 100, SHA256: sum[:]}, // уже в библиотеке {ClientID: "dup", Name: "b.flac", Size: 100, SHA256: sum[:]}, // уже в библиотеке
{ClientID: "big", Name: "c.flac", Size: 900, SHA256: []byte("cccccccccccccccccccccccccccccccc")}, {ClientID: "big", Name: "c.flac", Size: 900, SHA256: []byte("cccccccccccccccccccccccccccccccc")},
}, settings) }, settings, &coverPath)
require.NoError(t, err) require.NoError(t, err)
assert.NotEqual(t, uuid.Nil, batch) assert.NotEqual(t, uuid.Nil, batch)
byClient := map[string]uploads.Reserved{} byClient := map[string]uploads.Reserved{}
@@ -183,6 +184,11 @@ func TestLibraryAndUploads(t *testing.T) {
next, err := up.NextWithStatus(ctx, uploads.StatusProcessing) next, err := up.NextWithStatus(ctx, uploads.StatusProcessing)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, fileID, next.ID) 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), require.ErrorIs(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusUploading}, uploads.StatusExpired, nil),
uploads.ErrWrongStatus) uploads.ErrWrongStatus)
@@ -190,7 +196,7 @@ func TestLibraryAndUploads(t *testing.T) {
require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusProcessing}, uploads.StatusPendingReview, require.NoError(t, up.Transition(ctx, fileID, []uploads.Status{uploads.StatusProcessing}, uploads.StatusPendingReview,
func(tx pgx.Tx) error { func(tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE upload_files SET metadata = '{"artist":"Кино","album":"Группа крови","disc":2}', _, 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 return err
})) }))
f, err := up.File(ctx, fileID) f, err := up.File(ctx, fileID)
@@ -198,6 +204,8 @@ func TestLibraryAndUploads(t *testing.T) {
assert.Equal(t, uploads.StatusPendingReview, f.Status) assert.Equal(t, uploads.StatusPendingReview, f.Status)
require.NotNil(t, f.Meta) require.NotNil(t, f.Meta)
assert.Equal(t, "Кино", f.Meta.Artist) 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}) files, nextCursor, err := up.List(ctx, uploads.ListFilter{AuthorID: &author, Statuses: []uploads.Status{uploads.StatusPendingReview}, Limit: 1})
require.NoError(t, err) 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.ChangeStatus(ctx, fileID, []uploads.Status{uploads.StatusApproved}, uploads.StatusImporting))
require.NoError(t, up.CompleteImport(ctx, fileID, src.ID, "Кино - Группа крови.flac", fileIDs[1])) 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) 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) used, _, err = up.Usage(ctx, author)
require.NoError(t, err) require.NoError(t, err)
@@ -264,7 +276,7 @@ func TestLibraryAndUploads(t *testing.T) {
secondHash := sha256.Sum256([]byte("concurrent decision")) secondHash := sha256.Sum256([]byte("concurrent decision"))
_, reserved, err := up.CreateBatch(ctx, author, nil, []uploads.NewFile{ _, reserved, err := up.CreateBatch(ctx, author, nil, []uploads.NewFile{
{ClientID: "race", Name: "race.flac", Size: 300, SHA256: secondHash[:]}, {ClientID: "race", Name: "race.flac", Size: 300, SHA256: secondHash[:]},
}, settings) }, settings, nil)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, reserved, 1) require.Len(t, reserved, 1)
raceID := reserved[0].FileID raceID := reserved[0].FileID
@@ -0,0 +1,102 @@
package genius
import (
"context"
"errors"
"time"
"github.com/google/uuid"
)
const (
artistImageBatch = 20
artistImageRecheck = 30 * 24 * time.Hour
)
// artistSource — то, что нужно проходу по аватаркам от Genius-клиента.
type artistSource interface {
Search(ctx context.Context, query string) ([]Song, error)
Artist(ctx context.Context, id int64) (Artist, error)
}
// artistImage ищет исполнителя по имени: берёт песню, у которой primary_artist совпал с именем,
// и аватарку из неё либо из отдельного запроса артиста. Пустая строка — не нашли.
func artistImage(ctx context.Context, src artistSource, name string) (string, error) {
songs, err := src.Search(ctx, name)
if err != nil {
return "", err
}
for _, song := range songs {
if !sameArtist(song.Artist.Name, name) {
continue
}
if img := imageURL(song.Artist.ImageURL); img != "" {
return img, nil
}
if song.Artist.ID <= 0 {
continue
}
a, err := src.Artist(ctx, song.Artist.ID)
if err != nil {
var he *HTTPError
if errors.As(err, &he) && he.StatusCode == 404 {
continue
}
return "", err
}
return imageURL(a.ImageURL), nil
}
return "", nil
}
// fillArtistImages проходит по исполнителям без аватарки; каждого проверяет раз в artistImageRecheck.
func (s *Service) fillArtistImages(ctx context.Context) error {
var active bool
if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM genius_state WHERE NOT paused AND cooldown_until<=now())`).Scan(&active); err != nil || !active {
return err
}
rows, err := s.pool.Query(ctx, `SELECT id, name FROM artists
WHERE image_path IS NULL AND genius_image_url IS NULL
AND (genius_image_checked_at IS NULL OR genius_image_checked_at < now()-$1::interval)
ORDER BY genius_image_checked_at NULLS FIRST LIMIT $2`, artistImageRecheck.String(), artistImageBatch)
if err != nil {
return err
}
type row struct {
id uuid.UUID
name string
}
var batch []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.name); err != nil {
rows.Close()
return err
}
batch = append(batch, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, r := range batch {
if ctx.Err() != nil {
return ctx.Err()
}
reqCtx, cancel := context.WithTimeout(ctx, time.Minute)
img, err := artistImage(reqCtx, s.client, r.name)
cancel()
if err != nil {
var he *HTTPError
if errors.As(err, &he) && he.StatusCode == 429 {
_, _ = s.pool.Exec(ctx, `UPDATE genius_state SET cooldown_until=GREATEST(cooldown_until,$1)`, time.Now().Add(retryDelay(he.RetryAfter)))
}
return err
}
if _, err := s.pool.Exec(ctx, `UPDATE artists SET genius_image_checked_at=now(),
genius_image_url=COALESCE(genius_image_url, NULLIF($2,'')) WHERE id=$1`, r.id, img); err != nil {
return err
}
}
return nil
}
+13 -1
View File
@@ -16,11 +16,23 @@ func normalize(s string) string {
return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) }), " ") return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) }), " ")
} }
// sameArtist сравнивает имя на Genius с нашим: у Genius часто «имя (транслитерация)»,
// поэтому скобочный хвост допустим.
func sameArtist(geniusName, name string) bool {
if normalize(geniusName) == normalize(name) {
return true
}
if i := strings.Index(geniusName, "("); i > 0 && strings.HasSuffix(strings.TrimSpace(geniusName), ")") {
return normalize(geniusName[:i]) == normalize(name)
}
return false
}
// Exact normalized identity; retain version qualifiers such as live/remix. // Exact normalized identity; retain version qualifiers such as live/remix.
func matchSong(songs []Song, artist, title string) (Song, bool) { func matchSong(songs []Song, artist, title string) (Song, bool) {
var matched Song var matched Song
for _, s := range songs { for _, s := range songs {
if normalize(s.Title) == normalize(title) && normalize(s.Artist.Name) == normalize(artist) { if normalize(s.Title) == normalize(title) && sameArtist(s.Artist.Name, artist) {
if matched.ID != 0 && matched.ID != s.ID { if matched.ID != 0 && matched.ID != s.ID {
return Song{}, false return Song{}, false
} }
@@ -95,3 +95,25 @@ func TestAnnotations(t *testing.T) {
require.True(t, notes[0].Verified) require.True(t, notes[0].Verified)
require.Equal(t, "https://genius.com/annotations/1", notes[0].Url) require.Equal(t, "https://genius.com/annotations/1", notes[0].Url)
} }
type fakeArtists struct {
songs []Song
artist Artist
}
func (f fakeArtists) Search(context.Context, string) ([]Song, error) { return f.songs, nil }
func (f fakeArtists) Artist(context.Context, int64) (Artist, error) { return f.artist, nil }
func TestArtistImage(t *testing.T) {
ctx := context.Background()
src := fakeArtists{songs: []Song{
{Artist: Artist{ID: 1, Name: "Other", ImageURL: "https://images.genius.com/other.jpg"}},
{Artist: Artist{ID: 2, Name: "Некич (Nekich)"}},
}, artist: Artist{ID: 2, ImageURL: "https://images.genius.com/nekich.jpg"}}
img, err := artistImage(ctx, src, "некич")
require.NoError(t, err)
require.Equal(t, "https://images.genius.com/nekich.jpg", img)
img, err = artistImage(ctx, src, "Unknown")
require.NoError(t, err)
require.Empty(t, img)
}
+3
View File
@@ -48,6 +48,9 @@ func (s *Service) Run(ctx context.Context) {
if err := s.drain(ctx); err != nil && ctx.Err() == nil { if err := s.drain(ctx); err != nil && ctx.Err() == nil {
s.log.Error("genius worker", "err", err) s.log.Error("genius worker", "err", err)
} }
if err := s.fillArtistImages(ctx); err != nil && ctx.Err() == nil {
s.log.Error("genius artist images", "err", err)
}
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
@@ -79,6 +79,37 @@ type FPCalc struct {
Timeout time.Duration 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 не считаются. // ErrNoFPCalc — fpcalc не установлен: отпечатки Chromaprint не считаются.
var ErrNoFPCalc = errors.New("library: fpcalc not found") 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 { if err != nil {
return nil, err 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. // CheckWriteTemplate реализует gen.StrictServerInterface.
@@ -32,6 +32,9 @@ func TestSplitArtists(t *testing.T) {
assert.Equal(t, []string{"main:Simon & Garfunkel"}, names(SplitArtists("Simon & Garfunkel", known))) assert.Equal(t, []string{"main:Simon & Garfunkel"}, names(SplitArtists("Simon & Garfunkel", known)))
assert.Equal(t, []string{"main:Tom", "main:Jerry"}, names(SplitArtists("Tom & Jerry", known))) assert.Equal(t, []string{"main:Tom", "main:Jerry"}, names(SplitArtists("Tom & Jerry", known)))
assert.Equal(t, []string{"main:A", "main:B"}, names(SplitArtists("A; B", nil))) assert.Equal(t, []string{"main:A", "main:B"}, names(SplitArtists("A; B", nil)))
assert.Equal(t, []string{"main:A", "main:B", "main:C"}, names(SplitArtists("A, B, C", nil)))
assert.Equal(t, []string{"main:A", "main:B"}, names(SplitArtists("A, B", func(string) bool { return true })), "already glued name is still split")
assert.Equal(t, []string{"main:Tyler, the Creator"}, names(SplitArtists("Tyler, the Creator", nil)))
assert.Equal(t, []string{"main:A", "feat:B", "feat:C"}, names(SplitArtists("A feat. B & C", nil))) assert.Equal(t, []string{"main:A", "feat:B", "feat:C"}, names(SplitArtists("A feat. B & C", nil)))
assert.Equal(t, []string{"main:A"}, names(SplitArtists("A / a", nil)), "duplicates by key are dropped") assert.Equal(t, []string{"main:A"}, names(SplitArtists("A / a", nil)), "duplicates by key are dropped")
assert.Empty(t, SplitArtists(" ", nil)) assert.Empty(t, SplitArtists(" ", nil))
@@ -85,13 +85,15 @@ func SplitArtists(s string, known func(key string) bool) []ArtistCredit {
} }
} }
for _, part := range splitAny(main, artistSeparators) { for _, part := range splitAny(main, artistSeparators) {
if strings.Contains(part, " & ") && (known == nil || !known(Key(part))) { for _, seg := range splitComma(part) {
for _, p := range strings.Split(part, " & ") { if strings.Contains(seg, " & ") && (known == nil || !known(Key(seg))) {
add(p, "main") for _, p := range strings.Split(seg, " & ") {
add(p, "main")
}
continue
} }
continue add(seg, "main")
} }
add(part, "main")
} }
for _, part := range splitAny(feat, append(artistSeparators, " & ", ", ")) { for _, part := range splitAny(feat, append(artistSeparators, " & ", ", ")) {
add(part, "feat") add(part, "feat")
@@ -99,6 +101,20 @@ func SplitArtists(s string, known func(key string) bool) []ArtistCredit {
return out return out
} }
// splitComma делит по «, » без оглядки на библиотеку: иначе уже склеенное имя («A, B») навсегда
// остаётся «известным». Продолжение «the …» — часть имени («Tyler, the Creator»).
func splitComma(s string) []string {
var out []string
for _, p := range strings.Split(s, ", ") {
if n := len(out); n > 0 && strings.HasPrefix(strings.ToLower(p), "the ") {
out[n-1] += ", " + p
continue
}
out = append(out, p)
}
return out
}
func splitAny(s string, seps []string) []string { func splitAny(s string, seps []string) []string {
parts := []string{s} parts := []string{s}
for _, sep := range seps { for _, sep := range seps {
@@ -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 Channels int
DurationMs int DurationMs int
Tags meta.Fields Tags meta.Fields
RawTags map[string]string
} }
// Lossless сообщает, без потерь ли кодек. // Lossless сообщает, без потерь ли кодек.
@@ -160,6 +161,7 @@ func parseFFprobe(raw []byte, name string) (Probe, error) {
Track: first("track", "tracknumber"), Track: first("track", "tracknumber"),
Disc: first("disc", "discnumber"), Disc: first("disc", "discnumber"),
} }
p.RawTags = tags
return p, nil 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)
}
+11 -3
View File
@@ -422,7 +422,13 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
if m.Album == "" { if m.Album == "" {
m.Album = UnknownAlbum m.Album = UnknownAlbum
} }
albumArtist, err := sc.store.EnsureArtist(ctx, m.AlbumArtist) known := func(k string) bool { return sc.store.ArtistKeyExists(ctx, k) }
// Альбом принадлежит одному исполнителю: у составного «A, B» это первый основной.
albumArtistName := m.AlbumArtist
if credits := meta.SplitArtists(m.AlbumArtist, known); len(credits) > 0 {
albumArtistName = credits[0].Name
}
albumArtist, err := sc.store.EnsureArtist(ctx, albumArtistName)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -437,7 +443,9 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
if err != nil { if err != nil {
return false, err return false, err
} }
if !dup { // Тот же файл, перечитанный в тот же трек, заново разбирает исполнителей: правила разбора меняются.
sameTrack := dup && r.oldTrack != nil && *r.oldTrack == trackID
if !dup || sameTrack {
// Перечитанный файл сохраняет свой трек (лайки, история); новый — получает новый. // Перечитанный файл сохраняет свой трек (лайки, история); новый — получает новый.
if r.oldTrack != nil { if r.oldTrack != nil {
trackID = *r.oldTrack trackID = *r.oldTrack
@@ -445,7 +453,7 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
if trackID, err = sc.store.SaveTrack(ctx, trackID, in); err != nil { if trackID, err = sc.store.SaveTrack(ctx, trackID, in); err != nil {
return false, err return false, err
} }
credits := meta.SplitArtists(m.Artist, func(k string) bool { return sc.store.ArtistKeyExists(ctx, k) }) credits := meta.SplitArtists(m.Artist, known)
ids := make([]uuid.UUID, 0, len(credits)) ids := make([]uuid.UUID, 0, len(credits))
roles := make([]string, 0, len(credits)) roles := make([]string, 0, len(credits))
for _, c := range credits { for _, c := range credits {
+8 -8
View File
@@ -183,16 +183,16 @@ func (s *TemplateService) previewOne(ctx context.Context, root, rel string, patt
const templateSampleSize = 50 const templateSampleSize = 50
// SuggestWriteTemplate подбирает шаблон записи по существующим путям и выражениям источника. // 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 { if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return "", err return meta.TemplateSuggestion{}, err
} }
if _, err := s.store.GetSource(ctx, id); err != nil { if _, err := s.store.GetSource(ctx, id); err != nil {
return "", notFound(err) return meta.TemplateSuggestion{}, notFound(err)
} }
rows, err := s.store.Patterns(ctx, id) rows, err := s.store.Patterns(ctx, id)
if err != nil { if err != nil {
return "", err return meta.TemplateSuggestion{}, err
} }
var exprs []string var exprs []string
for _, r := range rows { for _, r := range rows {
@@ -200,13 +200,13 @@ func (s *TemplateService) SuggestWriteTemplate(ctx context.Context, id uuid.UUID
exprs = append(exprs, r.Regex) exprs = append(exprs, r.Regex)
} }
} }
paths, err := s.store.SampleFiles(ctx, id, 200) samples, err := s.store.TemplateSamples(ctx, id, 200)
if err != nil { if err != nil {
return "", err return meta.TemplateSuggestion{}, err
} }
tpl, err := meta.SuggestTemplate(exprs, paths) tpl, err := meta.SuggestFromSamples(exprs, samples)
if err != nil { 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 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. // API — конфигурация публичного сервиса api.
type API struct { type API struct {
ManagementDir string `env:"MANAGEMENT_DIR" envDefault:"/management"` AcoustIDAPIKey string `env:"ACOUSTID_API_KEY"`
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"` MusicBrainzEnabled bool `env:"MUSICBRAINZ_ENABLED" envDefault:"true"`
DatabaseURL string `env:"DATABASE_URL,required"` ManagementDir string `env:"MANAGEMENT_DIR" envDefault:"/management"`
RedisURL string `env:"REDIS_URL,required"` HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"` DatabaseURL string `env:"DATABASE_URL,required"`
MigrateOnStart bool `env:"MIGRATE_ON_START" envDefault:"true"` RedisURL string `env:"REDIS_URL,required"`
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"15s"` 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:","` SupportedLocales []string `env:"SUPPORTED_LOCALES" envDefault:"ru,en" envSeparator:","`
MinAndroidVersionCode int `env:"MIN_ANDROID_VERSION_CODE" envDefault:"0"` 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 { func (ix *Indexer) rebuild(ctx context.Context, s indexSpec) error {
next := s.uid + "_next" 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 return err
} }
if err := ix.meili.call(ctx, http.MethodPost, "/indexes", map[string]any{"uid": next, "primaryKey": "id"}); err != nil { 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) 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 отвечает. // Healthy — Meilisearch отвечает.
func (m *Meili) Healthy(ctx context.Context) error { func (m *Meili) Healthy(ctx context.Context) error {
return m.do(ctx, http.MethodGet, "/health", nil, nil) return m.do(ctx, http.MethodGet, "/health", nil, nil)
@@ -5,9 +5,59 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"reflect"
"testing" "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. // На чистом Meilisearch удаление несуществующего индекса падает в задаче, а не ответом 404.
func TestCallMapsFailedIndexTaskToNotFound(t *testing.T) { func TestCallMapsFailedIndexTaskToNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -85,6 +85,18 @@ func TestActivityRoomsAndChatPrivacy(t *testing.T) {
require.NoError(t, s.publishActivity(ctx, owner, activity)) require.NoError(t, s.publishActivity(ctx, owner, activity))
join := gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner} join := gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}
require.Error(t, s.listeningAction(ctx, friend, join)) // default nobody require.Error(t, s.listeningAction(ctx, friend, join)) // default nobody
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='friends',listening_auto_accept='nobody' WHERE id=$1`, owner)
require.NoError(t, e)
require.NoError(t, s.listeningAction(ctx, friend, join))
pendingJoin, e := s.listeningState(ctx, friend)
require.NoError(t, e)
require.Nil(t, pendingJoin.Room, "sending a request is not admission")
require.Len(t, pendingJoin.Requests, 1)
pendingHost, e := s.listeningState(ctx, owner)
require.NoError(t, e)
require.Len(t, pendingHost.Room.Participants, 1)
require.NoError(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: join.Id}))
join.Id = uuid.New()
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='friends',listening_auto_accept='everyone' WHERE id=$1`, owner) _, e = pool.Exec(ctx, `UPDATE users SET listening_requests='friends',listening_auto_accept='everyone' WHERE id=$1`, owner)
require.NoError(t, e) require.NoError(t, e)
require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner})) require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}))
@@ -94,6 +106,12 @@ func TestActivityRoomsAndChatPrivacy(t *testing.T) {
require.NotNil(t, state.Room) require.NotNil(t, state.Room)
require.Equal(t, owner, state.Room.OwnerId) require.Equal(t, owner, state.Room.OwnerId)
require.Len(t, state.Room.Participants, 2) require.Len(t, state.Room.Participants, 2)
// Reopening the dialog must not create another pending request for an
// already admitted listener, even with a new action ID.
require.NoError(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}))
state, e = s.listeningState(ctx, friend)
require.NoError(t, e)
require.Empty(t, state.Requests)
hostctx := auth.WithPrincipal(ctx, auth.Principal{UserID: owner}) hostctx := auth.WithPrincipal(ctx, auth.Principal{UserID: owner})
badctx := auth.WithPrincipal(ctx, auth.Principal{UserID: friend}) badctx := auth.WithPrincipal(ctx, auth.Principal{UserID: friend})
input := gen.ListeningRoomInput{RoomId: state.Room.Id, DeviceId: device, Sequence: 1, Playing: false, TrackId: &track, PositionMs: 3000} input := gen.ListeningRoomInput{RoomId: state.Room.Id, DeviceId: device, Sequence: 1, Playing: false, TrackId: &track, PositionMs: 3000}
@@ -113,11 +131,50 @@ func TestActivityRoomsAndChatPrivacy(t *testing.T) {
require.NoError(t, e) require.NoError(t, e)
require.Len(t, state.Requests, 1) require.Len(t, state.Requests, 1)
require.Nil(t, state.Room) require.Nil(t, state.Room)
reserved, e := s.listeningState(ctx, owner)
require.NoError(t, e)
require.NotNil(t, reserved.Room)
require.Len(t, reserved.Room.Participants, 1, "pending invite must not admit the guest")
require.NoError(t, s.listeningAction(ctx, owner, invite), "network retry is idempotent")
duplicate := invite
duplicate.Id = uuid.New()
require.ErrorContains(t, s.listeningAction(ctx, owner, duplicate), "listening_pending")
require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: invite.Id}))
require.NoError(t, s.listeningAction(ctx, owner, gen.ListeningAction{Id: uuid.New(), Action: "cancel", TargetId: invite.Id}))
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Empty(t, state.Requests)
require.Nil(t, state.Room)
invite.Id = uuid.New()
require.NoError(t, s.listeningAction(ctx, owner, invite))
_, e = pool.Exec(ctx, `UPDATE listening_requests SET expires_at=now()-interval '1 second' WHERE id=$1`, invite.Id)
require.NoError(t, e)
require.ErrorContains(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}), "listening_expired")
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Empty(t, state.Requests)
require.Nil(t, state.Room)
invite.Id = uuid.New()
require.NoError(t, s.listeningAction(ctx, owner, invite))
require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "decline", TargetId: invite.Id}))
state, e = s.listeningState(ctx, other)
require.NoError(t, e)
require.Empty(t, state.Requests)
require.Nil(t, state.Room)
invite.Id = uuid.New()
require.NoError(t, s.listeningAction(ctx, owner, invite))
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='nobody' WHERE id=$1`, other)
require.NoError(t, e)
require.ErrorContains(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}), "listening_unavailable")
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='everyone' WHERE id=$1`, other)
require.NoError(t, e)
require.Error(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id})) require.Error(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}))
require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id})) require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}))
state, e = s.listeningState(ctx, other) state, e = s.listeningState(ctx, other)
require.NoError(t, e) require.NoError(t, e)
require.Equal(t, owner, state.Room.OwnerId) require.Equal(t, owner, state.Room.OwnerId)
require.Len(t, state.Room.Participants, 2)
require.ErrorContains(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "invite", TargetId: other, DeviceId: &device, TrackId: &track}), "listening_busy")
_, e = pool.Exec(ctx, `UPDATE listening_rooms SET updated_at=now()-interval '31 seconds' WHERE id=$1`, state.Room.Id) _, e = pool.Exec(ctx, `UPDATE listening_rooms SET updated_at=now()-interval '31 seconds' WHERE id=$1`, state.Room.Id)
require.NoError(t, e) require.NoError(t, e)
state, e = s.listeningState(ctx, other) state, e = s.listeningState(ctx, other)
+16 -6
View File
@@ -17,6 +17,9 @@ func audienceAllows(policy string, friends bool) bool {
func validAudience(v gen.ActivityVisibility) bool { func validAudience(v gen.ActivityVisibility) bool {
return v == "nobody" || v == "friends" || v == "everyone" return v == "nobody" || v == "friends" || v == "everyone"
} }
func listeningDenied() error {
return apperr.Forbidden("social.listening_unavailable", "shared listening is not permitted")
}
func (h *Handler) SetListeningPreferences(ctx context.Context, r gen.SetListeningPreferencesRequestObject) (gen.SetListeningPreferencesResponseObject, error) { func (h *Handler) SetListeningPreferences(ctx context.Context, r gen.SetListeningPreferencesRequestObject) (gen.SetListeningPreferencesResponseObject, error) {
id, e := userID(ctx) id, e := userID(ctx)
if e != nil { if e != nil {
@@ -75,7 +78,7 @@ func (h *Handler) PublishListeningState(ctx context.Context, r gen.PublishListen
return nil, e return nil, e
} }
if !own { if !own {
return nil, denied() return nil, listeningDenied()
} }
} }
return gen.PublishListeningState204Response{}, nil return gen.PublishListeningState204Response{}, nil
@@ -147,7 +150,7 @@ func listeningPolicy(ctx context.Context, tx pgx.Tx, recipient, sender uuid.UUID
COALESCE(social_seen_at>now()-interval '45 seconds',false) AND EXISTS(SELECT 1 FROM users WHERE id=$2 AND social_seen_at>now()-interval '45 seconds') COALESCE(social_seen_at>now()-interval '45 seconds',false) AND EXISTS(SELECT 1 FROM users WHERE id=$2 AND social_seen_at>now()-interval '45 seconds')
FROM users WHERE id=$1`, recipient, sender).Scan(&policy, &automatic, &friends, &blocked, &online) FROM users WHERE id=$1`, recipient, sender).Scan(&policy, &automatic, &friends, &blocked, &online)
if errors.Is(e, pgx.ErrNoRows) { if errors.Is(e, pgx.ErrNoRows) {
return false, false, denied() return false, false, listeningDenied()
} }
if e != nil { if e != nil {
return false, false, e return false, false, e
@@ -164,7 +167,7 @@ func admitListening(ctx context.Context, tx pgx.Tx, request, room, guest, recipi
return e return e
} }
if !allowed { if !allowed {
return denied() return listeningDenied()
} }
result, e := tx.Exec(ctx, `INSERT INTO listening_members(user_id,room_id) VALUES($1,$2) ON CONFLICT(user_id) DO UPDATE SET last_seen=now() WHERE listening_members.room_id=EXCLUDED.room_id`, guest, room) result, e := tx.Exec(ctx, `INSERT INTO listening_members(user_id,room_id) VALUES($1,$2) ON CONFLICT(user_id) DO UPDATE SET last_seen=now() WHERE listening_members.room_id=EXCLUDED.room_id`, guest, room)
if e != nil { if e != nil {
@@ -199,7 +202,7 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
return e return e
} }
if a.Action == "end" && owner != id { if a.Action == "end" && owner != id {
return denied() return listeningDenied()
} }
if owner == id { if owner == id {
_, e = tx.Exec(ctx, `DELETE FROM listening_rooms WHERE id=$1`, a.TargetId) _, e = tx.Exec(ctx, `DELETE FROM listening_rooms WHERE id=$1`, a.TargetId)
@@ -217,7 +220,7 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
return e return e
} }
if (a.Action == "cancel" && sender != id) || (a.Action != "cancel" && recipient != id) { if (a.Action == "cancel" && sender != id) || (a.Action != "cancel" && recipient != id) {
return denied() return listeningDenied()
} }
if status != "pending" { if status != "pending" {
return nil return nil
@@ -257,7 +260,7 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
return err return err
} }
if !permitted { if !permitted {
return denied() return listeningDenied()
} }
var repeated bool var repeated bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_requests WHERE id=$1 AND sender_id=$2 AND owner_id=$3 AND guest_id=$4 AND kind=$5)`, a.Id, id, owner, guest, a.Action).Scan(&repeated); e != nil { if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_requests WHERE id=$1 AND sender_id=$2 AND owner_id=$3 AND guest_id=$4 AND kind=$5)`, a.Id, id, owner, guest, a.Action).Scan(&repeated); e != nil {
@@ -273,6 +276,13 @@ func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.Listeni
if busy { if busy {
return apperr.Conflict("social.listening_busy", "already in another room") return apperr.Conflict("social.listening_busy", "already in another room")
} }
var alreadyJoined bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_members m JOIN listening_rooms r ON r.id=m.room_id WHERE m.user_id=$1 AND r.owner_id=$2)`, guest, owner).Scan(&alreadyJoined); e != nil {
return e
}
if alreadyJoined {
return tx.Commit(ctx)
}
var recent int var recent int
if e = tx.QueryRow(ctx, `SELECT count(*) FROM listening_requests WHERE sender_id=$1 AND created_at>now()-interval '1 minute'`, id).Scan(&recent); e != nil { if e = tx.QueryRow(ctx, `SELECT count(*) FROM listening_requests WHERE sender_id=$1 AND created_at>now()-interval '1 minute'`, id).Scan(&recent); e != nil {
return e return e
+24
View File
@@ -26,6 +26,30 @@ type BatchPatch struct {
const maxCoverBytes = 8 << 20 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 применяет исполнителя, альбом, год и обложку к файлам пачки на рассмотрении. // UpdateBatch применяет исполнителя, альбом, год и обложку к файлам пачки на рассмотрении.
func (s *Service) UpdateBatch(ctx context.Context, id uuid.UUID, in BatchPatch) ([]File, error) { func (s *Service) UpdateBatch(ctx context.Context, id uuid.UUID, in BatchPatch) ([]File, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil { if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
+18 -7
View File
@@ -17,17 +17,18 @@ import (
type Handler struct { type Handler struct {
svc *Service svc *Service
signer *sign.Signer signer *sign.Signer
xAccel bool
} }
// NewHandler создаёт обработчик. signer подписывает ссылки прослушивания из staging. // NewHandler создаёт обработчик. signer подписывает ссылки прослушивания из staging.
func NewHandler(svc *Service, signer *sign.Signer, xAccel bool) *Handler { func NewHandler(svc *Service, signer *sign.Signer) *Handler {
return &Handler{svc: svc, signer: signer, xAccel: xAccel} return &Handler{svc: svc, signer: signer}
} }
func (h *Handler) fileDTO(ctx context.Context, f File) gen.UploadFile { func (h *Handler) fileDTO(ctx context.Context, f File) gen.UploadFile {
hasCover := f.CoverPath != nil && *f.CoverPath != ""
out := gen.UploadFile{ 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}, Author: gen.UserRef{Id: f.AuthorID, Username: f.AuthorName},
Status: gen.UploadStatus(f.Status), OriginalName: f.OriginalName, ClientRelPath: f.ClientRelPath, 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, 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, MetadataEdited: f.MetadataEdited, MatchedArtistId: f.MatchedArtistID, MatchedAlbumId: f.MatchedAlbumID,
DuplicateOfTrackId: f.DuplicateOfTrackID, PossibleDuplicate: f.PossibleDuplicate, QualityUpgrade: f.QualityUpgrade, DuplicateOfTrackId: f.DuplicateOfTrackID, PossibleDuplicate: f.PossibleDuplicate, QualityUpgrade: f.QualityUpgrade,
TargetSourceId: f.TargetSourceID, TargetRelPath: f.TargetRelPath, StatusReason: f.StatusReason, TargetSourceId: f.TargetSourceID, TargetRelPath: f.TargetRelPath, StatusReason: f.StatusReason,
ReviewedAt: f.ReviewedAt, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt, TargetSourceName: f.TargetSourceName,
TargetStoragePath: f.TargetStoragePath,
ReviewedAt: f.ReviewedAt, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt,
} }
if out.Tags == nil { if out.Tags == nil {
out.Tags = map[string]string{} 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 { 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}) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -181,7 +192,7 @@ func (h *Handler) CreateUploadBatch(ctx context.Context, req gen.CreateUploadBat
// ListMyUploads реализует gen.StrictServerInterface. // ListMyUploads реализует gen.StrictServerInterface.
func (h *Handler) ListMyUploads(ctx context.Context, req gen.ListMyUploadsRequestObject) (gen.ListMyUploadsResponseObject, error) { 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 { if err != nil {
return nil, err return nil, err
} }
+2
View File
@@ -140,6 +140,8 @@ type File struct {
PossibleDuplicate bool PossibleDuplicate bool
QualityUpgrade bool QualityUpgrade bool
TargetSourceID *uuid.UUID TargetSourceID *uuid.UUID
TargetSourceName *string
TargetStoragePath *string
TargetRelPath *string TargetRelPath *string
StatusReason *string StatusReason *string
ReviewedBy *uuid.UUID ReviewedBy *uuid.UUID
@@ -3,6 +3,8 @@ package uploads
import ( import (
"context" "context"
"errors" "errors"
"path/filepath"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
@@ -47,6 +49,41 @@ type placement struct {
root string 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 — хранилище: выбранное админом → по умолчанию → единственное принимающее загрузки. // pickSource — хранилище: выбранное админом → по умолчанию → единственное принимающее загрузки.
func (s *PlacementPlanner) pickSource(ctx context.Context, chosen *uuid.UUID) (library.Source, string) { func (s *PlacementPlanner) pickSource(ctx context.Context, chosen *uuid.UUID) (library.Source, string) {
id := chosen 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)
})
}
}
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more