Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75b0f7bec3 | ||
|
|
3edb1af037 | ||
|
|
0458cbc46c | ||
|
|
f7ffa5d742 | ||
|
|
5d0decbb2a | ||
|
|
3edbf489a3 | ||
|
|
82f3eed9a0 | ||
|
|
db4548b115 | ||
|
|
3309a79a98 | ||
|
|
bbd629dcf8 | ||
|
|
82bf015283 | ||
|
|
fd17c06397 | ||
|
|
f4681da876 | ||
|
|
5248074964 | ||
|
|
6a1382dad5 | ||
|
|
5876ae29cc | ||
|
|
39bf7d4b70 | ||
|
|
1e3a149aef | ||
|
|
e78cab6ce6 | ||
|
|
ca2a50b515 | ||
|
|
af06cf1e75 | ||
|
|
7d62531c02 | ||
|
|
37e33c656b | ||
|
|
30c3c85f89 | ||
|
|
eb75437708 | ||
|
|
64dd8ed05e | ||
|
|
5e2d1e902b | ||
|
|
b39e4dc28f | ||
|
|
f59d1e8d03 | ||
|
|
66d3897f66 | ||
|
|
17bb0e1846 | ||
|
|
17c85493b7 | ||
|
|
75f9c57eca | ||
|
|
8a007be036 | ||
|
|
f77148df2c | ||
|
|
139dd226fa | ||
|
|
795bb7de46 | ||
|
|
11b890c2e7 | ||
|
|
59983ed1b3 |
No files matched your search
@@ -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,10 @@ 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 — релевантность поиска на Meilisearch
|
||||||
|
working-directory: services/api
|
||||||
|
run: go test -tags integration -count=1 -v ./internal/search/
|
||||||
- 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/
|
||||||
|
|||||||
@@ -63,3 +63,12 @@ jobs:
|
|||||||
pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
|
pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
|
||||||
pnpm --filter @pipu/web exec playwright install chromium
|
pnpm --filter @pipu/web exec playwright install chromium
|
||||||
- run: pnpm --filter @pipu/web test:e2e
|
- run: pnpm --filter @pipu/web test:e2e
|
||||||
|
- name: E2E failure diagnostics
|
||||||
|
if: failure()
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: web-e2e-failure
|
||||||
|
path: apps/web/test-results/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 3
|
||||||
@@ -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,14 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
required: [files]
|
required: [files]
|
||||||
properties:
|
properties:
|
||||||
|
artist_image_base64:
|
||||||
|
type: string
|
||||||
|
maxLength: 11184812
|
||||||
|
description: Фото исполнителя JPEG, PNG или WebP, до 8 МБ. После одобрения применяется к исполнителю альбома только при отсутствии аватарки.
|
||||||
|
cover_base64:
|
||||||
|
type: string
|
||||||
|
maxLength: 11184812
|
||||||
|
description: Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки.
|
||||||
comment:
|
comment:
|
||||||
type: string
|
type: string
|
||||||
maxLength: 1000
|
maxLength: 1000
|
||||||
@@ -2914,6 +3027,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 +3078,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 +3832,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')} />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('an artist directory uploads each album as a separate batch with a shared portrait', async ({ page }, info) => {
|
||||||
|
const root = info.outputPath('творожный бизнес');
|
||||||
|
const albums = ['(2026) творог грехов - творожный бизнес', '(2025) молчанка - творожный бизнес'];
|
||||||
|
for (const album of albums) {
|
||||||
|
await mkdir(join(root, album), { recursive: true });
|
||||||
|
await writeFile(join(root, album, 'song.flac'), Buffer.alloc(128));
|
||||||
|
}
|
||||||
|
const portrait = Buffer.from('portrait');
|
||||||
|
await writeFile(join(root, 'artist.jpg'), portrait);
|
||||||
|
await writeFile(join(root, albums[0]!, 'cover.jpg'), Buffer.from('album cover'));
|
||||||
|
type Batch = { artist_image_base64?: string; cover_base64?: string; files: { client_id: string; rel_path: string }[] };
|
||||||
|
const batches: Batch[] = [];
|
||||||
|
await page.exposeFunction('recordArtistBatch', (batch: Batch) => { batches.push(batch); });
|
||||||
|
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) => new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
|
||||||
|
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();
|
||||||
|
await (window as unknown as { recordArtistBatch: (body: unknown) => Promise<void> }).recordArtistBatch(body);
|
||||||
|
return json({ batch_id: crypto.randomUUID(), files: body.files.map((f: { client_id: string }) => ({ client_id: f.client_id, upload_file_id: f.client_id, tus_url: `/api/v1/uploads/files/${f.client_id}` })) });
|
||||||
|
}
|
||||||
|
return original(input, init);
|
||||||
|
};
|
||||||
|
history.pushState({}, '', '/uploads/new');
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
|
});
|
||||||
|
await page.route('**/api/v1/uploads/files/*', async (route) => {
|
||||||
|
await route.fulfill({ status: route.request().method() === 'HEAD' ? 200 : 204,
|
||||||
|
headers: { 'Upload-Length': '128', 'Upload-Offset': route.request().method() === 'HEAD' ? '0' : '128', 'Tus-Resumable': '1.0.0' } });
|
||||||
|
});
|
||||||
|
await page.locator('input[webkitdirectory]').setInputFiles(root);
|
||||||
|
const send = page.getByRole('button', { name: /^Отправить/ });
|
||||||
|
await expect(send).toBeEnabled();
|
||||||
|
await expect(page.getByText(/Пропущен/)).toHaveCount(0);
|
||||||
|
await send.click();
|
||||||
|
await expect(page.getByText(/Готово: 2 файла/)).toBeVisible();
|
||||||
|
expect(batches).toHaveLength(2);
|
||||||
|
expect(batches.map((batch) => batch.files[0]!.rel_path).sort()).toEqual(albums.map((album) => `творожный бизнес/${album}/song.flac`).sort());
|
||||||
|
for (const batch of batches) {
|
||||||
|
expect(batch.files).toHaveLength(1);
|
||||||
|
expect(batch.artist_image_base64).toBe(portrait.toString('base64'));
|
||||||
|
}
|
||||||
|
expect(batches.find((batch) => batch.files[0]!.rel_path.includes(albums[0]!))!.cover_base64).toBe(Buffer.from('album cover').toString('base64'));
|
||||||
|
expect(batches.find((batch) => batch.files[0]!.rel_path.includes(albums[1]!))!.cover_base64).toBeUndefined();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -69,6 +69,8 @@ for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430
|
|||||||
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||||
await page.getByRole('dialog').getByRole('link', { name: 'Очередь', exact: true }).click();
|
await page.getByRole('dialog').getByRole('link', { name: 'Очередь', exact: true }).click();
|
||||||
await expect(page.getByRole('heading', { name: 'Очередь на добавление' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Очередь на добавление' })).toBeVisible();
|
||||||
|
await expect(page.locator('audio')).toBeHidden();
|
||||||
|
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||||||
await expect(page.locator('audio')).toBeVisible();
|
await expect(page.locator('audio')).toBeVisible();
|
||||||
await fits(page);
|
await fits(page);
|
||||||
await page.getByRole('checkbox', { name: /^Выбрать Длинное/ }).check();
|
await page.getByRole('checkbox', { name: /^Выбрать Длинное/ }).check();
|
||||||
|
|||||||
@@ -0,0 +1,462 @@
|
|||||||
|
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();
|
||||||
|
// Continue keyboard selection after keyboard expansion, avoiding another scroll-dependent pointer action.
|
||||||
|
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).press('Space');
|
||||||
|
await expect(page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true })).not.toBeChecked();
|
||||||
|
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}');
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
use: { baseURL: 'http://127.0.0.1:4173', ...devices['Desktop Chrome'] },
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:4173',
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'pnpm exec vite --port 4173 --strictPort',
|
command: 'pnpm exec vite --port 4173 --strictPort',
|
||||||
url: 'http://127.0.0.1:4173',
|
url: 'http://127.0.0.1:4173',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)} · ${t('release_lookup.matched_count', { matched: candidate.matched, total: 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>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(' · '),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { needsFormatAttention } from './files';
|
import { artistImageFor, isArtistImage, needsFormatAttention } from './files';
|
||||||
|
|
||||||
|
it('inherits the nearest artist portrait without crossing sibling folders', () => {
|
||||||
|
const root = new File(['portrait'], 'artist.jpg');
|
||||||
|
const local = new File(['local'], 'ARTIST.PNG');
|
||||||
|
const images = new Map([['творожный бизнес', root], ['творожный бизнес/Альбом', local]]);
|
||||||
|
expect(isArtistImage(root.name)).toBe(true);
|
||||||
|
expect(isArtistImage(local.name)).toBe(true);
|
||||||
|
expect(isArtistImage('cover.jpg')).toBe(false);
|
||||||
|
expect(artistImageFor('творожный бизнес/(2026) творог грехов', images)).toBe(root);
|
||||||
|
expect(artistImageFor('творожный бизнес/Альбом/CD1', images)).toBe(local);
|
||||||
|
expect(artistImageFor('другой исполнитель/Альбом', images)).toBeUndefined();
|
||||||
|
expect(artistImageFor('творожный бизнес 2/Альбом', images)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
describe('needsFormatAttention', () => {
|
describe('needsFormatAttention', () => {
|
||||||
it('marks everything except lossless formats', () => {
|
it('marks everything except lossless formats', () => {
|
||||||
|
|||||||
@@ -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,40 @@ 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 isArtistImage(name: string): boolean {
|
||||||
|
return /^artist\.(jpe?g|png|webp)$/i.test(name.split(/[/\\]/).pop() ?? name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nearest ancestor wins; sibling artist folders must never share portraits. */
|
||||||
|
export function artistImageFor(folder: string, images: ReadonlyMap<string, File>): File | undefined {
|
||||||
|
for (;;) {
|
||||||
|
const image = images.get(folder);
|
||||||
|
if (image) return image;
|
||||||
|
if (!folder) return undefined;
|
||||||
|
folder = albumFolder(folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
|||||||
@@ -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,101 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits an artist discography into album batches and inherits only that artist portrait', 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: { artist_image_base64?: string; 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);
|
||||||
|
const first = 'творожный бизнес/(2026) творог грехов - творожный бизнес';
|
||||||
|
const second = 'творожный бизнес/(2025) молчанка - творожный бизнес';
|
||||||
|
uploader.add([
|
||||||
|
{ file: new File(['portrait'], 'artist.jpg'), relPath: 'творожный бизнес/artist.jpg' },
|
||||||
|
{ file: new File(['cover'], 'cover.jpg'), relPath: `${first}/cover.jpg` },
|
||||||
|
{ file: new File(['a'], '02.flac'), relPath: `${first}/02.flac` },
|
||||||
|
{ file: new File(['b'], '03.flac'), relPath: `${first}/03.flac` },
|
||||||
|
{ file: new File(['c'], '02.flac'), relPath: `${second}/02.flac` },
|
||||||
|
{ file: new File(['d'], '02.flac'), relPath: 'Другой исполнитель/Альбом/02.flac' },
|
||||||
|
]);
|
||||||
|
await waitFor(() => expect(uploader.ready).toHaveLength(4));
|
||||||
|
expect(uploader.getState().skipped).toBe(0);
|
||||||
|
await uploader.start('Дискография');
|
||||||
|
expect(batches).toHaveLength(3);
|
||||||
|
const a = batches.find((batch) => batch.files[0]!.rel_path.startsWith(first))!;
|
||||||
|
const b = batches.find((batch) => batch.files[0]!.rel_path.startsWith(second))!;
|
||||||
|
const other = batches.find((batch) => batch.files[0]!.rel_path.startsWith('Другой'))!;
|
||||||
|
expect(a.files).toHaveLength(2);
|
||||||
|
expect(b.files).toHaveLength(1);
|
||||||
|
expect(a.artist_image_base64).toBe(btoa('portrait'));
|
||||||
|
expect(b.artist_image_base64).toBe(btoa('portrait'));
|
||||||
|
expect(a.cover_base64).toBe(btoa('cover'));
|
||||||
|
expect(b.cover_base64).toBeUndefined();
|
||||||
|
expect(other.artist_image_base64).toBeUndefined();
|
||||||
|
uploader.clear();
|
||||||
|
uploader.add([{ file: new File(['new'], '02.flac'), relPath: `${second}/02.flac` }]);
|
||||||
|
await waitFor(() => expect(uploader.ready).toHaveLength(1));
|
||||||
|
await uploader.start('New selection');
|
||||||
|
expect(batches[3]!.artist_image_base64).toBeUndefined();
|
||||||
|
});
|
||||||
@@ -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, artistImageFor, coverBase64, isAlbumCover, isArtistImage, 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,12 @@ 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>();
|
||||||
|
private artistImages = new Map<string, File>();
|
||||||
|
|
||||||
constructor(private readonly session: AuthSession) {
|
constructor(private readonly session: AuthSession) {
|
||||||
this.restore();
|
this.restore();
|
||||||
@@ -97,17 +103,22 @@ 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);
|
||||||
|
if (isArtistImage(f.file.name)) this.artistImages.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) && !isArtistImage(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 +135,8 @@ export class Uploader {
|
|||||||
clear() {
|
clear() {
|
||||||
if (this.state.busy) return;
|
if (this.state.busy) return;
|
||||||
this.hashQueue = [];
|
this.hashQueue = [];
|
||||||
|
this.folderCovers.clear();
|
||||||
|
this.artistImages.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 +230,46 @@ 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 artistImage = artistImageFor(folder, this.artistImages);
|
||||||
queue.push({ id: f.client_id, url: f.tus_url });
|
const res = unwrap(
|
||||||
|
await this.session.client.POST('/api/v1/uploads', {
|
||||||
|
body: {
|
||||||
|
comment: comment.trim() || undefined,
|
||||||
|
...(cover ? { cover_base64: await coverBase64(cover) } : {}),
|
||||||
|
...(artistImage ? { artist_image_base64: await coverBase64(artistImage) } : {}),
|
||||||
|
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 +277,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 +352,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 +363,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 +377,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 +395,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 +469,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();
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function TrackPage({ id }: { id: string }) {
|
|||||||
<img src={coverSrc(tr.album.cover_url, 300)} alt="" />
|
<img src={coverSrc(tr.album.cover_url, 300)} alt="" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Cover album={tr.album} size={280} className={c.heroCover} />
|
<Cover album={tr.album} size="100%" className={c.heroCover} />
|
||||||
<div className={c.heroBody}>
|
<div className={c.heroBody}>
|
||||||
<span className={c.kind}>{t('player.track_page')}</span>
|
<span className={c.kind}>{t('player.track_page')}</span>
|
||||||
<h1 className={c.heroTitle}>{tr.title}</h1>
|
<h1 className={c.heroTitle}>{tr.title}</h1>
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -99,6 +99,11 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.listen .heroCover {
|
||||||
|
max-width: 280px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.listen {
|
.listen {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 28px;
|
gap: 28px;
|
||||||
@@ -118,7 +123,12 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listen .heroCover {
|
||||||
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listen .heroTitle {
|
.listen .heroTitle {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 } }));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -75,3 +75,4 @@ export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-
|
|||||||
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
|
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
|
||||||
export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
|
export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
|
||||||
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
|
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
|
||||||
|
export { default as chevronDownIcon } from '@material-symbols/svg-400/rounded/keyboard_arrow_down.svg';
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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-labelledby={`${groupId}-title`} aria-describedby={folded && badge ? `${groupId}-badge` : undefined} aria-expanded={!folded} aria-controls={groupId} onClick={toggle}>
|
||||||
<span className={s.navSectionTitle}>{title}</span>
|
<span id={`${groupId}-title`} className={s.navSectionTitle}>{title}</span>
|
||||||
{folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
|
{folded && badge ? <span id={`${groupId}-badge`} 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
.root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg, #05070d);
|
||||||
|
animation: pipuIn 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: -20%;
|
||||||
|
opacity: 0.35;
|
||||||
|
filter: blur(80px) saturate(1.2);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
width: min(444px, calc(100vw - 32px), calc(100dvh - 240px));
|
||||||
|
min-width: 260px;
|
||||||
|
padding: 72px 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shade {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопки на обложке видны только при наведении / фокусе (на тач-экранах — всегда). */
|
||||||
|
.shade,
|
||||||
|
.round,
|
||||||
|
.controls,
|
||||||
|
.moreWrap {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.18s, background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .shade,
|
||||||
|
.card:hover .round,
|
||||||
|
.card:hover .controls,
|
||||||
|
.card:hover .moreWrap,
|
||||||
|
.card:has(:focus-visible) .shade,
|
||||||
|
.card:has(:focus-visible) .round,
|
||||||
|
.card:has(:focus-visible) .controls,
|
||||||
|
.card:has(:focus-visible) .moreWrap,
|
||||||
|
.cardOpen .shade,
|
||||||
|
.cardOpen .round,
|
||||||
|
.cardOpen .controls,
|
||||||
|
.cardOpen .moreWrap {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.shade,
|
||||||
|
.round,
|
||||||
|
.controls,
|
||||||
|
.moreWrap {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.round {
|
||||||
|
position: absolute;
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(20, 20, 20, 0.82);
|
||||||
|
color: #fff;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round:hover:not(:disabled) {
|
||||||
|
background: rgba(50, 50, 50, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls .round {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent {
|
||||||
|
color: #ffe600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topLeft {
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topRight {
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomRight {
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moreWrap {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.moreWrap .round {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 56px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(20, 20, 20, 0.96);
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuItem:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffe600;
|
||||||
|
color: #111;
|
||||||
|
transition: transform 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play:hover:not(:disabled) {
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play[aria-busy='true'] {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text, #fff);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artists {
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
appearance: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(90deg, #fff var(--pct, 0%), rgba(255, 255, 255, 0.25) var(--pct, 0%));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range::-moz-range-thumb {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.times {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text2);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useState, type CSSProperties } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { ArtistLinks, Cover, coverSrc } from '../entities/catalog';
|
||||||
|
import { AddToPlaylist } from '../entities/playlists';
|
||||||
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import {
|
||||||
|
chevronDownIcon,
|
||||||
|
likeFillIcon,
|
||||||
|
likeIcon,
|
||||||
|
moreIcon,
|
||||||
|
nextIcon,
|
||||||
|
pauseIcon,
|
||||||
|
playIcon,
|
||||||
|
prevIcon,
|
||||||
|
queueIcon,
|
||||||
|
repeatIcon,
|
||||||
|
repeatOneIcon,
|
||||||
|
shareIcon,
|
||||||
|
} from '../shared/ui/icons';
|
||||||
|
import { cx, Icon } from '../shared/ui/ui';
|
||||||
|
import s from './FullPlayer.module.css';
|
||||||
|
|
||||||
|
/** Полноэкранный плеер: обложка с кнопками поверх неё, название и прогресс. Открывается кликом по обложке в плеере. */
|
||||||
|
export function FullPlayer({ onClose, onQueue, share }: { onClose: () => void; onQueue: () => void; share: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const player = usePlayer();
|
||||||
|
const st = usePlayerState();
|
||||||
|
const [menu, setMenu] = useState(false);
|
||||||
|
const locked = st.listening?.locked === true;
|
||||||
|
const track = st.track;
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (menu) setMenu(false);
|
||||||
|
else onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [onClose, menu]);
|
||||||
|
if (!track) return null;
|
||||||
|
const duration = st.durationMs || track.duration_ms;
|
||||||
|
const liked = track.reaction === 'like';
|
||||||
|
return createPortal(
|
||||||
|
<div className={s.root} role="dialog" aria-modal="true" aria-label={t('player.now_playing')}>
|
||||||
|
{track.album.cover_url && (
|
||||||
|
<span className={s.bg} aria-hidden>
|
||||||
|
<img src={coverSrc(track.album.cover_url, 300)} alt="" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button type="button" className={s.close} aria-label={t('form.close')} onClick={onClose}>
|
||||||
|
<Icon src={chevronDownIcon} size={26} />
|
||||||
|
</button>
|
||||||
|
<div className={s.stage}>
|
||||||
|
<div className={cx(s.card, menu && s.cardOpen)}>
|
||||||
|
<Cover album={track.album} size="100%" className={s.cover} />
|
||||||
|
<div className={s.shade} aria-hidden />
|
||||||
|
<button type="button" className={cx(s.round, s.topRight)} aria-label={t('player.queue')} onClick={onQueue}>
|
||||||
|
<Icon src={queueIcon} size={22} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.round, s.topLeft, st.repeat !== 'off' && s.accent)}
|
||||||
|
aria-label={t(`player.repeat_${st.repeat}`)}
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => player.cycleRepeat()}
|
||||||
|
>
|
||||||
|
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
||||||
|
</button>
|
||||||
|
<div className={s.controls}>
|
||||||
|
<button type="button" className={s.round} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
|
||||||
|
<Icon src={prevIcon} size={22} />
|
||||||
|
</button>
|
||||||
|
<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={30} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={s.round} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
|
||||||
|
<Icon src={nextIcon} size={22} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={s.moreWrap}>
|
||||||
|
<button type="button" className={s.round} aria-label={t('player.more')} aria-expanded={menu} onClick={() => setMenu((v) => !v)}>
|
||||||
|
<Icon src={moreIcon} size={26} />
|
||||||
|
</button>
|
||||||
|
{menu && (
|
||||||
|
<div className={s.menu}>
|
||||||
|
<AddToPlaylist trackIds={[track.id]} className={s.menuItem} />
|
||||||
|
<button type="button" className={s.menuItem} onClick={() => { setMenu(false); share(); }}>
|
||||||
|
<Icon src={shareIcon} size={18} />
|
||||||
|
{t('player.share')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.round, s.bottomRight, liked && s.accent)}
|
||||||
|
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
||||||
|
aria-pressed={liked}
|
||||||
|
onClick={() => void player.react(track, liked ? null : 'like').catch(() => undefined)}
|
||||||
|
>
|
||||||
|
<Icon src={liked ? likeFillIcon : likeIcon} size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={s.meta}>
|
||||||
|
<Link to="/track/$id" params={{ id: track.id }} className={s.title} onClick={onClose}>
|
||||||
|
{track.title}
|
||||||
|
</Link>
|
||||||
|
<span className={s.artists}>
|
||||||
|
<ArtistLinks artists={track.artists} hideUrlPreview />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={s.progress}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className={s.range}
|
||||||
|
min={0}
|
||||||
|
max={duration}
|
||||||
|
step={1000}
|
||||||
|
value={Math.min(st.positionMs, duration)}
|
||||||
|
aria-label={t('player.progress')}
|
||||||
|
aria-valuetext={formatDuration(st.positionMs)}
|
||||||
|
disabled={locked}
|
||||||
|
onChange={(e) => player.seek(Number(e.target.value))}
|
||||||
|
style={{ '--pct': `${(st.positionMs / duration) * 100}%` } as CSSProperties}
|
||||||
|
/>
|
||||||
|
<span className={s.times}>
|
||||||
|
<span>{formatDuration(st.positionMs)}</span>
|
||||||
|
<span>{formatDuration(duration)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
waveIcon,
|
waveIcon,
|
||||||
} from '../shared/ui/icons';
|
} from '../shared/ui/icons';
|
||||||
import { cx, Icon } from '../shared/ui/ui';
|
import { cx, Icon } from '../shared/ui/ui';
|
||||||
|
import { FullPlayer } from './FullPlayer';
|
||||||
import s from './PlayerBar.module.css';
|
import s from './PlayerBar.module.css';
|
||||||
|
|
||||||
/** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */
|
/** Плеер: полоса внизу (`bar`, по умолчанию) или плавающий остров (`island`) — выбор в настройках (docs/design.md). */
|
||||||
@@ -43,6 +44,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
const locked = st.listening?.locked === true;
|
const locked = st.listening?.locked === true;
|
||||||
const narrow = useNarrow();
|
const narrow = useNarrow();
|
||||||
const [sheetOpen, setSheetOpen] = useState(false);
|
const [sheetOpen, setSheetOpen] = useState(false);
|
||||||
|
const [fullOpen, setFullOpen] = useState(false);
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
const queueButton = useRef<HTMLButtonElement>(null);
|
const queueButton = useRef<HTMLButtonElement>(null);
|
||||||
const queueId = useId();
|
const queueId = useId();
|
||||||
@@ -86,13 +88,11 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
|
<footer className={cx(s.bar, variant === 'island' && s.island)} aria-label={t('player.label')}>
|
||||||
<div
|
<div
|
||||||
className={s.now}
|
className={s.now}
|
||||||
onClick={
|
onClick={(e) => {
|
||||||
narrow
|
if ((e.target as HTMLElement).closest('a, button')) return;
|
||||||
? (e) => {
|
if (narrow) setSheetOpen(true);
|
||||||
if (!(e.target as HTMLElement).closest('a, button')) setSheetOpen(true);
|
else setFullOpen(true);
|
||||||
}
|
}}
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{narrow ? (
|
{narrow ? (
|
||||||
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
|
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
|
||||||
@@ -100,7 +100,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
|
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
|
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setFullOpen(true)}>
|
||||||
|
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className={s.meta}>
|
<div className={s.meta}>
|
||||||
<span className={s.title} title={track.title}>{track.title}</span>
|
<span className={s.title} title={track.title}>{track.title}</span>
|
||||||
@@ -143,7 +145,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} />
|
||||||
@@ -242,6 +244,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
share={share}
|
share={share}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{fullOpen && !narrow && (
|
||||||
|
<FullPlayer
|
||||||
|
onClose={() => setFullOpen(false)}
|
||||||
|
onQueue={() => {
|
||||||
|
setFullOpen(false);
|
||||||
|
setQueueOpen(true);
|
||||||
|
}}
|
||||||
|
share={share}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
@@ -335,8 +347,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} />
|
||||||
|
|||||||
@@ -4,10 +4,22 @@ import { defineConfig } from 'vite';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
// Keep Vitest's dependency cache separate from the browser test server.
|
||||||
|
cacheDir: process.env.VITE_E2E === '1' ? 'node_modules/.vite-e2e' : undefined,
|
||||||
|
optimizeDeps: process.env.VITE_E2E === '1' ? {
|
||||||
|
// Lazy routes and workers must be scanned before browsers cache React chunks.
|
||||||
|
// Late discovery otherwise mixes cached and newly optimized React instances
|
||||||
|
// across full document navigations when HMR is disabled.
|
||||||
|
entries: ['index.html', 'src/**/*.{ts,tsx}', '!src/**/*.test.{ts,tsx}', '!src/**/*.d.ts', '!src/test/**'],
|
||||||
|
include: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
|
||||||
|
} : undefined,
|
||||||
build: {
|
build: {
|
||||||
target: 'es2022',
|
target: 'es2022',
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
// E2E installs a stateful fake API in the page. A dev-server reload would
|
||||||
|
// discard it in the middle of a scenario (including other parallel workers).
|
||||||
|
hmr: process.env.VITE_E2E === '1' ? false : undefined,
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ server {
|
|||||||
real_ip_recursive on;
|
real_ip_recursive on;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
client_max_body_size 16m;
|
# Two images (album cover + artist portrait), each up to 8 MiB before base64.
|
||||||
|
client_max_body_size 32m;
|
||||||
|
|
||||||
include /etc/nginx/snippets/security-headers.conf;
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,15 @@
|
|||||||
|
|
||||||
## 2. Пользователи и роли
|
## 2. Пользователи и роли
|
||||||
|
|
||||||
|
В меню «Админка» пользователи доступны отдельным пунктом, остальные страницы собраны в подразделы:
|
||||||
|
|
||||||
|
- **Медиатека:** библиотека и хранилища.
|
||||||
|
- **Загрузки:** предложить треки, мои предложения и очередь на рассмотрение.
|
||||||
|
- **Система:** обновления, конфигурация, настройки и журнал.
|
||||||
|
|
||||||
|
На компьютере подразделы можно сворачивать; браузер запоминает выбор. Подраздел текущей страницы
|
||||||
|
раскрывается при переходе. На телефоне группы показаны заголовками в меню «Ещё». Видимость страниц зависит от прав.
|
||||||
|
|
||||||
«Администрирование → Пользователи».
|
«Администрирование → Пользователи».
|
||||||
|
|
||||||
| Роль | Что может |
|
| Роль | Что может |
|
||||||
|
|||||||
@@ -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` → трек в каталоге, поиске и очереди анализа.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -199,10 +199,17 @@ Meilisearch, индексы:
|
|||||||
| `albums` | `title`, `artist_name`, `title_translit` | `year`, `popularity` |
|
| `albums` | `title`, `artist_name`, `title_translit` | `year`, `popularity` |
|
||||||
| `tracks` | `title`, `artist_names`, `album_title`, `title_translit` | `genre`, `year`, `popularity`, `available` |
|
| `tracks` | `title`, `artist_names`, `album_title`, `title_translit` | `genre`, `year`, `popularity`, `available` |
|
||||||
|
|
||||||
- `*_translit` — транслитерация (кириллица ↔ латиница), плюс поле с **исправлением раскладки**
|
- `*_translit` — варианты транслитерации (кириллица ↔ латиница), включая словарь
|
||||||
|
устоявшихся написаний (`Boulevard` → `бульвар`) наряду с побуквенным вариантом.
|
||||||
|
**Исправление раскладки**
|
||||||
(`ghbdtn` → `привет`): при пустом результате клиентский запрос повторяется в альтернативной раскладке
|
(`ghbdtn` → `привет`): при пустом результате клиентский запрос повторяется в альтернативной раскладке
|
||||||
на стороне `api`.
|
на стороне `api`.
|
||||||
- Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней).
|
- Запрос учитывает все слова (`matchingStrategy=all`), не отбрасывая последнее ради заполнения списка.
|
||||||
|
Опечатки и поиск по префиксу по-прежнему обрабатывает Meilisearch.
|
||||||
|
- Ранжирование: `words`, `typo`, `exactness`, `proximity`, `attribute`, `sort`, `popularity:desc`.
|
||||||
|
Точное имя исполнителя (включая вариант транслитерации) закрепляется перед составными именами
|
||||||
|
в подсказках и не опускается из-за персонализации. Новые варианты появляются после переиндексации;
|
||||||
|
API запускает её при старте.
|
||||||
- `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс,
|
- `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс,
|
||||||
отменяет предыдущий запрос (`AbortController`).
|
отменяет предыдущий запрос (`AbortController`).
|
||||||
- Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api`
|
- Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api`
|
||||||
|
|||||||
@@ -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 остаются ограничениями проверки.
|
||||||
@@ -89,6 +89,14 @@ React Query; Android останавливает polling при уходе при
|
|||||||
«Подключиться» отправляет запрос хозяину и оставляет управление у него.
|
«Подключиться» отправляет запрос хозяину и оставляет управление у него.
|
||||||
«Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем.
|
«Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем.
|
||||||
Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину.
|
Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину.
|
||||||
|
Комната резервируется на время ожидания подтверждения. До принятия запроса
|
||||||
|
слушатель не становится участником, а баннер совместного прослушивания не показывается.
|
||||||
|
Исходное устройство ведущего поддерживает резервирование, не прерывая свою музыку.
|
||||||
|
После успешной отправки окно закрывается; ожидающий запрос можно отменить в разделе общения.
|
||||||
|
После отмены или истечения последнего запроса исходное устройство освобождает
|
||||||
|
пустую комнату при следующем опросе, не прерывая свою музыку.
|
||||||
|
Повторный запрос уже подключённого слушателя не создаёт нового ожидания.
|
||||||
|
Второе устройство ведущего не включает звук автоматически и при закрытии не завершает стрим.
|
||||||
Настройки приёма запросов/приглашений и автоматического принятия независимы,
|
Настройки приёма запросов/приглашений и автоматического принятия независимы,
|
||||||
обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории.
|
обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории.
|
||||||
Дополнительная настройка управляет запуском звука при подключении.
|
Дополнительная настройка управляет запуском звука при подключении.
|
||||||
|
|||||||
+143
-7
@@ -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,28 @@
|
|||||||
- 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 от прокси показывается как превышение размера запроса.
|
||||||
|
- `artist.jpg` (также `artist.jpeg`, `artist.png`, `artist.webp`, регистр не важен) в папке исполнителя
|
||||||
|
передаётся отдельно от обложки. Ближайшая такая картинка наследуется вложенными папками альбомов;
|
||||||
|
соседние исполнители не смешиваются. Например `Исполнитель/artist.jpg` и две папки альбомов
|
||||||
|
дают две отдельные пачки с одной фотографией исполнителя и своими обложками.
|
||||||
|
- При импорте трека картинки кладутся и в хранилище: `cover.<ext>` — в папку альбома, `artist.<ext>` — в папку
|
||||||
|
исполнителя; папки определяются по `write_template` (сегмент с `{album}` и первый сегмент с `{albumartist}`/`{artist}`).
|
||||||
|
Если в папке уже есть такая картинка (`cover`/`folder`/`front`/`album`/`albumart` или `artist`), она не перезаписывается.
|
||||||
|
Если шаблон не позволяет определить папку (плоская раскладка), картинка не записывается.
|
||||||
|
После одобрения и импорта трека фотография сохраняется в кеш WebP и ставится исполнителю альбома,
|
||||||
|
только если у него нет ни локальной аватарки, ни изображения Genius. Гостям она не назначается.
|
||||||
|
Существующая аватарка сохраняется, в том числе при одновременном импорте нескольких альбомов.
|
||||||
|
До одобрения каталог не меняется. Лимит фотографии — 8 МБ после уменьшения, как у обложки;
|
||||||
|
nginx принимает до 32 МБ, чтобы вместить обе картинки в base64. Временная фотография удаляется
|
||||||
|
после завершения всех файлов пачки, а готовая аватарка остаётся в кеше.
|
||||||
|
|
||||||
## 6. Обработка (`processing`)
|
## 6. Обработка (`processing`)
|
||||||
|
|
||||||
@@ -126,9 +147,10 @@
|
|||||||
| `[ ... ]` | Необязательный фрагмент: опускается целиком, если любой плейсхолдер внутри пуст, например `[{year} - ]{album}` |
|
| `[ ... ]` | Необязательный фрагмент: опускается целиком, если любой плейсхолдер внутри пуст, например `[{year} - ]{album}` |
|
||||||
|
|
||||||
**Как получить шаблон:**
|
**Как получить шаблон:**
|
||||||
- **Автоподбор**: система берёт выборку существующих путей хранилища, определяет, какой регэксп совпадает чаще всего,
|
- **Автоподбор**: система сравнивает пути и метаданные до 200 существующих файлов хранилища, строит варианты
|
||||||
и предлагает шаблон, порождающий такие же пути (группы регэкспа → плейсхолдеры, литералы между группами → текст шаблона).
|
раскладки и выбирает вариант с наибольшим числом точных совпадений. Сохранённые регэкспы также используются
|
||||||
Админ подтверждает или правит.
|
как кандидаты, но не обязательны. В интерфейсе показаны число совпадений и примеры реальных путей.
|
||||||
|
Если шаблон ещё не задан, предложение заполняется автоматически; администратор может изменить его и сохранить.
|
||||||
- **Проверка обратимости** (обязательна при сохранении): шаблон применяется к 50 существующим трекам хранилища,
|
- **Проверка обратимости** (обязательна при сохранении): шаблон применяется к 50 существующим трекам хранилища,
|
||||||
полученные пути прогоняются через регэкспы этого хранилища — поля должны распознаться обратно без изменений,
|
полученные пути прогоняются через регэкспы этого хранилища — поля должны распознаться обратно без изменений,
|
||||||
а в ≥ 90% случаев путь должен совпасть с реальным путём файла. Иначе — предупреждение с примерами расхождений.
|
а в ≥ 90% случаев путь должен совпасть с реальным путём файла. Иначе — предупреждение с примерами расхождений.
|
||||||
@@ -149,14 +171,125 @@
|
|||||||
|
|
||||||
Экран «Очередь на добавление»:
|
Экран «Очередь на добавление»:
|
||||||
- Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`.
|
- Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`.
|
||||||
|
Пачки по умолчанию свёрнуты; кнопка в заголовке раскрывает файлы, выбор всей пачки доступен и без раскрытия.
|
||||||
|
В заголовке указано наличие общей обложки пачки: «Есть обложка» / «Без обложки».
|
||||||
|
Там же показаны количества файлов по форматам и диапазоны битрейта, частоты и разрядности;
|
||||||
|
одинаковые значения не повторяются, отсутствующие измерения не учитываются как нули.
|
||||||
- Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество
|
- Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество
|
||||||
(формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище**
|
(формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище**
|
||||||
(пересчитывается live при правке метаданных).
|
с названием хранилища и путём относительно его корня. Полный адрес назначения (локальный путь API
|
||||||
|
или `server:/export/path` для NFS) доступен при наведении на путь. Для ожидающих рассмотрения, одобренных и ошибочных импортов
|
||||||
|
предпросмотр рассчитывается при чтении очереди по текущему шаблону — тем же планировщиком, что и импорт.
|
||||||
|
Сохранение шаблона обновляет кеш очереди. Уже импортированные файлы сохраняют фактический путь.
|
||||||
|
Если рассчитать путь нельзя, показаны причина и действие для исправления вместо старого пути.
|
||||||
|
Отсутствующий год отмечается предупреждением и не блокирует одобрение или импорт:
|
||||||
|
`{year}` опускается вместе со скобками или разделителем, необязательный фрагмент с годом опускается.
|
||||||
|
В теги неизвестный год не записывается.
|
||||||
|
- Прослушивание 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 в каталоге.
|
||||||
|
Несколько форматов одной песни не увеличивают число совпавших дорожек. Предположительные и неоднозначные
|
||||||
|
совпадения отмечаются отдельно. Для отсутствующих дорожек отдельно показаны совпадения с доступной
|
||||||
|
библиотекой по тегам и длительности; они не делают загруженную пачку полной.
|
||||||
|
|
||||||
|
Число файлов в пачке и число сопоставленных дорожек — разные показатели. «Файл не сопоставлен»
|
||||||
|
не доказывает отсутствие дорожки: она может быть среди файлов с отличающимися тегами. Для
|
||||||
|
предположительного совпадения допускается разная запись состава исполнителей (запятая, `feat.`, `;`)
|
||||||
|
и пометки `feat.` / `Prod.` в конце названия при совпадении номера, диска и длительности с допуском
|
||||||
|
2 секунды. Пометки версий (`live`, `remix`, `instrumental`) сохраняются. Такие совпадения требуют
|
||||||
|
проверки и сами по себе не подтверждают «Полный состав». При изменении правил сопоставления
|
||||||
|
сохранённые результаты поиска пересчитываются при открытии пачки.
|
||||||
|
|
||||||
|
Поиск сам не меняет теги, путь или обложку. В диалоге можно отдельно применить метаданные к совпавшим
|
||||||
|
редактируемым файлам и/или обложку. Обложка загружается из 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 +308,9 @@
|
|||||||
админ может повторить или отклонить.
|
админ может повторить или отклонить.
|
||||||
|
|
||||||
## 10. Уведомления
|
## 10. Уведомления
|
||||||
|
- В «Моих предложениях» файлы сгруппированы по пачкам. Можно выбрать отдельные файлы, загруженные строки
|
||||||
|
страницы или всю собственную пачку (с загрузкой всех её страниц) и отозвать выбранные до решения администратора.
|
||||||
|
Успешные отзывы освобождают квоту; при частичной ошибке показана причина, неудавшиеся файлы остаются выбранными.
|
||||||
- Автор видит статусы в «Мои предложения» (раздел админки модератора), бейдж с числом решений с последнего визита.
|
- Автор видит статусы в «Мои предложения» (раздел админки модератора), бейдж с числом решений с последнего визита.
|
||||||
- Аудитор загрузок видит бейдж «N на рассмотрении» в сайдбаре.
|
- Аудитор загрузок видит бейдж «N на рассмотрении» в сайдбаре.
|
||||||
|
|
||||||
@@ -200,7 +336,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`; после перезагрузки пользователь выбирает те же файлы,
|
||||||
|
|||||||
@@ -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,10 @@ export interface components {
|
|||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
UploadBatchRequest: {
|
UploadBatchRequest: {
|
||||||
|
/** @description Фото исполнителя JPEG, PNG или WebP, до 8 МБ. После одобрения применяется к исполнителю альбома только при отсутствии аватарки. */
|
||||||
|
artist_image_base64?: string;
|
||||||
|
/** @description Обложка альбома JPEG, PNG или WebP, до 8 МБ после декодирования. Применяется ко всем файлам пачки. */
|
||||||
|
cover_base64?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
files: {
|
files: {
|
||||||
client_id: string;
|
client_id: string;
|
||||||
@@ -2314,6 +2380,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 +2407,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 +2835,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 +4559,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 +4783,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;
|
||||||
|
|||||||
@@ -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 } },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,64 @@
|
|||||||
{
|
{
|
||||||
|
"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 · Unmatched tracks: {{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",
|
||||||
|
"matched_count": "matched {{matched}}/{{total}}",
|
||||||
|
"missing": "No matched file",
|
||||||
|
"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 +270,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 +333,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 +351,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 +427,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",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -119,7 +122,8 @@
|
|||||||
"source_wave_seed": "Artist wave · {{label}}",
|
"source_wave_seed": "Artist wave · {{label}}",
|
||||||
"queue_wave": "The wave never ends: next tracks are picked as you listen",
|
"queue_wave": "The wave never ends: next tracks are picked as you listen",
|
||||||
"now_playing": "Now playing",
|
"now_playing": "Now playing",
|
||||||
"expand": "Open player"
|
"expand": "Open player",
|
||||||
|
"more": "More"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -91,6 +104,7 @@
|
|||||||
"no_target": "No storage accepts uploads (or several do and none is the default)",
|
"no_target": "No storage accepts uploads (or several do and none is the default)",
|
||||||
"no_template": "The storage has no path template",
|
"no_template": "The storage has no path template",
|
||||||
"missing_field": "Not enough metadata to build the storage path",
|
"missing_field": "Not enough metadata to build the storage path",
|
||||||
|
"missing_year": "Year is missing — check the metadata. The year is omitted from the path",
|
||||||
"offset_mismatch": "The upload got out of sync; try again",
|
"offset_mismatch": "The upload got out of sync; try again",
|
||||||
"size_mismatch": "The file size does not match the declared one",
|
"size_mismatch": "The file size does not match the declared one",
|
||||||
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
|
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
|
||||||
@@ -124,6 +138,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",
|
||||||
|
|||||||
@@ -1,4 +1,64 @@
|
|||||||
{
|
{
|
||||||
|
"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": "Файл сопоставлен",
|
||||||
|
"matched_count": "сопоставлено {{matched}}/{{total}}",
|
||||||
|
"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 +270,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 +333,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 +351,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 +427,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": "Сохранить",
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
"main": "Навигация",
|
"main": "Навигация",
|
||||||
"home": "Главная",
|
"home": "Главная",
|
||||||
"admin": "Админка",
|
"admin": "Админка",
|
||||||
|
"admin_media": "Медиатека",
|
||||||
|
"admin_uploads": "Загрузки",
|
||||||
|
"admin_system": "Система",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"audit": "Журнал",
|
"audit": "Журнал",
|
||||||
"library": "Библиотека",
|
"library": "Библиотека",
|
||||||
@@ -119,7 +122,8 @@
|
|||||||
"source_wave_seed": "Волна по исполнителю · {{label}}",
|
"source_wave_seed": "Волна по исполнителю · {{label}}",
|
||||||
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания",
|
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания",
|
||||||
"now_playing": "Сейчас играет",
|
"now_playing": "Сейчас играет",
|
||||||
"expand": "Открыть плеер"
|
"expand": "Открыть плеер",
|
||||||
|
"more": "Ещё"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
|
|||||||
@@ -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": "Файл больше допустимого размера",
|
||||||
@@ -91,6 +104,7 @@
|
|||||||
"no_target": "Нет хранилища, принимающего загрузки (или их несколько и не выбрано по умолчанию)",
|
"no_target": "Нет хранилища, принимающего загрузки (или их несколько и не выбрано по умолчанию)",
|
||||||
"no_template": "У хранилища не задан шаблон пути",
|
"no_template": "У хранилища не задан шаблон пути",
|
||||||
"missing_field": "Не хватает метаданных для пути в хранилище",
|
"missing_field": "Не хватает метаданных для пути в хранилище",
|
||||||
|
"missing_year": "Не указан год — проверьте метаданные. Год пропущен в пути",
|
||||||
"offset_mismatch": "Загрузка рассинхронизировалась — повторите",
|
"offset_mismatch": "Загрузка рассинхронизировалась — повторите",
|
||||||
"size_mismatch": "Размер файла не совпал с заявленным",
|
"size_mismatch": "Размер файла не совпал с заявленным",
|
||||||
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
|
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
|
||||||
@@ -124,6 +138,7 @@
|
|||||||
"target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз."
|
"target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз."
|
||||||
},
|
},
|
||||||
"social": {
|
"social": {
|
||||||
|
"listening_unavailable": "Совместное прослушивание недоступно. Пользователь не принимает запросы или приглашения от вас либо больше недоступен. Настройки приёма находятся в меню профиля → «Приватность и общение».",
|
||||||
"person_offline": "Пользователь не в сети. Повторите приглашение, когда он подключится",
|
"person_offline": "Пользователь не в сети. Повторите приглашение, когда он подключится",
|
||||||
"invalid": "Проверьте сообщение или действие",
|
"invalid": "Проверьте сообщение или действие",
|
||||||
"unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен",
|
"unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен",
|
||||||
|
|||||||
Generated
+18
-23
@@ -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
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
@@ -120,6 +121,7 @@ func TestUserJourney(t *testing.T) {
|
|||||||
if dbURL == "" {
|
if dbURL == "" {
|
||||||
t.Skip("TEST_DATABASE_URL is not set")
|
t.Skip("TEST_DATABASE_URL is not set")
|
||||||
}
|
}
|
||||||
|
dbURL = userJourneyDatabase(t, dbURL)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -133,6 +135,8 @@ 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("BOOTSTRAP_ADMIN_USERNAME", "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
|
||||||
@@ -171,6 +175,7 @@ func TestUserJourney(t *testing.T) {
|
|||||||
BootstrapRequired bool `json:"bootstrap_required"`
|
BootstrapRequired bool `json:"bootstrap_required"`
|
||||||
}
|
}
|
||||||
c.ok("GET", "/api/v1/server-info", nil, &info)
|
c.ok("GET", "/api/v1/server-info", nil, &info)
|
||||||
|
require.True(t, info.BootstrapRequired, "user journey requires a fresh database before the first admin login")
|
||||||
var tokens struct {
|
var tokens struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
User struct {
|
User struct {
|
||||||
@@ -214,7 +219,10 @@ func TestUserJourney(t *testing.T) {
|
|||||||
}
|
}
|
||||||
eventually(t, "search index", func() bool {
|
eventually(t, "search index", func() bool {
|
||||||
time.Sleep(time.Second)
|
time.Sleep(time.Second)
|
||||||
return c.call("GET", "/api/v1/search?q="+url.QueryEscape("кино"), nil, &found) == 200 && len(found.Albums) == 1
|
// Artists, albums and tracks are swapped separately. Seeing the album
|
||||||
|
// does not mean the track index is ready for personalized suggestions.
|
||||||
|
return c.call("GET", "/api/v1/search?q="+url.QueryEscape("кино"), nil, &found) == 200 &&
|
||||||
|
len(found.Artists) == 1 && len(found.Albums) == 1 && len(found.Tracks) == 3
|
||||||
})
|
})
|
||||||
|
|
||||||
// 4. Альбом, исполнитель, популярные треки.
|
// 4. Альбом, исполнитель, популярные треки.
|
||||||
@@ -375,3 +383,29 @@ func TestUserJourney(t *testing.T) {
|
|||||||
})
|
})
|
||||||
fmt.Println("user journey ok")
|
fmt.Println("user journey ok")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Other integration packages deliberately create users and catalog fixtures.
|
||||||
|
// The bootstrap journey needs a new database, without modifying those fixtures.
|
||||||
|
// TEST_DATABASE_URL must use a role with CREATEDB (the CI Postgres owner does).
|
||||||
|
func userJourneyDatabase(t *testing.T, address string) string {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
admin, err := db.Connect(ctx, address)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(admin.Close)
|
||||||
|
name := "pipu_journey_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||||
|
identifier := pgx.Identifier{name}.Sanitize()
|
||||||
|
_, err = admin.Exec(ctx, "CREATE DATABASE "+identifier)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cleanup, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, err := admin.Exec(cleanup, "DROP DATABASE "+identifier+" WITH (FORCE)")
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
dsn, err := url.Parse(address)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, []string{"postgres", "postgresql"}, dsn.Scheme)
|
||||||
|
dsn.Path = "/" + name
|
||||||
|
return dsn.String()
|
||||||
|
}
|
||||||
@@ -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,17 @@ 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)
|
||||||
|
upSvc.SetArtistImages(library.NewArtistImages(libStore, covers))
|
||||||
|
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 +198,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package dbtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"pipu/api/internal/library"
|
||||||
|
"pipu/api/internal/library/meta"
|
||||||
|
"pipu/api/internal/platform/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type portraitProber struct{ artist string }
|
||||||
|
|
||||||
|
func (p portraitProber) Probe(context.Context, string) (library.Probe, error) {
|
||||||
|
return library.Probe{Format: "flac", Codec: "flac", DurationMs: 180000,
|
||||||
|
Tags: meta.Fields{Artist: p.artist + ", Guest " + p.artist, AlbumArtist: p.artist, Album: "Portrait album", Title: "Song", Track: "1"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtistImagesPreserveLocalExternalAndConcurrentPortraits(t *testing.T) {
|
||||||
|
pool := connect(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, db.Migrate(ctx, pool))
|
||||||
|
store := library.NewStore(pool)
|
||||||
|
source, err := store.SaveSource(ctx, library.Source{ID: uuid.New(), Name: "portraits", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, NFSOptions: "ro"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, store.DeleteSource(ctx, source.ID))
|
||||||
|
require.NoError(t, store.CleanupOrphans(ctx))
|
||||||
|
})
|
||||||
|
root := t.TempDir()
|
||||||
|
for _, scenario := range []string{"empty", "local", "external", "concurrent"} {
|
||||||
|
t.Run(scenario, func(t *testing.T) {
|
||||||
|
artist := "Portrait " + uuid.NewString()
|
||||||
|
scanner := library.NewScanner(store, portraitProber{artist}, library.NewProgressBoard(), slog.New(slog.DiscardHandler))
|
||||||
|
rel := scenario + ".flac"
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(root, rel), []byte(scenario), 0o644))
|
||||||
|
file, err := scanner.ScanFile(ctx, source, root, rel)
|
||||||
|
require.NoError(t, err)
|
||||||
|
id, err := store.ArtistWithoutImageForFile(ctx, file)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if scenario == "local" {
|
||||||
|
require.NoError(t, store.SetArtistImageIfMissing(ctx, id, "existing-local"))
|
||||||
|
}
|
||||||
|
if scenario == "external" || scenario == "concurrent" {
|
||||||
|
_, err = pool.Exec(ctx, `UPDATE artists SET genius_image_url='https://example.org/portrait.jpg' WHERE id=$1`, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
if scenario != "empty" {
|
||||||
|
_, err = store.ArtistWithoutImageForFile(ctx, file)
|
||||||
|
require.ErrorIs(t, err, library.ErrNotFound)
|
||||||
|
}
|
||||||
|
// Even a caller that looked up the artist before another writer cannot overwrite it.
|
||||||
|
require.NoError(t, store.SetArtistImageIfMissing(ctx, id, "uploaded-portrait"))
|
||||||
|
a, err := store.Artist(ctx, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
switch scenario {
|
||||||
|
case "empty":
|
||||||
|
require.Equal(t, "uploaded-portrait", *a.ImagePath)
|
||||||
|
case "local":
|
||||||
|
require.Equal(t, "existing-local", *a.ImagePath)
|
||||||
|
default:
|
||||||
|
require.Nil(t, a.ImagePath)
|
||||||
|
require.Equal(t, "https://example.org/portrait.jpg", *a.ImageURL)
|
||||||
|
}
|
||||||
|
var guestImage *string
|
||||||
|
require.NoError(t, pool.QueryRow(ctx, `SELECT image_path FROM artists WHERE name=$1`, "Guest "+artist).Scan(&guestImage))
|
||||||
|
require.Nil(t, guestImage, "a parent folder portrait belongs to the album artist, not guests")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type artistImageStore interface {
|
||||||
|
ArtistWithoutImageForFile(context.Context, uuid.UUID) (uuid.UUID, error)
|
||||||
|
SetArtistImageIfMissing(context.Context, uuid.UUID, string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageRenderer interface {
|
||||||
|
StoreImage(context.Context, []byte) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtistImages applies approved portraits to the album artist, never guests.
|
||||||
|
type ArtistImages struct {
|
||||||
|
store artistImageStore
|
||||||
|
images imageRenderer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArtistImages(store *Store, covers *Covers) *ArtistImages {
|
||||||
|
return &ArtistImages{store: store, images: covers}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ArtistImages) ApplyArtistImage(ctx context.Context, file uuid.UUID, data []byte) error {
|
||||||
|
id, err := a.store.ArtistWithoutImageForFile(ctx, file)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key, err := a.images.StoreImage(ctx, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.store.SetArtistImageIfMissing(ctx, id, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Covers) StoreImage(ctx context.Context, data []byte) (string, error) {
|
||||||
|
if err := os.MkdirAll(c.Dir, 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
key := hex.EncodeToString(sum[:8])
|
||||||
|
_, err := c.render(ctx, data, key)
|
||||||
|
return key, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *Store) ArtistWithoutImageForFile(ctx context.Context, file uuid.UUID) (uuid.UUID, error) {
|
||||||
|
var id uuid.UUID
|
||||||
|
err := st.pool.QueryRow(ctx, `SELECT a.id FROM media_files f JOIN tracks t ON t.id=f.track_id
|
||||||
|
JOIN albums al ON al.id=t.album_id JOIN artists a ON a.id=al.album_artist_id
|
||||||
|
WHERE f.id=$1 AND a.image_path IS NULL AND a.genius_image_url IS NULL`, file).Scan(&id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return id, ErrNotFound
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *Store) SetArtistImageIfMissing(ctx context.Context, id uuid.UUID, key string) error {
|
||||||
|
// Another import or Genius may have supplied a portrait during rendering.
|
||||||
|
_, err := st.pool.Exec(ctx, `UPDATE artists SET image_path=$2 WHERE id=$1
|
||||||
|
AND image_path IS NULL AND genius_image_url IS NULL`, id, key)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type portraitStore struct {
|
||||||
|
id uuid.UUID
|
||||||
|
err error
|
||||||
|
saved uuid.UUID
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *portraitStore) ArtistWithoutImageForFile(context.Context, uuid.UUID) (uuid.UUID, error) {
|
||||||
|
return s.id, s.err
|
||||||
|
}
|
||||||
|
func (s *portraitStore) SetArtistImageIfMissing(_ context.Context, id uuid.UUID, key string) error {
|
||||||
|
s.saved, s.key = id, key
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type portraitRenderer struct {
|
||||||
|
data []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *portraitRenderer) StoreImage(_ context.Context, data []byte) (string, error) {
|
||||||
|
r.data = data
|
||||||
|
return "portrait-key", r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestArtistPortraitOnlyRendersForAnArtistWithoutImage(t *testing.T) {
|
||||||
|
for _, scenario := range []string{"new portrait", "already has image", "lookup failure", "render failure"} {
|
||||||
|
t.Run(scenario, func(t *testing.T) {
|
||||||
|
st := &portraitStore{id: uuid.New()}
|
||||||
|
renderer := &portraitRenderer{}
|
||||||
|
switch scenario {
|
||||||
|
case "already has image":
|
||||||
|
st.err = ErrNotFound
|
||||||
|
case "lookup failure":
|
||||||
|
st.err = errors.New("database unavailable")
|
||||||
|
case "render failure":
|
||||||
|
renderer.err = errors.New("invalid image")
|
||||||
|
}
|
||||||
|
images := &ArtistImages{store: st, images: renderer}
|
||||||
|
err := images.ApplyArtistImage(context.Background(), uuid.New(), []byte("portrait"))
|
||||||
|
if scenario == "lookup failure" || scenario == "render failure" {
|
||||||
|
require.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
if scenario == "new portrait" {
|
||||||
|
require.Equal(t, st.id, st.saved)
|
||||||
|
require.Equal(t, "portrait-key", st.key)
|
||||||
|
require.Equal(t, []byte("portrait"), renderer.data)
|
||||||
|
} else {
|
||||||
|
require.Equal(t, uuid.Nil, st.saved)
|
||||||
|
}
|
||||||
|
if scenario == "already has image" || scenario == "lookup failure" {
|
||||||
|
require.Nil(t, renderer.data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ type TrackRow struct {
|
|||||||
// ArtistRow — исполнитель со счётчиками.
|
// ArtistRow — исполнитель со счётчиками.
|
||||||
type ArtistRow struct {
|
type ArtistRow struct {
|
||||||
ImageURL *string
|
ImageURL *string
|
||||||
|
ImagePath *string
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
Name string
|
Name string
|
||||||
AlbumCount int
|
AlbumCount int
|
||||||
@@ -196,10 +197,10 @@ func (st *Store) ArtistAlbums(ctx context.Context, artistID uuid.UUID) ([]AlbumR
|
|||||||
func (st *Store) Artist(ctx context.Context, id uuid.UUID) (ArtistRow, error) {
|
func (st *Store) Artist(ctx context.Context, id uuid.UUID) (ArtistRow, error) {
|
||||||
var a ArtistRow
|
var a ArtistRow
|
||||||
err := st.pool.QueryRow(ctx, `
|
err := st.pool.QueryRow(ctx, `
|
||||||
SELECT a.id, a.name, a.genius_image_url,
|
SELECT a.id, a.name, a.genius_image_url, a.image_path,
|
||||||
(SELECT count(*) FROM albums al WHERE al.album_artist_id = a.id),
|
(SELECT count(*) FROM albums al WHERE al.album_artist_id = a.id),
|
||||||
(SELECT count(DISTINCT ta.track_id) FROM track_artists ta WHERE ta.artist_id = a.id)
|
(SELECT count(DISTINCT ta.track_id) FROM track_artists ta WHERE ta.artist_id = a.id)
|
||||||
FROM artists a WHERE a.id = $1`, id).Scan(&a.ID, &a.Name, &a.ImageURL, &a.AlbumCount, &a.TrackCount)
|
FROM artists a WHERE a.id = $1`, id).Scan(&a.ID, &a.Name, &a.ImageURL, &a.ImagePath, &a.AlbumCount, &a.TrackCount)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return ArtistRow{}, ErrNotFound
|
return ArtistRow{}, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -485,7 +485,11 @@ func (h *Handler) GetArtist(ctx context.Context, req gen.GetArtistRequestObject)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, catalogErr(err)
|
return nil, catalogErr(err)
|
||||||
}
|
}
|
||||||
return gen.GetArtist200JSONResponse{Id: a.ID, Name: a.Name, ImageUrl: a.ImageURL, AlbumCount: a.AlbumCount, TrackCount: a.TrackCount}, nil
|
image := a.ImageURL
|
||||||
|
if a.ImagePath != nil {
|
||||||
|
image = h.coverURL(AlbumRow{ID: a.ID, CoverPath: a.ImagePath})
|
||||||
|
}
|
||||||
|
return gen.GetArtist200JSONResponse{Id: a.ID, Name: a.Name, ImageUrl: image, AlbumCount: a.AlbumCount, TrackCount: a.TrackCount}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListArtistAlbums реализует gen.StrictServerInterface.
|
// ListArtistAlbums реализует 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 {
|
||||||
|
|||||||
@@ -254,6 +254,65 @@ func (t Template) Render(in TemplateInput) (string, error) {
|
|||||||
return finishPath(b.String())
|
return finishPath(b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenderWithoutMissingYear omits an unknown year and its punctuation for uploads.
|
||||||
|
// Other required metadata still uses strict rendering. Optional fragments retain
|
||||||
|
// their usual all-or-nothing semantics.
|
||||||
|
func (t Template) RenderWithoutMissingYear(in TemplateInput) (string, error) {
|
||||||
|
if fieldValue(in, tnode{field: "year"}) != "" {
|
||||||
|
return t.Render(in)
|
||||||
|
}
|
||||||
|
var src strings.Builder
|
||||||
|
for _, n := range t.nodes {
|
||||||
|
if n.isOpt {
|
||||||
|
if _, ok := renderOptional(in, n.opt); ok {
|
||||||
|
src.WriteString("[")
|
||||||
|
for _, o := range n.opt {
|
||||||
|
src.WriteString(templateNodeSource(o))
|
||||||
|
}
|
||||||
|
src.WriteString("]")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
src.WriteString(templateNodeSource(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := src.String()
|
||||||
|
for _, re := range missingYearParts {
|
||||||
|
s = re.ReplaceAllString(s, "")
|
||||||
|
}
|
||||||
|
// A year-only directory disappears as a whole.
|
||||||
|
var segments []string
|
||||||
|
for _, seg := range strings.Split(s, "/") {
|
||||||
|
if strings.TrimSpace(seg) != "" {
|
||||||
|
segments = append(segments, seg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tpl, err := ParseTemplate(strings.Join(segments, "/"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return tpl.Render(in)
|
||||||
|
}
|
||||||
|
|
||||||
|
var missingYearParts = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`\(\s*\{year\}\s*\)`),
|
||||||
|
regexp.MustCompile(`\{year\}[ \t]*[-–—_][ \t]*`),
|
||||||
|
regexp.MustCompile(`[ \t]*[-–—_][ \t]*\{year\}`),
|
||||||
|
regexp.MustCompile(`\{year\}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateNodeSource(n tnode) string {
|
||||||
|
if n.field == "" {
|
||||||
|
return n.lit
|
||||||
|
}
|
||||||
|
if n.multi {
|
||||||
|
return "{" + n.field + ":1" + n.suffix + "}"
|
||||||
|
}
|
||||||
|
if n.pad > 0 {
|
||||||
|
return fmt.Sprintf("{%s:0%d}", n.field, n.pad)
|
||||||
|
}
|
||||||
|
return "{" + n.field + "}"
|
||||||
|
}
|
||||||
|
|
||||||
func renderOptional(in TemplateInput, nodes []tnode) (string, bool) {
|
func renderOptional(in TemplateInput, nodes []tnode) (string, bool) {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,41 @@ import (
|
|||||||
|
|
||||||
const docTemplate = "{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}"
|
const docTemplate = "{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}"
|
||||||
|
|
||||||
|
func TestRenderWithoutMissingYear(t *testing.T) {
|
||||||
|
for _, pattern := range []string{
|
||||||
|
"{artist}/{album} ({year})/{track:02} - {title}.{ext}",
|
||||||
|
"{artist}/{year} - {album}/{track:02} - {title}.{ext}",
|
||||||
|
"{artist}/{album} - {year}/{track:02} - {title}.{ext}",
|
||||||
|
"{artist}/{year}/{album}/{track:02} - {title}.{ext}",
|
||||||
|
"{artist}/[{year} - ]{album}/{track:02} - {title}.{ext}",
|
||||||
|
} {
|
||||||
|
t.Run(pattern, func(t *testing.T) {
|
||||||
|
tpl, err := ParseTemplate(pattern)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input := in(Fields{Artist: "A", Album: "B", Title: "C", Track: "1"}, 1, "mp3")
|
||||||
|
got, err := tpl.RenderWithoutMissingYear(input)
|
||||||
|
if err != nil || got != "A/B/01 - C.mp3" {
|
||||||
|
t.Fatalf("got %q, %v", got, err)
|
||||||
|
}
|
||||||
|
input.Fields.Title = ""
|
||||||
|
if _, err := tpl.RenderWithoutMissingYear(input); err == nil {
|
||||||
|
t.Fatal("missing title must still fail")
|
||||||
|
}
|
||||||
|
input.Fields.Title, input.Fields.Year = "C", "2026"
|
||||||
|
want, err := tpl.Render(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err = tpl.RenderWithoutMissingYear(input)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Fatalf("known year: got %q, %v; want %q", got, err, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func in(f Fields, discs int, ext string) TemplateInput {
|
func in(f Fields, discs int, ext string) TemplateInput {
|
||||||
return TemplateInput{Fields: f, DiscCount: discs, Ext: ext}
|
return TemplateInput{Fields: f, DiscCount: discs, Ext: ext}
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user